Skip to content

feat(desktop-new): prototype channel-backed Sessions client - #7366

Closed
morgmart wants to merge 32 commits into
mainfrom
morgan/desktop-new
Closed

feat(desktop-new): prototype channel-backed Sessions client#7366
morgmart wants to merge 32 commits into
mainfrom
morgan/desktop-new

Conversation

@morgmart

@morgmart morgmart commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Introduces desktop-new, a from-scratch Buzz desktop client experiment focused on channel-backed Sessions and a design-system-first interface.

  • adds a real relay-backed Channels and Sessions vertical slice with recoverable first-send creation
  • adds durable parent-channel Session links and inherited human access across query, count, live delivery, and writes
  • adds a minimal managed-agent runtime flow and owner-visible inline activity
  • brings in Dockview as the workspace geometry layer
  • renames the experimental client and branch from desktop-next to desktop-new
  • adds the semantic colour/type foundations plus forming spacing, radius, and motion tokens
  • adds Base UI-backed shared components with Astryx-style live specimen pages at /design/components
  • uses Tabler Icons for product UI

Status

Draft. The backend/runtime vertical slice works, but the current product shell is not the intended final visual direction. The next step is to rebuild the populated Messages surface from the shared components, using Cynthia’s cc-experiment prototype as the executable visual reference and Figma node 185:9217 as the parity target.

Composer work is intentionally deferred until its inline-token/chip editing contract is designed.

Validation

  • repository pre-push suite: all lanes passed
  • Rust tests and desktop Tauri checks passed
  • desktop-new TypeScript, Biome, type-system, and APCA contrast checks passed
  • desktop-new unit and Playwright flows passed
  • production frontend build passed
  • file-size gate passed

Known follow-ups

  • replace the current generic product shell with the component-led Cynthia/Figma composition
  • design the composer and inline chips before implementation
  • add stronger production relay fault-path coverage for Session recovery and authorization
  • paginate Session discovery beyond the current bounded first page
  • split the large frontend bundle

…design system route

Starts a second desktop client rather than reworking the existing one, so the
design system can be built from scratch without unpicking 476 raw colour usages
and 72 hover values first. The existing desktop/ client is untouched.

What lands:

- The colour system in three layers: private ramps hold values, public roles
  hold meanings, components only ever use roles. Tailwind's default palette is
  deleted with --color-*: initial, so a raw palette class is a build error.
- A machine-readable token registry, so the design system documentation renders
  from the system itself and cannot drift from it.
- /design — foundations, vocabulary, and the growth procedure, with a live
  light/dark toggle. Typography, spacing, radius, and motion are stubs that
  state what is still to decide rather than pretending to a system.
- DESIGN.md for the judgement tokens cannot express, and AGENTS.md for the rules
  an agent needs before writing any UI.

Accent is a slot rather than a colour: nothing above the ramp knows the hue, so
it can change, become a preference, or vary per theme without touching a
component. Every dark value is authored rather than observed, because the design
exploration it derives from is light-only.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
The design system site rendered black borders and dividers while the tokens
said `#d4d4d4`. The border roles were declared in layer 2 and never registered
with Tailwind, so `border-primary` silently resolved to `--color-primary` — the
*text* colour — and every hairline drew at text contrast. Tailwind's `--color-*`
is a single namespace and text/borders share the emphasis names while holding
different values, so borders now register as `--border-color-*`.

Add typography as the second foundation, same three layers as colour: a nine-step
size ramp plus leading, tracking, and weight ramps in layer 1; ten public roles
in layer 2, each carrying its whole setting via Tailwind v4's
`--text-<name>--<property>` convention, so `text-body` alone produces correctly
set text and two supposedly identical labels cannot drift apart. Inter Variable
and JetBrains Mono, both already shipped in every current Buzz client. Every
size derives from one virtual rem, so keyboard zoom and the font-size preference
work by construction.

Soften the documentation surface to match where the product is going: regions
separated by soft fills rather than outlines, hairlines only for genuine
boundaries and never above `border-secondary`, no page-wide gradient behind
reading columns, and no all-caps labels anywhere.

The `/design/typography` page renders from the registry, so it cannot drift from
the system. Add `scripts/check-type.mjs` enforcing the four rules a token cannot
express — no arbitrary sizes (px or rem), no `uppercase`, no hand-applied
tracking, no size role paired with a weight or leading utility.

Also collapse the nav to a wrapped row below `lg`; the fixed 256px column
overflowed the viewport under 420px.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
A swatch on a grey fill is being judged against a surface the product will never
reproduce, and the dividers between swatches were redundant — each colour already
separates itself. Same for type specimens: a box around a size sample changes the
contrast you are evaluating it in.

Roles and specimens now sit directly on the page, separated by space alone. The
distinction is between two list shapes rather than a preference: `Rows` keeps
dividers for uniform rows where the eye needs a line to track along (the size and
tracking ramps, the audit table); `Specimens` has neither container nor divider
for entries that carry their own visible difference.

The swatch keeps its hairline. `bg-panel` is white on a white page, so without it
the most-used role in the system renders as nothing — that is a genuine boundary,
not decoration, and it is what makes the no-container treatment safe in both modes.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
* origin/main:
  chore(release): release Buzz Desktop version 0.5.21 (#7301)
  fix(scripts): copy global-agent-config.json in buzz-adopt-prod-agents (#7303)
  feat(relay): add early startup lifecycle logs (#7258)
  docs(nip-fi): add Blossom kind-24242 media possession-proof exception (#7278)
  fix(desktop): wrap message tables within the available pane (#7279)
  🤖 fix(desktop): harden smoke E2E tests against Bestie overlay and toast timing (#7270)
  Show status and huddle indicators beside names (#7112)
  Add mobile voice notes (#7121)
  perf(desktop): publish mention sends before waking agents (#7154)

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
…d-text roles

The colour page explains the system but never lists the tokens themselves, so
there is nowhere to answer "what does bg-inset actually paint". Add
/design/color/table: every token in one table — the name you type, the base
token it resolves through, and the value it paints.

The values are read from the live CSS rather than typed into the registry. A hex
column written by hand is a second source of truth for the same fact: edit a ramp
and the documentation is silently wrong. This needs two different reads, because
neither answers both questions — getComputedStyle returns the resolved value
(#ffffff) and has already lost the indirection, while walking document.styleSheets
finds the declared value (var(--neutral-1)), which is the only place the chain
still exists. So CSSOM for what a token points at, computed style for what colour
that is. It re-resolves on scheme change, which is visible in dark mode where
bg-panel points at neutral 3 rather than neutral 1.

The table immediately found a real defect. The registry has documented
text-on-danger, text-on-success, text-on-warning, and text-on-info since the
status groups were generated, but tokens.css never declared them — all four
resolved to nothing, so text on a danger button fell back to inherited near-black
on red. Declare them and register them with Tailwind. text-on-warning is
neutral-12, not white: the warning fill is amber (#eab308) and white on it fails
contrast.

Also spell it "Color" in the nav, per the product's US English. The route is now
/design/color, with the table nested under it. The file is color_.table.tsx —
the trailing underscore keeps the URL while opting out of nesting, because
otherwise /design/color becomes a layout route whose component must render an
Outlet, and ColourPage does not, so the parent page rendered in place of the
table. Component and type names still say Colour; renaming those is a separate
change from what the interface says.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
The value column had been holding a single unbroken gradient literal, which under
the default `auto` layout claimed most of the table's width and pushed the other
two columns into a scroll. Switch to `table-fixed` with an explicit colgroup so
the thirds hold regardless of content, and let cells wrap instead of scrolling —
the horizontal scroll container is gone.

The value uses `break-all` rather than `break-words`: a gradient is one token with
no spaces to break at, so word-boundary wrapping still overflows. The swatch stays
pinned to the first line while the value wraps beside it, so the kind label moves
under the value rather than competing with it for the same line.

Verified equal columns and zero overflowing cells at 380, 480, 700, 1024, and
1280px.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
…gible

The default accent was #3b82f6, and white label text on it measures WCAG 2
3.68:1 — below the 4.5:1 AA minimum for normal text. That applied to every
primary button and active sidebar row in a fresh install, not to a setting
someone had opted into.

Move it to #2563eb, one step deeper: WCAG 5.17:1 and APCA Lc 80, so it now
passes both standards. Radix, Bootstrap, and Tailwind's blue-600 all sit in this
range, which is where a blue has to be to carry white text.

Darkening the fill is the fix rather than switching the label to black. Black on
the old hue scores WCAG 5.71 and would "pass", but measures APCA Lc 40 —
perceptually unreadable, and visibly worse. This is the documented WCAG 2 flaw
on saturated blue: the formula underweights the blue channel and ignores
polarity. Apple ships white on #0088ff–#3daefc in Messages at WCAG 2.4–3.5 for
the same reason. DESIGN.md now records the position (judge with APCA, report
WCAG, constrain the fill rather than degrade the text) so it is a decision with
evidence rather than a preference.

Retiring a hex is not only cosmetic: it leaves the old value in people's
storage, where it is no longer in ACCENT_COLORS and therefore fails the
community-theme allowlist — parseCommunityThemePreference would return null and
discard the user's syntax theme and follow-system flag along with the accent.
Both storage paths now migrate the retired value instead: the community
preference maps it on parse, and the local accent upgrades on read (all three
read sites go through one helper). A regression test covers the migration and
asserts an arbitrary hex is still rejected, so the migration cannot become a
general escape hatch; removing the mapping fails that test.

The communityThemeSync fixture moves to a current accent — it covers coordinate
logic, and a retired hex there would be rewritten mid-assertion for reasons
unrelated to what it tests.

Verified: 6112 desktop tests pass, tsc clean, biome clean.
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Three fixes, and a guard so they cannot regress silently.

`text-on-warning` pointed at `var(--neutral-12)`, which is near-black in light
mode and near-white in dark. The warning fill is the same amber in both, so dark
mode put #f5f5f5 on #eab308: APCA Lc 34, WCAG 1.76:1. It is a literal now — a
paired-text token follows its fill, never the mode.

The dark neutral text steps were too dim. `text-secondary` (#8f8f8f) measured
Lc 38–41 and `text-tertiary` (#6e6e6e) Lc 23–26 against an Lc 60 body target,
while passing WCAG AA at 4.4–5.6:1 — legal, and visibly murky. Raise dark
neutral-9 to #a6a6a6 and neutral-10 to #c1c1c1, sized against bg-float
(#2a2a2a), the lightest dark surface and so the binding case. Light neutral-9
moves #a3a3a3 -> #909090 for the same reason at the meta target. Both ramps stay
monotonic, and bg-inverse-hover (the other consumer of neutral-10) still carries
its paired text at Lc 70.

This is APCA's polarity asymmetry doing real work: light-on-dark needs more
separation than the same WCAG ratio implies, so the dark ramp's text steps sit
above where mirroring the light ramp would put them.

`text-disabled` stays below target deliberately — low contrast is the signal
that a control is unavailable, and WCAG exempts inactive controls for the same
reason. Raising it would make disabled read as enabled. Recorded as the one
exception, with that reason.

The guard is the durable part. `scripts/check-contrast.mjs` measures every text
role against every surface it can sit on, in both modes, and fails the build
below target. It parses `tokens.css` and resolves each `var()` chain rather than
holding a copied list of values, so it cannot drift from the system it audits.
`scripts/apca.mjs` is the algorithm, asserted against the published reference
values on every run so a bad edit to the maths fails loudly rather than quietly
shifting every verdict. Verified by falsification: reverting either fix fails
the check, and perturbing one APCA exponent trips the reference assertion.

Also corrects an earlier misreading: the two `.dark` blocks are not duplicate
declarations. The first holds the dark ramp, the second overrides surface roles
so the panel lifts off pure black. That is the intended layering.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
…en the audit

A second pass over the colour system, checking the surfaces the first audit did
not cover.

The gap was tint hovers. DESIGN.md says a tint carries meaning and takes coloured
text, but the audit only measured text against the four neutral surfaces — so
`--bg-*-tint-hover`, the hardest surface in each coloured family, was never
checked. Four pairings were below target: light danger (Lc 56.6) and warning
(59.2), and dark accent and info (58.4 each). All four passed WCAG AA, which is
why nothing else caught them.

Size step 11 against the tint hover rather than the neutral panel, the same rule
already applied to the neutral ramp: light danger-11 #b91c1c -> #a80008, light
warning-11 #a16207 -> #9c5e00, dark accent-11 and info-11 #8fbaff -> #95c0ff.
Minimum moves in OKLCH holding hue and chroma, so the hues do not shift.

The audit now covers every tint and tint hover in all five families. Verified by
falsification: reverting any one of the four fails the check.

Two findings that are deliberately not changes:

Hairline dividers stay at 1.2-1.8:1. WCAG's 3:1 non-text rule covers boundaries
needed to identify a control or its state, not grouping lines; Radix ships 1.29
and Apple 1.26 for the same job. Raising them would draw the box the fill already
implies. Recorded in DESIGN.md, along with the note that a control's own outline
is a different question when the input layer lands.

`--ring-focus` is an accent against accent: legible on every neutral surface at
3.0-3.5:1, but 1.1:1 on `bg-accent`, where it vanishes. Not yet reachable — the
token is unregistered and unused — so the constraint is documented at the token
instead of being solved speculatively: the component layer that introduces focus
owns a surface-coloured offset, and there is no second ring token.

Glass surfaces were checked by compositing each translucent value over the app
gradient's own stops; all clear the target in both modes, which the relit
neutral ramp is what earns.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
…essions

* origin/main:
  Add generic information-flow control core (#7293)
  feat(buzz-acp): update base prompt; add buzz context and skills to Pi agents (#7335)
  fix(desktop): restore mention chip identity icons (#7338)
  Persist video playback speed preference (#7336)
  Verify ACP relay events before prompt routing (#7010)
  fix(buzz-acp): bound busy-owner hold to prevent cross-channel starvation (#7337)
  feat(desktop): invite owned agents from standalone forums (#7125)
  fix(desktop): authorize remote mentions at publication (#7124)
  fix(acp): rename system tag to agent-instructions (#7332)
  fix(desktop): bind duplicate mention selections to exact recipients (#7133)
  refactor(relay): extract NIP-29 membership authorization (#7285)
  chore(release): release Buzz Desktop version 0.5.22 (#7308)
  feat(desktop): preserve mentions across copy and paste (#7228)
  test(desktop): await Bestie drag and profile hover endpoints (#7294)
  Collapse contiguous join messages (#7262)

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

🔐 Codex Security Review

Status: review required for the current range.

The current range is 3c7f288c60d67df78577b237e27c3dfc8831aaa1...d34c320fd211f77aed28f9439138965dd198dbc4.
A new review must complete for this exact range. When manual authorization
is required, a Block organization member must comment exactly
@buzz-security-review d34c320fd211f77aed28f9439138965dd198dbc4 to authorize a new review.
Any previous review applies only to its recorded range.

Answers the blocking question in the Desktop New plan before the composer is
designed: does staged character input (ja/ko/zh) survive beside an inline atom
tile? It does. Twelve tests, Chromium composition driven through CDP
Input.imeSetComposition plus a WebKit pass.

Measured findings:
- Composition beside a tile is safe in every position; a committed composition
  leaves the tile address byte-identical. No fence needed.
- selectable:false is load-bearing. With selectable:true an arrow key selects
  the tile and the next keystroke replaces it.
- atom:true is redundant for a contentless node (isAtom = isLeaf || spec.atom)
  and kept as documentation of intent.
- Whole-tile deletion works natively; no custom Backspace handler required.
- One real gap: the caret position before a leading tile. ArrowLeft reaches it,
  Home and a pointer click do not, in both engines. Those two tests carry
  test.fail() until the caret-boundary layer lands.

The harness is temporary and deleted when the real composer lands.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Implements the foundation the composer sits on: a reference is an address
(kind + identity), never the letters of a name. One shared InlineTile serves
both the composer and the conversation, mounted in the editor through a React
node view.

- shared/tiles/address.ts: kind + id, canonical buzz:// form, parse/format.
- shared/tiles/faceResolver.ts: resolves the displayed face from an address at
  render time, so a rename never edits anyone's draft or enters undo history.
  Community-scoped cache with an explicit reset.
- shared/ui/InlineTile.tsx: the one component, person/agent/channel kinds.
- features/composer/tileNode.ts: the document node. Stores only the address.
- New semantic tokens: --space-tile-inset, --space-tile-gap, --radius-tile.
- Specimen page at /design/components/inline-tile covering kinds, in-sentence
  use, read-only, unresolved, truncation, and activation.

Findings while building, all now covered by tests:
- A React node view REPAIRS a caret boundary. With static markup, Home before a
  leading tile landed after it; with the node view both Home and ArrowLeft land
  correctly. Only the pointer click on a leading tile's left edge still fails —
  now the single known caret gap, still marked test.fail().
- useSyncExternalStore needs a reference-stable snapshot. Building a fresh
  unresolved face per call was an infinite render loop that unmounted the whole
  editor. Unresolved faces are cached like resolved ones.
- An unresolved tile must not announce an abbreviated identity to a screen
  reader. TileFace now carries , and the accessible name says
  "Unresolved person" rather than reading the stand-in aloud.

Falsifiability checked by breaking each protection in turn: selectable:false,
the address text projection, and the community reset each turn tests red.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Accent hue is now Tailwind purple. The ramp is a slot, so this is five values
in tokens.css and every role follows; steps 2 and 3 are purple-100/200 exactly
because the chip design was drawn against them and the tint pair is what a
person sees most. Dark mode cannot use those two — near-white reads as a light
surface on a dark panel — so it holds the hue at dark violets instead.

Renamed InlineTile to InlineChip throughout, plus shared/tiles to shared/chips,
tileNode to chipNode, and every token, class, and identifier. A chip is what
this is.

Redesigned to match Figma 700-2782:
- One treatment for every kind: accent tint behind primary text. Kinds are told
  apart by sigil and, where the label is ambiguous, an icon — not by colour.
  This replaces the earlier per-kind colour split, which spent palette on a
  distinction the sigil already makes.
- Label is primary text, not accent text; the tint does the colour work.
- Two new kinds the design draws: message and link, both icon-led with no
  sigil. AUTHORABLE_CHIP_KINDS records that v1's picker still offers only
  person, agent, and channel; the others arrive by another route.
- Dropped avatar and status dot. The design has neither, and neither was
  earning its place at text size.
- Unresolved is now a real state: neutral fill, lesser text, and never a
  control, because there is nothing to open.
- Chip inset and gap intentionally sit below the 4px spacing floor (3px inset,
  4px gap) — a chip hugs its label. Recorded on the tokens.

Eight new rendered-contract tests bound to the real specimen page: sigils and
selective icons, assistive naming, unresolved-is-never-a-control, read-only
claims no stop, activation reports the address, truncation, never breaking
across a line, and hover deepening the tint without moving the text.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
The colour system had three layers, and the middle one held two different kinds
of thing under one name. --neutral-1..12 is a real scale; --accent-2 and
--danger-9 are five specific jobs (tint, tint-hover, border, fill, text) wearing
ramp-style numbering. So --accent-2 read as 'a light purple' when it meant 'the
surface an accent tint sits on'.

Underneath it was 114 hand-picked hex values with nothing enforcing that two
tokens doing the same job agreed. They drifted: --accent-2 and --tint-purple were
the same purple in light mode and two different purples in dark. I introduced
that yesterday and did not notice.

Four layers now:
  0 palette    --palette-purple-3   every hue, 12 steps, authored per mode
  1 families   --accent-tint        the five jobs a hue does, named not numbered
  2 roles      --bg-accent-tint     what a thing is in the interface
  3 components bg-accent-tint       Tailwind utilities

Dark stops being freehand. A family points at a palette step and .dark redefines
the whole palette, so 50 hand-picked dark values are simply gone.

This also answers a concrete trap. Tailwind's purple gets MORE saturated as it
descends (950 is 87% saturation), so a dark tint drawn from its bottom end reads
as oversaturated, and the only way to reach a muted deep purple was to write
purple-950/50 in a component. That value is correct — it composites to within a
few percent of the palette step now in use — but as an expression it has no name,
no light/dark pair, and nothing the contrast guard can measure. The palette's
dark ramps are authored for dark surfaces, so it is a step you pick.

New scripts/check-color.mjs enforces it: rejects opacity modifiers on colour
utilities, color-mix() and alpha rgb()/hsl() in component code, components
reaching past the role layer, and the layering itself — palette steps must be
literals, family steps must reference the palette, two tokens doing one job must
resolve to one step, and dark must not restate families. Falsified by
reintroducing the original drift and each other failure mode in turn.

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 documented divergences, both found by the contrast guard: family
text takes step 12 rather than the 11 Radix names 'low-contrast text' (Radix
sizes 11 for WCAG 4.5:1; every hue's 11 measured Lc 55-61 against our Lc 60
target), and dark neutral surfaces stay hand-authored because they were sized
against the real panel stack.

The colour page now renders the palette, so picking a step is visual.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
…oard

Two changes, both about finding out whether the colour system actually works.

Deleted the six categorical tint roles. Evidence: zero component usage — not one
reference outside the token file and the registry. They were hand-picked literals
of exactly the kind the palette exists to prevent, and the open question 'how
many categorical colours does the product need, and does a person choose one or
does the product assign it' is unanswered, so naming them now was guessing. The
eight palette hues stay; a categorical role gets named when a feature needs to
distinguish things.

That deletion turned a guard check vacuous, which is its own finding: the drift
check named four specific pairs (--tint-purple vs --accent-tint, etc.) and would
have passed silently forever once those tokens were gone. Replaced with the
general rule — no two family steps may resolve to the same palette step — which
catches the same class for any future family without naming pairs. Falsified by
pointing --danger-tint at the purple ramp.

Added /proving-ground: a fictional fleet-monitoring dashboard, deliberately
nothing to do with Buzz. Specimen pages are honest but sympathetic — one
component at a time, on a chosen backdrop, at a chosen size. A dashboard puts
forty decisions next to each other, which is where a missing role shows up. It
is also the surface a generated theme has to be judged on: 'Neon Tokyo' can look
fine as twelve swatches and be unusable as a page.

Built only from role tokens, and it held: no new roles were needed, contrast
passes both modes, and the layout works at 1440 and 620. Page-local parts live
in the feature rather than shared/ui on purpose — this page exists to find out
what repeats, so promoting its pieces first would be guessing.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
A card sits on the panel and is grouped by a hairline. Fill is reserved
for bg-hover, and only where the card is clickable, so a filled card
always means you are pointing at this rather than merely this is a box.

The region tiles were using bg-inset, which means pushed in - an input
or a code block - the opposite gesture from grouping, and read as a grid
of empty text fields inside a filled panel. No new role is needed: a
card with no default fill has nothing to name.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
A local Vite config can wrap the committed one to append a personal
dev-only plugin. Ignoring it makes that structurally uncommittable
rather than something to remember not to stage.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
It was meant to stress the design system by putting many decisions next
to each other. It did surface one real finding - the neutral hover step
is tuned for hovering a filled component, not a bare row, so the same
token is too weak for a quiet button and too strong for a bare row - but
the page was ~85% bespoke CSS routed around the shared components, so
most of what it showed was its own scaffolding rather than the system.

The app itself is the better proving ground. Removing this rather than
carrying a mockup whose defects have to be triaged separately from the
product's.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Registers all eight palette ramps as Tailwind classes, so a screen writes
`bg-purple-9` or `text-red-12` directly. This reverses the rule the colour
guard used to enforce, and the reversal turns on one fact: every step in this
palette is authored per mode, so `neutral-4` is one value in light and another
in dark and a component naming it 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.

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.

The fifteen that remain each earn the name: the four structural surfaces take a
different step per mode, so no class can express them; the emphasis roles enforce
a rule a ramp cannot state, which is that there are three levels of text and one
border weight; the glass materials are a bundled treatment; and
`--text-on-accent` follows its fill rather than the mode.

Also fixes a namespace collision that had shipped a real defect. `--color-x` is
not one utility, it is all of them — one declaration defines `bg-x`, `text-x`,
`border-x` and `ring-x` from a single value — so `text-danger` resolved to the
saturated fill (red-9) instead of the text step. Three error messages rendered
at APCA Lc 34 on a dark panel against a target of 60, and `check-contrast`
passed the whole time because it measured `--text-danger`, a token no class
could reach. Text roles now register as `--text-color-*`, matching the
`--border-color-*` split that fixed the same bug for borders.

Guards updated to enforce the new rule rather than the old one: `check-color`
now rejects a role whose two modes agree and names the class to write instead,
and `check-contrast` measures the ramp steps screens actually use, so deleting a
role cannot leave it auditing a name nobody types. `registry.test.ts` binds the
documented token list to the stylesheet, and `pnpm census` reports every role's
readers without failing a build.

Signed-off-by: Morgan Martin <morganmartin@squareup.com>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
A glass surface is four things at once — a translucent fill, a blur behind it, a
directional rim, and sometimes an outer lift — and they were four separate
things a consumer assembled by hand. Every consumer did, differently. The four
chrome surfaces meant to match disagreed about whether to carry an outer shadow,
and four inner panels set a translucent fill with no blur at all.

`glass-primary` and `glass-secondary` are Tailwind `@utility` blocks carrying the
whole material, plus `-interactive` variants that add the hover. Named by
stacking depth rather than by component: an earlier cut named them chrome /
panel / float and it was wrong twice over, because `chrome` and `float` both
landed on glass-3 — 66% opaque in dark, which is why chrome did not read as
glass — and naming a material after a component invites a second material the
first time a component wants a different depth.

`box-shadow` is composed inside each material because it has to be: a rim and a
lift both set it, so as separate utilities the second silently overwrites the
first. That is a structural argument for bundling, not a preference. The fills
are deliberately not registered as colour utilities, so a bare `bg-glass-primary`
class cannot exist.

Removing a redundant fill from the four workspace panels fixed something no
token held. They each set the same `glass-2` as the container they exactly cover
— measured 610x551 inside 612x553, the two pixels being its border — and two
identical translucent layers composite: 0.52 over 0.52 is 0.77, between glass-4
and glass-5. Panels meant to be the most translucent surface in the system read
as nearly solid.

That in turn exposed a bug the density had been masking since the dock landed:
dockview's theme sets `--dv-group-view-background-color` to a literal `white`
and `DockWorkspace` passes `themeLight` in both modes, so an opaque white sheet
sat behind the glass. Reset here, because every other dockview surface the
variable feeds was already overridden to transparent.

Signed-off-by: Morgan Martin <morganmartin@squareup.com>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
The old name described one variant's appearance — a segmented control — which is
exactly why a second appearance felt like it needed a second component. `Tabs`
names the behaviour, so both variants sit under it naturally. Base UI's own
export is also `Tabs`, so it is imported as `BaseTabs`; that reads better anyway,
since `BaseTabs.Root` says plainly which layer each part comes from.

`variant="chrome" | "panel"` is the chrome/surface axis DESIGN.md had deferred
until a second component made the case. `IconButton` made it once; this made it
twice, on a real screen. The failure: 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. Retinting `--bg-chrome-selected` would only have
moved the collision.

One component with a variant rather than two components, because behaviour,
keyboard model, accessibility, props, and the Base UI parts underneath are
identical — a sibling 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.
The panel variant still emits `role="tablist"`, correct `aria-selected`, roving
`tabIndex`, and arrow-key navigation, which is the concrete payoff.

Each variant states all three of its states in full rather than inheriting a
colour and overriding part of it. When a shared rule and a variant rule both set
a property, the more specific selector wins regardless of source order, and a
variant rule is always more specific — so the shared `[data-selected]` rule lost
and the selected tab silently rendered as unselected. Hover carries
`:not([data-selected])` because hover and selected genuinely overlap: a cursor
can sit on the selected tab, and DESIGN.md § State says a selected item in a
toggle group is not interactive. Those two selectors score identical specificity,
so without the exclusion only source order would keep it right. This follows
Chrome's modern CSS guidance: use `:not()` to say a state is irrelevant, never to
defend one rule from another.

`WorkspaceSurface` is renamed to `Panel` in the same change, since both touch the
same rules in components.css.

Signed-off-by: Morgan Martin <morganmartin@squareup.com>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
`ComponentAnatomy` is a table per component variant: each part, and the token
that draws its fill, text, border, shadow, blur, and radius. It answers the one
question a specimen cannot, because a picture of a control does not say whether
that pill is `bg-chrome-selected` or a hardcoded white.

It reads the live component rather than a hand-written list, for the same reason
the token table does — a typed-out list of parts and tokens is a second source of
truth that goes stale silently. Two things this needed that were not obvious:
Vite injects app CSS by setting `textContent` on a `<style>` tag at dev time and
those rules are not reachable through `sheet.cssRules`, so the stylesheet text is
parsed instead; and matching declarations are ranked by specificity before source
order, because ranking by order alone reported a selected tab as unselected once
a variant rule outranked a shared one. Both failures rendered a plausible table
that was wrong, which is the failure mode it exists to prevent.

Every specimen frame now carries a border. It was `bg-panel` on a page that is
also `bg-panel` — white on white in light mode, `#1c1c1c` on `#1c1c1c` in dark,
with no border and no shadow — so every plain specimen on every component page
had an invisible container and a reader could not tell where the documentation
stopped and the running component began. It hid because the gradient frames
always read as containers, so the pages using them looked correct. A hairline
rather than a tinted fill, because a specimen must show the component against the
surface it is designed for. Measured at `neutral-4` first: 1.23:1 against the
light page and 1.08:1 against the dark one — technically drawn, effectively
invisible. `neutral-6` is 1.41 and 1.35. The overview thumbnails had the same
defect and take the same fix.

The colour table splits into Roles and Ramps, because the ramps — 96 steps across
eight hues, the layer every role resolves to — were not shown anywhere. Blur moves
off the glass page onto elevation, where the other depth values live. The glass
page drops its text-only cards and shows each material over the real app
backdrop, since translucency can only be judged against what shows through it.

Frames scroll their own overflow: chrome tabs with icons and a trailing action
are 377px of unshrinkable inline-flex, so at 420px the whole page used to scroll
sideways. A specimen you swipe beats a documentation page that slides.

Signed-off-by: Morgan Martin <morganmartin@squareup.com>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
AGENTS.md held two stale copies of the colour system — one section said two
layers, another said three and listed an `--accent-1…12` ramp that only exists in
a comment explaining its removal. Documenting one subject twice in one file is how
that happened, so the duplicates are deleted rather than reconciled: DESIGN.md
holds what to do and why, AGENTS.md holds what this is, how to run it, and what
will fail you.

DESIGN.md gains the colour structure it was missing, rewritten around the reversal
in this branch — build from the ramps, and a role exists only when a step cannot
express it. The three cases where a name is earned are stated explicitly, since
`check-color` now enforces two of them. The step-to-role table survives as
guidance for which step to reach for; it is no longer a generator, which is what
it was when it produced twenty status roles nobody had designed.

Both files also record what is deliberately unresolved: contrast on glass, where
the guard measures opaque surfaces only and a translucent surface's real
background is the fill composited over a gradient that changes across the screen.
Measured, dark mode's brightest glass region puts `text-secondary` at Lc 58 and
`text-tertiary` at Lc 43 against targets of 60 and 45. Three candidate fixes are
listed and none is chosen, because it wants real content on screen rather than a
token edit.

Signed-off-by: Morgan Martin <morganmartin@squareup.com>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
`text-danger` was the role that resolved to the wrong red; error text is
`text-red-12`, measured against every surface it lands on. `border-secondary` was
one of three indistinguishable border weights and is now the single
`border-primary`.

Signed-off-by: Morgan Martin <morganmartin@squareup.com>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
…t came from

Both label sets came with the transcribed values and described a generic scale's
intentions: step 4 as "component hover", step 6 as "subtle border", step 7 as
"border". The product then decided otherwise and nobody updated the comments, so
the file said step 4 was a hover step while it held `border-primary`, the one
border weight, and said steps 6 and 7 were for borders while they had no readers
at all. A designer opening `tokens.css` or `/design/color` was told the wrong
thing by the only documentation to hand.

The neutral ramp's comments now name the roles that actually point at each step,
including the two steps that have no reader — an empty step is worth seeing,
because it is either a gap or a deletion nobody made yet.

`STEP_JOBS` keeps generic wording, because it is shared by all eight hues and a
hue's step 3 is a tinted surface whatever the product does with it. But it now
matches DESIGN.md's step-to-role table exactly, which it did not, and it says so
about itself: general intention here, real use in the neutral ramp's comments.

The reason it drifted is that it was never rendered. Every palette step carried
this text and no page displayed it, so it could only be checked by reading the
source. The colour page now shows each step's job under its swatch, which makes
the next divergence visible by looking rather than by audit.

Signed-off-by: Morgan Martin <morganmartin@squareup.com>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Light mode keeps neutral-4; dark mode moves to neutral-6. A hairline needs more
separation on a dark surface than the same step number provides, and the old
single value was invisible where it mattered: measured against every surface a
border sits on, neutral-4 is 1.08:1 on a dark panel and 1.09:1 on the floating
composer. Drawn, and not perceptible. neutral-6 is 1.35 and 1.14.

Not neutral-5, which is one step and the obvious answer. `bg-float` and
`bg-chrome-selected` are both neutral-5 in dark, so a border on a floating surface
would have measured 1.00:1 — the same colour as the thing it bounds.

This is the asymmetry the role exists for. Light and dark do not have to reach for
the same step, and no `border-neutral-4` class could express it; it is the same
test the four structural surfaces pass.

The /design specimen frames drop their hardcoded `neutral-6` and read the role
again. They only named a step directly because the role was invisible in dark, so
documentation furniture and product boundaries had diverged for a reason that no
longer holds.

Signed-off-by: Morgan Martin <morganmartin@squareup.com>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
A shared role that fails in a real context is a defect in the shared decision,
not a reason to name a nearby ramp step locally. The specimen frames exposed
this with border-primary: neutral-4 was visible enough in light mode and 1.08:1
against a dark panel. The first response bypassed it with neutral-6 just for the
documentation. The correct response was to measure every reader, repair the role
to neutral-4 light / neutral-6 dark, and return every caller to the same role.

DESIGN.md now gives the complete decision order: use a component first; add a
variant if the background changes; choose a surface by its spatial job; use the
shared boundary role; choose ramp steps directly for a hue decision; add a
proposed role only when one step cannot express both modes; and stop and ask
rather than making a local exception. It also records why the border uses two
different steps and why neutral-5 could not be the one-step dark adjustment.

Signed-off-by: Morgan Martin <morganmartin@squareup.com>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
The vault's Colour Token System duplicated the token specification, documented a
families layer, three border weights, component-named glass, and nine obsolete
neutral values. It is deleted; all remaining Buzz Redesign vault links now point
to DESIGN.md as the sole visual-system authority.

DESIGN.md gains a practical path from context to token. Use a component first;
add a variant when the context changes; choose a surface by spatial job; use the
shared boundary role; choose public ramp steps for one-step hue decisions; make a
proposed role only when one step cannot express both modes; and stop rather than
creating a local exception. The border incident is retained as the worked rule:
when a shared role fails, repair the role and return every caller to it.

Also corrects two stale statements inside DESIGN.md itself: ramps and roles are
both public, not roles alone, and growing the system now starts with "write the
mode-aware ramp step directly" rather than the obsolete instruction to search the
role list first.

Signed-off-by: Morgan Martin <morganmartin@squareup.com>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
PanelHeader is a header row, not a panel. Its specimen now renders only its own
optional icon, title, and actions — no invented Panel around it — and its action
is the existing IconButton supplied through the `actions` slot rather than a raw
button recreated in the specimen.

Panel owns only a surface: fill, border, shadow, rounded silhouette, and clipping.
It owns neither a header nor content, so its standalone specimen is now an empty
Panel against the gradient it is designed to sit on. The fake header and copy
made the live component appear to own a complete work surface, which it does not.

The two-panel pseudo-workspace is deleted. Resizing and split geometry belong to
DockWorkspace, which owns live layout, resize seams, and persistence policy; a
Panel can sit in ordinary layout, a fixed composition, or a workspace and must
not pretend it owns any of them. Real resizing gets documented on DockWorkspace's
page when that component has a specimen with a running dock.

Signed-off-by: Morgan Martin <morganmartin@squareup.com>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
@morgmart morgmart closed this Sep 8, 2026
@morgmart
morgmart deleted the morgan/desktop-new branch September 8, 2026 03:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant