Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d448e51
Adopt shared actions, switches, avatars and icons
mahanti Sep 15, 2026
1b8708c
Preserve human and agent avatar shape variants
mahanti Sep 15, 2026
942cc3b
Use the Block UI typography ramp throughout shared tokens
mahanti Sep 15, 2026
fc09005
Map xsmall semantics to the shared 12px size
mahanti Sep 15, 2026
fc1d326
Describe the current design system and remove adoption comparison
mahanti Sep 15, 2026
9134602
Preserve decorative icon semantics and picker clear visibility
mahanti Sep 15, 2026
f9eeaa7
Assert shared picker icon semantics across GIF tabs
mahanti Sep 15, 2026
3c8aa79
Keep the final message fully visible after fractional reflow
mahanti Sep 15, 2026
294516d
Round Virtua end offsets before native scroll writes
mahanti Sep 15, 2026
866484a
Keep notification settings readable at enlarged text sizes
mahanti Sep 15, 2026
0e4b4d8
Avoid flex growth overflow in notification actions
mahanti Sep 15, 2026
f3c9be6
Define semantic colors for Block UI adoption
mahanti Sep 17, 2026
99b12ed
Merge main into shared-control adoption and preserve current UI behavior
mahanti Sep 21, 2026
622cd0b
Preserve avatar checks and contain large emoji after adoption
mahanti Sep 21, 2026
7dec23e
Refresh semantic foundations and address contrast review
mahanti Sep 21, 2026
2b309cf
Align viewer checks with semantic roles and public source
mahanti Sep 21, 2026
27dd5d0
Wait for channel menu lifecycle before testing focus return
mahanti Sep 21, 2026
959aae7
Merge main and preserve shared notification controls
mahanti Sep 21, 2026
a8deea4
Merge refreshed design-system base with presence and notification set…
mahanti Sep 21, 2026
400118a
Merge main into semantic color foundations
Sep 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 27 additions & 60 deletions docs/design-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,66 +32,33 @@ Native window decorations and pre-WebView launch color are **not** controlled by
CSS attribute. An attended packaged-app check is still needed before claiming native
chrome/relaunch parity; no broad Tauri capability or CSP expansion was added here.

## Tokens are the shared contract

`src/shared/styles/tokens.css` is the only built-in color palette. Both modes define
every paint role. `globals.css` exposes the roles through Tailwind 4, base elements,
and small shared component classes. CSS modules and Tailwind use the same values.

| Roles | Use |
| --- | --- |
| `--workspace`, `--shell-image`, `--shell-dot` | App canvas and decorative shell art |
| `--surface`, `--surface-elevated` | Card and dialog/popover surfaces |
| `--surface-accent`, `--surface-control`, `--surface-input`, `--surface-hover` | Subtle, control, input and hover fills |
| `--text`, `--text-muted` | Primary and secondary text; do not lower text opacity to simulate muted text |
| `--border`, `--border-input`, `--focus` | Decorative separators, visible input boundaries, keyboard focus |
| `--primary` / `--on-primary`, `--action` / `--on-action` | Primary controls and the lavender composer action |
| `--selected` / `--on-selected` | Selected controls; also expose selection semantically |
| `--link`, `--danger`, `--warning`, `--success` | Meaningful foregrounds; pair status fills intentionally and include readable copy |
| `--overlay`, `--elevation-*` | Backdrops and card/popover/dialog/dock shadows |
| `--radius-card`, `--radius-card-compact`, `--radius-control` | Shared curvature; Tailwind `rounded-3xl` / `rounded-xl` map to card/control |

Existing `ink`, `muted`, `line`, `soft`, `shell` utilities remain compatible. New
`surface`, `elevated`, `primary`, `on-primary`, `input-line`, `focus`, `overlay` and
status utilities avoid literal palette colors. **Legacy `--accent` is a foreground**;
do not reinterpret it as shadcn's accent background. If adding shadcn components,
map their paired roles explicitly. No shadcn/Radix dependency was needed for this
slice: native radios, buttons, fields and the existing dialogs supply the behavior.

Typography uses the shared Inter/system sans stack with Tailwind's existing type
scale: `text-sm` controls, `text-base` body/labels, `text-lg` section headings and
`text-3xl` page headings. Existing conversation type sizes remain unchanged at 100%. Spacing
uses Tailwind's 4px rhythm; preserve established responsive card gutters. Avoid
creating new scales for the same values. Motion is optional and respects reduced
motion; theme changes must not fade through the old mode's foreground/background.
Existing shell/panel layering stays local to its owner; native modal dialogs use
the browser top layer, not ever-increasing global z-index values.

## Shared UI rules

- `.ui-card` is the shared surface recipe; `.ui-choice` is a labeled native-radio
selection with hover, selected and focus-within states. The existing `.notice`,
`.error`, `.danger`, `.actions` and shell classes use the same palette.
- Defaults/hover/focus/disabled come from base rules. Domain-specific components own
pressed/busy/error behavior. Disabled controls must not act; busy guards are not
replaced by CSS. A selected style must agree with ARIA (`aria-current="page"`
requires `aria-[current=page]:`, not Tailwind's boolean `aria-current:` variant).
- Shared React components should be extracted for actual repeated behavior, not
empty wrappers around every native element. Do not migrate all dialogs merely to
add a component-library badge. Use an accessible headless primitive when the next
complex interaction warrants one, and test keyboard/focus behavior in context.
- Do not invert images. Media/brand art retains its colors. All host-owned surfaces,
including loading/recovery, must inherit the mode. CSS variables inherit into
portals and shadow hosts; third-party Shadow DOM/canvas widgets may additionally
require an explicit mode adapter. Theme mode changes must not reset widget input.
- `/tests/fixtures/design-system.html` now hosts the design system this app is
moving to, with its own components, tokens and documentation. It uses no relay
or identity services. The earlier offline diagnostic at that URL — one
Appearance section, some native controls, profile fields and a conversation
row — was replaced by it. The rules above still govern the styling that ships
today; verify those in the running app and its browser journeys. Surfaces move
onto the new system incrementally, and its documentation is the reference for
anything already on it.
## Tokens and shared controls

`src/shared/design-system/styles/tokens.css` owns the public palette and semantic
roles. `src/shared/styles/tokens.css` is a compatibility bridge for older callers,
not a second palette. The app imports one Tailwind reset and keeps its existing
appearance service, storage and startup ownership.

| Group | Examples | Purpose |
| --- | --- | --- |
| Surface | `--surface-base`, `--surface-panel`, `--surface-popover` | Page, card, popup |
| Text | `--text-standard`, `--text-subtle`, `--text-inverse`, `--text-danger` | Meaning and emphasis |
| Border | `--border-standard`, `--border-prominent`, `--border-focus` | Edges and keyboard focus |
| Affordance | `--affordance-prominent`, `--affordance-subtle`, `--affordance-danger` | Controls and actions |

Shared components consume these roles; features consume shared components.
Base UI owns focus, keyboard interaction, selection, portals and dismissal.
Buzz owns visual styles and product behavior. Build missing shared components
from Base UI rather than copying private components or wrapping another library.

Use complete type roles with Inter and JetBrains Mono. Do not import proprietary
fonts, private packages or internal business examples. Default, hover, pressed,
focus, selected, disabled and loading states are shared component decisions.
Loading must prevent repeated actions while preserving the label footprint.

The standalone design viewer imports the real shared controls without app startup,
identity or relay services. Check the actual app as well as specimens, in both
themes and at narrow, intermediate and wide widths with enlarged text.

## Future theme contributions (design boundary, not implemented API)

Expand Down
77 changes: 19 additions & 58 deletions scripts/design-system/check-color.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -316,66 +316,27 @@ function auditLayers() {
}
}

// 2. THE ROLES THAT REMAIN MUST BE MODE-ASYMMETRIC.
//
// This replaces four checks that audited the identity families — that each
// referenced a palette step, that none was missing, that no two shared a step,
// that dark never restated one. All four are gone because their subject is:
// nineteen roles were deleted once palette steps became reachable as classes,
// and the ones left are the four surfaces plus emphasis.
//
// The invariant now worth enforcing is the TEST FOR WHETHER A ROLE IS EARNED.
// A surface role exists precisely because light and dark take *different* ramp
// steps, so no single class can express it. If someone adds a surface role
// whose two modes agree, the name is doing nothing and a class would say it —
// that is the mistake this catches, in the same shape it already happened.
//
// Two reasons earn a name whose modes agree, per DESIGN.md § When a name is
// earned. Each entry states which one, so the list reads as decisions rather
// than as accumulated exceptions.
//
// • the name enforces a rule a ramp cannot state — there are three levels
// of text and one border weight;
// • a pattern repeated across screens has been named for the pattern.
//
// What this still catches is the mistake it was written for: a role invented
// by symmetry, restating one step, that no design asked for.
const NAME_IS_EARNED = new Map([
[
"--border-control",
"An input boundary must clear 3:1 against its surface, unlike a decorative divider.",
],
["--text-primary", "Three text levels, enforced by name."],
["--text-secondary", "Three text levels, enforced by name."],
["--text-tertiary", "Three text levels, enforced by name."],
["--text-disabled", "Unavailability is a rule, not a fourth level."],
["--border-primary", "One shared border weight, enforced by name."],
["--text-on-accent", "Paired text follows its fill, not the mode."],
]);

const roleNames = [
...new Set(
[...modes.light.matchAll(/^\s*(--(?:bg|text|border)-[a-z0-9-]+):/gm)].map(
(m) => m[1],
),
// Semantic names describe purpose even when both themes choose the same step.
// Require every new semantic role to declare both modes and reference a token.
const roles = [
...modes.light.matchAll(
/^\s*(--(?:surface|affordance|text|border)-[a-z0-9-]+):/gm,
),
];

for (const name of roleNames) {
if (NAME_IS_EARNED.has(name)) continue;
// Glass materials are a bundled treatment, not a surface step, and `bg-app`
// swaps a whole gradient rather than a step.
if (name.includes("glass") || name === "--bg-app") continue;

const light = read(modes.light, name);
const dark = read(modes.dark, name);
if (dark && dark !== light) continue; // earns its name

failures.push({
at: rel,
found: `${name}: ${light}`,
why: `Same value in both modes. Write ${light?.replace(/var\(--(.+)\)/, "$1") ?? "the step"} where it is used — unless this name is earned, in which case add it to NAME_IS_EARNED with its reason: a rule a ramp cannot state, or a pattern repeated across screens that is now named for the pattern.`,
});
for (const [, name] of roles) {
for (const [mode, code] of Object.entries(modes)) {
if (name === "--text-on-accent") continue; // Fixed white paired with the accent fill; measured by check-contrast.
const value =
read(code, name) ??
(mode === "dark" ? read(modes.light, name) : undefined);
if (!value || !/^var\(--[a-z0-9-]+\)$/.test(value)) {
failures.push({
at: rel,
found: `${name} (${mode}): ${value ?? "missing"}`,
why: "Semantic roles must reference a shared token in both modes.",
});
}
}
}
}

Expand Down
76 changes: 70 additions & 6 deletions scripts/design-system/check-contrast.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,8 @@
* drift from the system it audits: resolve each role through its `var()` chain
* to a literal, per mode, then measure every pairing the roles allow.
*
* Judged with APCA, per DESIGN.md § Contrast. The WCAG 2 ratio is reported for
* context and never decides — it is the standard that calls #8f8f8f on #1c1c1c
* a pass at 5.27:1 while APCA scores it Lc 40.
* Text uses APCA, per DESIGN.md § Contrast. Control/state boundaries use the
* separate WCAG 3:1 non-text target on their supported opaque surfaces.
*/

import { readFileSync } from "node:fs";
Expand Down Expand Up @@ -48,7 +47,7 @@ const EXCEPTIONS = new Map([
]);

/** Roles measured at the meta target rather than the body target. */
const META_ROLES = new Set(["--text-tertiary"]);
const META_ROLES = new Set(["--text-tertiary", "--text-metadata"]);

/**
* Neutral surfaces any text may sit on.
Expand All @@ -57,7 +56,15 @@ const META_ROLES = new Set(["--text-tertiary"]);
* its step here. It still belongs in this list: a row under the cursor is a
* surface text sits on, whatever it is called.
*/
const SURFACES = ["--bg-panel", "--bg-float", "--neutral-2", "--neutral-4"];
const SURFACES = [
"--surface-base",
"--surface-panel",
"--surface-popover",
"--surface-inset",
"--affordance-subtle",
"--affordance-selected",
"--neutral-4",
];

/**
* Text that must be readable on every neutral surface.
Expand All @@ -74,6 +81,13 @@ const SURFACES = ["--bg-panel", "--bg-float", "--neutral-2", "--neutral-4"];
* does — and it is the step that fails, at 59.7 on a dark panel.
*/
const TEXT_ROLES = [
"--text-standard",
"--text-subtle",
"--text-metadata",
"--text-danger",
"--text-warning",
"--text-success",
"--text-accent",
"--text-primary",
"--text-secondary",
"--text-tertiary",
Expand All @@ -94,6 +108,18 @@ const TEXT_ROLES = [
* every fill it can actually sit on, and hover is one of them.
*/
const PAIRS = [
...["subtle", "subtle-hover", "subtle-pressed"].map((state) => [
"--text-standard",
`--affordance-${state}`,
]),
...["prominent", "prominent-hover", "prominent-pressed"].map((state) => [
"--text-inverse",
`--affordance-${state}`,
]),
...["danger", "danger-hover", "danger-pressed"].map((state) => [
"--text-danger",
`--affordance-${state}`,
]),
["--text-on-accent", "--purple-9"],
["--text-on-accent", "--purple-10"],
// `bg-neutral-11` with `text-neutral-1` — the inverse pair, written as steps
Expand All @@ -110,7 +136,21 @@ const PAIRS = [
* and its hover are both real surfaces — a chip at rest and a chip under the
* cursor — and the hover is the harder one, which is where the gap was.
*/
const BOUNDARY_SURFACES = [
"--surface-base",
"--surface-panel",
"--surface-inset",
"--surface-popover",
];
const BOUNDARY_ROLES = ["--border-danger", "--border-warning"];

const TINT_PAIRS = [
["--text-warning", "--affordance-warning"],
["--text-success", "--affordance-success"],
["--text-accent", "--affordance-accent"],
["--text-accent", "--affordance-accent-hover"],
["--text-on-accent", "--affordance-accent-prominent"],
["--text-on-accent", "--affordance-accent-prominent-hover"],
["--amber-12", "--amber-3"],
["--green-12", "--green-3"],
["--purple-12", "--purple-3"],
Expand Down Expand Up @@ -156,6 +196,7 @@ function resolve(map, name, depth = 0) {

const modes = declarationsByMode();
const failures = [];
const boundaryFailures = [];
const skipped = [];
/**
* Pairing-scoped exceptions this run actually needed. An exception nobody hits
Expand Down Expand Up @@ -200,6 +241,20 @@ for (const [mode, map] of Object.entries(modes)) {
}
for (const [role, fill] of PAIRS) check(role, fill);
for (const [text, tint] of TINT_PAIRS) check(text, tint);
for (const role of BOUNDARY_ROLES) {
for (const surface of BOUNDARY_SURFACES) {
const borderColor = resolve(map, role);
const surfaceColor = resolve(map, surface);
const ratio =
borderColor && surfaceColor
? wcagRatio(borderColor, surfaceColor)
: null;
if (ratio === null || ratio < 3)
boundaryFailures.push(
`${mode}: ${role} on ${surface} — ${ratio === null ? "unresolved color" : `${ratio.toFixed(3)}:1`}, needs 3:1`,
);
}
}
}

// Guard the maths itself: if these drift, every verdict above is wrong.
Expand All @@ -223,6 +278,12 @@ if (skipped.length > 0) {
for (const s of skipped) console.log(` ${s}`);
}

if (boundaryFailures.length > 0) {
console.error(
`\n✗ Control/state boundaries:\n ${boundaryFailures.join("\n ")}`,
);
}

if (failures.length > 0) {
console.error(
`\n✗ Contrast: ${failures.length} pairing(s) below their APCA target\n`,
Expand All @@ -241,7 +302,10 @@ if (failures.length > 0) {
process.exit(1);
}

console.log("✓ Contrast: every text role clears its APCA target in both modes");
if (boundaryFailures.length > 0) process.exit(1);
console.log(
"✓ Contrast: text and control/state boundaries clear their targets in both modes",
);
for (const [role, why] of EXCEPTIONS) {
console.log(` (exception) ${role} — ${why.split(";")[0]}`);
}
Expand Down
21 changes: 19 additions & 2 deletions src/bundled/channels/ChannelSidebarRow.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { afterEach, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import type { RelaySession } from "../../features/relay/session";
Expand Down Expand Up @@ -94,9 +94,26 @@ it("opens a compact action menu independently of selecting its channel", async (
);
expect(callbacks.onNewSession).toHaveBeenCalledWith("parent");
expect(callbacks.onSelect).not.toHaveBeenCalled();
// The first close must finish before reopening, and keyboard dismissal must
// start after the popup has taken focus rather than racing its focus effect.
await waitFor(() =>
expect(
screen.queryByRole("menu", { name: "More options for Engineering" }),
).not.toBeInTheDocument(),
);
await user.click(trigger);
await waitFor(() =>
expect(
screen.getByRole("menu", { name: "More options for Engineering" }),
).toHaveFocus(),
);
await user.keyboard("{Escape}");
expect(trigger).toHaveFocus();
await waitFor(() => {
expect(
screen.queryByRole("menu", { name: "More options for Engineering" }),
).not.toBeInTheDocument();
expect(trigger).toHaveFocus();
});
});
it("opens saved child sessions and retained drafts without a channel icon", async () => {
const user = userEvent.setup();
Expand Down
3 changes: 2 additions & 1 deletion src/shared/design-system/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
This is the app's design system. New UI and surfaces moving off the existing styles should use it.
This initial port does not migrate existing surfaces; that is a boundary of the PR, not a prohibition on adoption.
Read DESIGN.md and MAINTAINING_DESIGN_SYSTEM.md before editing.
Use authored ramps and named type roles; keep Base UI behavior and Phosphor icons through ../icons.
Use semantic color roles and complete type roles; keep Base UI behavior and Phosphor icons through ../icons.
Block UI is the visual target. Palette steps belong in shared token definitions, not new component recipes.
Preserve keyboard-only focus and test light/dark and narrow/intermediate/wide views.
Components live in ui/, values in styles/, documentation metadata in tokens/ and ui/registry.ts.
The standalone viewer lives in tests/fixtures/design-system and imports the real shared components.
Expand Down
Loading
Loading