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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
31 changes: 29 additions & 2 deletions scripts/design-system/check-contrast.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,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 +57,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 +82,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 +109,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 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 Tabler icons.
Use semantic color roles and complete type roles; keep Base UI behavior and Tabler 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