diff --git a/src/bundled/workflows/workflows.journey.mjs b/src/bundled/workflows/workflows.journey.mjs index c7d7890c..65f372a6 100644 --- a/src/bundled/workflows/workflows.journey.mjs +++ b/src/bundled/workflows/workflows.journey.mjs @@ -137,7 +137,7 @@ test("workflow editor preserves YAML, resolves exact saves, retains conflicts an await expect(button("Close editor")).toHaveCSS("color", "rgb(255, 255, 255)"); await expect(button("Close editor")).toHaveCSS( "background-color", - "rgb(16, 16, 16)", + "rgb(51, 51, 51)", ); await yaml.focus(); await page.keyboard.press("ArrowLeft"); diff --git a/src/features/relay/signed-boundary.test.ts b/src/features/relay/signed-boundary.test.ts index 5eb8633f..56b49e6d 100644 --- a/src/features/relay/signed-boundary.test.ts +++ b/src/features/relay/signed-boundary.test.ts @@ -115,6 +115,9 @@ it("a signer already waiting cannot bypass a newly learned shared cooldown", asy "relay", ); const one = t.query([{ kinds: [0], limit: 1 }]).catch((e) => e); + // Digest preparation runs on real crypto threads. Establish which request is + // waiting first before releasing pending[0]; completion order is otherwise free. + await vi.waitFor(() => expect(pending).toHaveLength(1)); const two = t.query([{ kinds: [0], limit: 2 }]).catch((e) => e); await vi.advanceTimersByTimeAsync(600); await vi.waitFor(() => expect(pending).toHaveLength(2)); diff --git a/src/features/relay/typing.test.ts b/src/features/relay/typing.test.ts index 7a69abb0..d0ce88e8 100644 --- a/src/features/relay/typing.test.ts +++ b/src/features/relay/typing.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { createTyping } from "./typing"; import { keypair, message, signed } from "./testing"; @@ -170,32 +170,36 @@ it("message suppression is signer/thread scoped and clears only older activity", ]); expect(snapshot()).toEqual([]); }); -it("bounds active and suppression records without eviction; teardown fences retained callbacks", () => { - const { owner, snapshot } = setup(); - // Distinct roots avoid generating 1025 signing keys. - owner.accept( - Array.from({ length: 1025 }, (_, i) => +describe("activity capacity", () => { + let pulses: ReturnType[]; + beforeAll(() => { + // Prepare real signed input outside the behavior check. Signing speed is + // not the capacity contract; keep all 1025 distinct scopes. + pulses = Array.from({ length: 1025 }, (_, i) => pulse([ ["h", "a"], ["e", i.toString(16).padStart(64, "0"), "", "reply"], ]), - ), - true, - ); - expect(snapshot()).toHaveLength(1024); - expect(vi.getTimerCount()).toBe(1); - const listener = vi.fn(); - const stop = owner.capability.subscribe(listener); - owner.clear(); - expect(snapshot()).toEqual([]); - expect(listener).toHaveBeenCalledTimes(1); - stop(); - owner.accept([pulse()], true); - expect(listener).toHaveBeenCalledTimes(1); - owner.dispose(); - owner.accept([pulse()], true); - expect(snapshot()).toEqual([]); - expect(vi.getTimerCount()).toBe(0); + ); + }); + it("bounds active and suppression records without eviction; teardown fences retained callbacks", () => { + const { owner, snapshot } = setup(); + owner.accept(pulses, true); + expect(snapshot()).toHaveLength(1024); + expect(vi.getTimerCount()).toBe(1); + const listener = vi.fn(); + const stop = owner.capability.subscribe(listener); + owner.clear(); + expect(snapshot()).toEqual([]); + expect(listener).toHaveBeenCalledTimes(1); + stop(); + owner.accept([pulse()], true); + expect(listener).toHaveBeenCalledTimes(1); + owner.dispose(); + owner.accept([pulse()], true); + expect(snapshot()).toEqual([]); + expect(vi.getTimerCount()).toBe(0); + }); }); for (const kind of [9, 40002]) { @@ -277,26 +281,31 @@ it("rejects delayed pre-message activity after the quiet period, but admits genu expect(vi.getTimerCount()).toBe(0); }); -it("retains completion evidence at capacity until stale pulses have expired", () => { - const { owner, snapshot } = setup(); - const messages = Array.from({ length: 1024 }, (_, i) => - signed(agent, { - kind: 9, - content: "complete", - created_at: epoch, - tags: [ - ["h", "a"], - ["e", i.toString(16).padStart(64, "0"), "", "reply"], - ], - }), - ); - owner.accept(messages); - vi.advanceTimersByTime(3000); - owner.accept([pulse(undefined, epoch + 3)], true); - expect(snapshot()).toEqual([]); - vi.advanceTimersByTime(5000); - owner.accept([pulse(undefined, epoch + 8)], true); - expect(snapshot()).toHaveLength(1); - owner.dispose(); - expect(vi.getTimerCount()).toBe(0); +describe("completion capacity", () => { + let messages: ReturnType[]; + beforeAll(() => { + messages = Array.from({ length: 1024 }, (_, i) => + signed(agent, { + kind: 9, + content: "complete", + created_at: epoch, + tags: [ + ["h", "a"], + ["e", i.toString(16).padStart(64, "0"), "", "reply"], + ], + }), + ); + }); + it("retains completion evidence at capacity until stale pulses have expired", () => { + const { owner, snapshot } = setup(); + owner.accept(messages); + vi.advanceTimersByTime(3000); + owner.accept([pulse(undefined, epoch + 3)], true); + expect(snapshot()).toEqual([]); + vi.advanceTimersByTime(5000); + owner.accept([pulse(undefined, epoch + 8)], true); + expect(snapshot()).toHaveLength(1); + owner.dispose(); + expect(vi.getTimerCount()).toBe(0); + }); }); diff --git a/src/shared/design-system/DESIGN.md b/src/shared/design-system/DESIGN.md index b0d82d3d..c56f8948 100644 --- a/src/shared/design-system/DESIGN.md +++ b/src/shared/design-system/DESIGN.md @@ -22,6 +22,14 @@ an actual use, document it in the registry and measure its intended pairings. Legacy utilities and host aliases remain while their callers migrate. Do not add new uses. Whole materials such as glass still travel as one shared recipe. +| Role | Use | +| --- | --- | +| `surface-panel` | Content panels and form fields. | +| `text-standard` | Main text on a neutral surface. | +| `text-subtle` | Supporting text. | +| `border-prominent` | Field boundaries and stronger separators. | +| `affordance-subtle-hover` | Hover on a quiet action. | + ## Foundations The interface uses shared color, type, spacing and shape roles. Primary actions @@ -75,6 +83,19 @@ Buzz is a place where people build together and bring their agents into the room - **A redundant fill on glass is not free — it compounds.** Two identical translucent layers are not one layer: `glass-2` over `glass-2` composites to **0.77 alpha**, a value no token holds. Four panels each set the same fill as the container they exactly covered, so panels meant to be the most translucent surface in the system read as nearly solid. Before giving a region a glass fill, check whether its parent already is glass; if the region covers it, it needs no fill of its own. - **A component that can sit on either the gradient or a panel says so, with a variant.** `Tabs` takes `chrome` (a glass pill for the app backdrop) or `panel` (an underline for a plain surface); `IconButton` has the same axis as its `chrome` variant. The failure that earned it: the chrome container is `glass-2`, which over a white panel composites to pure white, and its selected pill is `neutral-1` — also pure white. Container and selection became one colour with only a shadow between them, and no guard could see it because the component had no way to state which background it expected. **The fix was never to retint `--bg-chrome-selected`** — that moves the collision rather than removing it. **One component with a variant, not two components:** behaviour, keyboard model, accessibility, props, and the Base UI parts underneath are identical, so a sibling component would duplicate all of it to change how selection is drawn, and the two would drift exactly as the four hand-assembled chrome surfaces did. When adding a component that could appear in both places, give it the axis and put both on its specimen page — the chrome-only specimen is why this defect survived until it appeared on a real screen. +## Controls + +Button uses prominent, subtle, ghost, destructive and outline emphasis. Its +32 / 40 / 52px sizes are sm / md / lg at the default scale; labels may wrap and +increase height at larger text settings. Text buttons use pill corners. Fields +use the shared control corner. Legacy Button names map to these variants during +migration; do not add new primary/quiet or compact/default call sites. + +Field groups label, input, help and error using Base UI. Input and Textarea +carry the shared field appearance. RadioGroup is for one choice, Checkbox for an +independent choice and Switch for an immediate on/off setting. Use the native +form semantics exposed by those Base UI primitives rather than duplicating them. + ## State - **Design default, hover, pressed, focus, selected, disabled and loading states where they apply.** Pressed changes fill without moving the control. Loading keeps the label footprint and prevents repeated activation; CSS alone cannot enforce it. @@ -153,181 +174,22 @@ documented above define this system, including the 12px xsmall role. ## Colour structure -Two public layers, plus components: screens normally write a palette step, and use a role only for a decision a step cannot express. - -| Layer | Example | What it is | -|---|---|---| -| **0 palette** | `--purple-9`, `--neutral-4` | Every hue, twelve steps, authored per mode. The only place a literal lives. **Public: a screen writes `bg-purple-9`.** | -| **1 roles** | `--bg-panel` | The fifteen cases a step cannot express. Public too. | -| **2 components** | `bg-panel`, `bg-neutral-4` | Tailwind utilities, from either layer. | - -**Screens are built from the ramps.** This reverses the rule this file used to -state, and the reversal turns on one fact: **every palette step is authored per -mode.** `neutral-4` is `#e8e8e8` in light and `#232323` in dark, so a component -naming the step behaves correctly in both. That is what makes a raw step safe -here and unsafe in stock Tailwind, where `neutral-200` is a single literal — -naming it there really does break dark mode, and a semantic layer really is the -only fix. - -Once a step is mode-aware, **a role whose light and dark values are the same step -is a name in front of a number**, and a name in front of a number hides the -decision instead of recording it. `bg-accent` was `purple-9`; `text-error` was -`red-12`. Nineteen roles were exactly that and are gone. - -### When a name is earned - -Three cases, and `pnpm design:check` enforces the first two by rejecting any new -role that fails them: - -1. **Light and dark take different steps.** `bg-panel` is `neutral-1` in light and - `neutral-3` in dark. No single class can say that, so the name is load-bearing. - The four structural surfaces are all of this kind. -2. **The name enforces a rule a ramp cannot state.** There are deliberately three - levels of text and one border weight. `text-neutral-11` looks reasonable and is - how a fourth level appears without anyone deciding, so `text-secondary` stays - even though its step is identical in both modes. -3. **Morgan sees a repeated pattern and asks for one.** A tinted callout that - turns up on four screens earns a name — for the *pattern*, not the colour. This - is the only route by which the role layer grows, and it is deliberately manual. - -`neutral` is a hue like any other — the same twelve steps, the same naming. There -is no separate grey ramp and no `palette-` prefix: a step is `--neutral-4`, the -way Tailwind names a colour. - -**Tailwind's default palette is deleted** with `--color-*: initial`, so -`text-gray-500` does not exist. It is a build error, not a style choice. - -**There used to be a families layer** — `--accent-fill`, `--danger-tint`, thirty -steps in five families, sitting between the palette and the roles. It was -deleted. Every one of its thirty steps had exactly *one* reader, so it renamed a -colour rather than abstracting one, and answering "what colour is this button" -meant reading three lines in two places -(`bg-accent` → `accent-fill` → `palette-purple-9`). The naming survives where it -was always clearest — in the role names, which say *what the colour is for* -(`bg-accent-tint` is a background) rather than restating a job (`accent fill` -does not tell you where to put it). - -**The role layer was then cut from 54 names to 15, by the same test.** A census -counted every reader of every role — both `var(--x)` in a stylesheet and the -Tailwind class each role registers as, with the /design pages counted separately -from product code, since a page displaying a swatch proves only that the token -exists. Eighteen roles had no reader anywhere and six were read only by the docs -that documented them; the rest went once palette steps became reachable as -classes and the "same step in both modes" test above disqualified them. - -**The argument that lost is worth recording, because it is a good one.** A role is -a slot whose hue can change, so `bg-accent-tint` survives a retint where -`purple-3` does not. It lost to a fact: the accent hue *did* change, to Tailwind -purple, and it was five values in the ramp rather than a rename. The ramp is the -slot. A role in front of it only adds a hop. - -The palette stays because it is where **light and dark are reconciled**. A hue's -dark steps are not its light steps dimmed — purple's step 12 is near-black in -light and near-white in dark; step 3 is a lilac wash in light and a deep plum in -dark. Only step 9 is identical. Two hand-authored ramps under one name is what -lets a role be a single line and still behave in both modes, and what keeps -`.dark` to a restatement of values rather than the 50 hand-picked colours it used -to hold — which is where an accent tint and a categorical purple drifted into two -different purples in dark. - -To retint, change the ramp. The accent moved to Tailwind purple in five values, -which is the demonstration that **the ramp is the slot** — no rename, nothing -above it needed to know. - -### Which step, for what - -Twelve steps mean the same twelve jobs in every hue, so this is the map from a job -to a step. It used to generate roles; now it tells you which class to write: - -| step | job | example | -|---|---|---| -| 3 | a tinted surface | `bg-purple-3` | -| 4 | that tint, hovered | `bg-purple-4` | -| 8 | border, focus ring | `border-purple-8` | -| 9 | solid fill | `bg-purple-9` | -| 10 | that fill, hovered | `bg-purple-10` | -| 12 | coloured text on a neutral surface | `text-purple-12` | - -Adding a hue is mechanical — generate twelve steps, and the map above already -answers which one is the button. `cyan` and `orange` are authored and unused, so -that half is proven. - -**Being mechanical is exactly why it must not run ahead of the product.** This map -is how four status identities came to exist: twenty roles from one line of a -lookup table, nineteen of which nothing ever read. It tells you which step to take -*once a design needs the colour*. It is not a licence to pre-generate a set. - -**It is a good default, not a guarantee — measure the pair you actually use.** -Step 10 crosses over, darker than step 9 in light and lighter in dark, so a hover -reads as a press in light mode and a lift in dark with no special-casing. But -green's and blue's step 10 lift *too* far in dark mode and drop white text below -the APCA target. Likewise step 12 is the safe text step and step 11 is the -tempting one: red-11 is more obviously red and fails the Lc 60 body target on a -dark panel (59.7) and the dark composer (57.5), which is why error text is -`text-red-12`. A step used for text goes into `TEXT_ROLES` in -`scripts/design-system/check-contrast.mjs` so the guard measures what screens actually write. - -Palette values are Radix Colors (MIT), transcribed rather than depended on — -Radix is not on Block's Tech Radar, so this is a values-only copy with no -package. Its twelve-step contract is the one this system already described in -comments, step for step. Two deliberate divergences, both documented in -`tokens.css`: `text-*` roles take step 12 rather than the 11 Radix names -"low-contrast text" (Radix sizes 11 for WCAG 4.5:1; every hue's step 11 measured -Lc 55–61 against this system's Lc 60 target), and the **neutral ramp** is -hand-authored in both modes because it was sized against the real panel stack -rather than taken from an even ramp. - -### Naming grammar - -``` --[-][-][-] -``` - -Fixed order, so there is one correct spelling: `--bg-glass-primary-hover` is -legal, `--bg-glass-hover-primary` is not. One modifier, one material, one state per name. -This governs the fifteen roles; a ramp class is `--` and has -no grammar to get wrong, which is part of its appeal. - -Every word a token may be built from is listed in `VOCABULARY` in the registry. -No page renders it yet. Combining them freely is routine. Introducing a new -word is allowed but is the thing the audit reports on its own line — use an -existing word if one fits. - -### Text and borders register in their own namespaces - -**`--color-x` is not one utility. It is all of them.** One such line defines -`bg-x`, `text-x`, `border-x`, `ring-x` and the rest, every one pointing at the -same value. So the moment two roles differ only by *which prefix uses them*, that -namespace picks one and silently drops the other. - -This has now shipped twice, and both times the symptom looked like a design -mistake rather than a registration one. - -**Borders, first.** Text and borders shared the emphasis names while holding -different values — text at the dark end of the neutral ramp, borders at the light -end. Registered under `--color-*`, `border-primary` resolved to the *text* colour -and every hairline drew at near-black. It is why the first design system site had -black dividers while the tokens said `#d4d4d4`. Fixed with `--border-color-*`. - -**Text colour, second, and worse.** `--color-danger: var(--bg-danger)` also -defined `text-danger`, so error text rendered in red-9 — the saturated *fill* — -instead of red-12. On a dark panel that measured **APCA Lc 34 against a target of -60**, on real error messages, for months. And `check-contrast` passed the entire -time, because it measured `--text-danger`: a token that was declared, documented, -audited, and which no class could reach. - -So the rule, and it is a rule rather than a caution: - -> A text role registers as `--text-color-*`. A border role registers as -> `--border-color-*`. Only backgrounds use the shared `--color-*`. - -`src/shared/design-system/tokens/registry.test.ts` binds this to the file — a text or border -role registered under `--color-*` fails there now, rather than after shipping. - -**The general lesson is about the guards, not the namespaces.** A guard that -measures a token nothing resolves to is worse than no guard: it reports the -system is fine and is not wrong about the token, only about whether anything uses -it. When adding a check, verify it measures the value the *browser* computes. +The semantic grammar at the top of this guide is the current contract. +Palette steps supply values; shared roles name their purpose; components own +recipes and states; app screens compose those components. Equal values do not +make two purposes interchangeable. A border and a label may happen to share a +step today and still need separate roles. + +Text and border roles register as `--text-color-*` and `--border-color-*`. +Surface and affordance roles register as `--color-surface-*` and +`--color-affordance-*`. Keep these namespaces distinct: a utility such as +`text-danger` must never resolve to the destructive action's background. + +A color role always has a light and dark value. Paired text is measured against +all of its actual fills, including hover and pressed states. Status is conveyed +with text or an icon too. Keep glass as a complete material rather than using its +fill alone. Palette swatches in the viewer may inspect raw steps; product +components use semantic roles. ## Colour discipline diff --git a/src/shared/design-system/styles/components.css b/src/shared/design-system/styles/components.css index 8e49a76e..534c042b 100644 --- a/src/shared/design-system/styles/components.css +++ b/src/shared/design-system/styles/components.css @@ -1,78 +1,122 @@ @layer components { .buzz-button { - display: inline-flex; + position: relative; + display: inline-grid; + place-items: center; min-width: 0; - height: var(--size-control); - align-items: center; - justify-content: center; - gap: var(--space-row-gap); - border: 0; - border-radius: var(--radius-control); - padding: 0 var(--space-control-inset); + min-height: var(--size-control); + border: 1px solid transparent; + border-radius: var(--radius-pill); + padding: var(--space-1h) var(--space-control-inset); background: transparent; - color: var(--text-primary); + color: var(--text-standard); font-family: inherit; @apply text-label; cursor: pointer; transition: - color var(--duration-state) var(--easing-state), background-color var(--duration-state) var(--easing-state), - opacity var(--duration-fast) var(--easing-state), - box-shadow var(--duration-state) var(--easing-state); + color var(--duration-state) var(--easing-state); } - - .buzz-button[data-size="compact"] { + .buzz-button-label { + display: inline-flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: var(--space-row-gap); + overflow-wrap: anywhere; + } + .buzz-button[data-size="sm"] { @apply text-label-sm; - height: var(--size-row); - border-radius: var(--radius-row); - padding-inline: var(--space-control-inset); + min-height: var(--size-control-sm); + padding-block: var(--space-1); } - - .buzz-button[data-variant="primary"] { - background: var(--neutral-11); - color: var(--neutral-1); + .buzz-button[data-size="lg"] { + min-height: var(--size-control-lg); } - - /* The accent's own hover step, not a fade. `opacity: 0.9` lightened the label - along with the fill and composited an unnamed colour nothing could measure — - the move check-color.mjs rejects everywhere else. */ - .buzz-button[data-variant="primary"]:hover:not([data-disabled]) { - background: var(--neutral-12); + .buzz-button[data-variant="prominent"] { + background: var(--affordance-prominent); + color: var(--text-inverse); } - - .buzz-button[data-variant="quiet"] { - background: var(--neutral-2); - color: var(--text-primary); + .buzz-button[data-variant="prominent"]:hover:not([data-disabled]) { + background: var(--affordance-prominent-hover); } - - /* Scoped with `:not([data-icon-variant])` because IconButton passes - `variant="ghost"` down to Button and carries its real appearance in - `data-icon-variant`. Both selectors match, and this one is more specific - (two pseudo-classes), so an unscoped neutral hover repainted a solid accent - IconButton light grey while its text stayed white — Lc 13, invisible. An - icon button's hover belongs to its icon variant; see below. */ - .buzz-button[data-variant="quiet"]:hover:not([data-disabled]), - .buzz-button[data-variant="ghost"]:not( - [data-icon-variant], - [data-navigation-item] - ):hover:not([data-disabled]) { - background: var(--neutral-4); + .buzz-button[data-variant="prominent"]:active:not([data-disabled]) { + background: var(--affordance-prominent-pressed); } - - .buzz-button[data-disabled] { - color: var(--text-disabled); + .buzz-button[data-variant="subtle"] { + background: var(--affordance-subtle); + } + .buzz-button[data-variant="outline"] { + border-color: var(--border-prominent); + } + .buzz-button:is( + [data-variant="subtle"], + [data-variant="outline"], + [data-variant="ghost"] + ):not([data-icon-variant]):hover:not([data-disabled]) { + background: var(--affordance-subtle-hover); + } + .buzz-button:is( + [data-variant="subtle"], + [data-variant="outline"], + [data-variant="ghost"] + ):not([data-icon-variant]):active:not([data-disabled]) { + background: var(--affordance-subtle-pressed); + } + .buzz-button[data-variant="destructive"] { + background: var(--affordance-danger); + color: var(--text-danger); + } + .buzz-button[data-variant="destructive"]:hover:not([data-disabled]) { + background: var(--affordance-danger-hover); + } + .buzz-button[data-variant="destructive"]:active:not([data-disabled]) { + background: var(--affordance-danger-pressed); + } + .buzz-button[data-disabled]:not([data-loading]) { + color: var(--text-unavailable); + background: var(--affordance-disabled); cursor: not-allowed; } + .buzz-button[data-loading] { + cursor: progress; + } + .buzz-button[data-loading] .buzz-button-label { + opacity: 0; + } + .buzz-button-spinner { + position: absolute; + animation: buzz-button-spin 1s linear infinite; + } + @keyframes buzz-button-spin { + to { + transform: rotate(360deg); + } + } + @media (prefers-reduced-motion: reduce) { + .buzz-button-spinner { + animation: none; + } + } .buzz-button[data-icon-variant] { + min-height: 0; width: var(--size-control); + height: var(--size-control); + border-radius: var(--radius-control); flex: 0 0 auto; padding: 0; } + /* Keep pointer-origin keyboard traversal rooted at the button in WebKit. */ + .buzz-button[data-icon-variant] .buzz-button-label { + pointer-events: none; + } + .buzz-button[data-icon-size="compact"] { width: var(--size-row); height: var(--size-row); + min-height: var(--size-row); border-radius: var(--radius-row); } @@ -85,8 +129,8 @@ } .buzz-button[data-icon-size="large"] { - width: 2.5rem; - height: 2.5rem; + width: var(--size-control-lg); + height: var(--size-control-lg); border-radius: var(--radius-pill); } @@ -97,32 +141,32 @@ /* Every icon variant states its own hover. The one that did not — `solid` — fell through to the neutral ghost hover and lost its accent. */ .buzz-button[data-icon-variant="quiet"] { - background: var(--neutral-2); + background: var(--affordance-subtle); } .buzz-button[data-icon-variant="quiet"]:hover:not([data-disabled]), .buzz-button[data-icon-variant="ghost"]:hover:not([data-disabled]) { - background: var(--neutral-4); + background: var(--affordance-subtle-hover); } .buzz-button[data-icon-variant="solid"] { - background: var(--neutral-11); - color: var(--neutral-1); + background: var(--affordance-prominent-hover); + color: var(--text-inverse); } .buzz-button[data-icon-variant="solid"]:hover:not([data-disabled]) { - background: var(--neutral-12); + background: var(--affordance-prominent); } /* A gentle acknowledgement/submit action on a quiet surface. This cannot be the primary fill: its job is available without pulling focus from writing. */ .buzz-button[data-icon-variant="tint"] { - background: var(--purple-3); - color: var(--purple-12); + background: var(--affordance-accent); + color: var(--text-accent); } .buzz-button[data-icon-variant="tint"]:hover:not([data-disabled]) { - background: var(--purple-4); + background: var(--affordance-accent-hover); } /* Chrome sits on the backdrop and is clickable, so it takes the interactive @@ -132,18 +176,31 @@ @apply glass-primary-interactive; } + .buzz-button:is( + [data-icon-variant="quiet"], + [data-icon-variant="ghost"] + ):active:not([data-disabled]) { + background: var(--affordance-subtle-pressed); + } + .buzz-button[data-icon-variant="solid"]:active:not([data-disabled]) { + background: var(--affordance-prominent-pressed); + } + .buzz-button[data-icon-variant="tint"]:active:not([data-disabled]) { + background: var(--affordance-accent-hover); + } + /* Disabled owns the foreground across icon variants, independently of source order. Accent fills also yield to neutral so unavailability cannot look like an available send/stop action. Shape never changes state semantics. */ .buzz-button[data-icon-variant][data-disabled] { - color: var(--text-disabled); + color: var(--text-unavailable); } .buzz-button:is( [data-icon-variant="tint"], [data-icon-variant="solid"] )[data-disabled] { - background: var(--neutral-3); + background: var(--affordance-selected); } /* A preview is supplemental context; destination cards can also open it. @@ -154,10 +211,10 @@ width: max-content; max-width: min(18rem, 75vw); gap: var(--space-1); - border: 1px solid var(--border-primary); + border: 1px solid var(--border-standard); border-radius: var(--radius-control); padding: var(--space-2) var(--space-3); - background: var(--bg-float); + background: var(--surface-popover); box-shadow: var(--shadow-sm); pointer-events: none; transform-origin: var(--transform-origin); @@ -187,16 +244,16 @@ .buzz-preview-card-example { border: 0; - border-bottom: 1px solid var(--purple-8); + border-bottom: 1px solid var(--border-accent); padding: 0; background: transparent; - color: var(--text-primary); + color: var(--text-standard); font: inherit; cursor: pointer; } html[data-keyboard-navigation] .buzz-preview-card-example:focus-visible { - outline: 2px solid var(--text-primary); + outline: 2px solid var(--text-standard); outline-offset: 2px; } @@ -209,8 +266,8 @@ place-items: center; overflow: hidden; border-radius: var(--radius-pill); - background: var(--neutral-2); - color: var(--text-secondary); + background: var(--affordance-subtle); + color: var(--text-subtle); } .buzz-avatar[data-size="small"] { @@ -271,9 +328,9 @@ min-width: 0; min-height: 0; overflow: hidden; - border: 1px solid var(--border-primary); + border: 1px solid var(--border-standard); border-radius: var(--radius-panel); - background: var(--bg-panel); + background: var(--surface-panel); box-shadow: var(--shadow-xs); } @@ -371,7 +428,7 @@ height: 2rem; border-radius: var(--radius-pill); padding: 0 var(--space-4); - color: var(--text-secondary); + color: var(--text-subtle); } /* A hover *inside* a glass surface, which is not itself a material: no blur, @@ -392,11 +449,11 @@ state is irrelevant, not defending a rule from another rule. */ .buzz-tabs[data-variant="chrome"] .buzz-tabs-tab:hover:not([data-selected]) { background: var(--bg-glass-primary-hover); - color: var(--text-primary); + color: var(--text-standard); } .buzz-tabs[data-variant="chrome"] .buzz-tabs-tab[data-selected] { - color: var(--text-primary); + color: var(--text-standard); } .buzz-tabs[data-variant="chrome"] .buzz-tabs-indicator { @@ -433,16 +490,16 @@ .buzz-tabs[data-variant="panel"] .buzz-tabs-tab { height: 2.25rem; padding: 0; - color: var(--text-tertiary); + color: var(--text-metadata); } /* Excluded for the same reason as the chrome variant above. */ .buzz-tabs[data-variant="panel"] .buzz-tabs-tab:hover:not([data-selected]) { - color: var(--text-primary); + color: var(--text-standard); } .buzz-tabs[data-variant="panel"] .buzz-tabs-tab[data-selected] { - color: var(--text-primary); + color: var(--text-standard); } /* Sits on the container's bottom edge and spans the tab exactly. `height` is @@ -452,7 +509,7 @@ bottom: 0; height: 2px; border-radius: var(--radius-pill); - background: var(--text-primary); + background: var(--text-standard); } .panel-header { @@ -475,7 +532,7 @@ bottom: 0; left: var(--space-panel-inset); height: 1px; - background: var(--border-primary); + background: var(--border-standard); content: ""; pointer-events: none; } @@ -525,7 +582,7 @@ .conversation-header-icon { display: flex; flex: 0 0 auto; - color: var(--text-secondary); + color: var(--text-subtle); } .conversation-header-title-block { @@ -546,7 +603,7 @@ .conversation-header-metadata { flex: 0 0 auto; - color: var(--text-secondary); + color: var(--text-subtle); } .conversation-header-context { @@ -560,9 +617,10 @@ align-items: center; gap: var(--space-row-gap); border-radius: var(--radius-control); + border: 1px solid var(--border-prominent); padding: 0 var(--space-control-inset); - background: var(--neutral-2); - color: var(--text-tertiary); + background: var(--surface-panel); + color: var(--text-metadata); } .search-field[data-variant="navigator"] { @@ -575,17 +633,17 @@ border: 0; outline: 0; background: transparent; - color: var(--text-primary); + color: var(--text-standard); font: inherit; } html[data-keyboard-navigation] .search-field input:focus-visible { - outline: 2px solid var(--text-primary); + outline: 2px solid var(--text-standard); outline-offset: 2px; } .search-field input::placeholder { - color: var(--text-tertiary); + color: var(--text-metadata); } .search-field input::-webkit-search-cancel-button { @@ -645,7 +703,7 @@ border-radius: var(--radius-row); padding: 0 var(--space-control-inset); background: transparent; - color: var(--text-primary); + color: var(--text-standard); font: inherit; text-align: left; cursor: pointer; @@ -658,13 +716,13 @@ stronger statement. Keeping them separate prevents a selected destination from becoming indistinguishable the moment a pointer passes over it. */ .navigation-item:hover:not([data-selected]) { - background: var(--neutral-2); - color: var(--text-primary); + background: var(--affordance-subtle); + color: var(--text-standard); } .navigation-item[data-selected] { - background: var(--neutral-3); - color: var(--text-primary); + background: var(--affordance-selected); + color: var(--text-standard); } .navigation-item[data-inset] { @@ -681,13 +739,13 @@ .navigation-item-trailing { flex: 0 0 auto; - color: var(--text-tertiary); + color: var(--text-metadata); } html[data-keyboard-navigation] .buzz-button:focus-visible, html[data-keyboard-navigation] .navigation-item:focus-visible, html[data-keyboard-navigation] .buzz-tabs-tab:focus-visible { - outline: 2px solid var(--text-primary); + outline: 2px solid var(--text-standard); outline-offset: 2px; } @@ -706,7 +764,7 @@ flex-wrap: wrap; align-items: center; gap: var(--space-2); - color: var(--text-secondary); + color: var(--text-subtle); } .buzz-select-popup { min-width: var(--anchor-width); @@ -714,10 +772,10 @@ max-height: var(--available-height); overflow-y: auto; padding: var(--space-1); - border: 1px solid var(--border-primary); + border: 1px solid var(--border-standard); border-radius: var(--radius-control); - background: var(--bg-float); - color: var(--text-primary); + background: var(--surface-popover); + color: var(--text-standard); box-shadow: var(--shadow-sm); z-index: 1000; } @@ -734,11 +792,11 @@ cursor: default; } .buzz-select-option[data-highlighted] { - background: var(--neutral-4); + background: var(--affordance-subtle-hover); outline: none; } .buzz-select-option[data-selected] { - color: var(--text-primary); + color: var(--text-standard); } } @@ -760,15 +818,15 @@ border-radius: var(--radius-row); padding: var(--space-2); background: transparent; - color: var(--text-primary); + color: var(--text-standard); text-align: start; cursor: pointer; } .buzz-accordion:not([data-variant="activity"]) .buzz-accordion-trigger:hover { - background: var(--neutral-3); + background: var(--affordance-selected); } .buzz-accordion-trigger:focus-visible { - outline: 2px solid var(--text-primary); + outline: 2px solid var(--text-standard); outline-offset: 2px; } .buzz-accordion-trigger[aria-expanded="true"] > svg { @@ -793,7 +851,7 @@ display: inline-flex; align-items: center; gap: var(--space-3); - color: var(--text-primary); + color: var(--text-standard); cursor: pointer; } .buzz-switch-control { @@ -802,24 +860,24 @@ height: 1.25rem; align-items: center; padding: 0.125rem; - border: 1px solid var(--border-primary); + border: 1px solid var(--border-standard); border-radius: var(--radius-pill); - background: var(--neutral-4); + background: var(--affordance-subtle-hover); cursor: pointer; transition: background-color var(--duration-state) var(--easing-state), border-color var(--duration-state) var(--easing-state); } .buzz-switch-control[data-checked] { - border-color: var(--purple-8); - background: var(--purple-8); + border-color: var(--affordance-prominent); + background: var(--affordance-prominent); } .buzz-switch-thumb { display: block; width: 0.875rem; height: 0.875rem; border-radius: var(--radius-pill); - background: var(--bg-panel); + background: var(--text-inverse); box-shadow: var(--shadow-xs); transform: translateX(0); transition: transform var(--duration-settle) var(--easing-settle); @@ -829,18 +887,18 @@ } .buzz-switch-control[data-disabled] { cursor: not-allowed; - background: var(--neutral-3); + background: var(--affordance-selected); } .buzz-switch-control[data-disabled] .buzz-switch-thumb { - background: var(--neutral-5); + background: var(--affordance-subtle-pressed); box-shadow: none; } .buzz-switch:has(.buzz-switch-control[data-disabled]) { - color: var(--text-disabled); + color: var(--text-unavailable); cursor: not-allowed; } html[data-keyboard-navigation] .buzz-switch-control:focus-visible { - outline: 2px solid var(--text-primary); + outline: 2px solid var(--text-standard); outline-offset: 2px; } } @@ -852,7 +910,7 @@ .buzz-accordion[data-variant="activity"] .buzz-accordion-trigger { justify-content: flex-start; padding: var(--space-2) 0; - color: var(--text-secondary); + color: var(--text-subtle); overflow-wrap: anywhere; } .buzz-accordion[data-variant="activity"] .buzz-accordion-trigger > svg { @@ -895,20 +953,20 @@ .buzz-tabs[data-variant="workspace"] .buzz-tabs-tab { padding: var(--space-1) var(--space-3); border-radius: var(--radius-pill); - color: var(--text-secondary); + color: var(--text-subtle); background: transparent; } .buzz-tabs[data-variant="workspace"] .buzz-tabs-tab:hover:not([data-preview]) { - background: var(--neutral-3); - color: var(--text-primary); + background: var(--affordance-selected); + color: var(--text-standard); } .buzz-tabs[data-variant="workspace"] .buzz-tabs-tab[data-selected] { - color: var(--text-primary); + color: var(--text-standard); } .buzz-tabs[data-variant="workspace"] .buzz-tabs-tab[data-preview] { - background: var(--purple-3); - color: var(--text-primary); + background: var(--affordance-accent); + color: var(--text-standard); } .buzz-tabs[data-variant="workspace"] .buzz-tabs-indicator { display: none; @@ -924,7 +982,7 @@ z-index: -1; border-radius: inherit; pointer-events: none; - background: var(--purple-3); + background: var(--affordance-accent); animation: workspace-tab-arrival calc(var(--duration-settle) * 3) ease-out both; } diff --git a/src/shared/design-system/styles/forms.css b/src/shared/design-system/styles/forms.css new file mode 100644 index 00000000..222ab56f --- /dev/null +++ b/src/shared/design-system/styles/forms.css @@ -0,0 +1,122 @@ +@layer components { + .buzz-field { + display: grid; + gap: var(--space-2); + min-width: 0; + } + .buzz-field-label, + .buzz-choice-label { + @apply text-label-sm; + color: var(--text-standard); + } + .buzz-field-description, + .buzz-choice-description { + @apply text-body-sm; + display: block; + color: var(--text-subtle); + } + .buzz-field-error { + @apply text-body-sm; + color: var(--text-danger); + } + .buzz-input, + .buzz-textarea { + @apply text-body; + width: 100%; + min-width: 0; + min-height: var(--size-control); + border: 1px solid var(--border-prominent); + border-radius: var(--radius-control); + padding: var(--space-2) var(--space-3); + background: var(--surface-panel); + color: var(--text-standard); + } + .buzz-textarea { + min-height: 6rem; + resize: vertical; + } + .buzz-input::placeholder, + .buzz-textarea::placeholder { + color: var(--text-subtle); + } + .buzz-input:is([data-invalid], [aria-invalid="true"]), + .buzz-textarea:is([data-invalid], [aria-invalid="true"]) { + border-color: var(--border-danger); + } + .buzz-input:disabled, + .buzz-textarea:disabled { + color: var(--text-unavailable); + background: var(--affordance-disabled); + cursor: not-allowed; + } + .buzz-radio-group { + display: grid; + gap: var(--space-3); + } + .buzz-choice { + display: flex; + align-items: center; + gap: var(--space-3); + min-height: var(--size-control); + cursor: pointer; + } + .buzz-choice[data-variant="card"] { + padding: var(--space-4); + border: 1px solid var(--border-standard); + border-radius: var(--radius-control); + } + .buzz-choice[data-variant="card"]:has([data-checked]) { + border-color: var(--border-prominent); + background: var(--affordance-selected); + } + .buzz-choice:has([data-disabled]) { + cursor: not-allowed; + } + .buzz-choice:has([data-disabled]) .buzz-choice-label { + color: var(--text-unavailable); + } + .buzz-radio, + .buzz-checkbox { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + flex-shrink: 0; + border: 1px solid var(--border-prominent); + background: var(--surface-panel); + color: var(--text-inverse); + } + .buzz-radio { + border-radius: var(--radius-pill); + } + .buzz-checkbox { + border-radius: var(--space-1); + } + .buzz-radio[data-checked], + .buzz-checkbox:is([data-checked], [data-indeterminate]) { + background: var(--affordance-prominent); + border-color: var(--affordance-prominent); + } + .buzz-radio-indicator { + width: var(--space-2); + height: var(--space-2); + border-radius: var(--radius-pill); + background: var(--text-inverse); + } + .buzz-radio[data-disabled], + .buzz-checkbox[data-disabled] { + background: var(--affordance-disabled); + color: var(--text-unavailable); + } + html[data-keyboard-navigation] + :is( + .buzz-input, + .buzz-textarea, + .buzz-radio, + .buzz-checkbox + ):focus-visible { + outline: 2px solid var(--border-focus); + outline-offset: 2px; + } +} diff --git a/src/shared/design-system/styles/globals.css b/src/shared/design-system/styles/globals.css index 41540380..2974c7fc 100644 --- a/src/shared/design-system/styles/globals.css +++ b/src/shared/design-system/styles/globals.css @@ -22,6 +22,7 @@ @import "./typography.css"; @import "./typography-base.css"; @import "./components.css"; +@import "./forms.css"; @import "./chips.css"; @import "./bento.css"; @source "../"; diff --git a/src/shared/design-system/styles/tokens.css b/src/shared/design-system/styles/tokens.css index ad971f0c..984ed333 100644 --- a/src/shared/design-system/styles/tokens.css +++ b/src/shared/design-system/styles/tokens.css @@ -627,8 +627,10 @@ --space-chip-gap: var(--space-1); --size-topbar: 3.5rem; --size-panel-header: 3.5rem; - --size-control: 2.25rem; - --size-row: 1.875rem; + --size-control-sm: 2rem; + --size-control: 2.5rem; + --size-control-lg: 3.25rem; + --size-row: var(--size-control-sm); --size-navigator: 13.5rem; /* The grab area of a resize seam, deliberately wider than the gutter it sits in. The gutter is a visual decision; this is a hit target, and a person @@ -636,7 +638,7 @@ nothing on screen, so the two are separate decisions with separate names. */ --size-resize-seam: 0.875rem; --radius-panel: var(--corner-panel); - --radius-control: var(--corner-control); + --radius-control: 0.5rem; --radius-row: var(--corner-row); --radius-pill: var(--corner-round); /* A chip reads as a chip in running text, so it takes a tighter corner than diff --git a/src/shared/design-system/tokens/registry.ts b/src/shared/design-system/tokens/registry.ts index e7a26cd1..1f6bd2da 100644 --- a/src/shared/design-system/tokens/registry.ts +++ b/src/shared/design-system/tokens/registry.ts @@ -1092,8 +1092,8 @@ export const RADII = [ { token: "radius-control", variable: "--radius-control", - value: "12px", - use: "Inputs, buttons, tabs, and header actions.", + value: "8px", + use: "Inputs and compact icon controls. Text buttons use radius-pill.", }, { token: "radius-panel", diff --git a/src/shared/design-system/ui/Button.tsx b/src/shared/design-system/ui/Button.tsx index e17c30e3..b0139f51 100644 --- a/src/shared/design-system/ui/Button.tsx +++ b/src/shared/design-system/ui/Button.tsx @@ -1,8 +1,16 @@ import { Button as BaseButton } from "@base-ui/react/button"; +import { IconLoader2 } from "@tabler/icons-react"; import type { ComponentProps, ReactNode } from "react"; -type ButtonVariant = "primary" | "quiet" | "ghost"; -type ButtonSize = "compact" | "default"; +type ButtonVariant = + | "prominent" + | "subtle" + | "ghost" + | "destructive" + | "outline" + | "primary" + | "quiet"; +type ButtonSize = "sm" | "md" | "lg" | "compact" | "default"; export type ButtonProps = Omit< ComponentProps, @@ -11,25 +19,56 @@ export type ButtonProps = Omit< children: ReactNode; variant?: ButtonVariant; size?: ButtonSize; + loading?: boolean; }; +/** Base UI owns activation. Legacy variant/size names remain during migration. */ export function Button({ children, - variant = "quiet", - size = "default", + variant = "subtle", + size = "md", type = "button", + loading = false, + disabled, + onClick, ...props }: ButtonProps) { return ( { + if (loading || disabled) { + event.preventDefault(); + return; + } + onClick?.(event); + }} data-buzz-ui="" className="buzz-button" - data-variant={variant} - data-size={size} + data-variant={ + variant === "primary" + ? "prominent" + : variant === "quiet" + ? "subtle" + : variant + } + data-size={size === "compact" ? "sm" : size === "default" ? "md" : size} + data-loading={loading || undefined} > - {children} + {children} + {loading && ( + ); } diff --git a/src/shared/design-system/ui/Checkbox.tsx b/src/shared/design-system/ui/Checkbox.tsx new file mode 100644 index 00000000..7f755117 --- /dev/null +++ b/src/shared/design-system/ui/Checkbox.tsx @@ -0,0 +1,35 @@ +import { Checkbox as BaseCheckbox } from "@base-ui/react/checkbox"; +import { IconCheck, IconMinus } from "@tabler/icons-react"; +import { useId, type ComponentProps, type ReactNode } from "react"; + +export function Checkbox({ + label, + id, + indeterminate, + ...props +}: Omit, "className" | "children"> & { + label: ReactNode; +}) { + const generatedId = useId(); + const controlId = id ?? generatedId; + return ( + + ); +} diff --git a/src/shared/design-system/ui/Field.tsx b/src/shared/design-system/ui/Field.tsx new file mode 100644 index 00000000..e2c41986 --- /dev/null +++ b/src/shared/design-system/ui/Field.tsx @@ -0,0 +1,43 @@ +import { Field as BaseField } from "@base-ui/react/field"; +import type { ComponentProps, ReactNode } from "react"; + +export type FieldProps = Omit< + ComponentProps, + "className" +> & { + label: ReactNode; + description?: ReactNode; + error?: ReactNode; +}; + +/** Base UI connects the label, description, error and contained control. */ +export function Field({ + label, + description, + error, + children, + invalid, + ...props +}: FieldProps) { + return ( + + {label} + {children} + {description && ( + + {description} + + )} + {error && ( + + {error} + + )} + + ); +} diff --git a/src/shared/design-system/ui/Input.tsx b/src/shared/design-system/ui/Input.tsx new file mode 100644 index 00000000..c91baf5b --- /dev/null +++ b/src/shared/design-system/ui/Input.tsx @@ -0,0 +1,7 @@ +import { Input as BaseInput } from "@base-ui/react/input"; +import type { ComponentProps } from "react"; + +export type InputProps = Omit, "className">; +export function Input(props: InputProps) { + return ; +} diff --git a/src/shared/design-system/ui/RadioGroup.tsx b/src/shared/design-system/ui/RadioGroup.tsx new file mode 100644 index 00000000..ce8bb035 --- /dev/null +++ b/src/shared/design-system/ui/RadioGroup.tsx @@ -0,0 +1,62 @@ +import { Field as BaseField } from "@base-ui/react/field"; +import { RadioGroup as BaseRadioGroup } from "@base-ui/react/radio-group"; +import { Radio as BaseRadio } from "@base-ui/react/radio"; +import { useId, type ComponentProps, type ReactNode } from "react"; + +/** Compose inside Field so each Radio has its own labelled Field.Item. */ +export function RadioGroup({ + children, + ...props +}: Omit, "className">) { + return ( + + {children} + + ); +} + +export function Radio({ + label, + description, + variant = "default", + id, + ...props +}: Omit, "className" | "children"> & { + label: ReactNode; + description?: ReactNode; + variant?: "default" | "card"; +}) { + const generatedId = useId(); + const controlId = id ?? generatedId; + const descriptionId = `${controlId}-description`; + return ( + + + + ); +} diff --git a/src/shared/design-system/ui/SearchField.tsx b/src/shared/design-system/ui/SearchField.tsx index 9484ae6a..34825301 100644 --- a/src/shared/design-system/ui/SearchField.tsx +++ b/src/shared/design-system/ui/SearchField.tsx @@ -1,6 +1,7 @@ import { Field } from "@base-ui/react/field"; import { Input } from "@base-ui/react/input"; import { IconSearch, IconX } from "@tabler/icons-react"; +import { useRef, type ComponentProps, type Ref } from "react"; import { IconButton } from "./IconButton"; export function SearchField({ @@ -9,19 +10,32 @@ export function SearchField({ label = "Search", placeholder = "Search", variant = "default", + inputRef, + ...inputProps }: { + inputRef?: Ref; value: string; onValueChange: (value: string) => void; label?: string; placeholder?: string; /** Navigator search uses the panel's broad corner to echo its enclosing surface. */ variant?: "default" | "navigator"; -}) { +} & Omit< + ComponentProps, + "value" | "onValueChange" | "className" | "ref" | "render" | "type" +>) { + const localRef = useRef(null); return ( {label} diff --git a/src/shared/design-system/ui/Textarea.tsx b/src/shared/design-system/ui/Textarea.tsx new file mode 100644 index 00000000..a3973aad --- /dev/null +++ b/src/shared/design-system/ui/Textarea.tsx @@ -0,0 +1,14 @@ +import { Field as BaseField } from "@base-ui/react/field"; +import type { ComponentProps } from "react"; + +export type TextareaProps = Omit, "className">; +/** Field.Control supplies the same label/validation behavior as Input. */ +export function Textarea(props: TextareaProps) { + return ( + } + data-buzz-ui="" + className="buzz-textarea" + /> + ); +} diff --git a/src/shared/design-system/ui/controls.test.tsx b/src/shared/design-system/ui/controls.test.tsx new file mode 100644 index 00000000..60beff0b --- /dev/null +++ b/src/shared/design-system/ui/controls.test.tsx @@ -0,0 +1,161 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { afterEach, expect, test, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { createRef, useState } from "react"; +import { Button } from "./Button"; +import { Field } from "./Field"; +import { Input } from "./Input"; +import { Textarea } from "./Textarea"; +import { Radio, RadioGroup } from "./RadioGroup"; +import { Checkbox } from "./Checkbox"; +import { SearchField } from "./SearchField"; + +afterEach(cleanup); + +test("a loading action keeps its name and blocks pointer, keyboard and form submission until released", async () => { + const user = userEvent.setup(); + const action = vi.fn(); + const submit = vi.fn((event) => event.preventDefault()); + const view = render( +
+ +
, + ); + const button = screen.getByRole("button", { name: "Save changes" }); + expect(button).toHaveAttribute("aria-busy", "true"); + await user.click(button); + button.focus(); + await user.keyboard("{Enter} "); + expect(action).not.toHaveBeenCalled(); + expect(submit).not.toHaveBeenCalled(); + view.rerender( +
+ +
, + ); + await user.click(button); + expect(action).toHaveBeenCalledTimes(1); + expect(submit).toHaveBeenCalledTimes(1); +}); + +test("loading non-native actions prevent activation and navigation", async () => { + const action = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + const link = screen.getByRole("button", { name: "Open note" }); + let prevented = false; + const observe = (event: MouseEvent) => { + prevented = event.defaultPrevented; + }; + document.addEventListener("click", observe); + try { + await user.click(link); + } finally { + document.removeEventListener("click", observe); + } + expect(action).not.toHaveBeenCalled(); + expect(prevented).toBe(true); +}); + +test("fields connect labels, help and errors and keep textarea edits controlled", async () => { + const user = userEvent.setup(); + function Example() { + const [value, setValue] = useState("Draft"); + return ( + <> + + + + +