Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ Env vars are validated with zod in `apps/web/lib/env.ts` and loaded in `apps/rec
- **Fake secrets stay in test paths.** Push protection is on. Test fixtures that look like real credentials (Stripe-style `whsec_` secrets, signed vectors) belong under the paths in `.github/secret_scanning.yml`, which scanning skips; add a path there rather than bypassing a block, and never put a fixture like that in production code.
- **A release label and a changelog file on every PR; the web version is given at deploy.** Every pull request carries one label: `release: patch` (fixes and small features), `release: minor` (significant features), `release: major` (reserved for 1.0), or `release: none` (nothing shipped to the web app, receiver or MX host: SDK-only, CI, tooling). Unless it is `none`, it adds its own changelog file to `apps/web/changelog/web/` (a title and items, never a version; see the README there). The required `Release label` check enforces both. Never write a web version in a PR or a commit title: a shared version line made every pair of open PRs conflict. The ops repository's Deploy workflow runs `apps/web/scripts/release.mjs plan`, which takes the newest `web-v*` tag the deployed commit contains, applies the highest bump among the PRs merged since, and writes `apps/web/lib/changelog-release.json` for the build (in the repository it holds no version, so local and CI builds report `dev`; never commit a generated copy); a production deploy then tags the commit `web-vX.Y.Z` with the release GitHub App. A dry run is `X.Y.Z-dryrun` and is not tagged, deploying a tag again keeps its version, and an untagged commit behind the newest tag is refused (roll back by deploying a tag). Tag rulesets let only admins and the release app (`webhooks-cc-release`) create `web-v*` tags, and only admins move or delete them. Web entries up to 0.65.1 were numbered by hand and stay in `changelog.ts`. CLI, SDK, and MCP releases bump `CLI_VERSION`, `SDK_VERSION`, or `MCP_VERSION` and add an entry on their own track when the tag is cut (`v*`, `sdk-v*`, `mcp-v*`). A unit test keeps the SDK and MCP constants in sync with the package versions. The Python SDK's version lives in `packages/sdk-python/pyproject.toml` and `src/webhooks_cc/__init__.py`; a `sdk-python-v*` tag publishes it to PyPI through trusted publishing (no token), and the workflow refuses a tag that does not match the version. The pytest plugin's version lives in `packages/pytest-webhooks-cc/pyproject.toml` and `src/pytest_webhooks_cc/__init__.py`, and a `pytest-plugin-v*` tag publishes it the same way; it depends on the SDK from PyPI, so publish the SDK first when the plugin needs something new from it. The Go SDK's version is `Version` in `packages/sdk-go/doc.go`; Go modules are published by their tags, so a release is the tag `packages/sdk-go/v<version>` (the module sits in a subdirectory), pushed after the web release whose `/go` route serves the `go-import` tag; the release workflow checks the tag against `Version` and has the module proxy fetch it.
- **Tests live next to the code they cover.** Unit tests as `*.test.ts` beside the source, web integration suites in `apps/web/tests/integration/`, Rust tests in each crate. Scratch verification scripts stay out of the repo.
- **Design system.** One look: paper and ink (a cool paper canvas, white surfaces edged by hairlines rather than drop shadows, one pine green for buttons, links and selection, IBM Plex Sans with IBM Plex Mono), in light and dark mode. Every page gets its header from `components/nav/site-header.tsx`, anchored at the top, with page context passed as children. Colours are CSS variables in `apps/web/app/globals.css` (values in the base layer, mapped to Tailwind in `@theme`); write plain Tailwind against them (`text-primary`, `bg-selected`, `text-destructive-ink`). A plain `border` already takes the hairline colour; add `border-border` only to override a primitive's border colour through `cn()`. Shadows are `shadow-surface` at most on a resting surface and `shadow-float` or `shadow-float-lg` on floating layers, all tinted to the ink. The shared looks are components in `components/ui`: `Card` (`interactive` darkens its hairline on hover, as a card that is a link does), `Button` (control variants for forms and dialogs, `raised` for their lifted actions, and the padded surface variants `primary`, `surface`, `hairline` and `soft` used across the dashboard and site; `surface` is not the control `outline`), `CodeBox` and `InlineCode`, `FieldControl` (the field look on an input, or any control through `render`), and `field` on `Input`, `Textarea` and `SelectTrigger` (which keeps the control look and only adds the field's transition); `CodeInput` is the one-time code field (input-otp) and toasts go through Sonner's `toast.custom` with the app's own look (its Toaster is mounted only in `app/dashboard/layout.tsx`, so mount it where a new toast is needed). `Card`, `CodeBox`, `FieldControl` and `Button` take a `render` element for another tag. `CodeInput` draws its digits over input-otp's invisible input: a click puts the caret after the last digit, or selects the last digit once the code is complete, and a single digit cannot be clicked into. Their `ui-*` rules stay in `globals.css`'s components layer so a call site's utilities always win over them, and `lib/retired-classes.test.ts` fails on a `ui-*` look class outside `components/ui` (and on the classic style's old utilities). The primitives in `components/ui` wrap Base UI (`@base-ui/react`): dialogs, alert dialogs, menus, selects and the slider are its parts with our classes; tabs, collapsibles and the accordion are its unstyled parts (a tab bar draws its tabs with `data-active:` classes, a chevron turns with `group-data-panel-open:`), so build a new tab bar or show-and-hide panel on them, not by hand; and `Button` takes base-ui's `render` prop to draw as a link (`<Button render={<Link href="/" />}>`); there is no `asChild`. A menu item acts in `onClick` (a Radix-style `onSelect` is only the DOM's text-selection event there), a Select's trigger shows labels from its `SelectItem`s, and the docs that ship with the package (`node_modules/@base-ui/react/docs/`) are the reference. Check new UI in light and dark, and reuse those primitives rather than introducing new styling patterns.
- **Design system.** One look: paper and ink (a cool paper canvas, white surfaces edged by hairlines rather than drop shadows, one pine green for buttons, links and selection, IBM Plex Sans with IBM Plex Mono), in light and dark mode. Every page gets its header from `components/nav/site-header.tsx`, anchored at the top, with page context passed as children. Colours are CSS variables in `apps/web/app/globals.css` (values in the base layer, mapped to Tailwind in `@theme`); write plain Tailwind against them (`text-primary`, `bg-selected`, `text-destructive-ink`). A plain `border` already takes the hairline colour; add `border-border` only to override a primitive's border colour through `cn()`. Shadows are `shadow-surface` at most on a resting surface and `shadow-float` or `shadow-float-lg` on floating layers, all tinted to the ink. The shared looks are components in `components/ui`: `Card` (`interactive` darkens its hairline on hover, as a card that is a link does), `Button` (control variants for forms and dialogs, `raised` for their lifted actions, and the padded surface variants `primary`, `surface`, `hairline` and `soft` used across the dashboard and site; `surface` is not the control `outline`), `CodeBox` and `InlineCode`, `FieldControl` (the field look on an input, or any control through `render`), and `field` on `Input`, `Textarea` and `SelectTrigger` (which keeps the control look and only adds the field's transition); `CodeInput` is the one-time code field (input-otp) and toasts go through Sonner's `toast.custom` with the app's own look (its Toaster is mounted only in `app/dashboard/layout.tsx`, so mount it where a new toast is needed). `Card`, `CodeBox`, `FieldControl` and `Button` take a `render` element for another tag. `CodeInput` draws its digits over input-otp's invisible input: a click puts the caret after the last digit, or selects the last digit once the code is complete, and a single digit cannot be clicked into. Their `ui-*` rules stay in `globals.css`'s components layer so a call site's utilities always win over them, and `lib/retired-classes.test.ts` fails on a `ui-*` look class outside `components/ui` (and on the classic style's old utilities). The primitives in `components/ui` wrap Base UI (`@base-ui/react`): dialogs, alert dialogs, menus (`look="list"` for the dashboard's bordered list menus), the context menu, selects and the slider are its parts with our classes; tabs, collapsibles, the accordion, radio groups, the switch, the popover, the meter and a dialog drawn its own way (`DialogPortal`, `DialogBackdrop`, `DialogPopup`) are its unstyled parts (a tab bar draws its tabs with `data-active:` classes, a chevron turns with `group-data-panel-open:`), so build a new tab bar, show-and-hide panel, pick-one choice (a segmented control too) or usage bar on them, not by hand, and import Base UI only inside `components/ui`; a `RadioGroup` reports changes through `onValueChange`, never a radio's `onClick`, and leaves a hidden input beside each radio, so space its radios with `gap-*`; `lib/retired-classes.test.ts` fails on a hand-written widget role (`role="radio"`, `"menu"`, `"dialog"`, `aria-modal` and the like) outside `components/ui`, the cookie banner (a non-modal notice with no behaviour of its own) being the one file allowed; the Copy and Saved replies list menus are not modal (`modal={false}`), so a click outside one still does what it clicks, as the menus they replaced did, while the context menu is modal like the browser's own (Base UI allows nothing else); and `Button` takes base-ui's `render` prop to draw as a link (`<Button render={<Link href="/" />}>`); there is no `asChild`. A menu item acts in `onClick` (a Radix-style `onSelect` is only the DOM's text-selection event there), a Select's trigger shows labels from its `SelectItem`s, and the docs that ship with the package (`node_modules/@base-ui/react/docs/`) are the reference. Check new UI in light and dark, and reuse those primitives rather than introducing new styling patterns.
- **Formatting.** Prettier and ESLint are enforced in CI; run `pnpm format` before committing. `apps/web/public/email-templates/` is excluded because Prettier breaks Go template actions.
- **No em dashes** anywhere: code, comments, docs, commit messages.

Expand Down
17 changes: 8 additions & 9 deletions apps/web/app/(settings)/account/appearance/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Monitor, Moon, Sun, type LucideIcon } from "lucide-react";
import { useTheme, type Theme } from "@/components/providers/theme-provider";
import { Panel, SettingsPageHeader } from "@/components/settings/panel";
import { cn } from "@/lib/utils";
import { Radio, RadioGroup } from "@/components/ui/radio-group";

const THEMES: { value: Theme; label: string; icon: LucideIcon }[] = [
{ value: "light", label: "Light", icon: Sun },
Expand All @@ -26,21 +27,19 @@ export default function AppearancePage() {
title="Theme"
description="Light and Dark stay as picked; System follows your operating system and changes with it."
>
<div
role="radiogroup"
<RadioGroup
value={theme}
onValueChange={(next) => setTheme(next as Theme)}
aria-label="Theme"
className="inline-flex overflow-hidden rounded-md border"
>
{THEMES.map((option, index) => {
const Icon = option.icon;
const active = theme === option.value;
return (
<button
<Radio
key={option.value}
type="button"
role="radio"
aria-checked={active}
onClick={() => setTheme(option.value)}
value={option.value}
className={cn(
"flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors cursor-pointer",
index > 0 && "border-l border-border",
Expand All @@ -49,10 +48,10 @@ export default function AppearancePage() {
>
<Icon className="h-4 w-4" />
{option.label}
</button>
</Radio>
);
})}
</div>
</RadioGroup>
</Panel>
</>
);
Expand Down
18 changes: 12 additions & 6 deletions apps/web/app/changelog/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
TRACK_LABELS,
type ChangelogTrack,
} from "@/lib/changelog";
import { Radio, RadioGroup } from "@/components/ui/radio-group";

const TRACKS: (ChangelogTrack | "all")[] = ["all", "web", "cli", "sdk", "python", "go", "mcp"];

Expand Down Expand Up @@ -59,21 +60,26 @@ export default function ChangelogPage() {
</div>
</div>

<div className="flex gap-2 mb-8 flex-wrap">
<RadioGroup
value={activeTrack}
onValueChange={(next) => setActiveTrack(next as typeof activeTrack)}
aria-label="Releases to show"
className="flex gap-2 mb-8 flex-wrap"
>
{TRACKS.map((track) => (
<button
<Radio
key={track}
onClick={() => setActiveTrack(track)}
className={`px-3 py-1.5 text-xs font-bold border border-border rounded-md transition-colors ${
value={track}
className={`px-3 py-1.5 text-xs font-bold border border-border rounded-md transition-colors focus-visible:outline-offset-2 ${
activeTrack === track
? "bg-selected text-selected-foreground"
: "bg-background text-foreground hover:bg-muted"
}`}
>
{track === "all" ? "All" : TRACK_LABELS[track]}
</button>
</Radio>
))}
</div>
</RadioGroup>

<div className="space-y-8">
{filtered.map((entry) => (
Expand Down
4 changes: 4 additions & 0 deletions apps/web/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1713,6 +1713,10 @@ export default function DashboardPage() {

// Don't intercept when typing in inputs
if (isInput) return;
// An open menu or dialog has the keyboard: a letter there picks an item
// (a menu's type-ahead) or types, and must not act on the page behind it.
if ((e.target as HTMLElement)?.closest?.("[role=menu], [role=dialog], [role=alertdialog]"))
return;
// Don't intercept when modifiers are held (allow browser shortcuts)
if (e.ctrlKey || e.metaKey || e.altKey) return;

Expand Down
10 changes: 10 additions & 0 deletions apps/web/changelog/web/base-ui-menus-and-choices.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"title": "Menus, choices and meters on Base UI",
"items": [
"The request list's menu (right click, the keyboard's menu key or a long press) and the body's Copy menu work from the keyboard: arrow keys move through them, Enter picks, Escape closes, and the dashboard's shortcut keys leave an open menu alone. Like the browser's own, the right-click menu closes on a click outside without acting on it",
"Pick-one choices (filters, body and preview views, theme, plan, ticket category and rating) are one stop for Tab, and the arrow keys move the choice",
"The dashboard guide's sections are tabs: the arrow keys move between them",
"On phones, the docs contents keep focus inside while open, and once closed no longer hold links Tab could reach",
"Usage bars are announced as meters"
]
}
10 changes: 4 additions & 6 deletions apps/web/components/account/shares/shares-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import { CommandBlock } from "./command-block";
import { NamesPanel } from "./names-panel";
import { QrDialog } from "./qr-dialog";
import { RequestLog } from "./request-log";
import { Meter } from "@/components/ui/meter";

const REFRESH_MS = 10_000;
const INSTALL = "curl -fsSL https://webhooks.cc/install.sh | sh";
Expand Down Expand Up @@ -507,19 +508,16 @@ function DataMeter({
</p>
<p className="text-xs text-muted-foreground">Resets {longDate(resetsAt)}</p>
</div>
<div
role="progressbar"
aria-valuenow={percent}
aria-valuemin={0}
aria-valuemax={100}
<Meter
value={percent}
aria-label={`${percent}% of this month's preview data used`}
className="h-3 overflow-hidden rounded-sm bg-muted/60"
>
<div
className={cn("h-full", near ? "bg-destructive" : "bg-primary")}
style={{ width: `${Math.max(percent, bytes > 0 ? 1 : 0)}%` }}
/>
</div>
</Meter>
</div>
);
}
36 changes: 18 additions & 18 deletions apps/web/components/dashboard/dashboard-guide-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"use client";

import { useState } from "react";
import { cn } from "@/lib/utils";
import {
Dialog,
DialogContent,
Expand Down Expand Up @@ -32,6 +31,7 @@ import {
import Link from "next/link";
import { WEB_VERIFICATION_PROVIDER_OPTIONS } from "@/lib/provider-catalog";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsPanel, TabsTab } from "@/components/ui/tabs";

interface Section {
id: string;
Expand Down Expand Up @@ -544,40 +544,40 @@ export function DashboardGuideDialog({
)}
{/* As tall as the longer of the two columns, up to 85vh; each column scrolls past that. */}
<DialogContent className="max-w-3xl max-h-[85vh] p-0 overflow-hidden">
<div className="flex max-h-[85vh] min-h-0">
<Tabs
value={activeSection}
onValueChange={(next) => setActiveSection(next as string)}
orientation="vertical"
className="flex max-h-[85vh] min-h-0"
>
{/* Sidebar */}
<nav className="w-52 shrink-0 border-r overflow-y-auto py-4">
<div className="w-52 shrink-0 border-r overflow-y-auto py-4">
<DialogHeader className="px-4 pb-3">
<DialogTitle className="text-sm tracking-normal">Dashboard guide</DialogTitle>
</DialogHeader>
<div className="space-y-0.5">
<TabsList className="space-y-0.5">
{SECTIONS.map((section) => (
<button
<TabsTab
key={section.id}
onClick={() => setActiveSection(section.id)}
className={cn(
"w-full px-4 py-1.5 text-left text-xs font-bold flex items-center gap-2 cursor-pointer transition-colors",
activeSection === section.id
? "bg-selected text-selected-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
value={section.id}
className="w-full px-4 py-1.5 text-left text-xs font-bold flex items-center gap-2 cursor-pointer transition-colors data-active:bg-selected data-active:text-selected-foreground not-data-active:text-muted-foreground not-data-active:hover:text-foreground not-data-active:hover:bg-muted"
>
{section.icon}
{section.title}
</button>
</TabsTab>
))}
</div>
</nav>
</TabsList>
</div>

{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
<TabsPanel value={activeSection} scroller className="flex-1 overflow-y-auto p-6">
<h2 className="text-lg font-bold mb-4 flex items-center gap-2 tracking-normal">
{current.icon}
{current.title}
</h2>
<div className="text-sm leading-relaxed">{current.content}</div>
</div>
</div>
</TabsPanel>
</Tabs>
</DialogContent>
</Dialog>
);
Expand Down
Loading
Loading