From 7cde504d7bfc31e7515c067eaa3cc029884f671e Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:15:02 -0700 Subject: [PATCH 01/33] feat(desktop-next): scaffold new client with the colour system and a design system route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- desktop-next/.gitignore | 5 + desktop-next/AGENTS.md | 162 ++++++ desktop-next/DESIGN.md | 115 ++++ desktop-next/biome.json | 3 + desktop-next/index.html | 12 + desktop-next/package.json | 32 ++ desktop-next/postcss.config.js | 5 + desktop-next/src/app/routes/__root.tsx | 3 + desktop-next/src/app/routes/design.tsx | 7 + desktop-next/src/app/routes/design/colour.tsx | 7 + .../src/app/routes/design/elevation.tsx | 7 + desktop-next/src/app/routes/design/glass.tsx | 7 + desktop-next/src/app/routes/design/growth.tsx | 7 + desktop-next/src/app/routes/design/index.tsx | 5 + desktop-next/src/app/routes/design/motion.tsx | 7 + desktop-next/src/app/routes/design/radius.tsx | 7 + .../src/app/routes/design/spacing.tsx | 7 + .../src/app/routes/design/typography.tsx | 7 + .../src/app/routes/design/vocabulary.tsx | 7 + desktop-next/src/app/routes/index.tsx | 21 + .../features/design-system/ui/ColourPage.tsx | 138 +++++ .../design-system/ui/DesignSystemLayout.tsx | 104 ++++ .../design-system/ui/ElevationPage.tsx | 42 ++ .../features/design-system/ui/GlassPage.tsx | 125 +++++ .../features/design-system/ui/GrowthPage.tsx | 89 +++ .../features/design-system/ui/MotionPage.tsx | 41 ++ .../design-system/ui/OverviewPage.tsx | 81 +++ .../features/design-system/ui/RadiusPage.tsx | 24 + .../features/design-system/ui/SpacingPage.tsx | 25 + .../design-system/ui/TypographyPage.tsx | 40 ++ .../design-system/ui/VocabularyPage.tsx | 76 +++ .../features/design-system/ui/primitives.tsx | 117 ++++ desktop-next/src/main.tsx | 23 + desktop-next/src/shared/styles/globals.css | 53 ++ desktop-next/src/shared/styles/tokens.css | 338 ++++++++++++ .../src/shared/theme/useColorScheme.ts | 32 ++ desktop-next/src/shared/tokens/registry.ts | 507 ++++++++++++++++++ desktop-next/src/vite-env.d.ts | 1 + desktop-next/tsconfig.json | 21 + desktop-next/vite.config.ts | 21 + pnpm-lock.yaml | 165 ++++-- pnpm-workspace.yaml | 1 + 42 files changed, 2441 insertions(+), 56 deletions(-) create mode 100644 desktop-next/.gitignore create mode 100644 desktop-next/AGENTS.md create mode 100644 desktop-next/DESIGN.md create mode 100644 desktop-next/biome.json create mode 100644 desktop-next/index.html create mode 100644 desktop-next/package.json create mode 100644 desktop-next/postcss.config.js create mode 100644 desktop-next/src/app/routes/__root.tsx create mode 100644 desktop-next/src/app/routes/design.tsx create mode 100644 desktop-next/src/app/routes/design/colour.tsx create mode 100644 desktop-next/src/app/routes/design/elevation.tsx create mode 100644 desktop-next/src/app/routes/design/glass.tsx create mode 100644 desktop-next/src/app/routes/design/growth.tsx create mode 100644 desktop-next/src/app/routes/design/index.tsx create mode 100644 desktop-next/src/app/routes/design/motion.tsx create mode 100644 desktop-next/src/app/routes/design/radius.tsx create mode 100644 desktop-next/src/app/routes/design/spacing.tsx create mode 100644 desktop-next/src/app/routes/design/typography.tsx create mode 100644 desktop-next/src/app/routes/design/vocabulary.tsx create mode 100644 desktop-next/src/app/routes/index.tsx create mode 100644 desktop-next/src/features/design-system/ui/ColourPage.tsx create mode 100644 desktop-next/src/features/design-system/ui/DesignSystemLayout.tsx create mode 100644 desktop-next/src/features/design-system/ui/ElevationPage.tsx create mode 100644 desktop-next/src/features/design-system/ui/GlassPage.tsx create mode 100644 desktop-next/src/features/design-system/ui/GrowthPage.tsx create mode 100644 desktop-next/src/features/design-system/ui/MotionPage.tsx create mode 100644 desktop-next/src/features/design-system/ui/OverviewPage.tsx create mode 100644 desktop-next/src/features/design-system/ui/RadiusPage.tsx create mode 100644 desktop-next/src/features/design-system/ui/SpacingPage.tsx create mode 100644 desktop-next/src/features/design-system/ui/TypographyPage.tsx create mode 100644 desktop-next/src/features/design-system/ui/VocabularyPage.tsx create mode 100644 desktop-next/src/features/design-system/ui/primitives.tsx create mode 100644 desktop-next/src/main.tsx create mode 100644 desktop-next/src/shared/styles/globals.css create mode 100644 desktop-next/src/shared/styles/tokens.css create mode 100644 desktop-next/src/shared/theme/useColorScheme.ts create mode 100644 desktop-next/src/shared/tokens/registry.ts create mode 100644 desktop-next/src/vite-env.d.ts create mode 100644 desktop-next/tsconfig.json create mode 100644 desktop-next/vite.config.ts diff --git a/desktop-next/.gitignore b/desktop-next/.gitignore new file mode 100644 index 0000000000..56f1058162 --- /dev/null +++ b/desktop-next/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +*.log +.DS_Store +src/app/routeTree.gen.ts diff --git a/desktop-next/AGENTS.md b/desktop-next/AGENTS.md new file mode 100644 index 0000000000..1ae429ab4a --- /dev/null +++ b/desktop-next/AGENTS.md @@ -0,0 +1,162 @@ +# AGENTS.md — desktop-next + +This is a **new Buzz desktop client, built from scratch.** It is not a fork of +`desktop/` and not a migration target. The existing client stays untouched; this +one is being built to replace it eventually, starting from the design system +rather than from features. + +For repository-wide rules see the root [AGENTS.md](../AGENTS.md). Everything here +is specific to this client and takes precedence within it. + +--- + +## Read before writing any UI + +1. [DESIGN.md](./DESIGN.md) — the judgement tokens cannot express. Not optional. +2. `src/shared/tokens/registry.ts` — what exists, what each name is for. +3. Run `pnpm dev`, open `/design`, and look at the thing you are about to change. + +--- + +## Getting started + +```bash +pnpm install +pnpm dev # http://localhost:1430 +``` + +`/design` is the design system, rendered from the token registry. `/` is a +placeholder — the app shell and its capabilities come later. + +Port 1430, so it can run alongside the existing client on 1420. + +```bash +pnpm typecheck # tsc --noEmit +pnpm check # biome +pnpm biome check --write . # auto-fix +``` + +--- + +## Stack, and why + +| Choice | Reason | +|---|---| +| **Base UI** | Behaviour, accessibility, keyboard, and positioning with zero appearance. The visual language is authored here, not inherited then overridden. | +| **Tailwind v4** | Tokens are defined in CSS via `@theme`; the CSS *is* the config. No JS config file. | +| **Own colour tokens** | Not shadcn. Its vocabulary — `muted-foreground`, `secondary-foreground` — is what made colour illegible in the existing client. | +| **TanStack Router** | File-based routes, same as the existing client. | + +Astryx is a **reference** for token architecture and the agent-docs idea. Not a +dependency. + +--- + +## The colour system + +Three layers. Only the role layer is ever used when building a screen. + +``` +LAYER 1 --neutral-1…12 --accent-1…12 --glass-1…5 --gradient-1 +LAYER 2 --bg-panel: var(--neutral-1) +LAYER 3 bg-panel +``` + +**Tailwind's default palette is deleted** with `--color-*: initial`. `text-gray-500` +does not exist — it is a build error, not a style choice. + +Hard rules: + +- **Literal values exist only in the ramps.** A component writing a hex bypasses + the roles; a role writing a hex bypasses the ramp. Both look correct today and + break the first theme change, and the failure hides in dark mode because light + mode still looks right. +- **Never apply transparency to a token.** No `bg-panel/50`. Transparency lives + inside the value. The existing client has thirteen transparencies of one grey + and eleven of one accent because this rule did not exist. +- **Every role holds a light and a dark value under the same name.** A component + never contains an instruction about which mode is active. +- **Accent is a slot, not a colour.** Nothing above the ramp knows the hue, so it + can change, become a preference, or vary per theme. Do not hardcode a hue + anywhere above layer 1. + +The full exception list is in the registry and on `/design/colour`. It is short +and complete on purpose. + +### Naming grammar + +``` +-[-][-][-] +``` + +Fixed order, so there is one correct spelling. `bg-chrome-glass-hover` is legal; +`bg-chrome-hover-glass` is not. One modifier, one material, one state per name. + +Every word a token may be built from is listed in `VOCABULARY` in the registry +and on `/design/vocabulary`. 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. + +--- + +## Growing the system + +**Need something the system doesn't have? Add it to the registry, mark it +`proposed` with an owner, keep working.** No gate, no approval, no separate +mechanism for one-offs. The full procedure is in DESIGN.md § Growing the system. + +The only stop condition: **if you cannot describe it in one sentence, ask.** That +is the signal it is not a role. + +--- + +## Structure + +``` +src/ + app/routes/ file-based routes + shared/ + styles/tokens.css layers 1 and 2, and the Tailwind registration + styles/globals.css base styles and the rim/blur/texture utilities + tokens/registry.ts the machine-readable system description + theme/ colour scheme + features/ + design-system/ui/ the /design pages +``` + +`features/design-system/ui/primitives.tsx` is **documentation furniture, not a +component library.** The shared primitive layer gets built one component at a +time as the product repeats something — see DESIGN.md § Components. + +--- + +## What is not here yet + +Deliberately, so nobody assumes it was forgotten: + +- **Typography, spacing, radius, and motion tokens.** Their `/design` pages state + what is still to decide rather than pretending to a system. Typography is next. +- **Any product component.** No Button, no Dialog, no input layer. +- **Tauri.** This is a web app for now; the native shell comes with the app shell. +- **Relay, auth, event handling.** None of it. When it arrives it comes from the + shared Rust crates, not a reimplementation. +- **The computed paired-text rule.** `text-on-accent` and `text-on-inverse` hold + literals until the lightness computation lands. They are marked as exceptions. + +--- + +## Architecture + +This client is built on **composable capabilities** — see the plan in Morgan's +vault. The short version: a feature owns product behaviour, a view owns +arrangement, shared UI owns visuals, and a capability owns behaviour that should +move between surfaces intact. + +Two rules that matter from day one: + +- **A capability may own live state. Any state scoped to a community must + register its teardown** in the same change that adds it. The existing client + learned this the hard way; do not rebuild the problem. +- **Do not create a capability speculatively.** The bar is a durable product + identity and real composition pressure from two surfaces. Building a clean + codebase is not a licence to relax it. diff --git a/desktop-next/DESIGN.md b/desktop-next/DESIGN.md new file mode 100644 index 0000000000..0f5be1e4a4 --- /dev/null +++ b/desktop-next/DESIGN.md @@ -0,0 +1,115 @@ +# DESIGN.md + +How to design well in this client. The token registry says which value to use; this says what tokens cannot express — the judgement a designer makes without thinking and an agent gets wrong without being told. Read it before building a surface. + +Run `pnpm dev` and open `/design` to see the system rendered from the tokens themselves. + +## Posture + +Buzz is a place where people build together and bring their agents into the room. Everyday surfaces stay quiet, crisp, and highly functional; character shows up in identity, guidance, transitions, and ceremony rather than in the chrome of ordinary work. Colour is signal, not decoration. When in doubt, the interface gets out of the way of the conversation. + +## Surface and depth + +- **Panels sit on the backdrop; the backdrop is a gradient.** Everything else is a panel in a different place. The navigation column is not a special kind of surface. +- **Depth comes from hairline borders and near-invisible shadows, not from many surface colours.** Reach for a border before reaching for a new fill. +- **Shadows stay at the threshold of perception.** If a shadow is obvious, it is too strong. The two elevation values are the whole vocabulary. +- **Elevation is carried by shadow in light mode and by lightness in dark mode.** On a near-black background there is nothing darker for a shadow to cast, so a floating surface becomes a step lighter instead. Never reach for a stronger shadow to make something float in dark mode. +- **On a translucent surface, elevation reads as less translucency, not as a lighter colour.** A glass container with a fully opaque child looks layered; the same container with a merely brighter child looks unchanged. +- **Light comes from one direction, and every glass surface agrees on it.** A glass rim is bright along the lit edge and dimmer on the opposite one; that is what makes it read as a material rather than an outline. Two surfaces lit from different directions in the same view look like a mistake. +- **A glass rim is not an outline.** If a surface needs a visible boundary rather than a material edge, it wants a border role, not glass. +- **Glass needs something behind it worth seeing.** Translucency over a flat fill is wasted cost; use it where the gradient, an image, or content actually shows through. +- **Only panels and chrome should sit directly on the gradient as a default.** Text and hairlines on a gradient have position-dependent contrast. Good practice rather than a hard rule — a rotated label pill on the backdrop is fine. + +## State + +- **The interface has three states, plus disabled where it matters: default, hover, selected.** There is no pressed state: pressed is too fleeting to read and makes an interface feel jumpy. +- **Hover means one step more contrast, in whichever direction that surface needs.** A light row darkens, a dark chip lightens. Direction lives in the value. +- **Selected is a persistent statement, not a stronger hover.** It should be legible without a cursor present. +- **A selected item in a toggle group is not interactive.** Clicking it does nothing, so it gets no hover. +- **Disabled communicates unavailability, not quietness.** It is not a fourth level of the emphasis ramp. +- **Never hide the only way out of a state.** Before adding a visibility rule, ask what happens when the state it assumes is wrong, and whether the person can still recover. + +## Emphasis + +- **Three levels of text: normal, lesser, really lesser.** If a fourth seems necessary, the thing wants a different size, weight, or position instead of a fourth colour. +- **Two text colours do most of the work.** Treat the third level as genuinely for metadata. +- **Borders use the same three levels, and they mean the same thing.** Learn the ramp once. +- **Weight and size carry hierarchy before colour does.** Reaching for a louder colour to fix hierarchy usually means the size relationship is wrong. + +## Both modes + +- **Design in both modes, not in light and then dark.** Dark is not a filter applied afterwards: elevation, glass, and accent text all behave differently there. +- **Accent text moves in opposite directions between modes.** Darker than its fill on a light background, lighter on a dark one. +- **A tint is a pale wash in light mode and a deep one in dark.** The name describes the job, not the lightness. +- **Check the pairing, not the swatch.** A colour is only right in the context of what sits on it and behind it. +- **Every dark value in this system is authored rather than observed.** The design exploration it came from is light-only. Treat anything that looks wrong in dark as a finding. + +## Density and rhythm + +- **Dense data renders as rows with dividers, edge to edge.** Wrapping every list item in its own card is the most common way a functional surface becomes a marketing page. +- **Cards are for widgets, galleries, and settings groups.** A card is a bordered, padded region on the page, not a different depth. +- **Pick the frame before the content.** Decide what the surface is — a list, a reading column, a workspace — before filling it. +- **Whitespace is generous by default.** Crowding reads as a different product. + +## Motion + +- **Direct manipulation follows the pointer exactly, with no easing.** Smoothing during a drag or resize reads as lag. Spring physics belongs to what happens after release. +- **A drag gesture must not select text in whatever it passes over.** +- **Never animate blur.** Re-blurring a large surface every frame is expensive enough to feel. Animate opacity instead. +- **Motion explains a change; it does not decorate one.** If removing an animation loses no information, remove it. + +## Colour discipline + +- **Colour is signal.** Status, authorship, presence, and mentions earn colour. Ordinary structure does not. +- **Name colours after colour jobs, never after the thing on screen.** If the name is an interface element — mention, unread, badge, sidebar — it belongs in the component, assembled from roles that already exist. +- **A colour is used one of two ways: solid or tint.** Solid carries an action and takes its paired text; tint carries a meaning and takes coloured text. There is deliberately nothing between them. +- **Accent is signal, never structure.** Reaching for an accent surface where a neutral one belongs is the most common way a functional screen starts to look decorated. +- **Never use a status colour decoratively.** A green that does not mean success teaches people to stop trusting green. +- **Categorical colours are the one place appearance-naming is allowed.** Telling two projects apart genuinely is a choice about appearance. + +## Writing + +- **Every word earns its place.** Prefer the shortest phrasing that stays accurate. +- **Labels say what happens, not what the thing is called internally.** +- **Empty states say what this place is for and what to do next.** An empty state is a first impression, not an error. +- **Errors say what happened and what to do about it.** A message the person cannot act on is decoration. + +## Accessibility + +- **Every interactive element has explicit assistive semantics, and one owner per label.** Two widgets claiming the same label produces duplicate screen-reader stops. +- **Contrast comes from the paired token, not from judgement.** Where a background is not neutral, its text is named for it. +- **Keyboard, pointer, and shortcut paths must not diverge.** When adding an input handler, enumerate the ways a person can reach it and check the ones that are not the mouse. +- **Focus is always visible.** The focus ring is part of the design, not an artefact to suppress. +- **Colour is never the only carrier of meaning.** Pair it with text, shape, or position. + +## Responsiveness + +- **Design for narrow, intermediate, and wide, not just wide.** Intermediate widths are where layouts usually break. +- **Text scales with the person's preference and with zoom.** Anything readable uses relative units; fixed pixel text freezes and breaks zoom. + +## Growing the system + +Need something the system doesn't have? **Add it, mark it `proposed`, keep working.** There is no gate and no separate mechanism for one-offs — the moment the legal path is slower than writing a raw value, the system starts being bypassed. + +1. Search the role list by intent, not by colour. +2. A state of an existing role — add the `-hover`, `-selected`, or `-disabled` sibling with both values. +3. A material variant — add the `-glass` sibling with both values and its blur token. +4. A new role using existing words — add the name, both values, a one-sentence description, and an owner. +5. A new hue — generate its ramp, add roles pointing at steps. Never a literal. +6. A new vocabulary word — allowed, but it is the thing the audit reports on its own line, so use an existing word if one fits. +7. **Never write a raw value.** If nothing above applies, say so rather than reaching for a literal. + +Every addition lands in `src/shared/tokens/registry.ts` in the same change that needed it. Promotion from `proposed` to `core` is a metadata change, not a rename. + +## Components + +- **Compose existing components freely. Never reimplement one.** +- **Need a variant that doesn't exist? Add it, mark it proposed.** If a variant almost fits but you would cancel several of its states, the base is wrong for the job and the system is missing a variant. +- **Never add a boolean prop for a visual difference.** Variants are enumerable, so an agent can read the list and pick; booleans multiply, and nobody designed most of the combinations. New props are for data and behaviour, not appearance. +- **Used by one feature? It lives in that feature's folder.** Used by two? Propose it as shared. The folder is the namespace. + +## Using the system + +- **Use an existing component before creating one, and an existing role before adding one.** +- **A new visual treatment that repeats belongs in the system, not in the feature.** +- **If a screen looks right but breaks these rules, the rules are probably wrong — say so.** This document is meant to be argued with, not worked around. diff --git a/desktop-next/biome.json b/desktop-next/biome.json new file mode 100644 index 0000000000..12e778e4a9 --- /dev/null +++ b/desktop-next/biome.json @@ -0,0 +1,3 @@ +{ + "extends": ["../biome.json"] +} diff --git a/desktop-next/index.html b/desktop-next/index.html new file mode 100644 index 0000000000..3859bee835 --- /dev/null +++ b/desktop-next/index.html @@ -0,0 +1,12 @@ + + + + + + Buzz — Design System + + +
+ + + diff --git a/desktop-next/package.json b/desktop-next/package.json new file mode 100644 index 0000000000..c0c8917de9 --- /dev/null +++ b/desktop-next/package.json @@ -0,0 +1,32 @@ +{ + "name": "buzz-desktop-next", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "typecheck": "tsc --noEmit", + "preview": "vite preview", + "lint": "biome lint .", + "check": "biome check .", + "format": "biome format --write ." + }, + "dependencies": { + "@base-ui-components/react": "1.0.0-rc.0", + "@fontsource-variable/inter": "^5.2.8", + "@tanstack/react-router": "^1.168.10", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.3.0", + "@tanstack/router-plugin": "^1.167.12", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^6.0.0", + "tailwindcss": "^4.3.0", + "typescript": "~6.0.0", + "vite": "^8.0.0" + } +} diff --git a/desktop-next/postcss.config.js b/desktop-next/postcss.config.js new file mode 100644 index 0000000000..c2ddf74822 --- /dev/null +++ b/desktop-next/postcss.config.js @@ -0,0 +1,5 @@ +export default { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; diff --git a/desktop-next/src/app/routes/__root.tsx b/desktop-next/src/app/routes/__root.tsx new file mode 100644 index 0000000000..f17fcdad20 --- /dev/null +++ b/desktop-next/src/app/routes/__root.tsx @@ -0,0 +1,3 @@ +import { Outlet, createRootRoute } from "@tanstack/react-router"; + +export const Route = createRootRoute({ component: () => }); diff --git a/desktop-next/src/app/routes/design.tsx b/desktop-next/src/app/routes/design.tsx new file mode 100644 index 0000000000..bc2b1f366b --- /dev/null +++ b/desktop-next/src/app/routes/design.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { DesignSystemLayout } from "@/features/design-system/ui/DesignSystemLayout"; + +export const Route = createFileRoute("/design")({ + component: DesignSystemLayout, +}); diff --git a/desktop-next/src/app/routes/design/colour.tsx b/desktop-next/src/app/routes/design/colour.tsx new file mode 100644 index 0000000000..e75864e57f --- /dev/null +++ b/desktop-next/src/app/routes/design/colour.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { ColourPage } from "@/features/design-system/ui/ColourPage"; + +export const Route = createFileRoute("/design/colour")({ + component: ColourPage, +}); diff --git a/desktop-next/src/app/routes/design/elevation.tsx b/desktop-next/src/app/routes/design/elevation.tsx new file mode 100644 index 0000000000..916394b20d --- /dev/null +++ b/desktop-next/src/app/routes/design/elevation.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { ElevationPage } from "@/features/design-system/ui/ElevationPage"; + +export const Route = createFileRoute("/design/elevation")({ + component: ElevationPage, +}); diff --git a/desktop-next/src/app/routes/design/glass.tsx b/desktop-next/src/app/routes/design/glass.tsx new file mode 100644 index 0000000000..25516b947c --- /dev/null +++ b/desktop-next/src/app/routes/design/glass.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { GlassPage } from "@/features/design-system/ui/GlassPage"; + +export const Route = createFileRoute("/design/glass")({ + component: GlassPage, +}); diff --git a/desktop-next/src/app/routes/design/growth.tsx b/desktop-next/src/app/routes/design/growth.tsx new file mode 100644 index 0000000000..732be720fc --- /dev/null +++ b/desktop-next/src/app/routes/design/growth.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { GrowthPage } from "@/features/design-system/ui/GrowthPage"; + +export const Route = createFileRoute("/design/growth")({ + component: GrowthPage, +}); diff --git a/desktop-next/src/app/routes/design/index.tsx b/desktop-next/src/app/routes/design/index.tsx new file mode 100644 index 0000000000..cda74074ef --- /dev/null +++ b/desktop-next/src/app/routes/design/index.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { OverviewPage } from "@/features/design-system/ui/OverviewPage"; + +export const Route = createFileRoute("/design/")({ component: OverviewPage }); diff --git a/desktop-next/src/app/routes/design/motion.tsx b/desktop-next/src/app/routes/design/motion.tsx new file mode 100644 index 0000000000..427ec983e7 --- /dev/null +++ b/desktop-next/src/app/routes/design/motion.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { MotionPage } from "@/features/design-system/ui/MotionPage"; + +export const Route = createFileRoute("/design/motion")({ + component: MotionPage, +}); diff --git a/desktop-next/src/app/routes/design/radius.tsx b/desktop-next/src/app/routes/design/radius.tsx new file mode 100644 index 0000000000..a9c0904ecd --- /dev/null +++ b/desktop-next/src/app/routes/design/radius.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { RadiusPage } from "@/features/design-system/ui/RadiusPage"; + +export const Route = createFileRoute("/design/radius")({ + component: RadiusPage, +}); diff --git a/desktop-next/src/app/routes/design/spacing.tsx b/desktop-next/src/app/routes/design/spacing.tsx new file mode 100644 index 0000000000..94387c09f8 --- /dev/null +++ b/desktop-next/src/app/routes/design/spacing.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { SpacingPage } from "@/features/design-system/ui/SpacingPage"; + +export const Route = createFileRoute("/design/spacing")({ + component: SpacingPage, +}); diff --git a/desktop-next/src/app/routes/design/typography.tsx b/desktop-next/src/app/routes/design/typography.tsx new file mode 100644 index 0000000000..f78373a912 --- /dev/null +++ b/desktop-next/src/app/routes/design/typography.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { TypographyPage } from "@/features/design-system/ui/TypographyPage"; + +export const Route = createFileRoute("/design/typography")({ + component: TypographyPage, +}); diff --git a/desktop-next/src/app/routes/design/vocabulary.tsx b/desktop-next/src/app/routes/design/vocabulary.tsx new file mode 100644 index 0000000000..102554e6fe --- /dev/null +++ b/desktop-next/src/app/routes/design/vocabulary.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { VocabularyPage } from "@/features/design-system/ui/VocabularyPage"; + +export const Route = createFileRoute("/design/vocabulary")({ + component: VocabularyPage, +}); diff --git a/desktop-next/src/app/routes/index.tsx b/desktop-next/src/app/routes/index.tsx new file mode 100644 index 0000000000..cab3393803 --- /dev/null +++ b/desktop-next/src/app/routes/index.tsx @@ -0,0 +1,21 @@ +import { Link, createFileRoute } from "@tanstack/react-router"; + +function Home() { + return ( +
+

Buzz

+

+ The new client. Nothing here yet — the shell and its capabilities come + later. The design system is the foundation being built first. +

+ + Open the design system + +
+ ); +} + +export const Route = createFileRoute("/")({ component: Home }); diff --git a/desktop-next/src/features/design-system/ui/ColourPage.tsx b/desktop-next/src/features/design-system/ui/ColourPage.tsx new file mode 100644 index 0000000000..a146faf766 --- /dev/null +++ b/desktop-next/src/features/design-system/ui/ColourPage.tsx @@ -0,0 +1,138 @@ +import { + EXCEPTIONS, + RAMPS, + ROLE_GROUPS, + type Role, +} from "@/shared/tokens/registry"; + +import { Note, PageHeader, Section, Swatch } from "./primitives"; + +function RoleRow({ role }: { role: Role }) { + return ( +
+
+
+
+ {role.token} + {role.pointsAt} + {role.status !== "core" ? ( + + {role.status} + {role.owner ? ` · ${role.owner}` : ""} + + ) : null} +
+

{role.use}

+ {role.exception ? ( +

+ Exception: {role.exception} +

+ ) : null} +
+
+ ); +} + +export function ColourPage() { + return ( + <> + + +
+
+ {RAMPS.map((ramp) => ( +
+
+

+ {ramp.name} +

+

+ {ramp.description} +

+
+
6 + ? "grid-cols-4 sm:grid-cols-6" + : "grid-cols-3 sm:grid-cols-5" + } ${ramp.translucent ? "rounded-xl bg-app p-4" : ""}`} + > + {ramp.steps.map((step) => ( + + ))} +
+
+ ))} +
+
+ +
+
+ {ROLE_GROUPS.map((group) => ( +
+
+

+ {group.name} +

+

+ {group.description} +

+
+
+ {group.roles.map((role) => ( + + ))} +
+
+ ))} +
+
+ +
+
+ {EXCEPTIONS.map((exception) => ( +
+ + {exception.name} + +

+ {exception.why} +

+
+ ))} +
+
+ + + Every dark value in this system is authored rather than observed — the + design exploration it was derived from is light-only. Toggle the mode in + the sidebar and treat anything that looks wrong as a finding, not a + given. + + + ); +} diff --git a/desktop-next/src/features/design-system/ui/DesignSystemLayout.tsx b/desktop-next/src/features/design-system/ui/DesignSystemLayout.tsx new file mode 100644 index 0000000000..a6216225b7 --- /dev/null +++ b/desktop-next/src/features/design-system/ui/DesignSystemLayout.tsx @@ -0,0 +1,104 @@ +import { Link, Outlet } from "@tanstack/react-router"; +import type { ReactNode } from "react"; + +import { useColorScheme } from "@/shared/theme/useColorScheme"; + +const SECTIONS: Array<{ heading: string; items: Array<[string, string]> }> = [ + { + heading: "Foundations", + items: [ + ["Colour", "/design/colour"], + ["Typography", "/design/typography"], + ["Spacing", "/design/spacing"], + ["Radius", "/design/radius"], + ["Elevation", "/design/elevation"], + ["Glass", "/design/glass"], + ["Motion", "/design/motion"], + ], + }, + { + heading: "System", + items: [ + ["Vocabulary", "/design/vocabulary"], + ["Growing the system", "/design/growth"], + ], + }, + { + heading: "Components", + items: [], + }, +]; + +function NavLink({ to, children }: { to: string; children: ReactNode }) { + return ( + + {children} + + ); +} + +export function DesignSystemLayout() { + const { scheme, toggle } = useColorScheme(); + + return ( +
+ + +
+
+ +
+
+
+ ); +} diff --git a/desktop-next/src/features/design-system/ui/ElevationPage.tsx b/desktop-next/src/features/design-system/ui/ElevationPage.tsx new file mode 100644 index 0000000000..707bc183c9 --- /dev/null +++ b/desktop-next/src/features/design-system/ui/ElevationPage.tsx @@ -0,0 +1,42 @@ +import { ELEVATION } from "@/shared/tokens/registry"; + +import { Note, PageHeader, Section } from "./primitives"; + +export function ElevationPage() { + return ( + <> + + +
+
+ {ELEVATION.map((level) => ( +
+
+ + {level.token} + +
+ + {level.use} + +
+ ))} +
+
+ + + Elevation is carried by shadow in light mode and by lightness in dark + mode. On a near-black background there is nothing darker for a shadow to + cast, so a floating surface becomes a step lighter instead — which is + why `bg-float` and `bg-panel` share a light value and diverge in dark. + Never reach for a stronger shadow to make something float in dark mode. + + + ); +} diff --git a/desktop-next/src/features/design-system/ui/GlassPage.tsx b/desktop-next/src/features/design-system/ui/GlassPage.tsx new file mode 100644 index 0000000000..9485f000ab --- /dev/null +++ b/desktop-next/src/features/design-system/ui/GlassPage.tsx @@ -0,0 +1,125 @@ +import { BLUR, RAMPS } from "@/shared/tokens/registry"; + +import { Note, PageHeader, Section, Swatch } from "./primitives"; + +const GLASS = RAMPS.find((ramp) => ramp.id === "glass"); + +function ChromePill() { + const items: Array<[string, boolean]> = [ + ["Me", false], + ["Messages", true], + ["Projects", false], + ]; + return ( +
+ {items.map(([label, selected]) => ( + + ))} +
+ ); +} + +export function GlassPage() { + return ( + <> + + +
+
+ +
+
+ + container bg-chrome-glass +{" "} + rim-glass +{" "} + blur-chrome + + + selected bg-chrome-selected — + opaque where its container is glass + +
+
+ + {GLASS ? ( +
+
+ {GLASS.steps.map((step) => ( + + ))} +
+
+ ) : null} + +
+
+ {BLUR.map((blur) => ( +
+ + {blur.token} + + {blur.value} +
+ ))} +
+
+ +
+
+ + {`box-shadow:\n inset 0 1px 0 var(--rim-lit),\n inset 0 -1px 0 var(--rim-shade);`} + +
+ + CSS borders accept only solid colours, so every gradient-border + technique is a workaround. `border-image` takes a gradient but ignores + `border-radius`, which is fatal on a pill. The two-background + `background-clip` trick respects radius but needs an opaque inner + fill, so it bleeds across a translucent surface. Two inset shadows + respect radius, cost nothing, and work over translucency. The known + limit is that each shadow is one solid colour, so a corner transitions + in two discrete edges rather than a smooth sweep. + +
+ + + A glass hover changes opacity and never blur — re-blurring a large + surface every frame is expensive enough to feel. Glass surfaces use + their own `-glass-hover` rather than the shared `bg-hover`, which is a + neutral built to sit on an opaque surface. + + + ); +} diff --git a/desktop-next/src/features/design-system/ui/GrowthPage.tsx b/desktop-next/src/features/design-system/ui/GrowthPage.tsx new file mode 100644 index 0000000000..71c9e7fb31 --- /dev/null +++ b/desktop-next/src/features/design-system/ui/GrowthPage.tsx @@ -0,0 +1,89 @@ +import { GROWTH_PROCEDURE } from "@/shared/tokens/registry"; + +import { Note, PageHeader, Section } from "./primitives"; + +const AUDIT: Array<[string, string]> = [ + [ + "A newly introduced vocabulary word", + "Reported on its own line — this changes the system's shape", + ], + ["Two names with identical light and dark values", "Merged into one"], + [ + "Values within a hair of each other", + "Reported for a person to judge — usually drift, occasionally deliberate", + ], + ["Names nothing references", "Removed"], + [ + "Names that do not parse under the grammar", + "Reported with the correct spelling", + ], + [ + "A role value that is a literal rather than a ramp reference", + "Reported, unless on the deliberate-exception list", + ], + [ + "A proposed role used in several places by more than one feature", + "Nominated for promotion", + ], + [ + "A proposed role still used once, some time later", + "Nominated for deletion", + ], + ["A deprecated role still referenced", "Fails, naming its replacement"], + [ + "Any legal text-on-background pairing below contrast threshold", + "Fails, in either mode", + ], +]; + +export function GrowthPage() { + return ( + <> + + +
+
    + {GROWTH_PROCEDURE.map((step, index) => ( +
  1. + + {index + 1} + + + {step} + +
  2. + ))} +
+
+ +
+
+ {AUDIT.map(([check, action]) => ( +
+ {check} + {action} +
+ ))} +
+
+ + + Promotion from proposed to core is a metadata change, not a rename. + Renaming call sites would give promotion a migration cost, and anything + with a cost does not happen. + + + ); +} diff --git a/desktop-next/src/features/design-system/ui/MotionPage.tsx b/desktop-next/src/features/design-system/ui/MotionPage.tsx new file mode 100644 index 0000000000..63f568fb82 --- /dev/null +++ b/desktop-next/src/features/design-system/ui/MotionPage.tsx @@ -0,0 +1,41 @@ +import { PageHeader, Section, Stub } from "./primitives"; + +export function MotionPage() { + return ( + <> + + +
+ +
+ +
+
    + {[ + "Direct manipulation follows the pointer exactly, with no easing — smoothing during a drag reads as lag. Spring physics belongs to what happens after release.", + "Never animate blur. Re-blurring a large surface every frame is expensive enough to feel. Animate opacity instead.", + "Motion explains a change; it does not decorate one. If removing an animation loses no information, remove it.", + ].map((rule) => ( +
  • + {rule} +
  • + ))} +
+
+ + ); +} diff --git a/desktop-next/src/features/design-system/ui/OverviewPage.tsx b/desktop-next/src/features/design-system/ui/OverviewPage.tsx new file mode 100644 index 0000000000..11fe050188 --- /dev/null +++ b/desktop-next/src/features/design-system/ui/OverviewPage.tsx @@ -0,0 +1,81 @@ +import { Link } from "@tanstack/react-router"; + +import { GRAMMAR } from "@/shared/tokens/registry"; + +import { PageHeader, Section } from "./primitives"; + +const LAYERS: Array<[string, string, string]> = [ + ["Layer 0", "a hue", "a theme package, or a person's preference"], + ["Layer 1", "--accent-1…12 --neutral-1…12 --glass-1…5", "private ramps"], + ["Layer 2", "--bg-accent: var(--accent-9)", "public roles, point at steps"], + ["Layer 3", "bg-accent", "components, roles only"], +]; + +export function OverviewPage() { + return ( + <> + + +
+
+ {LAYERS.map(([layer, what, why]) => ( +
+ + {layer} + + + {what} + + {why} +
+ ))} +
+
+ +
+
+ {GRAMMAR} +
+

+ The order is fixed, so there is one correct spelling. See{" "} + + the vocabulary + {" "} + for every word a token can be built from. +

+
+ +
+
+ + Colour + + + Glass + + + Growing the system + +
+
+ + ); +} diff --git a/desktop-next/src/features/design-system/ui/RadiusPage.tsx b/desktop-next/src/features/design-system/ui/RadiusPage.tsx new file mode 100644 index 0000000000..6888a94dc6 --- /dev/null +++ b/desktop-next/src/features/design-system/ui/RadiusPage.tsx @@ -0,0 +1,24 @@ +import { PageHeader, Section, Stub } from "./primitives"; + +export function RadiusPage() { + return ( + <> + + +
+ +
+ + ); +} diff --git a/desktop-next/src/features/design-system/ui/SpacingPage.tsx b/desktop-next/src/features/design-system/ui/SpacingPage.tsx new file mode 100644 index 0000000000..88b60263ef --- /dev/null +++ b/desktop-next/src/features/design-system/ui/SpacingPage.tsx @@ -0,0 +1,25 @@ +import { PageHeader, Section, Stub } from "./primitives"; + +export function SpacingPage() { + return ( + <> + + +
+ +
+ + ); +} diff --git a/desktop-next/src/features/design-system/ui/TypographyPage.tsx b/desktop-next/src/features/design-system/ui/TypographyPage.tsx new file mode 100644 index 0000000000..c221aac87f --- /dev/null +++ b/desktop-next/src/features/design-system/ui/TypographyPage.tsx @@ -0,0 +1,40 @@ +import { PageHeader, Section, Stub } from "./primitives"; + +export function TypographyPage() { + return ( + <> + + +
+ +
+ +
+
+

+ The Cash Sans and BlockUI type variables that appear in the design + exploration are contamination from another Figma library linked into + that file. They exist nowhere in any Buzz codebase and need no + cleanup — only a decision not to inherit them. +

+
+
+ + ); +} diff --git a/desktop-next/src/features/design-system/ui/VocabularyPage.tsx b/desktop-next/src/features/design-system/ui/VocabularyPage.tsx new file mode 100644 index 0000000000..850afdadaf --- /dev/null +++ b/desktop-next/src/features/design-system/ui/VocabularyPage.tsx @@ -0,0 +1,76 @@ +import { + GRAMMAR, + GRAMMAR_EXAMPLES, + VOCABULARY, +} from "@/shared/tokens/registry"; + +import { Note, PageHeader, Section } from "./primitives"; + +export function VocabularyPage() { + return ( + <> + + +
+
+ {VOCABULARY.map((group) => ( +
+ + {group.group} + +
+ {group.words.map((word) => ( + + {word} + + ))} +
+
+ ))} +
+
+ +
+
+ {GRAMMAR} +
+
+
+

Legal

+ {GRAMMAR_EXAMPLES.legal.map((example) => ( + + {example} + + ))} +
+
+

Illegal

+ {GRAMMAR_EXAMPLES.illegal.map((example) => ( + + {example} + + ))} +
+
+
+ + + One modifier, one material, one state per name. A name that does not + parse under this grammar is reported by the audit with its correct + spelling. + + + ); +} diff --git a/desktop-next/src/features/design-system/ui/primitives.tsx b/desktop-next/src/features/design-system/ui/primitives.tsx new file mode 100644 index 0000000000..9d80820515 --- /dev/null +++ b/desktop-next/src/features/design-system/ui/primitives.tsx @@ -0,0 +1,117 @@ +import type { ReactNode } from "react"; + +/** + * Presentation pieces for the design system pages only. These are documentation + * furniture, not product components — the shared primitive layer gets built one + * component at a time as the product repeats something. + */ + +export function PageHeader({ + title, + intro, + status, +}: { + title: string; + intro: string; + status?: string; +}) { + return ( +
+
+

{title}

+ {status ? {status} : null} +
+

+ {intro} +

+
+ ); +} + +export function StatusPill({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +export function Section({ + title, + description, + children, +}: { + title: string; + description?: string; + children: ReactNode; +}) { + return ( +
+
+

{title}

+ {description ? ( +

+ {description} +

+ ) : null} +
+ {children} +
+ ); +} + +export function Note({ children }: { children: ReactNode }) { + return ( +

+ {children} +

+ ); +} + +export function Stub({ what, decide }: { what: string; decide: string[] }) { + return ( +
+

{what}

+
+

+ Still to decide +

+
    + {decide.map((item) => ( +
  • + {item} +
  • + ))} +
+
+
+ ); +} + +/** Renders a live swatch of whatever a CSS custom property currently holds. */ +export function Swatch({ + variable, + label, + sublabel, + translucent, +}: { + variable: string; + label: string; + sublabel?: string; + translucent?: boolean; +}) { + return ( +
+
+ {label} + {sublabel ? ( + {sublabel} + ) : null} +
+ ); +} diff --git a/desktop-next/src/main.tsx b/desktop-next/src/main.tsx new file mode 100644 index 0000000000..62d89b66cb --- /dev/null +++ b/desktop-next/src/main.tsx @@ -0,0 +1,23 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { RouterProvider, createRouter } from "@tanstack/react-router"; + +import "./shared/styles/globals.css"; +import { routeTree } from "./app/routeTree.gen"; + +const router = createRouter({ routeTree }); + +declare module "@tanstack/react-router" { + interface Register { + router: typeof router; + } +} + +const root = document.getElementById("root"); +if (!root) throw new Error("Missing #root element"); + +createRoot(root).render( + + + , +); diff --git a/desktop-next/src/shared/styles/globals.css b/desktop-next/src/shared/styles/globals.css new file mode 100644 index 0000000000..ddc273b6bf --- /dev/null +++ b/desktop-next/src/shared/styles/globals.css @@ -0,0 +1,53 @@ +@import "tailwindcss"; +@import "@fontsource-variable/inter"; +@import "./tokens.css"; + +@layer base { + html { + font-family: "Inter Variable", Inter, system-ui, sans-serif; + -webkit-font-smoothing: antialiased; + } + + body { + margin: 0; + background: var(--bg-app); + background-attachment: fixed; + color: var(--text-primary); + } +} + +/* + * The glass rim. Two inset shadows rather than a gradient border, because CSS + * borders accept only solid colours: border-image takes a gradient but ignores + * border-radius, and the two-background background-clip trick needs an opaque + * inner fill so it bleeds across a translucent surface. See DESIGN.md. + */ +@utility rim-glass { + box-shadow: + inset 0 1px 0 var(--rim-lit), + inset 0 -1px 0 var(--rim-shade); +} + +@utility blur-chrome { + backdrop-filter: blur(var(--blur-md)); +} + +@utility blur-panel { + backdrop-filter: blur(var(--blur-lg)); +} + +@utility texture-dots { + background-image: radial-gradient( + var(--texture-dots-color) 1px, + transparent 1px + ); + background-size: var(--texture-dots-size) var(--texture-dots-size); +} + +@utility elevate-xs { + box-shadow: var(--shadow-xs); +} + +@utility elevate-sm { + box-shadow: var(--shadow-sm); +} diff --git a/desktop-next/src/shared/styles/tokens.css b/desktop-next/src/shared/styles/tokens.css new file mode 100644 index 0000000000..ca0d6803ef --- /dev/null +++ b/desktop-next/src/shared/styles/tokens.css @@ -0,0 +1,338 @@ +/* + * Buzz colour tokens. + * + * Three layers, and only the role layer is ever used when building a screen: + * + * LAYER 1 private ramps --neutral-*, --accent-*, --glass-*, gradients + * LAYER 2 public roles --bg-panel, --text-secondary, ... + * LAYER 3 components bg-panel, text-secondary (Tailwind utilities) + * + * Rules: + * - Literal values exist only in the ramps. Nothing above a ramp holds one, + * except the documented exceptions at the bottom of this file. + * - A component never references a ramp step directly. + * - Transparency is never applied to a token. It lives inside the value. + * - Every role holds a light and a dark value under the same name. + * + * See DESIGN.md for the judgement this vocabulary cannot express. + */ + +/* ============================================================ + LAYER 1 — PRIVATE RAMPS + ============================================================ */ + +:root { + /* --- Neutral. Twelve steps, each step a fixed job. -------- */ + --neutral-1: #ffffff; /* app background */ + --neutral-2: #fafafa; /* subtle background */ + --neutral-3: #f5f5f5; /* component background */ + --neutral-4: #f0f0f0; /* component hover */ + --neutral-5: #e8e8e8; /* component selected */ + --neutral-6: #e5e5e5; /* subtle border */ + --neutral-7: #d4d4d4; /* border */ + --neutral-8: #b8b8b8; /* border hover */ + --neutral-9: #a3a3a3; /* solid fill */ + --neutral-10: #737373; /* solid fill hover */ + --neutral-11: #262626; /* dark fill */ + --neutral-12: #0a0a0a; /* high-contrast text */ + + /* --- Accent. A slot, not a colour. ----------------------- + Nothing above this ramp knows the hue. Replace these five + values to change the accent; every role below follows. + Reference hue: the blue from the design exploration. */ + --accent-2: #e7f1ff; /* tinted surface */ + --accent-3: #d0e3ff; /* tint hover / selected */ + --accent-8: #3d87ff; /* border, focus ring */ + --accent-9: #237eff; /* solid fill */ + --accent-11: #1d4ed8; /* accent text */ + + /* --- Status. Same five jobs as accent, per family. -------- */ + --danger-2: #fee2e2; + --danger-3: #fecaca; + --danger-8: #f5b5b5; + --danger-9: #dc2626; + --danger-11: #b91c1c; + + --success-2: #e8f7ee; + --success-3: #d1f0dd; + --success-8: #a3e0bd; + --success-9: #16a34a; + --success-11: #15803d; + + --warning-2: #fef3c7; + --warning-3: #fde68a; + --warning-8: #f2d98a; + --warning-9: #eab308; + --warning-11: #a16207; + + --info-2: #e7f1ff; + --info-3: #d0e3ff; + --info-8: #b8d4ff; + --info-9: #3d87ff; + --info-11: #1d4ed8; + + /* --- Glass. A translucency ramp of fills. ---------------- */ + --glass-1: #ffffff5c; /* barely there */ + --glass-2: #ffffff85; /* quiet glass */ + --glass-3: #ffffff9e; /* default glass */ + --glass-4: #ffffffc7; /* glass hovered */ + --glass-5: #ffffffe0; /* nearly solid */ + + /* --- Categorical. Named by appearance, deliberately: the + choice IS appearance — telling two projects apart. ------- */ + --tint-blue: #e7f1ff; + --tint-purple: #f3e8ff; + --tint-orange: #ffedd5; + --tint-green: #ecfccb; + --tint-red: #fee2e2; + --tint-cyan: #cffafe; + + /* --- Gradients. Numbered background treatments. ---------- */ + --gradient-1: + radial-gradient(at 85% 88%, #eef2d4 0%, transparent 62%), + linear-gradient(158deg, #f5f7f8 0%, #dfeaf3 52%, #e9efe0 100%); + + /* --- Texture. Separate from colour and gradient. --------- */ + --texture-dots-color: #0a0a0a0f; + --texture-dots-size: 28px; + + /* --- Blur. A separate axis from translucency. ------------ */ + --blur-sm: 8px; + --blur-md: 16px; + --blur-lg: 32px; + + /* --- Elevation. From the design exploration, unchanged. -- */ + --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-sm: 0 0 2px rgba(0, 0, 0, 0.1), 0 4px 8px -2px rgba(0, 0, 0, 0.06); +} + +.dark { + --neutral-1: #0f0f0f; + --neutral-2: #161616; + --neutral-3: #1c1c1c; + --neutral-4: #232323; + --neutral-5: #2a2a2a; + --neutral-6: #333333; + --neutral-7: #424242; + --neutral-8: #5a5a5a; + --neutral-9: #6e6e6e; + --neutral-10: #8f8f8f; + --neutral-11: #e5e5e5; + --neutral-12: #f5f5f5; + + /* Accent step 9 barely moves between modes — a saturated fill + reads correctly on white and on near-black. Step 11 inverts + direction: darker than the fill on light, lighter on dark. */ + --accent-2: #152845; + --accent-3: #1a3358; + --accent-8: #356dbd; + --accent-9: #237eff; + --accent-11: #8fbaff; + + --danger-2: #3a1515; + --danger-3: #4d1c1c; + --danger-8: #7a2e2e; + --danger-9: #dc2626; + --danger-11: #fca5a5; + + --success-2: #10261a; + --success-3: #163524; + --success-8: #2a5c3f; + --success-9: #16a34a; + --success-11: #86e0aa; + + --warning-2: #2e2410; + --warning-3: #423318; + --warning-8: #6b5320; + --warning-9: #eab308; + --warning-11: #f5d878; + + --info-2: #152845; + --info-3: #1a3358; + --info-8: #356dbd; + --info-9: #3d87ff; + --info-11: #8fbaff; + + --glass-1: #1c1c1c5c; + --glass-2: #1c1c1c85; + --glass-3: #1c1c1ca8; + --glass-4: #1c1c1cc7; + --glass-5: #1c1c1ce0; + + --tint-blue: #152845; + --tint-purple: #2b1f45; + --tint-orange: #3a2412; + --tint-green: #23300f; + --tint-red: #3a1515; + --tint-cyan: #0e2e33; + + --gradient-1: + radial-gradient(at 85% 88%, #23261a 0%, transparent 62%), + linear-gradient(158deg, #15171b 0%, #172231 52%, #191d19 100%); + + --texture-dots-color: #ffffff14; +} + +/* ============================================================ + LAYER 2 — PUBLIC ROLES + The only layer a screen may use. Every value below points at + a ramp step, except the documented exceptions at the bottom. + ============================================================ */ + +:root { + /* --- Structural surfaces. Named by region. --------------- + Ask one question: is it behind, on, above, or in? */ + --bg-app: var(--gradient-1); + --bg-panel: var(--neutral-1); + --bg-float: var(--neutral-1); + --bg-inset: var(--neutral-3); + --bg-hover: var(--neutral-4); + + /* --- Emphasis. One ramp shared by text and borders. ------ */ + --text-primary: var(--neutral-12); + --text-secondary: var(--neutral-10); + --text-tertiary: var(--neutral-9); + --text-disabled: var(--neutral-8); + + --border-primary: var(--neutral-7); + --border-secondary: var(--neutral-6); + --border-tertiary: var(--neutral-5); + + /* --- Identity. Two arrangements only: SOLID fill with + paired text on it, or TINT fill with coloured text. */ + --bg-accent: var(--accent-9); + --bg-accent-tint: var(--accent-2); + --bg-accent-tint-hover: var(--accent-3); + --text-accent: var(--accent-11); + --border-accent: var(--accent-8); + + --bg-inverse: var(--neutral-11); + --bg-inverse-hover: var(--neutral-10); + + /* --- Status. Identical shape, four times over. ----------- */ + --bg-danger: var(--danger-9); + --bg-danger-tint: var(--danger-2); + --bg-danger-tint-hover: var(--danger-3); + --text-danger: var(--danger-11); + --border-danger: var(--danger-8); + + --bg-success: var(--success-9); + --bg-success-tint: var(--success-2); + --bg-success-tint-hover: var(--success-3); + --text-success: var(--success-11); + --border-success: var(--success-8); + + --bg-warning: var(--warning-9); + --bg-warning-tint: var(--warning-2); + --bg-warning-tint-hover: var(--warning-3); + --text-warning: var(--warning-11); + --border-warning: var(--warning-8); + + --bg-info: var(--info-9); + --bg-info-tint: var(--info-2); + --bg-info-tint-hover: var(--info-3); + --text-info: var(--info-11); + --border-info: var(--info-8); + + /* --- Material. Translucency and blur live inside the value. + A component never assembles them. */ + --bg-chrome-glass: var(--glass-3); + --bg-chrome-glass-hover: var(--glass-4); + --bg-chrome-selected: var(--neutral-1); + --bg-panel-glass: var(--glass-2); + --bg-float-glass: var(--glass-3); + + /* --- Focus. ---------------------------------------------- */ + --ring-focus: var(--accent-8); + + /* ========================================================== + DELIBERATE EXCEPTIONS — the entire list. + ========================================================== + + 1. Paired text. Computed from its fill's lightness rather + than fixed, because white is readable on a blue or + purple fill and unreadable on yellow or lime. Until the + computation lands, these hold literals. */ + --text-on-accent: #ffffff; + --text-on-inverse: var(--neutral-1); + + /* 2. The glass rim. A directional light effect, not a solid + line: bright along the lit edge, dimmer on the opposite + one, from one fixed light direction every glass surface + in the product shares. Not on the glass ramp — that is + a ramp of fills, and in dark mode the fill is + translucent near-black while the rim stays translucent + white. Not one of the numbered gradients either: those + are background treatments, this is a material detail. + + Implemented as two inset shadows because CSS borders + accept only solid colours. See DESIGN.md. */ + --rim-lit: #ffffff73; + --rim-shade: #ffffff1f; +} + +.dark { + --bg-panel: var(--neutral-3); + --bg-float: var(--neutral-5); + --bg-inset: var(--neutral-2); + --bg-chrome-selected: var(--neutral-5); + + --rim-lit: #ffffff2e; + --rim-shade: #ffffff0f; +} + +/* ============================================================ + LAYER 3 — TAILWIND REGISTRATION + `--color-*: initial` deletes Tailwind's default palette, so + `text-gray-500` does not exist. Only the roles above do. + ============================================================ */ + +@theme inline { + --color-*: initial; + + --color-app: var(--bg-app); + --color-panel: var(--bg-panel); + --color-float: var(--bg-float); + --color-inset: var(--bg-inset); + --color-hover: var(--bg-hover); + + --color-primary: var(--text-primary); + --color-secondary: var(--text-secondary); + --color-tertiary: var(--text-tertiary); + --color-disabled: var(--text-disabled); + + --color-accent: var(--bg-accent); + --color-accent-tint: var(--bg-accent-tint); + --color-accent-tint-hover: var(--bg-accent-tint-hover); + --color-on-accent: var(--text-on-accent); + + --color-inverse: var(--bg-inverse); + --color-inverse-hover: var(--bg-inverse-hover); + --color-on-inverse: var(--text-on-inverse); + + --color-danger: var(--bg-danger); + --color-danger-tint: var(--bg-danger-tint); + --color-danger-tint-hover: var(--bg-danger-tint-hover); + --color-success: var(--bg-success); + --color-success-tint: var(--bg-success-tint); + --color-success-tint-hover: var(--bg-success-tint-hover); + --color-warning: var(--bg-warning); + --color-warning-tint: var(--bg-warning-tint); + --color-warning-tint-hover: var(--bg-warning-tint-hover); + --color-info: var(--bg-info); + --color-info-tint: var(--bg-info-tint); + --color-info-tint-hover: var(--bg-info-tint-hover); + + --color-chrome-glass: var(--bg-chrome-glass); + --color-chrome-glass-hover: var(--bg-chrome-glass-hover); + --color-chrome-selected: var(--bg-chrome-selected); + --color-panel-glass: var(--bg-panel-glass); + --color-float-glass: var(--bg-float-glass); + + --color-tint-blue: var(--tint-blue); + --color-tint-purple: var(--tint-purple); + --color-tint-orange: var(--tint-orange); + --color-tint-green: var(--tint-green); + --color-tint-red: var(--tint-red); + --color-tint-cyan: var(--tint-cyan); +} diff --git a/desktop-next/src/shared/theme/useColorScheme.ts b/desktop-next/src/shared/theme/useColorScheme.ts new file mode 100644 index 0000000000..f272a097b8 --- /dev/null +++ b/desktop-next/src/shared/theme/useColorScheme.ts @@ -0,0 +1,32 @@ +import { useCallback, useEffect, useState } from "react"; + +const STORAGE_KEY = "buzz-next-color-scheme"; + +export type ColorScheme = "light" | "dark"; + +function read(): ColorScheme { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === "light" || stored === "dark") return stored; + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; +} + +/** + * Light and dark are the same role names with different ramp values, so this + * only toggles one class on . No component knows which mode is active. + */ +export function useColorScheme() { + const [scheme, setScheme] = useState(read); + + useEffect(() => { + document.documentElement.classList.toggle("dark", scheme === "dark"); + localStorage.setItem(STORAGE_KEY, scheme); + }, [scheme]); + + const toggle = useCallback(() => { + setScheme((current) => (current === "light" ? "dark" : "light")); + }, []); + + return { scheme, toggle }; +} diff --git a/desktop-next/src/shared/tokens/registry.ts b/desktop-next/src/shared/tokens/registry.ts new file mode 100644 index 0000000000..1557ccce1e --- /dev/null +++ b/desktop-next/src/shared/tokens/registry.ts @@ -0,0 +1,507 @@ +/** + * The token registry. + * + * This is the machine-readable description of the colour system: what exists, + * what each name is for, what it points at, and whether it is core or proposed. + * The design system pages render from this, so a token added here appears in the + * documentation automatically and the docs cannot drift from the system. + * + * Adding to this file is a normal, unreviewed action — see the growth procedure + * in DESIGN.md. Every entry needs a `use` sentence and, if proposed, an `owner`. + */ + +/** Whether a token is part of the vetted system or someone's addition. */ +export type TokenStatus = "core" | "proposed" | "deprecated"; + +/** A private ramp step. Components never reference these. */ +export interface RampStep { + /** Position on the ramp. The number means a job, not a brightness. */ + step: number; + /** What this step is for. */ + job: string; + /** The CSS custom property, without `var()`. */ + variable: string; +} + +/** A private ramp. */ +export interface Ramp { + id: string; + name: string; + /** Why this ramp exists and how it behaves. */ + description: string; + steps: RampStep[]; + /** Rendered over the app backdrop so translucency is visible. */ + translucent?: boolean; +} + +/** A public role. The only layer a screen may use. */ +export interface Role { + /** The Tailwind class, e.g. `bg-panel`. */ + token: string; + /** The CSS custom property backing it. */ + variable: string; + /** What it points at, for display: `neutral 1`, or a note for exceptions. */ + pointsAt: string; + /** One sentence: when to use this. */ + use: string; + status: TokenStatus; + /** Required when status is `proposed`. */ + owner?: string; + /** Set when the value is a literal rather than a ramp reference. */ + exception?: string; +} + +export interface RoleGroup { + id: string; + name: string; + description: string; + roles: Role[]; +} + +/* ============================================================ + PRIVATE RAMPS + ============================================================ */ + +const NEUTRAL_JOBS = [ + "app background", + "subtle background", + "component background", + "component hover", + "component selected", + "subtle border", + "border", + "border hover", + "solid fill", + "solid fill hover", + "dark fill", + "high-contrast text", +]; + +/** The five accent steps any role points at. The rest exist so the ramp is + * complete and interpolation stays even. */ +const IDENTITY_STEPS: Array<[number, string]> = [ + [2, "tinted surface"], + [3, "tint hover / selected"], + [8, "border, focus ring"], + [9, "solid fill"], + [11, "accent text"], +]; + +function identityRamp(id: string, name: string, description: string): Ramp { + return { + id, + name, + description, + steps: IDENTITY_STEPS.map(([step, job]) => ({ + step, + job, + variable: `--${id}-${step}`, + })), + }; +} + +export const RAMPS: Ramp[] = [ + { + id: "neutral", + name: "Neutral", + description: + "Twelve steps, each step a fixed job. The number means a job, not a brightness — which is what lets a role encode a position that stays true across every palette.", + steps: NEUTRAL_JOBS.map((job, i) => ({ + step: i + 1, + job, + variable: `--neutral-${i + 1}`, + })), + }, + identityRamp( + "accent", + "Accent — a slot, not a colour", + "Nothing above this ramp knows the hue, so the accent can change, become a person's preference, or vary per theme without a single component changing. Step 9 barely moves between modes because a saturated fill reads on white and on near-black; step 11 inverts direction to stay readable.", + ), + identityRamp( + "danger", + "Danger", + "The same five jobs as accent. Learning one family teaches all of them.", + ), + identityRamp("success", "Success", "The same five jobs as accent."), + identityRamp("warning", "Warning", "The same five jobs as accent."), + identityRamp("info", "Info", "The same five jobs as accent."), + { + id: "glass", + name: "Glass", + description: + "A translucency ramp of fills. Each step is the mode's surface colour at an increasing opacity, which is what lets a hover move one step up the ramp instead of holding its own literal. Fills only — a glass surface's bright rim is a border, and it is a documented exception.", + translucent: true, + steps: [ + { step: 1, job: "barely there", variable: "--glass-1" }, + { step: 2, job: "quiet glass", variable: "--glass-2" }, + { step: 3, job: "default glass", variable: "--glass-3" }, + { step: 4, job: "glass hovered", variable: "--glass-4" }, + { step: 5, job: "nearly solid", variable: "--glass-5" }, + ], + }, +]; + +/* ============================================================ + PUBLIC ROLES + ============================================================ */ + +function identityGroup( + id: string, + label: string, + description: string, +): RoleGroup { + return { + id, + name: label, + description, + roles: [ + { + token: `bg-${id}`, + variable: `--bg-${id}`, + pointsAt: `${id} 9`, + use: "The solid fill: primary buttons, active toggles. Takes its paired text.", + status: "core", + }, + { + token: `text-on-${id}`, + variable: `--text-on-${id}`, + pointsAt: "computed from the fill's lightness", + use: `Text sitting on bg-${id}.`, + status: "core", + exception: + "Computed rather than fixed: white is readable on a blue or purple fill and unreadable on yellow or lime.", + }, + { + token: `text-${id}`, + variable: `--text-${id}`, + pointsAt: `${id} 11`, + use: "Coloured text on a neutral background: links, labels.", + status: "core", + }, + { + token: `bg-${id}-tint`, + variable: `--bg-${id}-tint`, + pointsAt: `${id} 2`, + use: "A tinted surface carrying meaning: chips, callouts, selected rows. Takes coloured text.", + status: "core", + }, + { + token: `bg-${id}-tint-hover`, + variable: `--bg-${id}-tint-hover`, + pointsAt: `${id} 3`, + use: "That tinted surface hovered or selected.", + status: "core", + }, + { + token: `border-${id}`, + variable: `--border-${id}`, + pointsAt: `${id} 8`, + use: "Focus rings and active borders.", + status: "core", + }, + ], + }; +} + +export const ROLE_GROUPS: RoleGroup[] = [ + { + id: "surfaces", + name: "Structural surfaces", + description: + "Named and closed, because there are only a few right answers and guessing produces a broken interface. Ask one question: is it behind, on, above, or in?", + roles: [ + { + token: "bg-app", + variable: "--bg-app", + pointsAt: "gradient-1", + use: "The backdrop everything sits on.", + status: "core", + }, + { + token: "bg-panel", + variable: "--bg-panel", + pointsAt: "neutral 1 light / neutral 3 dark", + use: "Everything sitting on the backdrop: the navigation column, content, timelines, cards, rows.", + status: "core", + }, + { + token: "bg-float", + variable: "--bg-float", + pointsAt: "neutral 1 light / neutral 5 dark", + use: "Anything hovering above the page: menus, dialogs, tooltips, toasts. Shares a light value with bg-panel and diverges in dark, because a shadow cannot carry elevation on a near-black background.", + status: "core", + }, + { + token: "bg-inset", + variable: "--bg-inset", + pointsAt: "neutral 3 light / neutral 2 dark", + use: "Anything pushed in: inputs, code blocks, quotes.", + status: "core", + }, + { + token: "bg-hover", + variable: "--bg-hover", + pointsAt: "neutral 4", + use: "Any neutral row or item under the cursor. Region-less — it works on any neutral surface, which is why there is only one of them.", + status: "core", + }, + ], + }, + { + id: "emphasis", + name: "Emphasis", + description: + "One three-level ramp shared by text and borders: normal, lesser, really lesser. States that are not levels of emphasis get their own names rather than extending the ramp.", + roles: [ + { + token: "text-primary", + variable: "--text-primary", + pointsAt: "neutral 12", + use: "Normal reading text.", + status: "core", + }, + { + token: "text-secondary", + variable: "--text-secondary", + pointsAt: "neutral 10", + use: "Supporting text: author names, labels.", + status: "core", + }, + { + token: "text-tertiary", + variable: "--text-tertiary", + pointsAt: "neutral 9", + use: "Metadata: timestamps, counts, hints.", + status: "core", + }, + { + token: "text-disabled", + variable: "--text-disabled", + pointsAt: "neutral 8", + use: "Unavailable. A state, not a fourth level of the ramp.", + status: "core", + }, + { + token: "border-primary", + variable: "--border-primary", + pointsAt: "neutral 7", + use: "The default visible line.", + status: "core", + }, + { + token: "border-secondary", + variable: "--border-secondary", + pointsAt: "neutral 6", + use: "A lighter separator inside a group.", + status: "core", + }, + { + token: "border-tertiary", + variable: "--border-tertiary", + pointsAt: "neutral 5", + use: "The faintest line.", + status: "core", + }, + ], + }, + identityGroup( + "accent", + "Accent", + "Two arrangements and no third: a solid fill with paired text on it, or a tint fill with coloured text on it. There is deliberately nothing between them.", + ), + { + id: "inverse", + name: "Inverse", + description: + "A high-contrast fill for surfaces that must stand apart from everything: tooltips, toasts.", + roles: [ + { + token: "bg-inverse", + variable: "--bg-inverse", + pointsAt: "neutral 11", + use: "High-contrast fill: tooltips, toasts.", + status: "core", + }, + { + token: "bg-inverse-hover", + variable: "--bg-inverse-hover", + pointsAt: "neutral 10", + use: "Its hover. Step 10 sits on the light side of step 11 in the light ramp and the dark side in the dark ramp, so the hover inverts direction with no special-casing.", + status: "core", + }, + { + token: "text-on-inverse", + variable: "--text-on-inverse", + pointsAt: "computed from the fill's lightness", + use: "Text sitting on bg-inverse.", + status: "core", + exception: "Computed rather than fixed.", + }, + ], + }, + identityGroup("danger", "Danger", "Destructive actions and error states."), + identityGroup("success", "Success", "Confirmation and healthy states."), + identityGroup("warning", "Warning", "Caution that is not yet an error."), + identityGroup("info", "Info", "Neutral information and guidance."), + { + id: "material", + name: "Material", + description: + "A translucent blurred surface is the same region in a different material. Translucency and blur live inside the value; a component never assembles them from a fill, a transparency, and a blur amount.", + roles: [ + { + token: "bg-chrome-glass", + variable: "--bg-chrome-glass", + pointsAt: "glass 3 + blur-md", + use: "The top bar and floating chrome.", + status: "core", + }, + { + token: "bg-chrome-glass-hover", + variable: "--bg-chrome-glass-hover", + pointsAt: "glass 4 + blur-md", + use: "A chrome control under the cursor. A glass hover moves one step up the ramp, changing opacity and never blur.", + status: "core", + }, + { + token: "bg-chrome-selected", + variable: "--bg-chrome-selected", + pointsAt: "neutral 1 light / neutral 5 dark", + use: "The selected item inside chrome. Carries no material suffix because omitting it means opaque — on glass, elevation reads as less translucency, not a lighter colour.", + status: "core", + }, + { + token: "bg-panel-glass", + variable: "--bg-panel-glass", + pointsAt: "glass 2 + blur-lg", + use: "A panel that should let the backdrop through.", + status: "core", + }, + { + token: "bg-float-glass", + variable: "--bg-float-glass", + pointsAt: "glass 3 + blur-md", + use: "A floating surface that should let content through.", + status: "core", + }, + ], + }, + { + id: "focus", + name: "Focus", + description: "Focus is part of the design, never an artefact to suppress.", + roles: [ + { + token: "ring-focus", + variable: "--ring-focus", + pointsAt: "accent 8", + use: "The keyboard focus ring.", + status: "core", + }, + ], + }, + { + id: "categorical", + name: "Categorical tints", + description: + "The one documented exception to naming colours after their job: telling two projects apart genuinely is a choice about appearance, and pretending otherwise would push people back to raw values.", + roles: (["blue", "purple", "orange", "green", "red", "cyan"] as const).map( + (hue) => ({ + token: `bg-tint-${hue}`, + variable: `--tint-${hue}`, + pointsAt: "a literal, by design", + use: `A categorical ${hue} tint for distinguishing one thing from another.`, + status: "core" as TokenStatus, + exception: "Named by appearance because the choice is appearance.", + }), + ), + }, +]; + +/* ============================================================ + THE VOCABULARY + Every token in the system is built from these words. A new name + combining existing words is routine; a new WORD is what the + audit reports on its own line. + ============================================================ */ + +export const VOCABULARY: Array<{ group: string; words: string[] }> = [ + { group: "property", words: ["bg", "text", "border", "ring"] }, + { group: "region", words: ["app", "panel", "float", "chrome", "inset"] }, + { group: "emphasis", words: ["primary", "secondary", "tertiary"] }, + { group: "state", words: ["hover", "selected", "disabled"] }, + { group: "material", words: ["glass"] }, + { group: "modifier", words: ["tint"] }, + { group: "identity", words: ["accent", "inverse"] }, + { group: "status", words: ["success", "warning", "danger", "info"] }, + { group: "paired", words: ["on-accent", "on-inverse"] }, + { + group: "categorical", + words: [ + "tint-blue", + "tint-purple", + "tint-orange", + "tint-green", + "tint-red", + "tint-cyan", + ], + }, +]; + +export const GRAMMAR = "-[-][-][-]"; + +/** Fixed order, so there is only one correct spelling. */ +export const GRAMMAR_EXAMPLES = { + legal: ["bg-chrome-glass-hover", "bg-accent-tint-hover", "text-secondary"], + illegal: ["bg-chrome-hover-glass", "bg-hover-chrome"], +}; + +/** Runs per change, by whoever needs the value. Nothing here needs permission. */ +export const GROWTH_PROCEDURE = [ + "Search the role list by intent, not by colour.", + "A state of an existing role — add the -hover, -selected, or -disabled sibling with both values.", + "A material variant of an existing role — add the -glass sibling with both values and its blur token.", + "A new role using existing words — add the name, both values, a one-sentence description, and an owner.", + "A new hue — generate its ramp, add roles pointing at steps. Never a literal.", + "A new vocabulary word — allowed, but it is the thing the audit reports on its own line, so use an existing word if one fits.", + "Never write a raw value. If nothing above applies, say so rather than reaching for a literal.", +]; + +export const ELEVATION = [ + { + token: "shadow-xs", + variable: "--shadow-xs", + use: "The default lift: a selected pill, a small raised control.", + }, + { + token: "shadow-sm", + variable: "--shadow-sm", + use: "A floating surface: menus, dialogs, popovers.", + }, +]; + +export const BLUR = [ + { token: "blur-sm", variable: "--blur-sm", value: "8px" }, + { token: "blur-md", variable: "--blur-md", value: "16px" }, + { token: "blur-lg", variable: "--blur-lg", value: "32px" }, +]; + +/** The entire exception list. Everything else points at a ramp step. */ +export const EXCEPTIONS = [ + { + name: "text-on-accent, text-on-inverse", + why: "Computed from their fill's lightness rather than fixed, because white is readable on a blue or purple fill and unreadable on yellow or lime. This is what keeps a free choice of accent hue from becoming a contrast lottery.", + }, + { + name: "--rim-lit, --rim-shade", + why: "The glass rim is a directional light effect, not a solid line. It is not on the glass ramp — that is a ramp of fills, and in dark mode the fill is translucent near-black while the rim stays translucent white. It is not one of the numbered gradients either: those are background treatments, this is a material detail.", + }, + { + name: "gradient-1, texture-dots", + why: "Not colours in the ramp sense.", + }, + { + name: "the categorical tints", + why: "Named by appearance because the choice is appearance.", + }, +]; diff --git a/desktop-next/src/vite-env.d.ts b/desktop-next/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/desktop-next/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/desktop-next/tsconfig.json b/desktop-next/tsconfig.json new file mode 100644 index 0000000000..c41c555c93 --- /dev/null +++ b/desktop-next/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "paths": { "@/*": ["./src/*"] }, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/desktop-next/vite.config.ts b/desktop-next/vite.config.ts new file mode 100644 index 0000000000..216f719887 --- /dev/null +++ b/desktop-next/vite.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { tanstackRouter } from "@tanstack/router-plugin/vite"; + +export default defineConfig({ + plugins: [ + tanstackRouter({ + target: "react", + routesDirectory: "./src/app/routes", + generatedRouteTree: "./src/app/routeTree.gen.ts", + quoteStyle: "double", + semicolons: true, + routeTreeFileHeader: [ + "// biome-ignore-all lint: generated by TanStack Router", + ], + }), + react(), + ], + resolve: { alias: { "@": "/src" } }, + server: { port: 1430, strictPort: true }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95612ae1fd..8cf7f5bc9f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -302,6 +302,49 @@ importers: specifier: ^8.0.0 version: 8.0.16(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0) + desktop-next: + dependencies: + '@base-ui-components/react': + specifier: 1.0.0-rc.0 + version: 1.0.0-rc.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@fontsource-variable/inter': + specifier: ^5.2.8 + version: 5.2.8 + '@tanstack/react-router': + specifier: ^1.168.10 + version: 1.170.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: + specifier: ^19.1.0 + version: 19.2.8 + react-dom: + specifier: ^19.1.0 + version: 19.2.8(react@19.2.8) + devDependencies: + '@tailwindcss/postcss': + specifier: ^4.3.0 + version: 4.3.0 + '@tanstack/router-plugin': + specifier: ^1.167.12 + version: 1.168.10(@tanstack/react-router@1.170.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(vite@8.0.16(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0)) + '@types/react': + specifier: ^19.1.8 + version: 19.2.18 + '@types/react-dom': + specifier: ^19.1.6 + version: 19.2.4(@types/react@19.2.18) + '@vitejs/plugin-react': + specifier: ^6.0.0 + version: 6.0.5(vite@8.0.16(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0)) + tailwindcss: + specifier: ^4.3.0 + version: 4.3.0 + typescript: + specifier: ~6.0.0 + version: 6.0.3 + vite: + specifier: ^8.0.0 + version: 8.0.16(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0) + web: dependencies: '@fontsource-variable/inter': @@ -420,10 +463,6 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} - '@babel/code-frame@7.29.0': - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} - engines: {node: '>=6.9.0'} - '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -466,10 +505,6 @@ packages: resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.29.7': resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} @@ -515,6 +550,29 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@base-ui-components/react@1.0.0-rc.0': + resolution: {integrity: sha512-9lhUFbJcbXvc9KulLev1WTFxS/alJRBWDH/ibKSQaNvmDwMFS2gKp1sTeeldYSfKuS/KC1w2MZutc0wHu2hRHQ==} + engines: {node: '>=14.0.0'} + deprecated: Package was renamed to @base-ui/react + peerDependencies: + '@types/react': ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + + '@base-ui-components/utils@0.2.2': + resolution: {integrity: sha512-rNJCD6TFy3OSRDKVHJDzLpxO3esTV1/drRtWNUpe7rCpPN9HZVHUCuP+6rdDYDGWfXnQHbqi05xOyRP2iZAlkw==} + deprecated: Package was renamed to @base-ui/utils + peerDependencies: + '@types/react': ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + '@biomejs/biome@2.4.16': resolution: {integrity: sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA==} engines: {node: '>=14.21.3'} @@ -537,7 +595,6 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [musl] '@biomejs/cli-linux-arm64@2.4.16': resolution: {integrity: sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ==} @@ -558,7 +615,6 @@ packages: engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [glibc] '@biomejs/cli-win32-arm64@2.4.16': resolution: {integrity: sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A==} @@ -1512,7 +1568,6 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.3': resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} @@ -1663,7 +1718,6 @@ packages: engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.0': resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} @@ -1801,14 +1855,12 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-arm64-musl@2.11.4': resolution: {integrity: sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': resolution: {integrity: sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==} @@ -1822,14 +1874,12 @@ packages: engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@tauri-apps/cli-linux-x64-musl@2.11.4': resolution: {integrity: sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@tauri-apps/cli-win32-arm64-msvc@2.11.4': resolution: {integrity: sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==} @@ -2753,7 +2803,6 @@ packages: engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} @@ -3006,11 +3055,6 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - nanoid@3.3.17: resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -3078,10 +3122,6 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@4.0.5: - resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} - engines: {node: '>=12'} - picomatch@4.0.7: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} @@ -3112,10 +3152,6 @@ packages: resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} engines: {node: '>=4'} - postcss@8.5.19: - resolution: {integrity: sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.5.26: resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} @@ -3293,6 +3329,9 @@ packages: require-main-filename@2.0.0: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + reselect@5.3.0: + resolution: {integrity: sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==} + rolldown@1.0.3: resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3403,6 +3442,9 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tabbable@6.5.0: + resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==} + tailwind-merge@3.6.0: resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} @@ -3694,7 +3736,7 @@ packages: engines: {node: '>=8'} wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + resolution: {integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=} ws@8.21.1: resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} @@ -3766,12 +3808,6 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} - '@babel/code-frame@7.29.0': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -3782,7 +3818,7 @@ snapshots: '@babel/core@7.29.0': dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.1 '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) @@ -3829,7 +3865,7 @@ snapshots: dependencies: '@babel/core': 7.29.0 '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-validator-identifier': 7.29.7 '@babel/traverse': 7.29.0 transitivePeerDependencies: - supports-color @@ -3838,8 +3874,6 @@ snapshots: '@babel/helper-string-parser@7.27.1': {} - '@babel/helper-validator-identifier@7.28.5': {} - '@babel/helper-validator-identifier@7.29.7': {} '@babel/helper-validator-option@7.27.1': {} @@ -3867,13 +3901,13 @@ snapshots: '@babel/template@7.28.6': dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 '@babel/parser': 7.29.3 '@babel/types': 7.29.0 '@babel/traverse@7.29.0': dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.1 '@babel/helper-globals': 7.28.0 '@babel/parser': 7.29.3 @@ -3886,7 +3920,32 @@ snapshots: '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-validator-identifier': 7.29.7 + + '@base-ui-components/react@1.0.0-rc.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.29.7 + '@base-ui-components/utils': 0.2.2(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@floating-ui/utils': 0.2.12 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + reselect: 5.3.0 + tabbable: 6.5.0 + use-sync-external-store: 1.6.0(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + + '@base-ui-components/utils@0.2.2(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.29.7 + '@floating-ui/utils': 0.2.12 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + reselect: 5.3.0 + use-sync-external-store: 1.6.0(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 '@biomejs/biome@2.4.16': optionalDependencies: @@ -6398,8 +6457,6 @@ snapshots: ms@2.1.3: {} - nanoid@3.3.16: {} - nanoid@3.3.17: {} node-releases@2.0.46: {} @@ -6466,8 +6523,6 @@ snapshots: picocolors@1.1.1: {} - picomatch@4.0.5: {} - picomatch@4.0.7: {} pify@4.0.1: {} @@ -6489,12 +6544,6 @@ snapshots: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss@8.5.19: - dependencies: - nanoid: 3.3.16 - picocolors: 1.1.1 - source-map-js: 1.2.1 - postcss@8.5.26: dependencies: nanoid: 3.3.17 @@ -6737,6 +6786,8 @@ snapshots: require-main-filename@2.0.0: {} + reselect@5.3.0: {} + rolldown@1.0.3: dependencies: '@oxc-project/types': 0.133.0 @@ -6864,6 +6915,8 @@ snapshots: symbol-tree@3.2.4: {} + tabbable@6.5.0: {} + tailwind-merge@3.6.0: {} tailwindcss@4.3.0: {} @@ -7017,8 +7070,8 @@ snapshots: vite@8.0.16(@types/node@25.6.0)(jiti@2.7.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 - picomatch: 4.0.5 - postcss: 8.5.19 + picomatch: 4.0.7 + postcss: 8.5.26 rolldown: 1.0.3 tinyglobby: 0.2.17 optionalDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index be122c50ce..a1bbc5bc7f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - "desktop" + - "desktop-next" - "web" - "admin-web" allowBuilds: From 30847f42ffb4a08b45499a861b24af684403f2e0 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:50:39 -0700 Subject: [PATCH 02/33] feat(desktop-next): add the type system, fix unreachable border roles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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---` 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> --- desktop-next/AGENTS.md | 60 ++++- desktop-next/DESIGN.md | 13 +- desktop-next/package.json | 6 +- desktop-next/scripts/check-type.mjs | 125 ++++++++++ desktop-next/src/app/routes/index.tsx | 6 +- .../features/design-system/ui/ColourPage.tsx | 37 ++- .../design-system/ui/DesignSystemLayout.tsx | 43 ++-- .../design-system/ui/ElevationPage.tsx | 6 +- .../features/design-system/ui/GlassPage.tsx | 14 +- .../features/design-system/ui/GrowthPage.tsx | 16 +- .../features/design-system/ui/MotionPage.tsx | 2 +- .../design-system/ui/OverviewPage.tsx | 26 +- .../design-system/ui/TypographyPage.tsx | 149 +++++++++-- .../design-system/ui/VocabularyPage.tsx | 20 +- .../features/design-system/ui/primitives.tsx | 68 ++++-- desktop-next/src/shared/styles/globals.css | 7 +- desktop-next/src/shared/styles/tokens.css | 11 + desktop-next/src/shared/styles/typography.css | 231 ++++++++++++++++++ desktop-next/src/shared/tokens/registry.ts | 181 ++++++++++++++ pnpm-lock.yaml | 3 + 20 files changed, 879 insertions(+), 145 deletions(-) create mode 100644 desktop-next/scripts/check-type.mjs create mode 100644 desktop-next/src/shared/styles/typography.css diff --git a/desktop-next/AGENTS.md b/desktop-next/AGENTS.md index 1ae429ab4a..3244d42cca 100644 --- a/desktop-next/AGENTS.md +++ b/desktop-next/AGENTS.md @@ -32,10 +32,16 @@ Port 1430, so it can run alongside the existing client on 1420. ```bash pnpm typecheck # tsc --noEmit -pnpm check # biome +pnpm check # biome + the type-system guard +pnpm check:type # the type-system guard alone pnpm biome check --write . # auto-fix ``` +`scripts/check-type.mjs` enforces four things the type system cannot express as +a token: no arbitrary text sizes (px **or** rem), no `uppercase`, no +hand-applied `tracking-*`, and no size role paired with a weight or leading +utility. Each one is a defect the existing client already paid for. + --- ## Stack, and why @@ -52,6 +58,53 @@ dependency. --- +## The type system + +Same three layers as colour, and the same rule: only roles are used when +building a screen. + +``` +LAYER 1 --type-size-1…9 --type-leading-* --type-tracking-* --type-weight-* +LAYER 2 --text-body: var(--type-size-4) + its line height, tracking, weight +LAYER 3 text-body +``` + +**A role carries its whole typographic setting**, using Tailwind v4's +`--text---` convention. So `text-body` sets size, line height, +letter spacing, and weight together — never pair a size role with a separate +`font-medium` or `leading-*`, because that is how two supposedly identical labels +drift apart. + +Ten roles: `text-display`, `text-title`, `text-heading`, `text-subheading`, +`text-body-lg`, `text-body`, `text-label`, `text-caption`, `text-meta`, +`text-code`. Two faces: `font-sans` (Inter Variable) and `font-mono` +(JetBrains Mono) — both already shipped in every current Buzz client. + +Hard rules: + +- **Never a px size.** Everything derives from one virtual rem, so keyboard zoom + and the font-size preference both work by construction. A px literal breaks + both, which the existing client learned by shipping the regression. +- **No `uppercase`, no hand-applied `tracking-*`.** All-caps labels are less + legible and read as enterprise chrome; tracking is already corrected per step. +- **Size roles and colour roles never collide.** Colour registers in + Tailwind's `--color-*` namespace and is named for emphasis (`text-primary`); + size registers in `--text-*` and is named for an editorial job (`text-body`). + So `text-primary text-body` is one colour plus one setting, and no name ever + means both. + +### Borders register in their own namespace + +`--color-*` is a single namespace, and text and borders share the emphasis names +`primary` / `secondary` / `tertiary` while holding different values — text at the +dark end of the neutral ramp, borders at the light end. Border roles therefore +register as `--border-color-*`, not `--color-border-*`. + +Get this wrong and there is no error: `border-primary` silently resolves to the +*text* colour and every hairline in the product draws at near-black. That is not +hypothetical — it shipped, and it is why the first design system site had black +dividers while the tokens said `#d4d4d4`. + ## The colour system Three layers. Only the role layer is ever used when building a screen. @@ -134,8 +187,9 @@ time as the product repeats something — see DESIGN.md § Components. Deliberately, so nobody assumes it was forgotten: -- **Typography, spacing, radius, and motion tokens.** Their `/design` pages state - what is still to decide rather than pretending to a system. Typography is next. +- **Spacing, radius, and motion tokens.** Their `/design` pages state what is + still to decide rather than pretending to a system. Typography has landed; + spacing is next. - **Any product component.** No Button, no Dialog, no input layer. - **Tauri.** This is a web app for now; the native shell comes with the app shell. - **Relay, auth, event handling.** None of it. When it arrives it comes from the diff --git a/desktop-next/DESIGN.md b/desktop-next/DESIGN.md index 0f5be1e4a4..2e964b4ca0 100644 --- a/desktop-next/DESIGN.md +++ b/desktop-next/DESIGN.md @@ -11,7 +11,9 @@ Buzz is a place where people build together and bring their agents into the room ## Surface and depth - **Panels sit on the backdrop; the backdrop is a gradient.** Everything else is a panel in a different place. The navigation column is not a special kind of surface. -- **Depth comes from hairline borders and near-invisible shadows, not from many surface colours.** Reach for a border before reaching for a new fill. +- **A region is separated by a soft fill, not by an outline.** Reach for `bg-inset` before reaching for a border. A bordered box announces its own edges; a filled one lets the content sit in a place. Grouping is the common case, so the quiet treatment is the default one. +- **A border is for a genuine boundary, and never above `border-secondary`.** A hairline that reads as a line — rather than as the edge where two surfaces meet — is too strong. Text and borders share the emphasis names but not their values: text lives at the dark end of the neutral ramp, borders at the light end. If a divider looks like text, it is pointed at the wrong role. +- **No page-wide gradient behind documentation or dense reading.** The gradient is the product's backdrop for chrome and panels. Behind a column of prose it fights the text and makes contrast position-dependent — such surfaces sit on `bg-panel`. - **Shadows stay at the threshold of perception.** If a shadow is obvious, it is too strong. The two elevation values are the whole vocabulary. - **Elevation is carried by shadow in light mode and by lightness in dark mode.** On a near-black background there is nothing darker for a shadow to cast, so a floating surface becomes a step lighter instead. Never reach for a stronger shadow to make something float in dark mode. - **On a translucent surface, elevation reads as less translucency, not as a lighter colour.** A glass container with a fully opaque child looks layered; the same container with a merely brighter child looks unchanged. @@ -36,6 +38,15 @@ Buzz is a place where people build together and bring their agents into the room - **Borders use the same three levels, and they mean the same thing.** Learn the ramp once. - **Weight and size carry hierarchy before colour does.** Reaching for a louder colour to fix hierarchy usually means the size relationship is wrong. +## Type + +- **A type role carries its whole setting.** Size, line height, letter spacing, and weight are one decision, not four. `text-body` alone produces correctly set text — never pair a size role with a separate weight or leading utility, because that is how two supposedly identical labels drift apart. +- **Roles are named for the job the text does, never for its size.** `text-title`, not `text-28`. A size name is a value in disguise and goes stale the moment the ramp moves. +- **Never all-caps, and never tracked-out labels.** A capitalised label is harder to read than its sentence-case version and reads as enterprise chrome. A quiet label earns its quietness from size and colour — `text-meta` on `text-tertiary` — rather than from being shouted. There is deliberately no uppercase utility in this system. +- **Every size is relative.** Nothing may be expressed in px: fixed pixel text freezes against keyboard zoom and ignores the person's font-size preference. The existing client shipped a regression from exactly this. +- **Tracking is an optical correction, not a style.** Inter needs progressively tighter spacing as it grows. The ramp already applies it per step; do not add tracking by hand. +- **Code is one step below body.** At equal size a monospace face reads larger than Inter and pulls the eye off the sentence. + ## Both modes - **Design in both modes, not in light and then dark.** Dark is not a filter applied afterwards: elevation, glass, and accent text all behave differently there. diff --git a/desktop-next/package.json b/desktop-next/package.json index c0c8917de9..aa996daad8 100644 --- a/desktop-next/package.json +++ b/desktop-next/package.json @@ -9,12 +9,14 @@ "typecheck": "tsc --noEmit", "preview": "vite preview", "lint": "biome lint .", - "check": "biome check .", - "format": "biome format --write ." + "check": "biome check . && node scripts/check-type.mjs", + "format": "biome format --write .", + "check:type": "node scripts/check-type.mjs" }, "dependencies": { "@base-ui-components/react": "1.0.0-rc.0", "@fontsource-variable/inter": "^5.2.8", + "@fontsource/jetbrains-mono": "^5.3.0", "@tanstack/react-router": "^1.168.10", "react": "^19.1.0", "react-dom": "^19.1.0" diff --git a/desktop-next/scripts/check-type.mjs b/desktop-next/scripts/check-type.mjs new file mode 100644 index 0000000000..3833679f1f --- /dev/null +++ b/desktop-next/scripts/check-type.mjs @@ -0,0 +1,125 @@ +#!/usr/bin/env node +/** + * Type-system guard. + * + * Three rules from DESIGN.md § Type, enforced rather than trusted. Each one has + * already cost the existing client real work: + * + * 1. No arbitrary text sizes — `text-[15px]`, `text-[0.9rem]`, `font-size:`. + * Fixed px froze against keyboard zoom and shipped a message-timeline + * regression; arbitrary rem re-fragments the scale we just consolidated. + * 2. No `uppercase` / `tracking-*` utilities. All-caps labels are less legible + * and read as enterprise chrome, and tracking is corrected per ramp step. + * 3. No size role paired with a weight or leading utility. A role carries its + * whole setting; pairing one with `font-medium` is how two supposedly + * identical labels drift apart. + * + * Run: pnpm check:type + */ + +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; + +const SRC = new URL("../src", import.meta.url).pathname; + +/** Size roles a component may use. Kept in sync with typography.css. */ +const SIZE_ROLES = [ + "display", + "title", + "heading", + "subheading", + "body-lg", + "body", + "label", + "caption", + "meta", + "code", +]; + +const RULES = [ + { + id: "arbitrary-text-size", + // text-[...] with any unit, plus raw CSS font-size declarations. + pattern: /\btext-\[[^\]]*(?:px|rem|em|pt|%)[^\]]*\]|font-size\s*:/g, + message: + "arbitrary text size — use a named role (text-body, text-label, …). px freezes against zoom; arbitrary rem re-fragments the scale.", + }, + { + id: "uppercase", + // Only inside a className/class string — otherwise prose describing the + // rule trips it, and a guard that cries wolf gets ignored. + pattern: /class(?:Name)?=(?:"|'|`)[^"'`]*\buppercase\b/g, + message: + "all-caps text — DESIGN.md § Type forbids it. A quiet label uses text-meta on text-tertiary instead.", + }, + { + id: "manual-tracking", + pattern: /\btracking-(?:tighter|tight|normal|wide|wider|widest|\[)/g, + message: + "hand-applied tracking — the ramp already corrects tracking per step.", + }, + { + id: "role-plus-weight", + // A size role sharing a *className string* with a weight or leading + // utility. Anchoring to the attribute keeps the ramp's own + // `--text-body--line-height` declarations out of it — those are the + // definitions, not a component overriding one. + pattern: new RegExp( + `class(?:Name)?=(?:"|'|\`)[^"'\`]*\\btext-(?:${SIZE_ROLES.join("|")})\\b[^"'\`]*\\b(?:font-(?:thin|extralight|light|normal|medium|semibold|bold|extrabold|black)|leading-)`, + "g", + ), + message: + "size role paired with a weight/leading utility — a role already carries its whole setting.", + }, +]; + +function walk(dir) { + return readdirSync(dir).flatMap((entry) => { + const full = join(dir, entry); + if (statSync(full).isDirectory()) return walk(full); + return /\.(tsx?|css)$/.test(full) ? [full] : []; + }); +} + +const failures = []; + +for (const file of walk(SRC)) { + const source = readFileSync(file, "utf8"); + // The typography ramp itself is the one place literals are legal — it is + // layer 1, where values live by design. + const isRamp = file.endsWith("typography.css"); + const lines = source.split("\n"); + + lines.forEach((line, index) => { + // Skip comment-only lines: the docs quote the very patterns they forbid. + const trimmed = line.trim(); + if ( + trimmed.startsWith("*") || + trimmed.startsWith("//") || + trimmed.startsWith("/*") + ) { + return; + } + + for (const rule of RULES) { + if (isRamp && rule.id === "arbitrary-text-size") continue; + rule.pattern.lastIndex = 0; + const match = rule.pattern.exec(line); + if (match) { + failures.push( + `${relative(process.cwd(), file)}:${index + 1} ${rule.id}\n ${trimmed}\n → ${rule.message}`, + ); + } + } + }); +} + +if (failures.length > 0) { + console.error( + `\n✗ Type system: ${failures.length} violation${failures.length === 1 ? "" : "s"}\n`, + ); + console.error(`${failures.join("\n\n")}\n`); + process.exit(1); +} + +console.log("✓ Type system: no violations"); diff --git a/desktop-next/src/app/routes/index.tsx b/desktop-next/src/app/routes/index.tsx index cab3393803..bf23e9dbea 100644 --- a/desktop-next/src/app/routes/index.tsx +++ b/desktop-next/src/app/routes/index.tsx @@ -3,14 +3,14 @@ import { Link, createFileRoute } from "@tanstack/react-router"; function Home() { return (
-

Buzz

-

+

Buzz

+

The new client. Nothing here yet — the shell and its capabilities come later. The design system is the foundation being built first.

Open the design system diff --git a/desktop-next/src/features/design-system/ui/ColourPage.tsx b/desktop-next/src/features/design-system/ui/ColourPage.tsx index a146faf766..05ab4037de 100644 --- a/desktop-next/src/features/design-system/ui/ColourPage.tsx +++ b/desktop-next/src/features/design-system/ui/ColourPage.tsx @@ -11,23 +11,23 @@ function RoleRow({ role }: { role: Role }) { return (
- {role.token} - {role.pointsAt} + {role.token} + {role.pointsAt} {role.status !== "core" ? ( - + {role.status} {role.owner ? ` · ${role.owner}` : ""} ) : null}
-

{role.use}

+

{role.use}

{role.exception ? ( -

+

Exception: {role.exception}

) : null} @@ -52,10 +52,8 @@ export function ColourPage() { {RAMPS.map((ramp) => (
-

- {ramp.name} -

-

+

{ramp.name}

+

{ramp.description}

@@ -89,14 +87,12 @@ export function ColourPage() { {ROLE_GROUPS.map((group) => (
-

- {group.name} -

-

+

{group.name}

+

{group.description}

-
+
{group.roles.map((role) => ( ))} @@ -112,14 +108,9 @@ export function ColourPage() { >
{EXCEPTIONS.map((exception) => ( -
- - {exception.name} - -

+

+ {exception.name} +

{exception.why}

diff --git a/desktop-next/src/features/design-system/ui/DesignSystemLayout.tsx b/desktop-next/src/features/design-system/ui/DesignSystemLayout.tsx index a6216225b7..7b1ba6408f 100644 --- a/desktop-next/src/features/design-system/ui/DesignSystemLayout.tsx +++ b/desktop-next/src/features/design-system/ui/DesignSystemLayout.tsx @@ -33,10 +33,10 @@ function NavLink({ to, children }: { to: string; children: ReactNode }) { return ( {children} @@ -48,37 +48,44 @@ export function DesignSystemLayout() { const { scheme, toggle } = useColorScheme(); return ( -
+ /* Narrow: the nav stacks above the content as a wrapped list, because a + 256px column beside a reading column leaves neither enough room. From lg + it becomes the sticky side rail. */ +
-
-
+
+
diff --git a/desktop-next/src/features/design-system/ui/ElevationPage.tsx b/desktop-next/src/features/design-system/ui/ElevationPage.tsx index 707bc183c9..cbaee31b71 100644 --- a/desktop-next/src/features/design-system/ui/ElevationPage.tsx +++ b/desktop-next/src/features/design-system/ui/ElevationPage.tsx @@ -18,11 +18,9 @@ export function ElevationPage() { className="flex h-24 w-44 items-center justify-center rounded-xl bg-panel" style={{ boxShadow: `var(${level.variable})` }} > - - {level.token} - + {level.token}
- + {level.use}
diff --git a/desktop-next/src/features/design-system/ui/GlassPage.tsx b/desktop-next/src/features/design-system/ui/GlassPage.tsx index 9485f000ab..3789c7e37f 100644 --- a/desktop-next/src/features/design-system/ui/GlassPage.tsx +++ b/desktop-next/src/features/design-system/ui/GlassPage.tsx @@ -18,8 +18,8 @@ function ChromePill() { type="button" className={ selected - ? "elevate-xs rounded-full bg-chrome-selected px-5 py-2 text-sm font-medium text-primary" - : "rounded-full px-5 py-2 text-sm text-secondary transition-colors hover:bg-chrome-glass-hover hover:text-primary" + ? "elevate-xs rounded-full bg-chrome-selected px-5 py-2 text-label text-primary" + : "rounded-full px-5 py-2 text-body text-secondary transition-colors hover:bg-chrome-glass-hover hover:text-primary" } > {label} @@ -44,7 +44,7 @@ export function GlassPage() {
-
+
container bg-chrome-glass +{" "} rim-glass +{" "} @@ -84,10 +84,8 @@ export function GlassPage() { className="flex flex-col items-center gap-2 rounded-lg bg-chrome-glass px-6 py-5" style={{ backdropFilter: `blur(${blur.value})` }} > - - {blur.token} - - {blur.value} + {blur.token} + {blur.value}
))}
@@ -98,7 +96,7 @@ export function GlassPage() { description="A deliberate exception: two literal values, not a ramp step and not one of the numbered gradients. Those are background treatments; this is a material detail. Real glass catches light along one edge and falls away on the opposite one, so the rim is a directional pair from one fixed light direction that every glass surface shares." >
- + {`box-shadow:\n inset 0 1px 0 var(--rim-lit),\n inset 0 -1px 0 var(--rim-shade);`}
diff --git a/desktop-next/src/features/design-system/ui/GrowthPage.tsx b/desktop-next/src/features/design-system/ui/GrowthPage.tsx index 71c9e7fb31..a38a49d5f0 100644 --- a/desktop-next/src/features/design-system/ui/GrowthPage.tsx +++ b/desktop-next/src/features/design-system/ui/GrowthPage.tsx @@ -48,15 +48,13 @@ export function GrowthPage() { title="The procedure" description="Runs per change, by whoever needs the value. Nothing here needs permission, and every addition arrives in the same change that needed it, carrying its values, its description, and its owner." > -
    +
      {GROWTH_PROCEDURE.map((step, index) => (
    1. - + {index + 1} - - {step} - + {step}
    2. ))}
    @@ -66,14 +64,16 @@ export function GrowthPage() { title="The audit" description="Runs on a schedule rather than per change. It reports rather than silently rewrites, except where the fix is unambiguous. Growth without pruning is how a system accumulates thirteen transparencies of one colour." > -
    +
    {AUDIT.map(([check, action]) => (
    - {check} - {action} + {check} + + {action} +
    ))}
    diff --git a/desktop-next/src/features/design-system/ui/MotionPage.tsx b/desktop-next/src/features/design-system/ui/MotionPage.tsx index 63f568fb82..5fb0ae6e0b 100644 --- a/desktop-next/src/features/design-system/ui/MotionPage.tsx +++ b/desktop-next/src/features/design-system/ui/MotionPage.tsx @@ -30,7 +30,7 @@ export function MotionPage() { "Never animate blur. Re-blurring a large surface every frame is expensive enough to feel. Animate opacity instead.", "Motion explains a change; it does not decorate one. If removing an animation loses no information, remove it.", ].map((rule) => ( -
  1. +
  2. {rule}
  3. ))} diff --git a/desktop-next/src/features/design-system/ui/OverviewPage.tsx b/desktop-next/src/features/design-system/ui/OverviewPage.tsx index 11fe050188..f45f7c8712 100644 --- a/desktop-next/src/features/design-system/ui/OverviewPage.tsx +++ b/desktop-next/src/features/design-system/ui/OverviewPage.tsx @@ -23,16 +23,16 @@ export function OverviewPage() { title="The layers" description="Only the role layer is ever used when building a screen. That separation is what lets the entire look change without editing a component." > -
    +
    {LAYERS.map(([layer, what, why]) => (
    - + {layer} - + {what} - {why} + {why}
    ))}
    @@ -40,9 +40,9 @@ export function OverviewPage() {
    - {GRAMMAR} + {GRAMMAR}
    -

    +

    The order is fixed, so there is one correct spelling. See{" "} the vocabulary @@ -53,24 +53,30 @@ export function OverviewPage() {

    Colour + + Typography + Glass Growing the system diff --git a/desktop-next/src/features/design-system/ui/TypographyPage.tsx b/desktop-next/src/features/design-system/ui/TypographyPage.tsx index c221aac87f..38282df09d 100644 --- a/desktop-next/src/features/design-system/ui/TypographyPage.tsx +++ b/desktop-next/src/features/design-system/ui/TypographyPage.tsx @@ -1,40 +1,141 @@ -import { PageHeader, Section, Stub } from "./primitives"; +import { + TYPE_FAMILIES, + TYPE_RAMPS, + TYPE_ROLES, + type TypeRole, +} from "@/shared/tokens/registry"; + +import { Note, PageHeader, Row, Rows, Section } from "./primitives"; + +/** + * Every specimen below is set in the role it documents, so the page is the + * system rather than a description of it. A role that reads badly here reads + * badly in the product. + */ +function RoleSpecimen({ role }: { role: TypeRole }) { + return ( + +
    +

    + {role.mono ? "createChannel(name, members)" : "Bring your agents in"} +

    +
    + {role.token} + {role.pointsAt} + + {role.size} / {role.lineHeight} / {role.tracking} / {role.weight} + +
    +

    {role.use}

    +
    +
    + ); +} export function TypographyPage() { return ( <> -
    - +
    + + {TYPE_FAMILIES.map((family) => ( + +
    +

    + {family.name} +

    + {family.token} +

    + {family.use} +

    +
    +
    + ))} +
    -
    -

    - The Cash Sans and BlockUI type variables that appear in the design - exploration are contamination from another Figma library linked into - that file. They exist nowhere in any Buzz codebase and need no - cleanup — only a decision not to inherit them. -

    -
    + + {TYPE_ROLES.map((role) => ( + + ))} +
    + + {TYPE_RAMPS.map((ramp) => ( +
    + + {ramp.steps.map((step) => ( + +
    + + {ramp.id} {step.step} + + + {step.value} + + {step.job} +
    +
    + ))} +
    +
    + ))} + +
    + + +

    + Every size is relative. Never px. +

    +

    + Fixed pixel text freezes against keyboard zoom and ignores the + person's font-size preference. The current client shipped a + message-timeline regression from exactly this and now has a CI + guard rejecting arbitrary size literals. This ramp derives + entirely from one virtual rem, so both dials work by construction. +

    +
    + +

    + No all-caps, and no tracked-out labels. +

    +

    + A capitalised label is harder to read than the sentence-case + version and reads as enterprise chrome. Section labels earn their + quietness from size and colour — text-meta on text-tertiary — + rather than from being shouted. There is no uppercase utility in + this system. +

    +
    +
    +
    + + + The Cash Sans and BlockUI type variables that appear in the design + exploration are contamination from another Figma library linked into + that file. They exist nowhere in any Buzz codebase and need no cleanup — + only a decision not to inherit them. + ); } diff --git a/desktop-next/src/features/design-system/ui/VocabularyPage.tsx b/desktop-next/src/features/design-system/ui/VocabularyPage.tsx index 850afdadaf..16b2bccad0 100644 --- a/desktop-next/src/features/design-system/ui/VocabularyPage.tsx +++ b/desktop-next/src/features/design-system/ui/VocabularyPage.tsx @@ -15,20 +15,20 @@ export function VocabularyPage() { />
    -
    +
    {VOCABULARY.map((group) => (
    - + {group.group}
    {group.words.map((word) => ( {word} @@ -44,21 +44,21 @@ export function VocabularyPage() { description="A name reads property, then role, then modifier, then material, then state. The order is fixed, so an agent writing a hover for a glass surface produces the same name every time instead of two equally plausible ones." >
    - {GRAMMAR} + {GRAMMAR}
    -
    -

    Legal

    +
    +

    Legal

    {GRAMMAR_EXAMPLES.legal.map((example) => ( - + {example} ))}
    -
    -

    Illegal

    +
    +

    Illegal

    {GRAMMAR_EXAMPLES.illegal.map((example) => ( - + {example} ))} diff --git a/desktop-next/src/features/design-system/ui/primitives.tsx b/desktop-next/src/features/design-system/ui/primitives.tsx index 9d80820515..f4d479a640 100644 --- a/desktop-next/src/features/design-system/ui/primitives.tsx +++ b/desktop-next/src/features/design-system/ui/primitives.tsx @@ -4,6 +4,11 @@ import type { ReactNode } from "react"; * Presentation pieces for the design system pages only. These are documentation * furniture, not product components — the shared primitive layer gets built one * component at a time as the product repeats something. + * + * Surface convention on these pages: a region is separated by a soft fill, not + * by an outline. Reach for `bg-inset` before reaching for a border; use a + * hairline only where a genuine boundary is needed, and never above + * `border-secondary`. See DESIGN.md § Surface and depth. */ export function PageHeader({ @@ -16,21 +21,19 @@ export function PageHeader({ status?: string; }) { return ( -
    +
    -

    {title}

    +

    {title}

    {status ? {status} : null}
    -

    - {intro} -

    +

    {intro}

    ); } export function StatusPill({ children }: { children: ReactNode }) { return ( - + {children} ); @@ -46,13 +49,11 @@ export function Section({ children: ReactNode; }) { return ( -
    -
    -

    {title}

    +
    +
    +

    {title}

    {description ? ( -

    - {description} -

    +

    {description}

    ) : null}
    {children} @@ -62,7 +63,7 @@ export function Section({ export function Note({ children }: { children: ReactNode }) { return ( -

    +

    {children}

    ); @@ -70,15 +71,13 @@ export function Note({ children }: { children: ReactNode }) { export function Stub({ what, decide }: { what: string; decide: string[] }) { return ( -
    -

    {what}

    -
    -

    - Still to decide -

    -
      +
      +

      {what}

      +
      +

      Still to decide

      +
        {decide.map((item) => ( -
      • +
      • {item}
      • ))} @@ -88,6 +87,25 @@ export function Stub({ what, decide }: { what: string; decide: string[] }) { ); } +/** + * A group of rows that reads as one region. + * + * The fill does the separating, so the container needs no outline. Rows inside + * are divided by the lightest hairline in the system, because dense data wants + * an edge-to-edge divider rather than a card each — see DESIGN.md § Density. + */ +export function Rows({ children }: { children: ReactNode }) { + return
        {children}
        ; +} + +export function Row({ children }: { children: ReactNode }) { + return ( +
        + {children} +
        + ); +} + /** Renders a live swatch of whatever a CSS custom property currently holds. */ export function Swatch({ variable, @@ -101,16 +119,16 @@ export function Swatch({ translucent?: boolean; }) { return ( -
        +
        - {label} + {label} {sublabel ? ( - {sublabel} + {sublabel} ) : null}
        ); diff --git a/desktop-next/src/shared/styles/globals.css b/desktop-next/src/shared/styles/globals.css index ddc273b6bf..ee3c46406c 100644 --- a/desktop-next/src/shared/styles/globals.css +++ b/desktop-next/src/shared/styles/globals.css @@ -1,13 +1,10 @@ @import "tailwindcss"; @import "@fontsource-variable/inter"; +@import "@fontsource/jetbrains-mono"; @import "./tokens.css"; +@import "./typography.css"; @layer base { - html { - font-family: "Inter Variable", Inter, system-ui, sans-serif; - -webkit-font-smoothing: antialiased; - } - body { margin: 0; background: var(--bg-app); diff --git a/desktop-next/src/shared/styles/tokens.css b/desktop-next/src/shared/styles/tokens.css index ca0d6803ef..77324fb975 100644 --- a/desktop-next/src/shared/styles/tokens.css +++ b/desktop-next/src/shared/styles/tokens.css @@ -301,6 +301,17 @@ --color-tertiary: var(--text-tertiary); --color-disabled: var(--text-disabled); + /* Borders register in their OWN namespace, not `--color-*`. + Text and borders share the emphasis names — primary, secondary, tertiary — + but hold different values: text is the dark end of the neutral ramp + (12/10/9), borders the light end (7/6/5). `--color-*` is a single namespace, + so registering borders there would make `border-primary` resolve to the text + colour and draw every hairline at near-black. It did exactly that until + these three lines existed. */ + --border-color-primary: var(--border-primary); + --border-color-secondary: var(--border-secondary); + --border-color-tertiary: var(--border-tertiary); + --color-accent: var(--bg-accent); --color-accent-tint: var(--bg-accent-tint); --color-accent-tint-hover: var(--bg-accent-tint-hover); diff --git a/desktop-next/src/shared/styles/typography.css b/desktop-next/src/shared/styles/typography.css new file mode 100644 index 0000000000..0b7370a73d --- /dev/null +++ b/desktop-next/src/shared/styles/typography.css @@ -0,0 +1,231 @@ +/* + * Buzz typography tokens. + * + * Same three layers as colour, and only the role layer is ever used when + * building a screen: + * + * LAYER 1 private ramps --type-size-*, --type-leading-*, --type-tracking-* + * LAYER 2 public roles --text-body, --text-title, ... + * LAYER 3 components text-body, text-title (Tailwind utilities) + * + * Rules, matching the colour system: + * - Literal sizes exist only in the ramp. A role points at a step; a component + * points at a role. No component holds a size. + * - A role carries its whole typographic setting — size, line height, letter + * spacing, weight — because those four are one decision, not four. Tailwind + * v4 supports this via the `--text---` convention, so + * `text-body` alone produces correctly set text. + * - Every size is relative. Nothing here may be expressed in px: fixed pixel + * text freezes against zoom and breaks the person's font-size preference. + * This is the one rule the existing client already learned the hard way. + * + * Naming note: colour roles are also spelled `--text-*` (`--text-primary`) but + * are registered in Tailwind's `--color-*` namespace, while sizes register in + * `--text-*`. So `text-primary` is a colour and `text-body` is a setting — the + * same split Tailwind itself makes. Size roles are named for editorial jobs + * (body, title, caption) and colour roles for emphasis levels (primary, + * secondary), so no one name ever means both. + * + * See DESIGN.md for the judgement these values cannot express. + */ + +/* ============================================================ + LAYER 1 — PRIVATE RAMPS + ============================================================ */ + +:root { + /* --- The zoom and preference dials. ----------------------- + Two independent controls compose here, as in the existing client: + + - Cmd +/- scales the real root font size, so every rem in the app — text, + gaps, radii — grows together. True zoom. + - The font-size preference sets --type-scale, nudging only the type ramp + below, so text changes size without moving the layout. + + Every size derives from this virtual rem, which is itself rem-relative and + therefore rides on top of zoom automatically. At the default preference it + equals 1rem, making --type-size-4 read as 14px. */ + --type-scale: 1; + --type-rem: calc(1rem * var(--type-scale)); + + /* --- Size. Nine steps, each step an editorial job. -------- + A modern ramp for a dense product: tight and frequent through the working + sizes where real interface text lives, opening up at the display end where + a step needs to be a visible jump rather than a near-miss. */ + --type-size-1: calc(var(--type-rem) * 0.6875); /* 11px meta */ + --type-size-2: calc(var(--type-rem) * 0.75); /* 12px caption */ + --type-size-3: calc(var(--type-rem) * 0.8125); /* 13px label */ + --type-size-4: calc(var(--type-rem) * 0.875); /* 14px body */ + --type-size-5: calc(var(--type-rem) * 1); /* 16px body large */ + --type-size-6: calc(var(--type-rem) * 1.125); /* 18px subheading */ + --type-size-7: calc(var(--type-rem) * 1.375); /* 22px heading */ + --type-size-8: calc(var(--type-rem) * 1.75); /* 28px title */ + --type-size-9: calc(var(--type-rem) * 2.25); /* 36px display */ + + /* --- Line height. Unitless, so it scales with its own size. + Tight for display text, generous for anything read in quantity, and exactly + 1 for single-line meta so a timestamp cannot enlarge its own row. */ + --type-leading-flat: 1; + --type-leading-tight: 1.2; + --type-leading-snug: 1.35; + --type-leading-normal: 1.5; + --type-leading-relaxed: 1.6; + + /* --- Tracking. An optical correction, not a style. -------- + Inter is drawn for interface text and needs negative tracking as it grows: + spacing that looks correct at 14px looks loose at 36px. The ramp mirrors + the size ramp so the correction is applied per step rather than guessed, + and turns slightly positive at meta sizes where letters need air to stay + legible. */ + --type-tracking-display: -0.022em; + --type-tracking-title: -0.019em; + --type-tracking-heading: -0.017em; + --type-tracking-subheading: -0.014em; + --type-tracking-large: -0.011em; + --type-tracking-body: -0.006em; + --type-tracking-label: -0.003em; + --type-tracking-caption: 0em; + --type-tracking-meta: 0.005em; + + /* --- Weight. Four, and the gaps are deliberate. ----------- + Inter Variable can render any weight, which is exactly why the system names + only these: 500 and 600 sit far enough apart to read as intent rather than + as a rendering artefact. */ + --type-weight-normal: 400; + --type-weight-medium: 500; + --type-weight-semibold: 600; + --type-weight-bold: 700; +} + +/* Conversation type contract, carried over from the existing client: + 13 / 14 / 15px before keyboard zoom. */ +:root[data-font-size="smaller"] { + --type-scale: calc(13 / 14); +} + +:root[data-font-size="larger"] { + --type-scale: calc(15 / 14); +} + +/* ============================================================ + LAYER 3 — PUBLIC ROLES, REGISTERED WITH TAILWIND + + Layers 2 and 3 are one block for typography. A colour role can be a bare + custom property because a component reads it through `bg-*` or `text-*`; a + type role has to be a Tailwind theme key or the paired line height, tracking, + and weight cannot travel with the size. So the role IS the registration. + + Named for the job the text does, never for its size — `text-title`, not + `text-28`. A size name is a value in disguise and goes stale the moment the + ramp moves. + ============================================================ */ + +@theme { + --font-sans: + "Inter Variable", Inter, ui-sans-serif, system-ui, -apple-system, sans-serif; + --font-mono: + "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + + /* --- Display. One per page at most, and many pages need none. + The only step that is allowed to be decorative. */ + --text-display: var(--type-size-9); + --text-display--line-height: var(--type-leading-tight); + --text-display--letter-spacing: var(--type-tracking-display); + --text-display--font-weight: var(--type-weight-semibold); + + /* --- Title. The name of the thing you are looking at. ----- */ + --text-title: var(--type-size-8); + --text-title--line-height: var(--type-leading-tight); + --text-title--letter-spacing: var(--type-tracking-title); + --text-title--font-weight: var(--type-weight-semibold); + + /* --- Heading. A section within a page. ------------------- */ + --text-heading: var(--type-size-7); + --text-heading--line-height: var(--type-leading-snug); + --text-heading--letter-spacing: var(--type-tracking-heading); + --text-heading--font-weight: var(--type-weight-semibold); + + /* --- Subheading. A group within a section. --------------- */ + --text-subheading: var(--type-size-6); + --text-subheading--line-height: var(--type-leading-snug); + --text-subheading--letter-spacing: var(--type-tracking-subheading); + --text-subheading--font-weight: var(--type-weight-semibold); + + /* --- Body large. Introductions and reading columns, where a + paragraph is the point rather than a description of something else. */ + --text-body-lg: var(--type-size-5); + --text-body-lg--line-height: var(--type-leading-relaxed); + --text-body-lg--letter-spacing: var(--type-tracking-large); + --text-body-lg--font-weight: var(--type-weight-normal); + + /* --- Body. The default. If you are unsure, this is it. --- */ + --text-body: var(--type-size-4); + --text-body--line-height: var(--type-leading-normal); + --text-body--letter-spacing: var(--type-tracking-body); + --text-body--font-weight: var(--type-weight-normal); + + /* --- Label. Interface text that is acted on rather than + read: buttons, tabs, menu items, form labels, table headers. Medium weight + because a label competes with content around it and needs to hold its edge + without going up a size. */ + --text-label: var(--type-size-3); + --text-label--line-height: var(--type-leading-snug); + --text-label--letter-spacing: var(--type-tracking-label); + --text-label--font-weight: var(--type-weight-medium); + + /* --- Caption. A sentence about something else: help text, + descriptions, empty-state detail. */ + --text-caption: var(--type-size-2); + --text-caption--line-height: var(--type-leading-normal); + --text-caption--letter-spacing: var(--type-tracking-caption); + --text-caption--font-weight: var(--type-weight-normal); + + /* --- Meta. Timestamps, counts, tracking numbers. Flat line + height so a single-line value cannot grow its own row. */ + --text-meta: var(--type-size-1); + --text-meta--line-height: var(--type-leading-flat); + --text-meta--letter-spacing: var(--type-tracking-meta); + --text-meta--font-weight: var(--type-weight-medium); + + /* --- Code. Mono, one step down: at equal size a monospace + face reads larger than Inter and pulls the eye off the sentence. */ + --text-code: var(--type-size-3); + --text-code--line-height: var(--type-leading-normal); + --text-code--letter-spacing: var(--type-tracking-caption); + --text-code--font-weight: var(--type-weight-normal); +} + +/* ============================================================ + BASE + ============================================================ */ + +@layer base { + html { + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + body { + /* Every readable surface starts from the body role and steps away from it + deliberately. */ + font-size: var(--text-body); + line-height: var(--text-body--line-height); + letter-spacing: var(--text-body--letter-spacing); + + /* Never synthesise a weight Inter Variable can render properly, and never + let a font fall back mid-string. */ + font-synthesis-weight: none; + text-rendering: optimizeLegibility; + } + + code, + kbd, + pre, + samp { + font-family: var(--font-mono); + /* Mono digits and letters share one advance width, so a changing value does + not shift the text around it. */ + font-variant-numeric: tabular-nums; + } +} diff --git a/desktop-next/src/shared/tokens/registry.ts b/desktop-next/src/shared/tokens/registry.ts index 1557ccce1e..861f42728f 100644 --- a/desktop-next/src/shared/tokens/registry.ts +++ b/desktop-next/src/shared/tokens/registry.ts @@ -467,6 +467,187 @@ export const GROWTH_PROCEDURE = [ "Never write a raw value. If nothing above applies, say so rather than reaching for a literal.", ]; +/* ============================================================ + TYPOGRAPHY + ============================================================ */ + +/** A public type role. Carries its whole setting, because size, line height, + * tracking, and weight are one decision rather than four. */ +export interface TypeRole { + /** The Tailwind class, e.g. `text-body`. */ + token: string; + /** Step on the size ramp this points at, for display. */ + pointsAt: string; + /** Rendered size at the default preference and zoom, for display only — + * never a value a component may use. */ + size: string; + lineHeight: string; + tracking: string; + weight: string; + /** One sentence: when to use this. */ + use: string; + status: TokenStatus; + /** Set when the role renders in the mono face. */ + mono?: boolean; +} + +/** Ordered loudest to quietest, which is also the order they are chosen in. */ +export const TYPE_ROLES: TypeRole[] = [ + { + token: "text-display", + pointsAt: "size 9", + size: "36px", + lineHeight: "1.2", + tracking: "-0.022em", + weight: "600", + use: "One per page at most, and many pages need none. The only step allowed to be decorative.", + status: "core", + }, + { + token: "text-title", + pointsAt: "size 8", + size: "28px", + lineHeight: "1.2", + tracking: "-0.019em", + weight: "600", + use: "The name of the thing you are looking at.", + status: "core", + }, + { + token: "text-heading", + pointsAt: "size 7", + size: "22px", + lineHeight: "1.35", + tracking: "-0.017em", + weight: "600", + use: "A section within a page.", + status: "core", + }, + { + token: "text-subheading", + pointsAt: "size 6", + size: "18px", + lineHeight: "1.35", + tracking: "-0.014em", + weight: "600", + use: "A group within a section.", + status: "core", + }, + { + token: "text-body-lg", + pointsAt: "size 5", + size: "16px", + lineHeight: "1.6", + tracking: "-0.011em", + weight: "400", + use: "Introductions and reading columns, where a paragraph is the point rather than a description of something else.", + status: "core", + }, + { + token: "text-body", + pointsAt: "size 4", + size: "14px", + lineHeight: "1.5", + tracking: "-0.006em", + weight: "400", + use: "The default. If you are unsure, this is it.", + status: "core", + }, + { + token: "text-label", + pointsAt: "size 3", + size: "13px", + lineHeight: "1.35", + tracking: "-0.003em", + weight: "500", + use: "Interface text that is acted on rather than read: buttons, tabs, menu items, form labels, table headers.", + status: "core", + }, + { + token: "text-caption", + pointsAt: "size 2", + size: "12px", + lineHeight: "1.5", + tracking: "0em", + weight: "400", + use: "A sentence about something else: help text, descriptions, empty-state detail.", + status: "core", + }, + { + token: "text-meta", + pointsAt: "size 1", + size: "11px", + lineHeight: "1", + tracking: "0.005em", + weight: "500", + use: "Timestamps, counts, tracking labels. Flat line height so a single-line value cannot grow its own row.", + status: "core", + }, + { + token: "text-code", + pointsAt: "size 3", + size: "13px", + lineHeight: "1.5", + tracking: "0em", + weight: "400", + use: "Inline code and code blocks. One step below body, because at equal size a monospace face reads larger than Inter and pulls the eye off the sentence.", + status: "core", + mono: true, + }, +]; + +/** The two faces. Both already shipped in every current Buzz client. */ +export const TYPE_FAMILIES = [ + { + token: "font-sans", + name: "Inter Variable", + use: "Everything. Drawn for interface text at small sizes, and already the sans in desktop, web, and mobile.", + }, + { + token: "font-mono", + name: "JetBrains Mono", + use: "Code, keys, and identifiers. Already the mono in the existing client's terminal.", + }, +]; + +/** The private ramps a type role points at. Components never reference these. */ +export const TYPE_RAMPS = [ + { + id: "size", + name: "Size", + description: + "Nine steps, each step an editorial job. Tight and frequent through the working sizes where real interface text lives, opening up at the display end where a step needs to be a visible jump rather than a near-miss. Every value derives from a virtual rem, so the whole ramp follows the person's font-size preference and keyboard zoom.", + steps: [ + { step: 1, job: "meta", value: "11px" }, + { step: 2, job: "caption", value: "12px" }, + { step: 3, job: "label, code", value: "13px" }, + { step: 4, job: "body", value: "14px" }, + { step: 5, job: "body large", value: "16px" }, + { step: 6, job: "subheading", value: "18px" }, + { step: 7, job: "heading", value: "22px" }, + { step: 8, job: "title", value: "28px" }, + { step: 9, job: "display", value: "36px" }, + ], + }, + { + id: "tracking", + name: "Tracking", + description: + "An optical correction, not a style. Inter needs progressively tighter spacing as it grows — tracking that looks correct at 14px looks loose at 36px — so the ramp mirrors the size ramp and the correction is applied per step rather than guessed. It turns slightly positive at meta sizes, where letters need air to stay legible.", + steps: [ + { step: 9, job: "display", value: "-0.022em" }, + { step: 8, job: "title", value: "-0.019em" }, + { step: 7, job: "heading", value: "-0.017em" }, + { step: 6, job: "subheading", value: "-0.014em" }, + { step: 5, job: "body large", value: "-0.011em" }, + { step: 4, job: "body", value: "-0.006em" }, + { step: 3, job: "label", value: "-0.003em" }, + { step: 2, job: "caption", value: "0em" }, + { step: 1, job: "meta", value: "0.005em" }, + ], + }, +]; + export const ELEVATION = [ { token: "shadow-xs", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8cf7f5bc9f..d3aa086b10 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -310,6 +310,9 @@ importers: '@fontsource-variable/inter': specifier: ^5.2.8 version: 5.2.8 + '@fontsource/jetbrains-mono': + specifier: ^5.3.0 + version: 5.3.0 '@tanstack/react-router': specifier: ^1.168.10 version: 1.170.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8) From 61e3792118f811fac9e50f0c4d7ff0f3d35fc9cd Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:07:51 -0700 Subject: [PATCH 03/33] refactor(desktop-next): take colour and type specimens out of cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- desktop-next/DESIGN.md | 2 + .../features/design-system/ui/ColourPage.tsx | 24 +++++-- .../design-system/ui/TypographyPage.tsx | 68 +++++++++---------- .../features/design-system/ui/primitives.tsx | 27 ++++++-- 4 files changed, 73 insertions(+), 48 deletions(-) diff --git a/desktop-next/DESIGN.md b/desktop-next/DESIGN.md index 2e964b4ca0..27bc0632dc 100644 --- a/desktop-next/DESIGN.md +++ b/desktop-next/DESIGN.md @@ -58,6 +58,8 @@ Buzz is a place where people build together and bring their agents into the room ## Density and rhythm - **Dense data renders as rows with dividers, edge to edge.** Wrapping every list item in its own card is the most common way a functional surface becomes a marketing page. +- **Content that separates itself needs no divider, and no container.** A divider is for uniform rows where the eye needs a line to track along. When each entry already carries a visible difference — a colour swatch, a type specimen, an avatar — the content is the separator, and adding a rule or a card on top is redundant structure. Space alone is enough. +- **Never judge a value against a surface it will not be used on.** A swatch on a grey fill, or a type specimen in a tinted box, is being evaluated in a context the product will never reproduce. Samples sit on the page. The one exception is a value that needs a backdrop to exist at all — translucency needs something behind it, and a white surface swatch needs a hairline or it renders as nothing. - **Cards are for widgets, galleries, and settings groups.** A card is a bordered, padded region on the page, not a different depth. - **Pick the frame before the content.** Decide what the surface is — a list, a reading column, a workspace — before filling it. - **Whitespace is generous by default.** Crowding reads as a different product. diff --git a/desktop-next/src/features/design-system/ui/ColourPage.tsx b/desktop-next/src/features/design-system/ui/ColourPage.tsx index 05ab4037de..a410145a7d 100644 --- a/desktop-next/src/features/design-system/ui/ColourPage.tsx +++ b/desktop-next/src/features/design-system/ui/ColourPage.tsx @@ -7,9 +7,18 @@ import { import { Note, PageHeader, Section, Swatch } from "./primitives"; +/** + * A role, sitting directly on the page. + * + * No card and no divider: the swatch is its own separator, and a colour judged + * on a grey card is not being judged on the surface it will actually be used on. + * The hairline stays on the swatch itself — `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 rather than decoration. + */ function RoleRow({ role }: { role: Role }) { return ( -
        +
        -
        +
        {group.roles.map((role) => ( ))} @@ -106,11 +115,14 @@ export function ColourPage() { title="Deliberate exceptions" description="Literal values exist only in the ramps, and nothing above a ramp holds one — except these. The list is short and complete on purpose: a vague exception policy is how a layered system quietly erodes." > -
        + {/* No swatch here to do the separating, so these entries keep a little + structure — the token name leads and the spacing groups it with its + reason. Still no card: this is prose, not data. */} +
        {EXCEPTIONS.map((exception) => ( -
        - {exception.name} -

        +

        + {exception.name} +

        {exception.why}

        diff --git a/desktop-next/src/features/design-system/ui/TypographyPage.tsx b/desktop-next/src/features/design-system/ui/TypographyPage.tsx index 38282df09d..e140ead63e 100644 --- a/desktop-next/src/features/design-system/ui/TypographyPage.tsx +++ b/desktop-next/src/features/design-system/ui/TypographyPage.tsx @@ -5,7 +5,7 @@ import { type TypeRole, } from "@/shared/tokens/registry"; -import { Note, PageHeader, Row, Rows, Section } from "./primitives"; +import { Note, PageHeader, Row, Rows, Section, Specimens } from "./primitives"; /** * Every specimen below is set in the role it documents, so the page is the @@ -14,23 +14,21 @@ import { Note, PageHeader, Row, Rows, Section } from "./primitives"; */ function RoleSpecimen({ role }: { role: TypeRole }) { return ( - -
        -

        - {role.mono ? "createChannel(name, members)" : "Bring your agents in"} -

        -
        - {role.token} - {role.pointsAt} - - {role.size} / {role.lineHeight} / {role.tracking} / {role.weight} - -
        -

        {role.use}

        +
        +

        + {role.mono ? "createChannel(name, members)" : "Bring your agents in"} +

        +
        + {role.token} + {role.pointsAt} + + {role.size} / {role.lineHeight} / {role.tracking} / {role.weight} +
        - +

        {role.use}

        +
        ); } @@ -46,36 +44,34 @@ export function TypographyPage() { title="The faces" description="Both already ship in every current Buzz client, so this is a decision to keep rather than to make. Inter is drawn for interface text at small sizes, which is most of this product." > - + {TYPE_FAMILIES.map((family) => ( - -
        -

        - {family.name} -

        - {family.token} -

        - {family.use} -

        -
        -
        +
        +

        + {family.name} +

        + {family.token} +

        + {family.use} +

        +
        ))} -
        +
    - + {TYPE_ROLES.map((role) => ( ))} - +
    {TYPE_RAMPS.map((ramp) => ( diff --git a/desktop-next/src/features/design-system/ui/primitives.tsx b/desktop-next/src/features/design-system/ui/primitives.tsx index f4d479a640..0b93d2d666 100644 --- a/desktop-next/src/features/design-system/ui/primitives.tsx +++ b/desktop-next/src/features/design-system/ui/primitives.tsx @@ -88,24 +88,39 @@ export function Stub({ what, decide }: { what: string; decide: string[] }) { } /** - * A group of rows that reads as one region. + * A list of uniform rows — the tabular case, where every row looks alike and the + * eye needs a line to track along. Dividers, no container: the section heading + * already says these belong together, so a fill behind them adds a box without + * adding meaning. See DESIGN.md § Density and rhythm. * - * The fill does the separating, so the container needs no outline. Rows inside - * are divided by the lightest hairline in the system, because dense data wants - * an edge-to-edge divider rather than a card each — see DESIGN.md § Density. + * If the rows carry their own visual difference — a swatch, a type specimen — use + * `Specimens` instead. Content that separates itself needs no divider. */ export function Rows({ children }: { children: ReactNode }) { - return
    {children}
    ; + return
    {children}
    ; } export function Row({ children }: { children: ReactNode }) { return ( -
    +
    {children}
    ); } +/** + * A list whose entries are visibly different from each other — colour swatches, + * type specimens, elevation samples. + * + * No container and no dividers. The specimen is its own separator, and two + * things a colour system must never do are judge a swatch against a fill it + * will never sit on, or set a type specimen in a box that changes its contrast. + * Separation comes from space alone. + */ +export function Specimens({ children }: { children: ReactNode }) { + return
    {children}
    ; +} + /** Renders a live swatch of whatever a CSS custom property currently holds. */ export function Swatch({ variable, From 69149c7a1be6b17b5140a7dd836262e3fce75f13 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:35:39 -0700 Subject: [PATCH 04/33] feat(desktop-next): add a token table, and declare four missing paired-text roles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- .../routes/design/{colour.tsx => color.tsx} | 2 +- .../src/app/routes/design/color_.table.tsx | 14 ++ .../design-system/ui/ColorTablePage.tsx | 200 ++++++++++++++++++ .../design-system/ui/DesignSystemLayout.tsx | 42 +++- .../design-system/ui/OverviewPage.tsx | 4 +- .../design-system/useResolvedToken.ts | 153 ++++++++++++++ desktop-next/src/shared/styles/tokens.css | 16 ++ desktop-next/src/shared/tokens/registry.ts | 2 +- 8 files changed, 421 insertions(+), 12 deletions(-) rename desktop-next/src/app/routes/design/{colour.tsx => color.tsx} (73%) create mode 100644 desktop-next/src/app/routes/design/color_.table.tsx create mode 100644 desktop-next/src/features/design-system/ui/ColorTablePage.tsx create mode 100644 desktop-next/src/features/design-system/useResolvedToken.ts diff --git a/desktop-next/src/app/routes/design/colour.tsx b/desktop-next/src/app/routes/design/color.tsx similarity index 73% rename from desktop-next/src/app/routes/design/colour.tsx rename to desktop-next/src/app/routes/design/color.tsx index e75864e57f..2c3ac7ed26 100644 --- a/desktop-next/src/app/routes/design/colour.tsx +++ b/desktop-next/src/app/routes/design/color.tsx @@ -2,6 +2,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { ColourPage } from "@/features/design-system/ui/ColourPage"; -export const Route = createFileRoute("/design/colour")({ +export const Route = createFileRoute("/design/color")({ component: ColourPage, }); diff --git a/desktop-next/src/app/routes/design/color_.table.tsx b/desktop-next/src/app/routes/design/color_.table.tsx new file mode 100644 index 0000000000..a7f650fab7 --- /dev/null +++ b/desktop-next/src/app/routes/design/color_.table.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { ColorTablePage } from "@/features/design-system/ui/ColorTablePage"; + +/** + * `color_.table` rather than `color.table`: the trailing underscore keeps the + * `/design/color/table` URL while opting out of nesting inside the colour route. + * Without it, `/design/color` becomes a layout route whose component must render + * an `` — and since ColourPage does not, the parent page rendered in + * place of this one. + */ +export const Route = createFileRoute("/design/color_/table")({ + component: ColorTablePage, +}); diff --git a/desktop-next/src/features/design-system/ui/ColorTablePage.tsx b/desktop-next/src/features/design-system/ui/ColorTablePage.tsx new file mode 100644 index 0000000000..ae751af24b --- /dev/null +++ b/desktop-next/src/features/design-system/ui/ColorTablePage.tsx @@ -0,0 +1,200 @@ +import { Link } from "@tanstack/react-router"; +import { Fragment } from "react"; + +import { + describeValueKind, + humanizeVariable, + isHex, + useResolvedTokens, +} from "@/features/design-system/useResolvedToken"; +import { RAMPS, ROLE_GROUPS } from "@/shared/tokens/registry"; + +import { Note, PageHeader, Section } from "./primitives"; + +/** + * Every token in one table: the name you type, the base token it resolves + * through, and the value it actually paints. + * + * The values are read from the live cascade rather than typed into the registry, + * so this table reports what the product would really render and cannot drift + * from `tokens.css`. It also means it re-resolves in dark mode, where the same + * names hold different values — which is the whole point of the role layer. + */ + +interface TableRow { + /** The Tailwind class where one exists, else the custom property. */ + token: string; + variable: string; + /** Which layer this row belongs to. */ + layer: "role" | "ramp"; + group: string; +} + +function collectRows(): TableRow[] { + const rows: TableRow[] = []; + + for (const group of ROLE_GROUPS) { + for (const role of group.roles) { + rows.push({ + token: role.token, + variable: role.variable, + layer: "role", + group: group.name, + }); + } + } + + for (const ramp of RAMPS) { + for (const step of ramp.steps) { + rows.push({ + token: step.variable.replace(/^--/, ""), + variable: step.variable, + layer: "ramp", + group: `${ramp.name} ramp`, + }); + } + } + + return rows; +} + +function ValueSwatch({ value }: { value: string }) { + return ( +
    )} diff --git a/desktop-next/src/features/design-system/ui/OverviewPage.tsx b/desktop-next/src/features/design-system/ui/OverviewPage.tsx index f45f7c8712..f231e8b170 100644 --- a/desktop-next/src/features/design-system/ui/OverviewPage.tsx +++ b/desktop-next/src/features/design-system/ui/OverviewPage.tsx @@ -57,10 +57,10 @@ export function OverviewPage() { >
    - Colour + Color { + const declared = new Map(); + + for (const sheet of Array.from(document.styleSheets)) { + let rules: CSSRuleList; + try { + rules = sheet.cssRules; + } catch { + // A cross-origin sheet throws on access. None of ours are, and a font + // stylesheet holds no tokens, so skipping is correct rather than lossy. + continue; + } + + for (const rule of Array.from(rules)) { + if (!(rule instanceof CSSStyleRule)) continue; + + const selector = rule.selectorText; + const isDarkRule = selector.includes(".dark"); + // A `.dark` rule only describes the page when dark is active. + if (isDarkRule && !isDark) continue; + + for (const property of Array.from(rule.style)) { + if (property.startsWith("--")) { + declared.set(property, rule.style.getPropertyValue(property).trim()); + } + } + } + } + + return declared; +} + +/** + * Resolves a list of custom properties, re-running whenever the colour scheme + * changes. + */ +export function useResolvedTokens( + variables: string[], +): Map { + const [resolved, setResolved] = useState>( + () => new Map(), + ); + + // Callers build this array by mapping the registry, so it is a fresh array + // every render. Key the effect off its content instead of its identity. + const key = variables.join(","); + + useEffect(() => { + const root = document.documentElement; + + const read = () => { + const isDark = root.classList.contains("dark"); + const declaredValues = readDeclaredValues(isDark); + const computed = getComputedStyle(root); + + const next = new Map(); + for (const variable of key.split(",")) { + if (variable === "") continue; + + const declared = declaredValues.get(variable) ?? ""; + const match = SINGLE_VAR.exec(declared); + + next.set(variable, { + variable, + declared, + pointsAtVariable: match ? match[1] : null, + // The computed value is already fully resolved, which is exactly what + // the value column wants. + value: computed.getPropertyValue(variable).trim(), + }); + } + setResolved(next); + }; + + read(); + + // `.dark` toggles on the root, and a class change does not otherwise tell + // React that every colour on the page just changed. + const observer = new MutationObserver(read); + observer.observe(root, { attributeFilter: ["class", "style"] }); + return () => observer.disconnect(); + }, [key]); + + return resolved; +} + +/** `--neutral-7` → `neutral 7`, for display. */ +export function humanizeVariable(variable: string): string { + return variable.replace(/^--/, "").replace(/-/g, " "); +} + +const HEX = /^#[0-9a-f]{3,8}$/i; + +export function isHex(value: string): boolean { + return HEX.test(value); +} + +/** Labels what kind of value this is, when it is not a plain hex. */ +export function describeValueKind(value: string): string { + if (HEX.test(value)) return "hex"; + if (/^rgba?\(/i.test(value)) return "rgba"; + if (/^hsla?\(/i.test(value)) return "hsl"; + if (/gradient\(/i.test(value)) return "gradient"; + if (/^color-mix\(/i.test(value)) return "color-mix"; + return "value"; +} diff --git a/desktop-next/src/shared/styles/tokens.css b/desktop-next/src/shared/styles/tokens.css index 77324fb975..a437dc0823 100644 --- a/desktop-next/src/shared/styles/tokens.css +++ b/desktop-next/src/shared/styles/tokens.css @@ -256,6 +256,18 @@ --text-on-accent: #ffffff; --text-on-inverse: var(--neutral-1); + /* The status families need their paired text for the same reason accent does: + `bg-danger` is a saturated fill, and text on it must be the paired value + rather than whatever it inherits. The registry documented these four before + they were declared, so `text-on-danger` resolved to nothing and text on a + danger button fell back to near-black on red. The token table found it. + Literals for now, on the same exception as text-on-accent — they become + computed when the lightness rule lands. */ + --text-on-danger: #ffffff; + --text-on-success: #ffffff; + --text-on-warning: var(--neutral-12); + --text-on-info: #ffffff; + /* 2. The glass rim. A directional light effect, not a solid line: bright along the lit edge, dimmer on the opposite one, from one fixed light direction every glass surface @@ -324,9 +336,13 @@ --color-danger: var(--bg-danger); --color-danger-tint: var(--bg-danger-tint); --color-danger-tint-hover: var(--bg-danger-tint-hover); + --color-on-danger: var(--text-on-danger); --color-success: var(--bg-success); --color-success-tint: var(--bg-success-tint); --color-success-tint-hover: var(--bg-success-tint-hover); + --color-on-success: var(--text-on-success); + --color-on-warning: var(--text-on-warning); + --color-on-info: var(--text-on-info); --color-warning: var(--bg-warning); --color-warning-tint: var(--bg-warning-tint); --color-warning-tint-hover: var(--bg-warning-tint-hover); diff --git a/desktop-next/src/shared/tokens/registry.ts b/desktop-next/src/shared/tokens/registry.ts index 861f42728f..e7a46efc8f 100644 --- a/desktop-next/src/shared/tokens/registry.ts +++ b/desktop-next/src/shared/tokens/registry.ts @@ -670,7 +670,7 @@ export const BLUR = [ /** The entire exception list. Everything else points at a ramp step. */ export const EXCEPTIONS = [ { - name: "text-on-accent, text-on-inverse", + name: "text-on-accent, text-on-inverse, and the four status pairings", why: "Computed from their fill's lightness rather than fixed, because white is readable on a blue or purple fill and unreadable on yellow or lime. This is what keeps a free choice of accent hue from becoming a contrast lottery.", }, { From 4c2e417b379428ba8155010a2d09c1c4d2bd746c Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:43:00 -0700 Subject: [PATCH 05/33] style(desktop-next): wrap the token table into three equal columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- .../design-system/ui/ColorTablePage.tsx | 144 ++++++++++-------- 1 file changed, 79 insertions(+), 65 deletions(-) diff --git a/desktop-next/src/features/design-system/ui/ColorTablePage.tsx b/desktop-next/src/features/design-system/ui/ColorTablePage.tsx index ae751af24b..58c573c649 100644 --- a/desktop-next/src/features/design-system/ui/ColorTablePage.tsx +++ b/desktop-next/src/features/design-system/ui/ColorTablePage.tsx @@ -122,79 +122,93 @@ function TokenTable({ let lastGroup: string | null = null; return ( - /* Scrolls horizontally rather than wrapping cells: a table of values is - unreadable once a hex breaks across two lines. */ -
    - - - - - - - - - - - {rows.map((row) => { - const token = resolved.get(row.variable); - const headingRow = - showGroups && row.group !== lastGroup ? row.group : null; - lastGroup = row.group; + /* `table-fixed` with three equal columns: the natural `auto` layout gives + the value column most of the width, because one gradient literal is longer + than every other cell in the table combined. Fixed makes the thirds hold + regardless of content, and cells wrap instead of scrolling. */ +
    - Colour tokens, the base token each resolves through, and the value it - paints -
    - Token - - Base - - Value -
    + + + + + + + + + + + + + + + {rows.map((row) => { + const token = resolved.get(row.variable); + const headingRow = + showGroups && row.group !== lastGroup ? row.group : null; + lastGroup = row.group; - return ( - - {headingRow ? ( - - - - ) : null} - - - - + + + ) : null} + + + + - - - ); - })} - -
    + Colour tokens, the base token each resolves through, and the value it + paints +
    + Token + + Base + + Value +
    - {headingRow} -
    - {row.token} - - {token?.pointsAtVariable ? ( - - {humanizeVariable(token.pointsAtVariable)} - - ) : ( - — - )} - - + return ( + + {headingRow ? ( +
    + {headingRow} +
    + + {row.token} + + + {token?.pointsAtVariable ? ( + + {humanizeVariable(token.pointsAtVariable)} + + ) : ( + — + )} + + + - + + + {/* `break-all`, not `break-words`: a gradient literal is + one unbroken token with no spaces to break at, so + word-boundary wrapping would overflow the column. */} + {token?.value ?? "…"} {token && !isHex(token.value) ? ( - + {describeValueKind(token.value)} ) : null} -
    -
    + + + + + ); + })} + + ); } From 972edc6fa619e75c3e0b7b510f45bfb5785c29c3 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:16:58 -0700 Subject: [PATCH 06/33] fix(desktop): deepen the default accent so its white label text is legible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- desktop-next/DESIGN.md | 30 +++++++++++++ desktop/src/shared/theme/ThemeProvider.tsx | 45 ++++++++++++++----- .../theme/communityThemePreference.test.mjs | 42 +++++++++++++++++ .../shared/theme/communityThemePreference.ts | 27 +++++++++-- .../shared/theme/communityThemeSync.test.mjs | 2 +- 5 files changed, 131 insertions(+), 15 deletions(-) diff --git a/desktop-next/DESIGN.md b/desktop-next/DESIGN.md index 27bc0632dc..421c264953 100644 --- a/desktop-next/DESIGN.md +++ b/desktop-next/DESIGN.md @@ -80,6 +80,36 @@ Buzz is a place where people build together and bring their agents into the room - **Never use a status colour decoratively.** A green that does not mean success teaches people to stop trusting green. - **Categorical colours are the one place appearance-naming is allowed.** Telling two projects apart genuinely is a choice about appearance. +## Contrast + +Buzz judges contrast with **APCA** (the perceptual algorithm in the WCAG 3 +draft), not the WCAG 2 ratio. Target **Lc 60** for body text, Lc 45 for large +or non-essential text. This is a deliberate position, taken with evidence, and +it is the rule a generated theme is measured against. + +- **Why.** The WCAG 2 ratio underweights blue and ignores polarity, so it + systematically recommends dark text on saturated mid-tone fills where light + text is plainly more readable. Measured: white on `#3b82f6` scores WCAG 3.68 + (fail) but APCA Lc 69 (pass); black on the same fill scores WCAG 5.71 (pass) + but Lc 40 — badly unreadable. Apple ships white on `#0088ff`–`#3daefc` in + Messages at WCAG 2.4–3.5, and Tailwind, Bootstrap, and Radix all ship white + on their primary blue below or near the WCAG threshold. Three independent + signals agree with the eye; one number disagrees with all of them. +- **APCA is not the looser choice.** It is stricter wherever WCAG 2 is + permissive: red on black (WCAG 5.25 pass, Lc 38 fail) and every dark-mode + mid-grey. Adopting it tightens more pairings than it relaxes. +- **Report both.** WCAG 2 is what an audit measures and what regulators + recognise today. Design to APCA, and know the WCAG number before shipping a + surface that will be scanned. Where they disagree, say so in the change. +- **Constrain the fill, never degrade the text.** If neither black nor white + carries a fill legibly, the fill is wrong — it is not a valid solid. Move the + fill's lightness and keep the hue; do not settle for the less-bad text. +- **A paired text token is derived, not authored.** `text-on-*` is a function of + its fill, so it is generated with the fill and never hand-set. Every hand-set + pairing in this system has been wrong at least once. +- **One implementation of the rule.** Desktop, mobile, and web must not each + compute their own pairing; they diverge and the same defect ships three times. + ## Writing - **Every word earns its place.** Prefer the shortest phrasing that stays accurate. diff --git a/desktop/src/shared/theme/ThemeProvider.tsx b/desktop/src/shared/theme/ThemeProvider.tsx index 63fd3adb4d..65651bc9fd 100644 --- a/desktop/src/shared/theme/ThemeProvider.tsx +++ b/desktop/src/shared/theme/ThemeProvider.tsx @@ -43,7 +43,7 @@ const GLASS_VIBRANCY_MATERIAL = "sidebar"; export const ACCENT_COLORS = [ { name: "Neutral", value: NEUTRAL_ACCENT }, - { name: "Blue", value: "#3b82f6" }, + { name: "Blue", value: "#2563eb" }, { name: "Cyan", value: "#06b6d4" }, { name: "Green", value: "#22c55e" }, { name: "Orange", value: "#f97316" }, @@ -54,7 +54,37 @@ export const ACCENT_COLORS = [ { name: "Indigo", value: "#6366f1" }, ] as const; -const DEFAULT_ACCENT = "#3b82f6"; +/** + * One step deeper than the blue it replaced (`#3b82f6`), so white label text is + * legible on it by both standards: WCAG 5.17:1 and APCA Lc 80, against 3.68 and + * Lc 69 before. The old value failed WCAG AA on every primary button and active + * sidebar row in the default install. + * + * Darkening the fill is the fix rather than switching the label to black — + * black on this hue measures Lc 40, which is perceptually unreadable even + * though WCAG 2 scores it 5.71 and calls it a pass. See DESIGN.md § Contrast. + */ +const DEFAULT_ACCENT = "#2563eb"; + +/** + * Accent hexes that were offered once and are still in people's storage, mapped + * to what replaced them. + * + * A retired hex is not merely cosmetic debt: it is no longer in + * `ACCENT_COLORS`, so it fails the community-theme allowlist and would + * otherwise persist locally forever, leaving one surface on a value the product + * has decided is not legible. + */ +const RETIRED_ACCENTS: Record = { + "#3b82f6": "#2563eb", +}; + +/** Reads the stored accent, upgrading a retired value on the way through. */ +function readStoredAccent(): string { + const stored = getStorageItem(ACCENT_STORAGE_KEY); + if (!stored) return DEFAULT_ACCENT; + return RETIRED_ACCENTS[stored] ?? stored; +} type ThemeContextValue = { themeName: string; @@ -410,7 +440,7 @@ function applyCachedVars(): string | null { applyBuzzSidebar(themeName); glassThemeReady = true; - const accent = getStorageItem(ACCENT_STORAGE_KEY) ?? DEFAULT_ACCENT; + const accent = readStoredAccent(); // Pin Buzz themes to the neutral accent here too, matching applyTheme. // Otherwise a cached Buzz theme + non-neutral stored accent flashes the // old accent on reload until the async applyTheme effect runs. @@ -457,12 +487,7 @@ async function applyTheme(name: SyntaxThemeName): Promise<{ // microtask (e.g. the caller's `.then`) let the previous accent flash on the // new theme for a frame — the flicker seen when switching to Buzz. Buzz // themes resolve to the neutral accent regardless of the stored value. - applyAccentColor( - resolveEffectiveAccent( - name, - getStorageItem(ACCENT_STORAGE_KEY) ?? DEFAULT_ACCENT, - ), - ); + applyAccentColor(resolveEffectiveAccent(name, readStoredAccent())); // Cache for FOUC prevention try { @@ -499,7 +524,7 @@ export function ThemeProvider({ const [accentColor, setAccentColorState] = useState(() => { // block/buzz#5078 — use the throw-safe accessor for init-time reads; a // denied-storage origin would otherwise kill the root on first mount. - return getStorageItem(ACCENT_STORAGE_KEY) ?? DEFAULT_ACCENT; + return readStoredAccent(); }); const [glassBackground, setGlassBackgroundState] = useState(() => { const stored = getStorageItem(GLASS_BACKGROUND_STORAGE_KEY); diff --git a/desktop/src/shared/theme/communityThemePreference.test.mjs b/desktop/src/shared/theme/communityThemePreference.test.mjs index 1cc689cf4b..fbe2833070 100644 --- a/desktop/src/shared/theme/communityThemePreference.test.mjs +++ b/desktop/src/shared/theme/communityThemePreference.test.mjs @@ -209,3 +209,45 @@ test("community switch defers stale outgoing appearance persistence", () => { ); assert.equal(communityThemePersistenceAction(null, incoming), "persist"); }); + +test("a retired accent hex is migrated rather than discarding the whole theme", () => { + // #3b82f6 was the default until it was deepened to #2563eb for legible white + // label text. It is no longer in ACCENT_COLORS, so without a migration it + // fails the allowlist and takes the user's syntax theme and follow-system + // flag down with it. + const stored = { + version: 1, + theme: "catppuccin-latte", + accent: "#3b82f6", + followSystem: false, + }; + + const parsed = parseCommunityThemePreference(stored); + + assert.notEqual( + parsed, + null, + "a retired accent must not void the preference", + ); + assert.equal(parsed.accent, "#2563eb", "the retired hex is upgraded"); + assert.equal( + parsed.theme, + "catppuccin-latte", + "the rest of the theme survives", + ); + assert.equal(parsed.followSystem, false); +}); + +test("an accent that was never offered is still rejected", () => { + // The migration must not become a general-purpose escape hatch: an arbitrary + // hex is not a retired value and still fails the allowlist. + assert.equal( + parseCommunityThemePreference({ + version: 1, + theme: "buzz", + accent: "#123456", + followSystem: true, + }), + null, + ); +}); diff --git a/desktop/src/shared/theme/communityThemePreference.ts b/desktop/src/shared/theme/communityThemePreference.ts index d9b13c0562..0115c8f511 100644 --- a/desktop/src/shared/theme/communityThemePreference.ts +++ b/desktop/src/shared/theme/communityThemePreference.ts @@ -16,13 +16,28 @@ export type CommunityThemePreference = { export const DEFAULT_COMMUNITY_THEME: CommunityThemePreference = Object.freeze({ version: 1, theme: "buzz", - accent: "#3b82f6", + accent: "#2563eb", followSystem: true, }); const THEME_NAMES = new Set(SYNTAX_THEMES); const ACCENTS = new Set(ACCENT_COLORS.map(({ value }) => value)); +/** + * Accent hexes that are no longer offered but are still sitting in people's + * stored preferences, mapped to their replacement. + * + * Without this, retiring a hex from `ACCENT_COLORS` makes every stored + * preference holding it fail validation — `parseCommunityThemePreference` + * returns null and the whole saved theme, including the chosen syntax theme and + * follow-system flag, is silently discarded. Migrating the one field preserves + * the rest. + */ +const RETIRED_ACCENTS = new Map([ + // Deepened so white label text is legible on it; see DESIGN.md § Contrast. + ["#3b82f6", "#2563eb"], +]); + export function communityThemeStorageKey( pubkey: string, relayUrl: string, @@ -44,12 +59,16 @@ export function parseCommunityThemePreference( return null; } const candidate = value as Record; + const accent = + typeof candidate.accent === "string" + ? (RETIRED_ACCENTS.get(candidate.accent) ?? candidate.accent) + : candidate.accent; if ( candidate.version !== 1 || typeof candidate.theme !== "string" || !THEME_NAMES.has(candidate.theme) || - typeof candidate.accent !== "string" || - !ACCENTS.has(candidate.accent) || + typeof accent !== "string" || + !ACCENTS.has(accent) || typeof candidate.followSystem !== "boolean" ) { return null; @@ -57,7 +76,7 @@ export function parseCommunityThemePreference( return { version: 1, theme: candidate.theme as SyntaxThemeName, - accent: candidate.accent, + accent, followSystem: candidate.followSystem, }; } diff --git a/desktop/src/shared/theme/communityThemeSync.test.mjs b/desktop/src/shared/theme/communityThemeSync.test.mjs index 956197125c..280529e28f 100644 --- a/desktop/src/shared/theme/communityThemeSync.test.mjs +++ b/desktop/src/shared/theme/communityThemeSync.test.mjs @@ -11,7 +11,7 @@ import { const preference = { version: 1, theme: "houston", - accent: "#3b82f6", + accent: "#2563eb", followSystem: false, }; From b7dbe900014716156167234d54d90d291c7d257a Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:41:51 -0700 Subject: [PATCH 07/33] fix(desktop-next): relight the dark text ramp and enforce contrast in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- desktop-next/AGENTS.md | 11 +- desktop-next/DESIGN.md | 14 ++ desktop-next/package.json | 5 +- desktop-next/scripts/apca.mjs | 99 ++++++++++++ desktop-next/scripts/check-contrast.mjs | 189 ++++++++++++++++++++++ desktop-next/src/shared/styles/tokens.css | 22 ++- 6 files changed, 333 insertions(+), 7 deletions(-) create mode 100644 desktop-next/scripts/apca.mjs create mode 100644 desktop-next/scripts/check-contrast.mjs diff --git a/desktop-next/AGENTS.md b/desktop-next/AGENTS.md index 3244d42cca..7866fbaa80 100644 --- a/desktop-next/AGENTS.md +++ b/desktop-next/AGENTS.md @@ -32,8 +32,9 @@ Port 1430, so it can run alongside the existing client on 1420. ```bash pnpm typecheck # tsc --noEmit -pnpm check # biome + the type-system guard +pnpm check # biome + the type-system and contrast guards pnpm check:type # the type-system guard alone +pnpm check:contrast # the contrast guard alone pnpm biome check --write . # auto-fix ``` @@ -42,6 +43,14 @@ a token: no arbitrary text sizes (px **or** rem), no `uppercase`, no hand-applied `tracking-*`, and no size role paired with a weight or leading utility. Each one is a defect the existing client already paid for. +`scripts/check-contrast.mjs` measures every text role against every surface it +can sit on, in both modes, and fails the build below its **APCA** target (Lc 60 +body, Lc 45 meta). It parses `tokens.css` rather than a copied list of values, +so it cannot drift from the tokens it audits. Buzz judges contrast with APCA, +not the WCAG 2 ratio — see DESIGN.md § Contrast for the evidence, and note the +consequence: a pairing can pass WCAG AA and still fail here, which is the point. +`#8f8f8f` on `#1c1c1c` scores WCAG 5.27:1 and APCA Lc 40. + --- ## Stack, and why diff --git a/desktop-next/DESIGN.md b/desktop-next/DESIGN.md index 421c264953..1a04005a72 100644 --- a/desktop-next/DESIGN.md +++ b/desktop-next/DESIGN.md @@ -109,6 +109,20 @@ it is the rule a generated theme is measured against. pairing in this system has been wrong at least once. - **One implementation of the rule.** Desktop, mobile, and web must not each compute their own pairing; they diverge and the same defect ships three times. +- **Size a text step against the worst surface it can land on**, not the most + common one. `bg-float` is the lightest dark surface, so it is the binding case + in dark mode; a step that only clears the target on `bg-inset` fails wherever + a popover opens. +- **Dark mode is not light mode inverted.** APCA is polarity-asymmetric: + light-on-dark needs more separation than the same WCAG ratio suggests. The + dark ramp's text steps are therefore lighter than a mirrored ramp would put + them — steps 9 and 10 sit above where linear spacing would. +- **`text-disabled` is deliberately below target.** Low contrast is the signal + that a control is unavailable. Never put information a person needs there. +- **`pnpm check:contrast` enforces this.** Every text role is measured against + every surface it can sit on, in both modes, parsed from `tokens.css` so the + check cannot drift from the tokens. Exceptions live in that script with a + stated reason, which keeps the list short and arguable. ## Writing diff --git a/desktop-next/package.json b/desktop-next/package.json index aa996daad8..6e0bdff5cc 100644 --- a/desktop-next/package.json +++ b/desktop-next/package.json @@ -9,9 +9,10 @@ "typecheck": "tsc --noEmit", "preview": "vite preview", "lint": "biome lint .", - "check": "biome check . && node scripts/check-type.mjs", + "check": "biome check . && node scripts/check-type.mjs && node scripts/check-contrast.mjs", "format": "biome format --write .", - "check:type": "node scripts/check-type.mjs" + "check:type": "node scripts/check-type.mjs", + "check:contrast": "node scripts/check-contrast.mjs" }, "dependencies": { "@base-ui-components/react": "1.0.0-rc.0", diff --git a/desktop-next/scripts/apca.mjs b/desktop-next/scripts/apca.mjs new file mode 100644 index 0000000000..d31a3895d9 --- /dev/null +++ b/desktop-next/scripts/apca.mjs @@ -0,0 +1,99 @@ +/** + * APCA 0.1.9 — the perceptual contrast algorithm in the WCAG 3 draft. + * + * Buzz judges contrast with this rather than the WCAG 2 ratio; see + * DESIGN.md § Contrast for why, with the measured evidence. + * + * Validated against the published reference values: + * black on white -> Lc 106.04 + * white on black -> Lc -107.88 + * #888 on white -> Lc 63.06 + * `scripts/check-contrast.mjs` asserts these on every run, so a bad edit to the + * maths fails loudly instead of silently shifting every verdict. + */ + +const EXP = 2.4; +const R_CO = 0.2126729; +const G_CO = 0.7151522; +const B_CO = 0.072175; + +const BLACK_THRESHOLD = 0.022; +// A named constant from the APCA specification that happens to read like an +// approximation of Math.SQRT2. It is not one — do not "correct" it. +// biome-ignore lint/suspicious/noApproximativeNumericConstant: APCA spec value, not Math.SQRT2 +const BLACK_CLAMP = 1.414; +const SCALE = 1.14; +const DELTA_Y_MIN = 0.1; +const LO_OFFSET = 0.027; + +// Dark text on a light background. +const BOW_TEXT = 0.57; +const BOW_BG = 0.56; +// Light text on a dark background — different exponents, because perception is +// not symmetric between the two polarities. This asymmetry is the whole reason +// APCA disagrees with WCAG 2 about dark mode. +const WOB_TEXT = 0.62; +const WOB_BG = 0.65; + +/** Parses `#rgb` / `#rrggbb` into 0–255 channels. */ +export function parseHex(hex) { + const raw = hex.trim().replace(/^#/, ""); + const full = + raw.length === 3 + ? raw + .split("") + .map((c) => c + c) + .join("") + : raw; + if (!/^[0-9a-fA-F]{6}$/.test(full)) { + throw new Error(`not an opaque hex colour: ${hex}`); + } + return [0, 2, 4].map((i) => Number.parseInt(full.slice(i, i + 2), 16)); +} + +/** Screen luminance, APCA's variant — a simple power curve, no sRGB kink. */ +function screenLuminance(hex) { + const [r, g, b] = parseHex(hex).map((c) => (c / 255) ** EXP); + return R_CO * r + G_CO * g + B_CO * b; +} + +/** Lifts near-black so tiny luminance differences don't overstate contrast. */ +function clampBlack(y) { + return y < BLACK_THRESHOLD ? y + (BLACK_THRESHOLD - y) ** BLACK_CLAMP : y; +} + +/** + * Lightness contrast between text and its background. + * + * Returns a signed value: positive for dark-on-light, negative for + * light-on-dark. Compare `Math.abs()` against a target. + */ +export function apcaContrast(textHex, backgroundHex) { + const text = clampBlack(screenLuminance(textHex)); + const background = clampBlack(screenLuminance(backgroundHex)); + + if (Math.abs(background - text) < DELTA_Y_MIN) return 0; + + if (background > text) { + const c = (background ** BOW_BG - text ** BOW_TEXT) * SCALE; + return c < DELTA_Y_MIN ? 0 : (c - LO_OFFSET) * 100; + } + const c = (background ** WOB_BG - text ** WOB_TEXT) * SCALE; + return c > -DELTA_Y_MIN ? 0 : (c + LO_OFFSET) * 100; +} + +/** WCAG 2 relative luminance — reported alongside APCA, never used to decide. */ +function relativeLuminance(hex) { + const [r, g, b] = parseHex(hex).map((c) => { + const v = c / 255; + return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +/** WCAG 2 contrast ratio, for the number an audit will ask about. */ +export function wcagRatio(a, b) { + const la = relativeLuminance(a); + const lb = relativeLuminance(b); + return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); +} diff --git a/desktop-next/scripts/check-contrast.mjs b/desktop-next/scripts/check-contrast.mjs new file mode 100644 index 0000000000..9a75a75557 --- /dev/null +++ b/desktop-next/scripts/check-contrast.mjs @@ -0,0 +1,189 @@ +#!/usr/bin/env node +/** + * Fails the build when a text role cannot be read on a surface it is used on. + * + * This exists because the token table found four broken pairings by rendering + * them, and a defect found by looking is a defect that ships until someone + * looks. The table stays as the readable view; this is the check that runs. + * + * It parses `tokens.css` rather than a duplicate list of values, so it cannot + * 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. + */ + +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { apcaContrast, wcagRatio } from "./apca.mjs"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const TOKENS = join(ROOT, "src/shared/styles/tokens.css"); + +/** Body text. Anything a person must read to use the product. */ +const TARGET_BODY = 60; +/** Large or non-essential text: timestamps, counts, meta. */ +const TARGET_META = 45; + +/** + * Pairings that are deliberately below target, with the reason. + * + * An exception must name a role and say why, so the list stays short and + * arguable rather than becoming a place to hide failures. + */ +const EXCEPTIONS = new Map([ + [ + "--text-disabled", + "Low contrast is the signal that a control is unavailable; WCAG exempts inactive controls. Never carries information a person needs.", + ], +]); + +/** Roles measured at the meta target rather than the body target. */ +const META_ROLES = new Set(["--text-tertiary"]); + +/** Neutral surfaces any text role may sit on. */ +const SURFACES = ["--bg-panel", "--bg-float", "--bg-inset", "--bg-hover"]; + +/** Text roles that must be readable on every neutral surface. */ +const TEXT_ROLES = [ + "--text-primary", + "--text-secondary", + "--text-tertiary", + "--text-disabled", + "--text-accent", + "--text-danger", + "--text-success", + "--text-warning", + "--text-info", +]; + +/** Paired text and the one fill it is defined against. */ +const PAIRS = [ + ["--text-on-accent", "--bg-accent"], + ["--text-on-inverse", "--bg-inverse"], + ["--text-on-danger", "--bg-danger"], + ["--text-on-success", "--bg-success"], + ["--text-on-warning", "--bg-warning"], + ["--text-on-info", "--bg-info"], +]; + +const css = readFileSync(TOKENS, "utf8"); + +/** + * Collects declarations per mode, honouring source order so a later block wins + * exactly as the cascade would. `.dark` inherits `:root` and overrides it, so + * dark is light-then-dark rather than dark alone. + */ +function declarationsByMode() { + const light = new Map(); + const dark = new Map(); + // Split on top-level selector blocks and track which we are inside. + const blocks = css.matchAll(/(:root|\.dark)\s*\{([^}]*)\}/g); + for (const [, selector, body] of blocks) { + for (const [, name, value] of body.matchAll(/(--[\w-]+):\s*([^;]+);/g)) { + const v = value.trim(); + if (selector === ":root") { + light.set(name, v); + // Dark starts from the light value and is overridden below. + dark.set(name, v); + } else { + dark.set(name, v); + } + } + } + return { light, dark }; +} + +/** Follows a `var()` chain to its literal, or null if it is not a plain colour. */ +function resolve(map, name, depth = 0) { + if (depth > 10) return null; + const value = map.get(name); + if (!value) return null; + const single = /^var\(\s*(--[\w-]+)\s*\)$/.exec(value); + if (single) return resolve(map, single[1], depth + 1); + return /^#[0-9a-fA-F]{3,6}$/.test(value) ? value : null; +} + +const modes = declarationsByMode(); +const failures = []; +const skipped = []; + +for (const [mode, map] of Object.entries(modes)) { + const check = (textRole, surfaceRole) => { + const text = resolve(map, textRole); + const surface = resolve(map, surfaceRole); + // A translucent or gradient value cannot be measured against one colour; + // record it rather than passing it silently. + if (!text || !surface) { + skipped.push(`${mode}: ${textRole} on ${surfaceRole}`); + return; + } + const lc = Math.abs(apcaContrast(text, surface)); + const target = META_ROLES.has(textRole) ? TARGET_META : TARGET_BODY; + if (lc >= target) return; + if (EXCEPTIONS.has(textRole)) return; + failures.push({ + mode, + textRole, + text, + surfaceRole, + surface, + lc, + target, + wcag: wcagRatio(text, surface), + }); + }; + + for (const role of TEXT_ROLES) { + for (const surface of SURFACES) check(role, surface); + } + for (const [role, fill] of PAIRS) check(role, fill); +} + +// Guard the maths itself: if these drift, every verdict above is wrong. +const REFERENCE = [ + ["#000000", "#ffffff", 106.04], + ["#ffffff", "#000000", -107.88], + ["#888888", "#ffffff", 63.06], +]; +for (const [text, bg, expected] of REFERENCE) { + const got = apcaContrast(text, bg); + if (Math.abs(got - expected) > 0.05) { + console.error( + `✗ APCA implementation drifted: ${text} on ${bg} gave ${got.toFixed(2)}, expected ${expected}`, + ); + process.exit(1); + } +} + +if (skipped.length > 0) { + console.log(`ℹ ${skipped.length} pairing(s) not measurable as flat colour:`); + for (const s of skipped) console.log(` ${s}`); +} + +if (failures.length > 0) { + console.error( + `\n✗ Contrast: ${failures.length} pairing(s) below their APCA target\n`, + ); + for (const f of failures) { + console.error( + ` ${f.mode.padEnd(5)} ${f.textRole} (${f.text}) on ${f.surfaceRole} (${f.surface})`, + ); + console.error( + ` Lc ${f.lc.toFixed(1)} — needs ${f.target} [WCAG ${f.wcag.toFixed(2)}:1]`, + ); + } + console.error( + "\nRaise the ramp step the role points at, or add a documented exception\nin scripts/check-contrast.mjs with a reason. See DESIGN.md § Contrast.\n", + ); + process.exit(1); +} + +console.log("✓ Contrast: every text role clears its APCA target in both modes"); +for (const [role, why] of EXCEPTIONS) { + console.log(` (exception) ${role} — ${why.split(";")[0]}`); +} diff --git a/desktop-next/src/shared/styles/tokens.css b/desktop-next/src/shared/styles/tokens.css index a437dc0823..9d3605e4f5 100644 --- a/desktop-next/src/shared/styles/tokens.css +++ b/desktop-next/src/shared/styles/tokens.css @@ -31,7 +31,7 @@ --neutral-6: #e5e5e5; /* subtle border */ --neutral-7: #d4d4d4; /* border */ --neutral-8: #b8b8b8; /* border hover */ - --neutral-9: #a3a3a3; /* solid fill */ + --neutral-9: #909090; /* solid fill, tertiary text */ --neutral-10: #737373; /* solid fill hover */ --neutral-11: #262626; /* dark fill */ --neutral-12: #0a0a0a; /* high-contrast text */ @@ -115,8 +115,14 @@ --neutral-6: #333333; --neutral-7: #424242; --neutral-8: #5a5a5a; - --neutral-9: #6e6e6e; - --neutral-10: #8f8f8f; + /* Steps 9 and 10 are lighter than a linear ramp would put them, because in + dark mode they carry text and APCA is polarity-asymmetric: light-on-dark + needs more separation than the WCAG ratio implies. At #6e6e6e and #8f8f8f + they measured Lc 23–26 and Lc 38–41 against the Lc 60 body-text target + while passing WCAG AA — legal, and visibly murky. Sized against the + lightest dark surface (bg-float #2a2a2a), which is the binding case. */ + --neutral-9: #a6a6a6; + --neutral-10: #c1c1c1; --neutral-11: #e5e5e5; --neutral-12: #f5f5f5; @@ -192,6 +198,10 @@ --text-primary: var(--neutral-12); --text-secondary: var(--neutral-10); --text-tertiary: var(--neutral-9); + /* Deliberately below the contrast targets: low contrast is the signal that a + control is unavailable, and WCAG exempts inactive controls for the same + reason. Raising it to Lc 60 would make disabled read as enabled. Never put + information here that a person needs. */ --text-disabled: var(--neutral-8); --border-primary: var(--neutral-7); @@ -265,7 +275,11 @@ computed when the lightness rule lands. */ --text-on-danger: #ffffff; --text-on-success: #ffffff; - --text-on-warning: var(--neutral-12); + /* A literal, not `var(--neutral-12)`: the warning fill is the same amber in + both modes, so its paired text must be dark in both. neutral-12 flips to + near-white in dark mode, which put #f5f5f5 on #eab308 — APCA Lc 34, WCAG + 1.76. A paired-text token follows its fill, never the mode. */ + --text-on-warning: #0a0a0a; --text-on-info: #ffffff; /* 2. The glass rim. A directional light effect, not a solid From f8f07a3b33635434ca41cc48c82238cc59d2d8d5 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:07:31 -0700 Subject: [PATCH 08/33] fix(desktop-next): make coloured text legible on tint hovers, and widen the audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- desktop-next/DESIGN.md | 9 +++++++++ desktop-next/scripts/check-contrast.mjs | 11 +++++++++++ desktop-next/src/shared/styles/tokens.css | 16 +++++++++++----- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/desktop-next/DESIGN.md b/desktop-next/DESIGN.md index 1a04005a72..abdbe2c3bf 100644 --- a/desktop-next/DESIGN.md +++ b/desktop-next/DESIGN.md @@ -123,6 +123,15 @@ it is the rule a generated theme is measured against. every surface it can sit on, in both modes, parsed from `tokens.css` so the check cannot drift from the tokens. Exceptions live in that script with a stated reason, which keeps the list short and arguable. +- **A tint's hover is the hardest surface in its family**, so a `text-*` step is + sized against that rather than against the neutral panel. Every failure the + audit found in the coloured families was on a tint-hover, never at rest. +- **Hairline dividers are not held to a contrast target.** WCAG's 3:1 non-text + rule covers boundaries needed to identify a *control* or its state, not + grouping lines. Buzz's borders measure 1.2–1.8:1, which is where Radix and + Apple ship theirs; raising them would draw the box the fill already implies. + When the input layer lands, a control's own outline is a different question and + does need the 3:1 treatment. ## Writing diff --git a/desktop-next/scripts/check-contrast.mjs b/desktop-next/scripts/check-contrast.mjs index 9a75a75557..7cd81f171d 100644 --- a/desktop-next/scripts/check-contrast.mjs +++ b/desktop-next/scripts/check-contrast.mjs @@ -71,6 +71,13 @@ const PAIRS = [ ["--text-on-info", "--bg-info"], ]; +/** + * The families with a tint surface. DESIGN.md: "tint carries a meaning and takes + * coloured text", so each family's `text-*` must be readable on both its tint + * and that tint's hover — the hover state is the harder one and was the gap. + */ +const TINT_FAMILIES = ["accent", "danger", "success", "warning", "info"]; + const css = readFileSync(TOKENS, "utf8"); /** @@ -142,6 +149,10 @@ for (const [mode, map] of Object.entries(modes)) { for (const surface of SURFACES) check(role, surface); } for (const [role, fill] of PAIRS) check(role, fill); + for (const family of TINT_FAMILIES) { + check(`--text-${family}`, `--bg-${family}-tint`); + check(`--text-${family}`, `--bg-${family}-tint-hover`); + } } // Guard the maths itself: if these drift, every verdict above is wrong. diff --git a/desktop-next/src/shared/styles/tokens.css b/desktop-next/src/shared/styles/tokens.css index 9d3605e4f5..50e4176969 100644 --- a/desktop-next/src/shared/styles/tokens.css +++ b/desktop-next/src/shared/styles/tokens.css @@ -51,7 +51,7 @@ --danger-3: #fecaca; --danger-8: #f5b5b5; --danger-9: #dc2626; - --danger-11: #b91c1c; + --danger-11: #a80008; --success-2: #e8f7ee; --success-3: #d1f0dd; @@ -63,7 +63,7 @@ --warning-3: #fde68a; --warning-8: #f2d98a; --warning-9: #eab308; - --warning-11: #a16207; + --warning-11: #9c5e00; --info-2: #e7f1ff; --info-3: #d0e3ff; @@ -133,7 +133,7 @@ --accent-3: #1a3358; --accent-8: #356dbd; --accent-9: #237eff; - --accent-11: #8fbaff; + --accent-11: #95c0ff; --danger-2: #3a1515; --danger-3: #4d1c1c; @@ -157,7 +157,7 @@ --info-3: #1a3358; --info-8: #356dbd; --info-9: #3d87ff; - --info-11: #8fbaff; + --info-11: #95c0ff; --glass-1: #1c1c1c5c; --glass-2: #1c1c1c85; @@ -252,7 +252,13 @@ --bg-panel-glass: var(--glass-2); --bg-float-glass: var(--glass-3); - /* --- Focus. ---------------------------------------------- */ + /* --- Focus. ---------------------------------------------- + Legible on every neutral surface (WCAG 3.0–3.5:1), but it is an accent + against accent: on `bg-accent` it measures 1.1:1 and disappears. A focused + primary button therefore needs the ring separated from the fill — an offset + in the surface colour, not a different ring hue, so one focus treatment + works everywhere. Whatever component layer introduces focus owns that + offset; there is no second ring token. */ --ring-focus: var(--accent-8); /* ========================================================== From 2d4d36e086ee6c426a48f6a2250c176932c387c5 Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:02:30 -0700 Subject: [PATCH 09/33] feat(desktop-next): build channel-backed Sessions workspace Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> --- Justfile | 73 +++ crates/buzz-core/src/kind.rs | 8 + crates/buzz-db/src/store/channel_members.rs | 183 ++++++ crates/buzz-relay/src/api/bridge.rs | 13 +- crates/buzz-relay/src/handlers/count.rs | 26 +- crates/buzz-relay/src/handlers/event.rs | 12 +- crates/buzz-relay/src/handlers/ingest.rs | 80 ++- crates/buzz-relay/src/handlers/req.rs | 25 +- .../buzz-relay/src/handlers/side_effects.rs | 4 + desktop-next/.gitignore | 2 + desktop-next/AGENTS.md | 34 +- desktop-next/DESIGN.md | 15 +- desktop-next/package.json | 12 +- desktop-next/playwright.config.ts | 11 + desktop-next/scripts/check-type.mjs | 132 ++++- desktop-next/src/app/routes/index.tsx | 23 +- desktop-next/src/features/agents/runtime.ts | 180 ++++++ desktop-next/src/features/agents/types.ts | 37 ++ .../features/agents/ui/AgentsWorkspace.tsx | 342 +++++++++++ .../design-system/ui/ColorTablePage.tsx | 18 +- .../features/design-system/ui/ColourPage.tsx | 22 +- .../design-system/ui/DesignSystemLayout.tsx | 14 +- .../design-system/ui/ElevationPage.tsx | 4 +- .../features/design-system/ui/GlassPage.tsx | 10 +- .../features/design-system/ui/GrowthPage.tsx | 6 +- .../design-system/ui/OverviewPage.tsx | 12 +- .../design-system/ui/TypographyPage.tsx | 30 +- .../design-system/ui/VocabularyPage.tsx | 14 +- .../features/design-system/ui/primitives.tsx | 12 +- .../features/sessions/activityProjection.ts | 126 ++++ .../sessions/channelWindowProjection.test.ts | 33 ++ .../sessions/channelWindowProjection.ts | 55 ++ .../src/features/sessions/sessionRegistry.ts | 52 ++ desktop-next/src/features/sessions/types.ts | 79 +++ .../features/sessions/ui/AgentActivity.tsx | 85 +++ .../src/features/sessions/ui/AppShell.tsx | 450 ++++++++++++++ .../features/sessions/ui/NewSessionView.tsx | 87 +++ .../sessions/ui/ParticipantDialog.tsx | 157 +++++ .../features/sessions/ui/SessionComposer.tsx | 67 +++ .../src/features/sessions/ui/SessionView.tsx | 316 ++++++++++ desktop-next/src/main.tsx | 2 + desktop-next/src/shared/runtime/client.ts | 549 ++++++++++++++++++ desktop-next/src/shared/styles/agents.css | 157 +++++ desktop-next/src/shared/styles/globals.css | 4 +- desktop-next/src/shared/styles/product.css | 501 ++++++++++++++++ desktop-next/src/shared/styles/typography.css | 179 +++--- desktop-next/src/shared/tokens/registry.ts | 138 ++--- desktop-next/src/shared/ui/DockWorkspace.tsx | 61 ++ desktop-next/tauri.conf.json | 34 ++ desktop-next/tests/sessions.spec.ts | 73 +++ desktop/src-tauri/src/commands/agents.rs | 6 + desktop/src-tauri/src/commands/mod.rs | 4 + .../src/commands/session_bootstrap.rs | 357 ++++++++++++ desktop/src-tauri/src/commands/sessions.rs | 84 +++ desktop/src-tauri/src/events.rs | 23 + desktop/src-tauri/src/lib.rs | 3 + .../src/managed_agents/types/requests.rs | 6 + desktop/src-tauri/src/models.rs | 2 +- pnpm-lock.yaml | 54 ++ 59 files changed, 4809 insertions(+), 289 deletions(-) create mode 100644 desktop-next/playwright.config.ts create mode 100644 desktop-next/src/features/agents/runtime.ts create mode 100644 desktop-next/src/features/agents/types.ts create mode 100644 desktop-next/src/features/agents/ui/AgentsWorkspace.tsx create mode 100644 desktop-next/src/features/sessions/activityProjection.ts create mode 100644 desktop-next/src/features/sessions/channelWindowProjection.test.ts create mode 100644 desktop-next/src/features/sessions/channelWindowProjection.ts create mode 100644 desktop-next/src/features/sessions/sessionRegistry.ts create mode 100644 desktop-next/src/features/sessions/types.ts create mode 100644 desktop-next/src/features/sessions/ui/AgentActivity.tsx create mode 100644 desktop-next/src/features/sessions/ui/AppShell.tsx create mode 100644 desktop-next/src/features/sessions/ui/NewSessionView.tsx create mode 100644 desktop-next/src/features/sessions/ui/ParticipantDialog.tsx create mode 100644 desktop-next/src/features/sessions/ui/SessionComposer.tsx create mode 100644 desktop-next/src/features/sessions/ui/SessionView.tsx create mode 100644 desktop-next/src/shared/runtime/client.ts create mode 100644 desktop-next/src/shared/styles/agents.css create mode 100644 desktop-next/src/shared/styles/product.css create mode 100644 desktop-next/src/shared/ui/DockWorkspace.tsx create mode 100644 desktop-next/tauri.conf.json create mode 100644 desktop-next/tests/sessions.spec.ts create mode 100644 desktop/src-tauri/src/commands/session_bootstrap.rs create mode 100644 desktop/src-tauri/src/commands/sessions.rs diff --git a/Justfile b/Justfile index e44d315877..707a56c6cc 100644 --- a/Justfile +++ b/Justfile @@ -655,6 +655,79 @@ dev *ARGS: bootstrap _ensure-sidecar-stubs _ensure-migrations FEATURES=(); [[ -n "{{mesh}}" ]] && FEATURES=(--features mesh-llm) pnpm exec tauri dev ${FEATURES[@]+"${FEATURES[@]}"} --config "$BUZZ_TAURI_CONFIG" {{ARGS}} + +# Run desktop-next in dev mode with the same local relay, services, ports, and identity as `dev`. +desktop-next-dev *ARGS: bootstrap _ensure-sidecar-stubs _ensure-migrations + #!/usr/bin/env bash + set -euo pipefail + export PATH="{{justfile_directory()}}/bin:$PATH" + set -o allexport + source .env + set +o allexport + bind_addr="${BUZZ_BIND_ADDR:-0.0.0.0:3000}" + relay_port="${bind_addr##*:}"; [[ -n "$relay_port" ]] || relay_port=3000 + health_port="${BUZZ_HEALTH_PORT:-8080}" + metrics_port="${BUZZ_METRICS_PORT:-9102}" + if command -v lsof >/dev/null 2>&1; then + for spec in "relay:$relay_port" "health:$health_port" "metrics:$metrics_port"; do + name="${spec%%:*}"; port="${spec##*:}" + if lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then + echo "Error: $name port $port is already in use; refusing to launch desktop against a stale relay." >&2 + lsof -nP -iTCP:"$port" -sTCP:LISTEN >&2 || true + echo "Stop the process above (often a stale buzz-relay) and rerun: just dev" >&2 + exit 1 + fi + done + fi + cargo build -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr -p buzz-relay + # Docker Desktop's forwarded MinIO port can stall under the deployment + # probe's 32 concurrent writers. Keep the gate enabled in local dev, using + # the bounded profile already used by the relay test launcher. + export BUZZ_GIT_PROBE_WRITERS="${BUZZ_GIT_PROBE_WRITERS:-8}" + export BUZZ_GIT_PROBE_ROUNDS="${BUZZ_GIT_PROBE_ROUNDS:-2}" + TARGET_DIR=$(cargo metadata --format-version 1 --no-deps | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).target_directory") + "${TARGET_DIR}/debug/buzz-relay" & + RELAY_PID=$! + cleanup() { + [[ -n "${INSTANCE_ID:-}" ]] && ../scripts/cleanup-instance-agents.sh "$INSTANCE_ID" || true + kill "$RELAY_PID" 2>/dev/null || true + } + trap cleanup EXIT + relay_ready=false + for _ in $(seq 1 120); do + if ! kill -0 "$RELAY_PID" 2>/dev/null; then + echo "Error: buzz-relay exited during startup; refusing to launch desktop." >&2 + wait "$RELAY_PID" || true + exit 1 + fi + if curl --silent --fail --max-time 1 "http://127.0.0.1:${health_port}/_readiness" >/dev/null; then + relay_ready=true + break + fi + sleep 0.5 + done + if [[ "$relay_ready" != true ]]; then + echo "Error: buzz-relay did not become healthy within 60 seconds; refusing to launch desktop." >&2 + exit 1 + fi + [[ -d desktop/node_modules ]] || pnpm --dir desktop install + [[ -d desktop-next/node_modules ]] || pnpm --dir desktop-next install + cd {{desktop_dir}} + source ../scripts/instance-env.sh + # Keep instance-env's worktree-specific identifier, ports, icon, reset URL, + # and identity, but point the native runtime at the independent client. + export BUZZ_TAURI_CONFIG=$(node -e ' + const config = JSON.parse(process.env.BUZZ_TAURI_CONFIG); + config.productName = `Buzz Sessions (${process.env.BUZZ_WORKTREE_LABEL ?? "Dev"})`; + config.build.devUrl = `http://localhost:${process.env.BUZZ_VITE_PORT}${process.env.BUZZ_RESET_WEBVIEW_STATE === "1" ? "?resetDevState=1" : ""}`; + config.build.beforeDevCommand = `exec pnpm --dir ../desktop-next exec vite --port ${process.env.BUZZ_VITE_PORT} --strictPort`; + console.log(JSON.stringify(config)); + ') + INSTANCE_ID=$(node -e "console.log(JSON.parse(process.env.BUZZ_TAURI_CONFIG).identifier)") + echo "Starting desktop-next on Vite port ${BUZZ_VITE_PORT}, relay ${BUZZ_RELAY_URL}" + FEATURES=(); [[ -n "{{mesh}}" ]] && FEATURES=(--features mesh-llm) + pnpm exec tauri dev ${FEATURES[@]+"${FEATURES[@]}"} --config "$BUZZ_TAURI_CONFIG" {{ARGS}} + # Run only the desktop app. No relay, database, Docker, migrations, or .env are needed. # The app opens normally and asks for a community before making a relay connection. desktop-standalone *ARGS: _ensure-sidecar-stubs diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 4e1ab1c7f5..c860fd81ce 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -491,6 +491,13 @@ pub const KIND_STREAM_MESSAGE_SCHEDULED: u32 = 40006; pub const KIND_STREAM_REMINDER: u32 = 40007; /// A diff/patch message showing file changes (unified diff format). pub const KIND_STREAM_MESSAGE_DIFF: u32 = 40008; +/// A durable link from a parent channel to a channel-backed Session. +/// +/// The event is stored in the parent (`h` tag). Its content names the child +/// Session channel as `{ "session_channel_id": "" }`. Human discovery +/// and transcript authorization inherit from the parent channel; the child +/// remains an ordinary private stream channel for messaging and agent scope. +pub const KIND_SESSION_LINK: u32 = 40009; /// Canvas (shared document) for a channel. pub const KIND_CANVAS: u32 = 40100; /// System message for channel state changes (join, leave, rename, etc.). @@ -709,6 +716,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_REMINDER, KIND_STREAM_MESSAGE_DIFF, + KIND_SESSION_LINK, KIND_CANVAS, KIND_SYSTEM_MESSAGE, KIND_CHANNEL_SUMMARY, diff --git a/crates/buzz-db/src/store/channel_members.rs b/crates/buzz-db/src/store/channel_members.rs index 8280ca01f8..40f885398c 100644 --- a/crates/buzz-db/src/store/channel_members.rs +++ b/crates/buzz-db/src/store/channel_members.rs @@ -687,6 +687,64 @@ pub async fn is_member( Ok(cnt > 0) } +/// Return whether a pubkey inherits access to a Session through an active +/// parent-channel link. The link must be creator-authenticated and the Session +/// must still be an active private stream channel. +pub async fn has_session_parent_access( + pool: &PgPool, + community_id: CommunityId, + session_channel_id: Uuid, + pubkey: &[u8], +) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let allowed: bool = sqlx::query_scalar( + r#" + SELECT EXISTS ( + SELECT 1 + FROM events session_link + JOIN channels parent + ON parent.community_id = session_link.community_id + AND parent.id = session_link.channel_id + AND parent.deleted_at IS NULL + JOIN channels session_channel + ON session_channel.community_id = session_link.community_id + AND session_channel.id = $2 + AND session_channel.id::text = CASE + WHEN session_link.content IS JSON OBJECT + THEN (session_link.content::json ->> 'session_channel_id') + ELSE NULL + END + AND session_channel.created_by = session_link.pubkey + AND session_channel.visibility = 'private' + AND session_channel.channel_type = 'stream' + AND session_channel.ttl_seconds IS NULL + AND session_channel.archived_at IS NULL + AND session_channel.deleted_at IS NULL + JOIN channel_members parent_member + ON parent_member.community_id = parent.community_id + AND parent_member.channel_id = parent.id + AND parent_member.pubkey = $3 + AND parent_member.role <> 'bot' + AND parent_member.removed_at IS NULL + WHERE session_link.community_id = $1 + AND session_link.kind = $4 + AND session_link.deleted_at IS NULL + ) + "#, + ) + .bind(community_id.as_uuid()) + .bind(session_channel_id) + .bind(pubkey) + .bind(buzz_core::kind::KIND_SESSION_LINK as i32) + .fetch_one(&mut *connection) + .await?; + Ok(allowed) +} + /// Return which of the given (channel, pubkey) combinations are active /// memberships, restricted to non-deleted channels — one statement for any /// batch size (T2b). Semantics per pair match [`is_member`]. @@ -824,10 +882,40 @@ pub async fn get_accessible_channel_ids( SELECT id AS channel_id FROM channels WHERE community_id = $1 AND visibility = 'open' AND deleted_at IS NULL + UNION + SELECT session_channel.id AS channel_id + FROM events session_link + JOIN channels parent + ON parent.community_id = session_link.community_id + AND parent.id = session_link.channel_id + AND parent.deleted_at IS NULL + JOIN channels session_channel + ON session_channel.community_id = session_link.community_id + AND session_channel.id::text = CASE + WHEN session_link.content IS JSON OBJECT + THEN (session_link.content::json ->> 'session_channel_id') + ELSE NULL + END + AND session_channel.created_by = session_link.pubkey + AND session_channel.visibility = 'private' + AND session_channel.channel_type = 'stream' + AND session_channel.ttl_seconds IS NULL + AND session_channel.archived_at IS NULL + AND session_channel.deleted_at IS NULL + JOIN channel_members parent_member + ON parent_member.community_id = parent.community_id + AND parent_member.channel_id = parent.id + AND parent_member.pubkey = $2 + AND parent_member.role <> 'bot' + AND parent_member.removed_at IS NULL + WHERE session_link.community_id = $1 + AND session_link.kind = $3 + AND session_link.deleted_at IS NULL "#, ) .bind(community_id.as_uuid()) .bind(pubkey) + .bind(buzz_core::kind::KIND_SESSION_LINK as i32) .fetch_all(&mut *connection) .await?; @@ -1364,6 +1452,17 @@ impl Db { is_member(&self.pool, community_id, channel_id, pubkey).await } + /// Return whether the pubkey inherits Session access from a parent channel. + #[datastore_span(name = "has_session_parent_access", system = "postgresql")] + pub async fn has_session_parent_access( + &self, + community_id: CommunityId, + session_channel_id: Uuid, + pubkey: &[u8], + ) -> Result { + has_session_parent_access(&self.pool, community_id, session_channel_id, pubkey).await + } + /// Return the active (channel, pubkey) membership pairs among the given /// sets, in one statement. #[datastore_span(name = "membership_pairs", system = "postgresql")] @@ -1601,6 +1700,90 @@ mod postgres_tests { crate::channel::get_channel(pool, CommunityId::from_uuid(community_id), id).await } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn session_parent_access_inherits_to_humans_but_not_bots() { + let pool = setup_pool().await; + let community_id = make_test_community(&pool).await; + let community = CommunityId::from_uuid(community_id); + let creator = random_pubkey(); + let human = random_pubkey(); + let bot = random_pubkey(); + for pubkey in [&creator, &human, &bot] { + ensure_user(&pool, community, pubkey) + .await + .expect("ensure user"); + } + let parent = create_test_channel( + &pool, + community_id, + "parent", + ChannelType::Stream, + ChannelVisibility::Private, + None, + &creator, + None, + ) + .await + .expect("create parent"); + let session = create_test_channel( + &pool, + community_id, + "session", + ChannelType::Stream, + ChannelVisibility::Private, + None, + &creator, + None, + ) + .await + .expect("create session"); + for (pubkey, role) in [(&human, "member"), (&bot, "bot")] { + sqlx::query("INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by) VALUES ($1,$2,$3,$4::member_role,$5)") + .bind(community_id) + .bind(parent.id) + .bind(pubkey) + .bind(role) + .bind(&creator) + .execute(&pool) + .await + .expect("add parent member"); + } + // Insert a structurally equivalent stored link directly to isolate the + // authorization SQL from event-signing behavior. + let event_id = random_pubkey(); + sqlx::query("INSERT INTO events (id, community_id, pubkey, created_at, kind, tags, content, sig, channel_id) VALUES ($1,$2,$3,NOW(),$4,$5,$6,$7,$8)") + .bind(event_id) + .bind(community_id) + .bind(&creator) + .bind(buzz_core::kind::KIND_SESSION_LINK as i32) + .bind(serde_json::json!([["h", parent.id.to_string()]])) + .bind(serde_json::json!({"session_channel_id": session.id}).to_string()) + .bind(vec![0_u8; 64]) + .bind(parent.id) + .execute(&pool) + .await + .expect("insert Session link"); + assert!( + has_session_parent_access(&pool, community, session.id, &human) + .await + .expect("human access") + ); + assert!( + !has_session_parent_access(&pool, community, session.id, &bot) + .await + .expect("bot access") + ); + remove_member(&pool, community, parent.id, &human, &creator) + .await + .expect("remove parent member"); + assert!( + !has_session_parent_access(&pool, community, session.id, &human) + .await + .expect("revoked access") + ); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn get_users_bulk_is_scoped_when_pubkey_exists_in_multiple_communities() { diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 37c549610d..45757d55d9 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -1531,11 +1531,22 @@ async fn repair_requested_channel_access( if accessible_channels.contains(&channel_id) { continue; } - let is_member = state + let direct_member = state .db .is_member(tenant.community(), channel_id, pubkey_bytes) .await .map_err(|e| internal_error(&format!("channel membership confirmation: {e}")))?; + let is_member = if direct_member { + true + } else { + state + .db + .has_session_parent_access(tenant.community(), channel_id, pubkey_bytes) + .await + .map_err(|e| { + internal_error(&format!("Session parent access confirmation: {e}")) + })? + }; crate::handlers::req::resolve_request_local_access( accessible_channels, channel_id, diff --git a/crates/buzz-relay/src/handlers/count.rs b/crates/buzz-relay/src/handlers/count.rs index 938674301e..004704744d 100644 --- a/crates/buzz-relay/src/handlers/count.rs +++ b/crates/buzz-relay/src/handlers/count.rs @@ -136,7 +136,31 @@ pub async fn handle_count( .is_member(conn.tenant.community(), ch_id, &pubkey_bytes) .await { - Ok(member) => Some(member), + Ok(direct_member) => { + if direct_member { + Some(true) + } else { + match state + .db + .has_session_parent_access( + conn.tenant.community(), + ch_id, + &pubkey_bytes, + ) + .await + { + Ok(inherited) => Some(inherited), + Err(e) => { + warn!(sub_id = %sub_id, "Session parent access confirmation failed: {e}"); + conn.send(RelayMessage::closed( + &sub_id, + "error: database error", + )); + return; + } + } + } + } Err(e) => { warn!(sub_id = %sub_id, "Channel membership confirmation failed: {e}"); conn.send(RelayMessage::closed(&sub_id, "error: database error")); diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 66a8ff9e7c..83af504b3a 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -212,7 +212,17 @@ pub async fn filter_fanout_by_access( .await { Ok(true) => allowed.push((conn_id, sub_id)), - Ok(false) => {} + Ok(false) => match state + .db + .has_session_parent_access(community_id, channel_id, &pubkey) + .await + { + Ok(true) => allowed.push((conn_id, sub_id)), + Ok(false) => {} + Err(e) => { + warn!(%channel_id, "fan-out access filter: Session parent lookup failed: {e}"); + } + }, Err(e) => { warn!(%channel_id, "fan-out access filter: membership lookup failed: {e}"); } diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index ee1d0312be..a0c05da6fa 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -29,12 +29,12 @@ use buzz_core::kind::{ KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, KIND_PRIVATE_MANAGED_AGENT, KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION, - KIND_READ_STATE, KIND_REPORT, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, - KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, - KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, - KIND_TEAM_CATALOG, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, - RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, - RELAY_ADMIN_SET_WORKSPACE_PROFILE, + KIND_READ_STATE, KIND_REPORT, KIND_SESSION_LINK, KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, + KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, + KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEAM_CATALOG, KIND_TEXT_NOTE, KIND_USER_STATUS, + KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, + RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, }; use buzz_core::tenant::TenantContext; use buzz_core::verification::verify_event; @@ -142,6 +142,55 @@ async fn validate_huddle_lifecycle_event( Ok(()) } +async fn validate_session_link( + tenant: &TenantContext, + state: &AppState, + event: &Event, +) -> Result<(), IngestError> { + if event_kind_u32(event) != KIND_SESSION_LINK { + return Ok(()); + } + let parent_channel_id = extract_channel_id(event).ok_or_else(|| { + IngestError::Rejected("invalid: Session link must name its parent channel".into()) + })?; + let content: serde_json::Value = serde_json::from_str(&event.content) + .map_err(|_| IngestError::Rejected("invalid: Session link content must be JSON".into()))?; + let session_channel_id = content + .get("session_channel_id") + .and_then(serde_json::Value::as_str) + .and_then(|value| Uuid::parse_str(value).ok()) + .ok_or_else(|| { + IngestError::Rejected("invalid: Session link must name a session_channel_id".into()) + })?; + if session_channel_id == parent_channel_id { + return Err(IngestError::Rejected( + "invalid: a Session cannot be its own parent channel".into(), + )); + } + let backing = state + .db + .get_channel_for_event_write(tenant.community(), session_channel_id) + .await + .map_err(|error| match error { + buzz_db::DbError::ChannelNotFound(_) => { + IngestError::Rejected("invalid: Session channel not found".into()) + } + error => IngestError::Internal(format!("error: loading Session channel: {error}")), + })?; + if backing.created_by.as_slice() != event.pubkey.to_bytes().as_slice() + || backing.channel_type != "stream" + || backing.visibility != "private" + || backing.ttl_seconds.is_some() + || backing.archived_at.is_some() + { + return Err(IngestError::Rejected( + "invalid: Session link must reference the signer's active private stream channel" + .into(), + )); + } + Ok(()) +} + fn validate_custom_emoji_tags(event: &Event) -> Result<(), IngestError> { for tag in event.tags.iter() { let parts = tag.as_slice(); @@ -515,7 +564,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::ChannelsWrite), + KIND_NIP29_CREATE_GROUP | KIND_CANVAS | KIND_SESSION_LINK => Ok(Scope::ChannelsWrite), KIND_NIP29_JOIN_REQUEST | KIND_NIP29_LEAVE_REQUEST | KIND_NIP43_LEAVE_REQUEST => { Ok(Scope::ChannelsRead) } @@ -715,6 +764,7 @@ pub(crate) fn requires_h_channel_scope(kind: u32) -> bool { | KIND_STREAM_MESSAGE_SCHEDULED | KIND_STREAM_REMINDER | KIND_STREAM_MESSAGE_DIFF + | KIND_SESSION_LINK | KIND_CANVAS | KIND_FORUM_POST | KIND_FORUM_VOTE @@ -757,7 +807,16 @@ pub(crate) async fn check_channel_membership( Ok(false) => {} Err(e) => return Err(format!("error: database error: {e}")), } - // Not a member — check if channel is open. + match state + .db + .has_session_parent_access(tenant.community(), ch_id, pubkey_bytes) + .await + { + Ok(true) => return Ok(()), + Ok(false) => {} + Err(e) => return Err(format!("error: database error: {e}")), + } + // Not a member and no inherited Session access — check if channel is open. let is_open = match channel { Some(ch) => ch.visibility == "open", None => state @@ -2667,6 +2726,7 @@ async fn ingest_event_inner( } validate_huddle_lifecycle_event(tenant, state, &event, kind_u32).await?; + validate_session_link(tenant, state, &event).await?; if crate::handlers::side_effects::is_admin_kind(kind_u32) { crate::handlers::side_effects::validate_admin_event(tenant, kind_u32, &event, state) @@ -3211,6 +3271,10 @@ async fn ingest_event_inner( }); } + if kind_u32 == KIND_SESSION_LINK { + state.invalidate_all_accessible_channels(tenant); + } + if crate::handlers::side_effects::is_side_effect_kind(kind_u32) { if let Err(e) = crate::handlers::side_effects::handle_side_effects(tenant, kind_u32, &event, state) diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 9a141e86ff..d8cf96aaa4 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -159,7 +159,30 @@ pub async fn handle_req( .is_member(conn.tenant.community(), ch_id, &pubkey_bytes) .await { - Ok(member) => { + Ok(direct_member) => { + let member = if direct_member { + true + } else { + match state + .db + .has_session_parent_access( + conn.tenant.community(), + ch_id, + &pubkey_bytes, + ) + .await + { + Ok(inherited) => inherited, + Err(e) => { + warn!(conn_id = %conn_id, "Session parent access confirmation failed: {e}"); + conn.send(RelayMessage::closed( + &sub_id, + "error: database error", + )); + return; + } + } + }; if let Some(state_snap) = trace_state.as_ref() { crate::conformance::record_req_authcheck( &state.tracer, diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 7282c91342..b79a0ee089 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -1327,6 +1327,7 @@ async fn handle_put_user( ) .await?; state.invalidate_membership(tenant, channel_id, &target_pubkey); + state.invalidate_all_accessible_channels(tenant); let actor_hex = hex::encode(&actor_bytes); let target_hex = hex::encode(&target_pubkey); @@ -1390,6 +1391,7 @@ async fn handle_remove_user( .remove_member(tenant.community(), channel_id, &target_pubkey, &actor_bytes) .await?; state.invalidate_membership(tenant, channel_id, &target_pubkey); + state.invalidate_all_accessible_channels(tenant); evict_live_channel_subscriptions(tenant, state, channel_id, &target_pubkey).await; disable_departed_member_workflows(tenant, state, channel_id, &target_pubkey).await; @@ -1744,6 +1746,8 @@ async fn handle_delete_event_side_effect( return Ok(()); // No-op: skip system message to avoid false audit records. } + state.invalidate_all_accessible_channels(tenant); + // Thread counters were decremented in the same transaction — push a fresh // relay-signed 39005 so live badge counts also count *down*. if let Some(root_id) = root_id { diff --git a/desktop-next/.gitignore b/desktop-next/.gitignore index 56f1058162..f3a0b5e2f3 100644 --- a/desktop-next/.gitignore +++ b/desktop-next/.gitignore @@ -1,5 +1,7 @@ node_modules dist +playwright-report +test-results *.log .DS_Store src/app/routeTree.gen.ts diff --git a/desktop-next/AGENTS.md b/desktop-next/AGENTS.md index 7866fbaa80..a173d42126 100644 --- a/desktop-next/AGENTS.md +++ b/desktop-next/AGENTS.md @@ -40,8 +40,21 @@ pnpm biome check --write . # auto-fix `scripts/check-type.mjs` enforces four things the type system cannot express as a token: no arbitrary text sizes (px **or** rem), no `uppercase`, no -hand-applied `tracking-*`, and no size role paired with a weight or leading -utility. Each one is a defect the existing client already paid for. +hand-applied `tracking-*`, and no font weight outside 400 and 600. Each one is a +defect the existing client already paid for. + +**Weight is two values, not a ramp.** 400 is content, 600 is structure and +emphasis, and `font-semibold` is what bold means here. Bold body text is +`text-body font-semibold` — composed, because the size is the paragraph's +decision and the weight is the phrase's. 500 does not read as intent at body +size; 700 is louder than anything here needs. State (selected, active, unread) +is said with colour, a fill, or a dot — never weight. + +Every rule has a named escape hatch in `OVERRIDES`, keyed `path:matchedText` +with a reason. **Adding one is a normal edit.** The guard exists because an +agent has no basis for preferring `font-medium` to `font-semibold` and will +otherwise pick either; it is not there to stop a designer who has looked at the +screen. Stale overrides are reported so the list cannot rot. `scripts/check-contrast.mjs` measures every text role against every surface it can sit on, in both modes, and fails the build below its **APCA** target (Lc 60 @@ -61,6 +74,7 @@ consequence: a pairing can pass WCAG AA and still fail here, which is the point. | **Tailwind v4** | Tokens are defined in CSS via `@theme`; the CSS *is* the config. No JS config file. | | **Own colour tokens** | Not shadcn. Its vocabulary — `muted-foreground`, `secondary-foreground` — is what made colour illegible in the existing client. | | **TanStack Router** | File-based routes, same as the existing client. | +| **Tabler Icons** | The only icon set for product UI. Do not add or use Lucide icons. | Astryx is a **reference** for token architecture and the agent-docs idea. Not a dependency. @@ -84,10 +98,18 @@ letter spacing, and weight together — never pair a size role with a separate `font-medium` or `leading-*`, because that is how two supposedly identical labels drift apart. -Ten roles: `text-display`, `text-title`, `text-heading`, `text-subheading`, -`text-body-lg`, `text-body`, `text-label`, `text-caption`, `text-meta`, -`text-code`. Two faces: `font-sans` (Inter Variable) and `font-mono` -(JetBrains Mono) — both already shipped in every current Buzz client. +Nine roles. Sans: `text-display` 32, `text-title` 24, `text-heading` 16/600, +`text-body-lg` 16, `text-body` 14, `text-body-sm` 12. Mono: `text-mono-lg` 15, +`text-mono` 13, `text-mono-sm` 11 — each one step below its sans partner, because +at equal size a monospace face reads larger than Inter. + +The ramp is short on purpose: across 73 real Buzz screens 90% of text is one size, +and 16/18/20/22 together were 1.5%. There is nothing between 16 and 24, and +**12px is the floor** for anything readable. `text-heading` and `text-body-lg` are +the same size, separated by weight alone. + +Two faces: `font-sans` (Inter Variable) and `font-mono` (JetBrains Mono) — both +already shipped in every current Buzz client. Hard rules: diff --git a/desktop-next/DESIGN.md b/desktop-next/DESIGN.md index abdbe2c3bf..4d660bba5f 100644 --- a/desktop-next/DESIGN.md +++ b/desktop-next/DESIGN.md @@ -40,12 +40,21 @@ Buzz is a place where people build together and bring their agents into the room ## Type -- **A type role carries its whole setting.** Size, line height, letter spacing, and weight are one decision, not four. `text-body` alone produces correctly set text — never pair a size role with a separate weight or leading utility, because that is how two supposedly identical labels drift apart. +- **A type role carries its whole setting.** Size, line height, letter spacing, and weight are one decision, not four. `text-body` alone produces correctly set text, and its line height is never overridden — that is how two supposedly identical labels drift apart. +- **There are two weights: 400 and 600.** 400 is content — everything read. 600 is structure and emphasis: the thing that names what you are looking at, or the words a sentence leans on. `font-semibold` is what bold means here. +- **Bold body text is two utilities, composed.** `text-body font-semibold`, `text-body-sm font-semibold`. This is the one place a component adds a weight, and it is deliberate: **the size is the paragraph's decision, the weight is the phrase's.** A `text-body-bold` role would fuse them, so an agent emphasising three words would also be re-asserting a size it has no business choosing. Composing also means one rule covers every size instead of doubling the ramp. +- **500 and 700 are not in the system.** 500 was measured against 400 at body size and does not read as intent in a scanned list — subtle enough to miss, heavy enough to muddy a column. 700 is louder than anything here needs. Weight is not a ramp; it is two values with two jobs. +- **State is not weight.** Selected, active, and unread are said with colour, a fill, or a dot — all three already exist in the colour system. Reserve 600 for structure and emphasis, or it stops meaning either. +- **The guard has a named escape hatch, and using it is normal.** `scripts/check-type.mjs` rejects other weights because an agent has no basis for preferring `font-medium` to `font-semibold` and will otherwise pick either. A designer who finds a genuine optical exception adds it to `OVERRIDES` with a reason and moves on — that is an ordinary edit, not an escalation. The point is that the next person reads a decision instead of guessing at an accident. If overrides start accumulating in one direction, the system is missing a role; fix the system rather than adding a tenth entry. - **Roles are named for the job the text does, never for its size.** `text-title`, not `text-28`. A size name is a value in disguise and goes stale the moment the ramp moves. -- **Never all-caps, and never tracked-out labels.** A capitalised label is harder to read than its sentence-case version and reads as enterprise chrome. A quiet label earns its quietness from size and colour — `text-meta` on `text-tertiary` — rather than from being shouted. There is deliberately no uppercase utility in this system. +- **The ramp is short because the product is.** Across 73 real Buzz screens, 90% of all text is one size and two cover 93%; 16/18/20/22 together were 1.5%, scattered and inconsistent. So there is nothing between 16 and 24, and that gap is deliberate — an app has panels with names, not a document outline. Four steps above body invited a hierarchy the product does not have. +- **A heading is body-large in a different weight.** `text-heading` and `text-body-lg` are both 16px, separated by weight alone — which is what the shipping client does at 16/600, and the only text on a channel screen larger than a message. In a dense app a section name needs to be identifiable, not loud. +- **12px is the floor.** Nothing readable goes below it: the most compressed text in Berd — a timestamp in a hover slot, a model name in a 24px pill — is 12px. If something must be smaller it is not text, it is a glyph inside a component, and that component owns the size as a documented exception. Do not add a general-purpose smaller step. +- **Mono is one step below its sans partner, always.** 11↔12, 13↔14, 15↔16. At equal size a monospace face reads larger than Inter and pulls the eye off the sentence, so the correction is a rule rather than a judgement: pick the sans size, step down. Mono never exceeds body size in ordinary interface text; the one exception is a code or key a person must transcribe, which is what `text-mono-lg` is for. +- **Mono roles are named for the setting, not the content.** `text-mono`, not `text-code` — most mono in a product is a pubkey, a path, a branch name, or a hex value. Calling the role `code` made it read as a lie everywhere except an actual code block. +- **Never all-caps, and never tracked-out labels.** A capitalised label is harder to read than its sentence-case version and reads as enterprise chrome. A quiet label earns its quietness from size and colour — `text-body-sm` on `text-tertiary` — rather than from being shouted. There is deliberately no uppercase utility in this system. - **Every size is relative.** Nothing may be expressed in px: fixed pixel text freezes against keyboard zoom and ignores the person's font-size preference. The existing client shipped a regression from exactly this. - **Tracking is an optical correction, not a style.** Inter needs progressively tighter spacing as it grows. The ramp already applies it per step; do not add tracking by hand. -- **Code is one step below body.** At equal size a monospace face reads larger than Inter and pulls the eye off the sentence. ## Both modes diff --git a/desktop-next/package.json b/desktop-next/package.json index 6e0bdff5cc..3c512d6e35 100644 --- a/desktop-next/package.json +++ b/desktop-next/package.json @@ -12,17 +12,24 @@ "check": "biome check . && node scripts/check-type.mjs && node scripts/check-contrast.mjs", "format": "biome format --write .", "check:type": "node scripts/check-type.mjs", - "check:contrast": "node scripts/check-contrast.mjs" + "check:contrast": "node scripts/check-contrast.mjs", + "test": "vitest run src", + "test:e2e": "playwright test", + "tauri:dev": "node ../desktop/scripts/tauri-command.mjs dev --config ../desktop-next/tauri.conf.json --config ../desktop/src-tauri/tauri.dev.conf.json" }, "dependencies": { "@base-ui-components/react": "1.0.0-rc.0", "@fontsource-variable/inter": "^5.2.8", "@fontsource/jetbrains-mono": "^5.3.0", + "@tabler/icons-react": "^3.46.0", "@tanstack/react-router": "^1.168.10", + "@tauri-apps/api": "~2.11.1", + "dockview-react": "8.2.0", "react": "^19.1.0", "react-dom": "^19.1.0" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@tailwindcss/postcss": "^4.3.0", "@tanstack/router-plugin": "^1.167.12", "@types/react": "^19.1.8", @@ -30,6 +37,7 @@ "@vitejs/plugin-react": "^6.0.0", "tailwindcss": "^4.3.0", "typescript": "~6.0.0", - "vite": "^8.0.0" + "vite": "^8.0.0", + "vitest": "^4.1.11" } } diff --git a/desktop-next/playwright.config.ts b/desktop-next/playwright.config.ts new file mode 100644 index 0000000000..709790c75d --- /dev/null +++ b/desktop-next/playwright.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests", + use: { baseURL: "http://127.0.0.1:1430/?mock=1" }, + webServer: { + command: "pnpm dev --host 127.0.0.1", + url: "http://127.0.0.1:1430/?mock=1", + reuseExistingServer: false, + }, +}); diff --git a/desktop-next/scripts/check-type.mjs b/desktop-next/scripts/check-type.mjs index 3833679f1f..6d02d8cd7c 100644 --- a/desktop-next/scripts/check-type.mjs +++ b/desktop-next/scripts/check-type.mjs @@ -2,7 +2,7 @@ /** * Type-system guard. * - * Three rules from DESIGN.md § Type, enforced rather than trusted. Each one has + * Four rules from DESIGN.md § Type, enforced rather than trusted. Each one has * already cost the existing client real work: * * 1. No arbitrary text sizes — `text-[15px]`, `text-[0.9rem]`, `font-size:`. @@ -10,9 +10,18 @@ * regression; arbitrary rem re-fragments the scale we just consolidated. * 2. No `uppercase` / `tracking-*` utilities. All-caps labels are less legible * and read as enterprise chrome, and tracking is corrected per ramp step. - * 3. No size role paired with a weight or leading utility. A role carries its - * whole setting; pairing one with `font-medium` is how two supposedly - * identical labels drift apart. + * 3. No size role paired with a leading utility. A role carries its own line + * height; overriding it is how two identical labels drift apart. + * 4. No weight outside 400 and 600. `font-semibold` is bold. + * + * What this guard is for: an agent building a screen has no reason to prefer + * `font-medium` over `font-semibold`, so left unguided it picks either, and the + * product ends up with four weights that mean nothing. The guard removes the + * choice where there is no judgement to apply. + * + * What it is NOT for: stopping a designer. Every rule has a named override with + * a reason, and adding one is a normal edit — not an escalation. A guard that + * blocks real work gets deleted; a guard that asks you to say why does not. * * Run: pnpm check:type */ @@ -27,15 +36,47 @@ const SIZE_ROLES = [ "display", "title", "heading", - "subheading", "body-lg", + "body-sm", "body", - "label", - "caption", - "meta", - "code", + "mono-lg", + "mono-sm", + "mono", ]; +/** + * Roles that existed before the ramp was sized from the product, with what to + * use instead. Kept as a rule rather than deleted quietly: the names read + * plausible, so a stale one would otherwise resolve to nothing and render at the + * inherited size — a silent failure that looks like a design choice. + */ +const RETIRED_ROLES = new Map([ + ["subheading", "text-heading, or text-body-lg if it is prose"], + ["label", "text-body, or text-body-sm in dense chrome"], + ["caption", "text-body-sm"], + ["meta", "text-body-sm"], + ["code", "text-mono"], +]); + +/** + * Deliberate exceptions, as `relativePath:matchedText`. + * + * The escape hatch, and it is meant to be used. A one-off where another weight + * genuinely looks better is a real thing; the only requirement is that it says + * so out loud, so the next person reads a decision instead of guessing at an + * accident. Matching the text rather than a line number keeps entries stable + * when unrelated edits move code around. + * + * If this list starts growing in one direction — several `font-medium` entries, + * say — that is the signal the system is missing a role, not that the rule is + * wrong. Fix the system at that point rather than adding a tenth override. + */ +const OVERRIDES = new Map([ + // Example of the shape. Remove when a real exception replaces it. + // ["features/foo/ui/Bar.tsx:font-medium", "Optical match to the adjacent + // native control, which renders at 500 and cannot be changed."], +]); + const RULES = [ { id: "arbitrary-text-size", @@ -59,17 +100,49 @@ const RULES = [ "hand-applied tracking — the ramp already corrects tracking per step.", }, { - id: "role-plus-weight", - // A size role sharing a *className string* with a weight or leading - // utility. Anchoring to the attribute keeps the ramp's own - // `--text-body--line-height` declarations out of it — those are the - // definitions, not a component overriding one. + id: "role-plus-leading", + // A size role sharing a class attribute with a line-height utility. A role + // carries its own leading, so overriding it here is how two supposedly + // identical labels drift apart. + // + // Weight is deliberately NOT in this rule: `text-body font-semibold` is the + // documented way to bold body text, because size is the paragraph's + // decision and weight is the phrase's. Weight is policed by name below. pattern: new RegExp( - `class(?:Name)?=(?:"|'|\`)[^"'\`]*\\btext-(?:${SIZE_ROLES.join("|")})\\b[^"'\`]*\\b(?:font-(?:thin|extralight|light|normal|medium|semibold|bold|extrabold|black)|leading-)`, + `class(?:Name)?=(?:"|'|\`)[^"'\`]*\\btext-(?:${SIZE_ROLES.join("|")})\\b[^"'\`]*\\bleading-`, "g", ), message: - "size role paired with a weight/leading utility — a role already carries its whole setting.", + "size role paired with a leading utility — a role already carries its own line height.", + }, + { + id: "off-ramp-weight", + // The system has two weights: 400 (content) and 600 (structure and + // emphasis). 500 was measured against 400 at body size and does not read as + // intent in a scanned list — it is heavy enough to muddy a column and too + // subtle to signal. 700 was rejected as louder than anything Buzz needs. + // The rest have never had a use. + // + // `font-semibold` and `font-normal` are absent from this list on purpose: + // they are the two legal weights. + pattern: /\bfont-(?:thin|extralight|light|medium|bold|extrabold|black)\b/g, + message: + "off-ramp font weight — the system is 400 and 600. Bold is font-semibold. If a one-off genuinely needs another weight, add it to OVERRIDES with a reason.", + }, + { + id: "retired-role", + // A retired size role. Tailwind emits no utility for these, so the element + // silently renders at whatever it inherits — right often enough to pass a + // glance, wrong wherever the inherited size differs. + pattern: new RegExp( + `\\btext-(?:${[...RETIRED_ROLES.keys()].join("|")})\\b`, + "g", + ), + message: "retired size role", + detail: (matched) => { + const role = matched.replace("text-", ""); + return `use ${RETIRED_ROLES.get(role)} — this role no longer exists, so it resolves to nothing and inherits a size instead.`; + }, }, ]; @@ -82,6 +155,8 @@ function walk(dir) { } const failures = []; +/** Overrides actually hit this run, so stale entries can be reported. */ +const used = new Set(); for (const file of walk(SRC)) { const source = readFileSync(file, "utf8"); @@ -105,15 +180,32 @@ for (const file of walk(SRC)) { if (isRamp && rule.id === "arbitrary-text-size") continue; rule.pattern.lastIndex = 0; const match = rule.pattern.exec(line); - if (match) { - failures.push( - `${relative(process.cwd(), file)}:${index + 1} ${rule.id}\n ${trimmed}\n → ${rule.message}`, - ); + if (!match) continue; + + const rel = relative(SRC, file); + if (OVERRIDES.has(`${rel}:${match[0]}`)) { + used.add(`${rel}:${match[0]}`); + continue; } + + const detail = rule.detail ? rule.detail(match[0]) : rule.message; + failures.push( + `${relative(process.cwd(), file)}:${index + 1} ${rule.id}\n ${trimmed}\n → ${detail}`, + ); } }); } +// A stale override is worse than none: it silently permits whatever moves into +// its place. Report them rather than letting the list rot. +const stale = [...OVERRIDES.keys()].filter((k) => !used.has(k)); +if (stale.length > 0) { + console.warn( + `⚠ ${stale.length} unused override${stale.length === 1 ? "" : "s"} in check-type.mjs — remove ${stale.length === 1 ? "it" : "them"}:`, + ); + for (const k of stale) console.warn(` ${k}`); +} + if (failures.length > 0) { console.error( `\n✗ Type system: ${failures.length} violation${failures.length === 1 ? "" : "s"}\n`, diff --git a/desktop-next/src/app/routes/index.tsx b/desktop-next/src/app/routes/index.tsx index bf23e9dbea..efc9299dae 100644 --- a/desktop-next/src/app/routes/index.tsx +++ b/desktop-next/src/app/routes/index.tsx @@ -1,21 +1,4 @@ -import { Link, createFileRoute } from "@tanstack/react-router"; +import { createFileRoute } from "@tanstack/react-router"; +import { AppShell } from "@/features/sessions/ui/AppShell"; -function Home() { - return ( -
    -

    Buzz

    -

    - The new client. Nothing here yet — the shell and its capabilities come - later. The design system is the foundation being built first. -

    - - Open the design system - -
    - ); -} - -export const Route = createFileRoute("/")({ component: Home }); +export const Route = createFileRoute("/")({ component: AppShell }); diff --git a/desktop-next/src/features/agents/runtime.ts b/desktop-next/src/features/agents/runtime.ts new file mode 100644 index 0000000000..ba5b3714c3 --- /dev/null +++ b/desktop-next/src/features/agents/runtime.ts @@ -0,0 +1,180 @@ +import { invoke, isTauri } from "@tauri-apps/api/core"; +import type { AgentRuntime, ManagedAgent } from "./types"; + +type RawManagedAgent = { + pubkey: string; + name: string; + runtime?: string | null; + agent_command: string; + system_prompt?: string | null; + model?: string | null; + provider?: string | null; + status: ManagedAgent["status"]; + last_error?: string | null; +}; + +type RawRuntime = { + id: string; + label: string; + availability: AgentRuntime["availability"]; + command: string | null; + auth_status: AgentRuntime["authStatus"]; + install_hint: string; + login_hint?: string | null; +}; + +const mockAgents: ManagedAgent[] = [ + { + pubkey: "vogue-agent", + name: "Vogue", + runtime: "goose", + agentCommand: "goose", + systemPrompt: "Shape product interfaces with clarity and taste.", + model: "Claude Fable 5", + provider: "Databricks", + status: "running", + lastError: null, + }, +]; + +function agent(raw: RawManagedAgent): ManagedAgent { + return { + pubkey: raw.pubkey, + name: raw.name, + runtime: raw.runtime ?? null, + agentCommand: raw.agent_command, + systemPrompt: raw.system_prompt ?? null, + model: raw.model ?? null, + provider: raw.provider ?? null, + status: raw.status, + lastError: raw.last_error ?? null, + }; +} + +function catalogEntry(raw: RawRuntime): AgentRuntime { + return { + id: raw.id, + label: raw.label, + availability: raw.availability, + command: raw.command, + authStatus: raw.auth_status, + installHint: raw.install_hint, + loginHint: raw.login_hint ?? null, + }; +} + +export const agentRuntime = { + async list(): Promise { + if (!isTauri()) return [...mockAgents]; + return (await invoke("list_managed_agents")).map(agent); + }, + + async catalog(): Promise { + if (!isTauri()) { + return [ + { + id: "goose", + label: "Goose", + availability: "available", + command: "goose", + authStatus: { status: "not_applicable" }, + installHint: "", + loginHint: null, + }, + ]; + } + return ( + await invoke("discover_acp_providers", { force: false }) + ).map(catalogEntry); + }, + + async create(input: { + name: string; + instructions: string; + runtime: AgentRuntime; + relayUrl?: string; + signerPubkey?: string; + }): Promise { + if (!input.runtime.command) throw new Error("This runtime is not ready."); + if (!isTauri()) { + const created: ManagedAgent = { + pubkey: crypto.randomUUID(), + name: input.name, + runtime: input.runtime.id, + agentCommand: input.runtime.command, + systemPrompt: input.instructions, + model: null, + provider: null, + status: "running", + lastError: null, + }; + mockAgents.unshift(created); + return created; + } + const response = await invoke<{ + agent: RawManagedAgent; + spawn_error?: string | null; + }>("create_managed_agent", { + input: { + name: input.name, + relayUrl: null, + expectedRelayUrl: input.relayUrl ?? null, + expectedSignerPubkey: input.signerPubkey ?? null, + acpCommand: "buzz-acp", + agentCommand: input.runtime.command, + harnessOverride: true, + agentArgs: [], + mcpCommand: null, + turnTimeoutSeconds: null, + idleTimeoutSeconds: null, + maxTurnDurationSeconds: null, + parallelism: null, + systemPrompt: input.instructions, + avatarUrl: null, + model: null, + provider: null, + envVars: {}, + spawnAfterCreate: true, + startOnAppLaunch: false, + backend: { type: "local" }, + respondTo: "owner-only", + respondToAllowlist: [], + relayMesh: null, + }, + }); + if (response.spawn_error) throw new Error(response.spawn_error); + return agent(response.agent); + }, + + async start( + pubkey: string, + scope?: { relayUrl: string; signerPubkey: string }, + ): Promise { + if (!isTauri()) { + const current = mockAgents.find((item) => item.pubkey === pubkey); + if (!current) throw new Error("Agent not found."); + current.status = "running"; + return { ...current }; + } + return agent( + await invoke("start_managed_agent", { + pubkey, + expectedRelayUrl: scope?.relayUrl ?? null, + expectedSignerPubkey: scope?.signerPubkey ?? null, + replayFloorUnix: null, + }), + ); + }, + + async stop(pubkey: string): Promise { + if (!isTauri()) { + const current = mockAgents.find((item) => item.pubkey === pubkey); + if (!current) throw new Error("Agent not found."); + current.status = "stopped"; + return { ...current }; + } + return agent( + await invoke("stop_managed_agent", { pubkey }), + ); + }, +}; diff --git a/desktop-next/src/features/agents/types.ts b/desktop-next/src/features/agents/types.ts new file mode 100644 index 0000000000..1019943f42 --- /dev/null +++ b/desktop-next/src/features/agents/types.ts @@ -0,0 +1,37 @@ +export type AgentStatus = "running" | "stopped" | "deployed" | "not_deployed"; + +export type ManagedAgent = { + pubkey: string; + name: string; + runtime: string | null; + agentCommand: string; + systemPrompt: string | null; + model: string | null; + provider: string | null; + status: AgentStatus; + lastError: string | null; +}; + +export type RuntimeAvailability = + | "available" + | "adapter_missing" + | "adapter_outdated" + | "cli_missing" + | "not_installed"; + +export type RuntimeAuthStatus = + | { status: "logged_in" } + | { status: "logged_out" } + | { status: "config_invalid"; diagnostic: string } + | { status: "not_applicable" } + | { status: "unknown" }; + +export type AgentRuntime = { + id: string; + label: string; + availability: RuntimeAvailability; + command: string | null; + authStatus: RuntimeAuthStatus; + installHint: string; + loginHint: string | null; +}; diff --git a/desktop-next/src/features/agents/ui/AgentsWorkspace.tsx b/desktop-next/src/features/agents/ui/AgentsWorkspace.tsx new file mode 100644 index 0000000000..c7bca253f4 --- /dev/null +++ b/desktop-next/src/features/agents/ui/AgentsWorkspace.tsx @@ -0,0 +1,342 @@ +import { + IconPlayerPlay, + IconPlayerStop, + IconPlus, + IconRobot, +} from "@tabler/icons-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { runtime } from "@/shared/runtime/client"; +import { agentRuntime } from "../runtime"; +import type { AgentRuntime, ManagedAgent } from "../types"; + +function runtimeReady(runtime: AgentRuntime) { + return ( + runtime.availability === "available" && + (runtime.authStatus.status === "logged_in" || + runtime.authStatus.status === "not_applicable") + ); +} + +export function AgentsNavigator({ + agents, + selectedPubkey, + onSelect, + onNew, +}: { + agents: ManagedAgent[]; + selectedPubkey?: string; + onSelect: (pubkey: string) => void; + onNew: () => void; +}) { + return ( + + ); +} + +function AgentCreation({ + runtimes, + onCreated, +}: { + runtimes: AgentRuntime[]; + onCreated: (agent: ManagedAgent) => void; +}) { + const available = runtimes.filter(runtimeReady); + const [name, setName] = useState(""); + const [instructions, setInstructions] = useState(""); + const [runtimeId, setRuntimeId] = useState(available[0]?.id ?? ""); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const selected = runtimes.find((runtime) => runtime.id === runtimeId); + + async function create() { + if (!selected || !runtimeReady(selected)) return; + setSaving(true); + setError(null); + try { + const [relayUrl, identity] = await Promise.all([ + runtime.relayUrl(), + runtime.identity(), + ]); + onCreated( + await agentRuntime.create({ + name: name.trim(), + instructions: instructions.trim(), + runtime: selected, + relayUrl, + signerPubkey: identity.pubkey, + }), + ); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + } finally { + setSaving(false); + } + } + + return ( +
    +
    +

    New agent

    +
    +
    + +