diff --git a/DESIGN.md b/DESIGN.md index c78ef5c4..62705e72 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,438 +1,432 @@ # VisionSet design foundations -## Purpose - -This document is the visual and interaction contract for every VisionSet interface: the -M5 app (`@visionset/app` + `@visionset/ui-core`), the annotator demo, and any surface -built after them. It answers one question — **how should a VisionSet interface look, -feel, and behave visually?** — and deliberately answers nothing else. - -What this document does **not** own: product behaviour +## Purpose and Ownership + +This document governs how VisionSet interfaces look and behave. It is not a component +catalogue: the components are shadcn's, and their classes, anatomy and states are read from +the files the CLI wrote rather than transcribed here. What it owns is the *governance* — which +layer decides what, what VisionSet may add, what it may not change, and which gate holds each +rule. Where a rule is machine-checked the gate is named, because a rule nothing checks is a +preference. It does not own product behaviour ([`docs/content/ui/product-principles.md`](docs/content/ui/product-principles.md)), navigation ([`docs/content/ui/navigation.md`](docs/content/ui/navigation.md)), the annotation workspace ([`docs/content/ui/annotator.md`](docs/content/ui/annotator.md)), the data shell -([`docs/content/ui.md`](docs/content/ui.md)), frontend architecture and library choices -([`docs/content/architecture/frontend/`](docs/content/architecture/frontend/README.md)), or test -mechanics. Rules here are stated as the present contract; enforcement details live with -the tests and contributor docs. +([`docs/content/ui.md`](docs/content/ui.md)), or frontend architecture +([`docs/content/architecture/frontend/`](docs/content/architecture/frontend/README.md)). ## Source of Truth -VisionSet's visual language is a **shadcn preset**, not a hand-authored system: preset -code `b2iH`, decoded as `style: nova` (on the Radix base — `radix-nova` in -`components.json`), `baseColor: neutral`, `chart: neutral`, `icons: lucide`, -`font: geist`, `heading: inherit`, `radius: medium`, `menu: inverted/subtle`, with the -CLI's `--pointer` option on. The preset was generated with shadcn CLI **4.19.0** and -transcribed verbatim into this repository; that CLI version is the reference for every -value in this document. Nothing here is invented — every token, every derived radius, -every chart colour traces back to the CLI's own output. - -`heading: inherit` is the one decoded value with no token of its own: it means the -heading face *is* the body face, which the stylesheet spells as -`--font-heading: var(--font-sans)` rather than as a second family. +Six layers decide the visual result. **When two disagree, the earlier one wins.** -`@visionset/ui-core` owns the implementation, in exactly three files: - -| File | Role | -| --- | --- | -| [`frontend/ui-core/components.json`](frontend/ui-core/components.json) | The preset properties shadcn's own tools read — the fields its config schema defines, and no others | -| [`frontend/ui-core/src/styles.css`](frontend/ui-core/src/styles.css) | The tokens that run — `:root`, `.dark`, `@theme inline`, the base layer | -| [`frontend/ui-core/src/tokens.ts`](frontend/ui-core/src/tokens.ts) | The TypeScript mirror, for a caller that cannot read CSS | - -**One intent, three layers — and they do not hold the same fields.** `components.json` -carries only what shadcn's config schema defines (`style`, `tailwind.baseColor`, -`iconLibrary`, `menuColor`, `menuAccent`, `rsc`/`tsx`/`rtl`, the aliases, the registries). -That schema is **strict**: a decoded property it has no field for is *rejected*, not -ignored, so the file cannot be made to restate the whole preset even as documentation. -Everything else the preset decides — the radius, both fonts, the chart palette, every -colour — is a *value*, and values live in `styles.css`. A decoded property missing from -`components.json` is therefore the design working as intended, never drift: - -| Layer | Owns | Radius, as the worked example | +| # | Layer | Where it lives | Decides | +| --- | --- | --- | --- | +| 1 | CLI configuration | [`frontend/ui-core/components.json`](frontend/ui-core/components.json) | The fields shadcn's own tools read — style, base colour, icon library, menu treatment, aliases | +| 2 | Decoded preset intent | preset code `b2iH`, shadcn CLI **4.19.0** | Every value the config schema has no field for — radius, fonts, chart palette, both themes | +| 3 | Official registry output | [`frontend/ui-core/shadcn/`](frontend/ui-core/shadcn) snapshots, realised in [`frontend/ui-core/src/primitives/`](frontend/ui-core/src/primitives) | A primitive's API, anatomy, variants, sizes, states and styling | +| 4 | VisionSet additive extensions | `primitives/badge.tsx`, `src/tokens.ts`, `src/styles.css` | Semantic variants and tokens the foundation has no name for | +| 5 | Patterns | [`frontend/ui-core/src/patterns/`](frontend/ui-core/src/patterns) | Composition of primitives into VisionSet shapes, and layout rhythm | +| 6 | Screens and the annotator | `src/screens/`, `src/annotator/` | Which shape a page uses, and when | + +A screen may not reach past layer 5 to restyle a primitive, and a primitive may not encode a +screen's decision. A value missing from `components.json` is not drift: the config schema is +**strict** and rejects a property it has no field for, so the preset's radius, fonts and chart +palette live in `styles.css` as runtime values instead. The stylesheet is the one that runs; +[`frontend/ui-core/src/tokens.ts`](frontend/ui-core/src/tokens.ts) mirrors it for callers that +cannot read CSS (a ``, a test), and `tokens.test.ts` asserts the two agree declaration +for declaration. + +## shadcn Foundation + +Preset `b2iH`, generated with shadcn CLI **4.19.0**, decodes to: + +| Property | Value | Where it lands | | --- | --- | --- | -| Preset intent | What the code decodes to (`shadcn preset decode b3bXyyPdWj`) | `radius: medium` | -| Runtime | The value that actually paints, in `styles.css` | `--radius: 0.625rem` | -| CLI configuration | The schema-supported fields, in `components.json` | no `radius` field — the schema defines none | - -Three machine gates hold this contract, each in one line: `tokens.test.ts` asserts -`styles.css` and `tokens.ts` agree, declaration for declaration, and that no retired -token has crept back in; `tests/scripts/design_tokens.test.mjs` bans a raw colour in any -class string, bans a second `tailwind.config.js`, confines `brand` to its two identity -sites, holds `components.json` to the schema-supported field set, and re-checks the -retired vocabulary by an independent method; `tests/scripts/docs_links.test.mjs` keeps -every link and heading anchor in this document itself honest. - -Radix stays the behaviour layer under every primitive — this is a visual foundation -rewrite, not a component replacement. - -## Design Character - -Each principle is a decision rule, not a slogan. When two of them conflict on a concrete -screen, the earlier one wins. - -1. **Content first.** The interface recedes; the user's data — images, annotations, - counts — is the loudest thing on screen. Chrome earns its pixels: hairlines, a - resting shadow, no gradients, no decoration. -2. **One action colour.** `primary` is the one high-emphasis colour, product-wide: the - dominant action, the active tool, the selected surface. Colour elsewhere is either a - hover/focus surface (`accent`) or a status hue — never a second reading of emphasis. -3. **Precise.** Alignment, spacing, and geometry sit on the scales below — never - eyeballed. Two screens built by two people are indistinguishable in styling. -4. **Hierarchy through structure, not volume.** Typography, spacing, hairlines, and - surfaces carry hierarchy. Reaching for a louder colour or a heavier shadow to make - something "stand out" is a hierarchy failure upstream. -5. **Dense where the content is plural.** VisionSet's users are ML engineers and - professional annotators; on data surfaces, showing more of the thing the user came - for beats whitespace. Generosity belongs to forms and prose, read one thing at a - time. -6. **Fast and quiet.** Interfaces respond on the frame the user acts; motion orients or - confirms and never makes anyone wait. -7. **Accessible by default.** Semantic HTML, keyboard operability, visible focus, and - redundant (never colour-only) signals are part of the definition of done. -8. **Consistent over novel.** Reuse the token, the role, the primitive. A new visual - treatment is a decision made in this document, not in a component diff. -9. **Roles, not polarity.** Every rule below is written against a semantic role, not a - fixed light-mode value — "the page" resolves per theme; it is never asserted to be - white, and the sidebar is never asserted to be dark. Light and dark are both defined - from the preset, in full, from the start. - -## Theme Token Model - -shadcn's semantic names are the only vocabulary components speak. There is no second -naming layer (`surface`, `error`, `foreground-secondary`) and no alias that renames what -a token already means. - -| Token(s) | Meaning | +| `style` | `nova` on the Radix base | `components.json` → `"style": "radix-nova"` | +| `baseColor` | `neutral` | `components.json` → `tailwind.baseColor` | +| `chart` | `neutral` | `styles.css` → `--chart-1` … `--chart-5` | +| `icons` | `lucide` | `components.json` → `iconLibrary` | +| `font` | `geist` | `styles.css` → `--font-sans: 'Geist Variable', sans-serif` | +| `heading` | `inherit` | `styles.css` → `--font-heading: var(--font-sans)` | +| `radius` | `medium` | `styles.css` → `--radius: 0.625rem` | +| `menu` | `inverted` / `subtle` | `components.json` → `menuColor`, `menuAccent` | +| `pointer` | on | `styles.css` base layer → `cursor: pointer` on pressable controls | + +**What `components.json` can hold** is its whole current content: `style`, `rsc` (`false`), +`tsx` (`true`), `tailwind` (`config` empty, `css` `src/styles.css`, `baseColor` `neutral`, +`cssVariables` `true`, `prefix` empty), `iconLibrary`, `rtl` (`false`), the five `aliases` +(`@/components`, `@/lib/cn`, `@/primitives`, `@/lib`, `@/hooks`), `menuColor`, `menuAccent`, +`registries` (empty). **What it cannot hold:** the radius, either font, the chart palette, any +colour — `design_tokens.test.mjs` holds the file to that set, so nobody adds a documentary +field the CLI then rejects. Radix supplies behaviour under every primitive and Nova is the +styling on top; Base UI is an accepted official dependency for the same reason — shadcn's own +Combobox is Base UI-backed, and nothing else here uses it. + +## Primitive Governance + +> Components in the primitive layer preserve the API, anatomy, official variants, default +> variants, sizes, states, data attributes, accessibility behavior, and Nova styling generated +> by the configured shadcn foundation. VisionSet may add semantic variants only when they are +> additive and do not reinterpret an upstream variant. Product-specific compositions belong +> above primitives. A behavioral deviation from upstream requires a reproduced defect, a +> regression test, and an explicit SHADCN DEVIATION comment. + +The primitive layer is exactly these twenty-one files: `alert`, `badge`, `button`, `card`, +`combobox`, `dialog`, `dropdown-menu`, `field`, `input`, `input-group`, `label`, `progress`, +`select`, `separator`, `sheet`, `skeleton`, `sonner`, `table`, `tabs`, `textarea`, `tooltip`. + +**The snapshot gate.** `frontend/ui-core/shadcn/.tsx` is the CLI's pristine output, and +`tests/scripts/shadcn_canonical.test.mjs` refuses any primitive that is not its snapshot **plus +added lines** — a removed line, a reworded class, a renamed prop all fail. Import paths are +relativised (`@/lib/cn` → `../lib/cn`, `@/primitives/x` → `./x`) because ui-core is compiled by +`tsc` rather than bundled with a path alias; the gate accounts for that rewrite and nothing +else. + +**Allowed:** an added `cva` variant line whose meaning the foundation has no name for (today, +four on the Badge), and a framework adapter under *Allowed shadcn Deviations*. **Forbidden:** +changing an official variant's classes; renaming or dropping a subcomponent; adding a prop; +changing a default variant or size; touching focus, keyboard or `aria` behaviour; adjusting +geometry. Each of those belongs above the primitive — at a call site, or in a pattern. + +## Composition Layers + +- **Primitives** (`src/primitives/`) are shadcn's, governed above. They know nothing about + datasets, frames or models. +- **Patterns** (`src/patterns/`) compose primitives into VisionSet shapes, and are where layout + rhythm and product vocabulary meet. +- **Screens and the annotator** (`src/screens/`, `src/annotator/`) decide which pattern a page + uses and what it says. + +**No wrapper that merely renames.** A component whose whole body forwards props to one +primitive is a second name for the same thing, and the second name is what drifts: compose at +the call site, or add the pattern that earns its name by holding a decision. The rhythm that +follows — content under a `line` `TabsList` takes 16px — is applied in +`patterns/ProjectNav.tsx` (`mt-2` on its `TabsContent`), never in `tabs.tsx`. + +## Color and Theme + +shadcn's semantic names are the only vocabulary. There is no second layer (`surface`, `error`, +`foreground-secondary`) and no alias renaming what a token already means — `destructive` stays +`destructive`. Two roles are worth restating because they are the two that get confused: +`primary` is the one high-emphasis colour, and `accent` is the interactive hover/focus surface, +the token a row or a menu item lights up with and never what a button fills with. +`chart-1`…`chart-5` identify a series, never a status. + +Both themes are declared in full, so switching theme is a variable swap. Every rule here is +written against a role rather than a light-mode value: "the page" resolves per theme and is +never asserted to be white. + +**Retained VisionSet extensions**, each for a role the foundation has no name for: `stage` (the +neutral surround a photograph is judged against — distinguishable from both `background` and +`muted`, so a white asset edge still shows where it ends), `brand` (Robomous coral, identity +only), and `origin-hub` / `origin-custom` / `origin-robomous` (a model's provenance as a card's +accent edge — a mark, never a surface, ink or status; theme-stable like the chart palette). + +The former `success` and `warning` tokens are **retired**, with their `-foreground` companions: +status colour is a Badge variant or `patterns/statusTone.ts`, so a status hue is spelled in one +place instead of two. `tests/scripts/design_tokens.test.mjs` re-checks the retired vocabulary +by an independent method from `tokens.test.ts`. **There is no `info` token** — the Badge has an +`info` variant and `statusTone.ts` an `info` ink, neither of which implies one; adding one is a +product decision to make here first. + +### Where the brand is + +Robomous coral is identity: the `AppShell` wordmark and its styleguide swatch. Two sites, +enumerated in `tests/scripts/design_tokens.test.mjs`. A functional control reaching for `brand` +is a semantic-colour violation however many other sites already use it correctly, and the gate +and this section move together or not at all. + +### Menus and the inverted subtree + +`menuColor: inverted` means every menu, select and combobox surface carries the literal `dark` +class, so a floating surface is the dark theme's `popover` whatever the page is doing. That is +contrast on purpose. The tooltip reaches the same intent without a subtree — Nova gives it +`bg-foreground text-background`, which flips by construction, because one line of text has no +palette of its own to keep in step. `menuAccent: subtle` means items highlight with `accent`, +the token every other hover state uses. + +## Action Hierarchy + +Six official Button variants, one intent each: + +| Variant | Intent | | --- | --- | -| `background` / `foreground` | The page, and the ink that sits directly on it | -| `card` / `card-foreground` | A raised surface — panels, cards | -| `popover` / `popover-foreground` | A floating surface — menus, popovers, tooltips, dialogs | -| `primary` / `primary-foreground` | **High-emphasis actions and selected surfaces.** The one dominant-action colour | -| `secondary` / `secondary-foreground` | Lower-emphasis filled actions — a second filled weight, not a second accent | -| `muted` / `muted-foreground` | Subtle fills and lower-emphasis (secondary, meta) content | -| `accent` / `accent-foreground` | **Interactive hover/focus/active surfaces.** Not the action colour — this is the token a row highlights or a menu item lights up with, never what a button fills with | -| `destructive` | Destructive actions and their state. Stays `destructive` — never renamed `error` | -| `border` / `input` / `ring` | Hairlines and dividers; field borders; the focus ring's colour | -| `chart-1` … `chart-5` | Series colours in a chart. Identify a series, never a status | -| `sidebar` / `sidebar-foreground` / `sidebar-primary(-foreground)` / `sidebar-accent(-foreground)` / `sidebar-border` / `sidebar-ring` | The navigation rail's own surface, ink, active-item fill, and hairline — a parallel set so the rail can differ from `card` without inventing a name | -| `radius` | The one geometry constant every radius step derives from | - -Both themes are declared **in full** from the preset — `:root` and `.dark` each name -every token above, so switching theme is a variable swap, never a redesign. A rule that -only makes sense in one theme ("the page is near-white") is written as its role instead. - -## Surfaces - -`background` is the page. `card` and `popover` are the two raised materials — `card` for -content that sits in place, `popover` for anything that floats and closes (see *Sidebar -/ Menu* for the one deliberate exception to popover's own colour). `muted` is a recessed -or subtle fill — a footer strip, a quiet chip, secondary content's backdrop. A component -picks one of these three; it does not compose a fill from scratch. - -## Actions and Interactive States - -- **One dominant action per view**, in `primary`; supporting actions take `secondary` or - a quieter (ghost/link) treatment. Which action is dominant on which screen, and how it - tracks state, is product behaviour — [`docs/content/ui/product-principles.md`](docs/content/ui/product-principles.md). -- **Hover** on a filled control is the same fill at reduced opacity (`hover:bg-primary/80`), - not a colour change of meaning; a menu or list item highlights with `accent` instead. -- **Press** reads as the control moving, not recolouring. -- **The cursor turns to a hand over anything pressable**, which is the preset's - `--pointer` option: `button` and `[role="button"]`, in the base layer. It stops at - `:not(:disabled)` on purpose — a hand over a control that will not respond is the - cursor making a promise the control does not keep, and the disabled rule below is what - the reader should be getting instead. -- **Disabled** is uniform reduced opacity plus `pointer-events-none` — the control dims - as itself rather than swapping to a separate greyed-out skin. A disabled control still - explains itself; see the product principles' never-disable-without-explanation rule. -- **Destructive** actions use a soft treatment — tinted background and ink, not a solid - fill — so the one action that can end something does not read louder than `primary`. -- **Focus** is always visible (see *Borders and Focus*). +| `default` | The one dominant action in the view | +| `outline` | A supporting action that still reads as a control | +| `secondary` | A filled second weight, quieter than `default` | +| `ghost` | An action inside dense chrome — toolbars, rows, icon-only controls | +| `destructive` | The action that ends something | +| `link` | Navigation wearing a control's affordance | -## Typography +Sizes are `default`, `xs`, `sm`, `lg`, `icon`, `icon-xs`, `icon-sm`, `icon-lg`. **One dominant +action per view**; which action that is on which screen is product behaviour +([`docs/content/ui/product-principles.md`](docs/content/ui/product-principles.md)). + +**A status is not a step in the action hierarchy.** `success` describes an outcome, never a +control's emphasis, and there is no variant for it — a saved form reports itself with a toast or +a Badge while its button stays `default`. Three names a v1 call site may still reach for — +`primary`, `success`, `md` — **do not exist** here, and +`tests/scripts/shadcn_extensions.test.mjs` pins the variant and size lists exactly. + +Every ` } diff --git a/frontend/app/src/styleguide/Styleguide.tsx b/frontend/app/src/styleguide/Styleguide.tsx index 0f9fed5d..ba064071 100644 --- a/frontend/app/src/styleguide/Styleguide.tsx +++ b/frontend/app/src/styleguide/Styleguide.tsx @@ -19,6 +19,10 @@ */ import { + Alert, + AlertAction, + AlertDescription, + AlertTitle, Badge, Button, Card, @@ -27,6 +31,12 @@ import { CardHeader, CardTitle, ClassListRow, + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, DistributionBar, formatCount, formatPercent, @@ -45,12 +55,18 @@ import { DropdownMenuTrigger, EmptyState, ErrorState, - FieldHint, + Field, + FieldDescription, + FieldError, + FieldGroup, + FieldLabel, + FieldLegend, + FieldSet, Input, - Label, LIGHT_THEME, LoadingState, Progress, + progressAria, PROJECT_SECTIONS, ProjectEyebrow, ProjectNav, @@ -60,6 +76,13 @@ import { SelectItem, SelectTrigger, SelectValue, + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, Table, TableBody, TableCell, @@ -78,9 +101,12 @@ import { TooltipTrigger, classColor, formatGeometries, + inlineLink, + menuSurface, toast, + twoLineTrigger, } from "@visionset/ui-core"; -import { MousePointer2, Plus, Square, Trash2 } from "lucide-react"; +import { Info, MousePointer2, Plus, Square, Trash2 } from "lucide-react"; import type { JSX, ReactNode } from "react"; /** The demo schema, borrowed so the swatches show the real palette rule. */ @@ -112,6 +138,13 @@ const DISTRIBUTION = [ // old stacked-card schema editor became unusable at. const ONTOLOGY = Array.from({ length: 40 }, (_, index) => `class-${String(index + 1).padStart(2, "0")}`); +/** The Combobox demo's items — one of them ("lane") is the sole `an` match. */ +const DEMO_CLASSES = ["barrier", "bicycle", "cyclist", "lane", "sign", "truck", "vehicle"]; + +/** `Badge`'s shadcn variants, then VisionSet's four status extensions. */ +const BADGE_OFFICIAL = ["default", "secondary", "outline", "destructive", "ghost", "link"] as const; +const BADGE_STATUS = ["success", "warning", "info", "quiet"] as const; + export function Styleguide(): JSX.Element { return ( @@ -184,8 +217,6 @@ export function Styleguide(): JSX.Element { inspected rather than used. Its two product sites are the rail's wordmark and the progress fill. */} - - @@ -201,25 +232,32 @@ export function Styleguide(): JSX.Element {

Meta — 0.75rem, muted

-
+
- - + + - +
+ + + + - @@ -227,14 +265,28 @@ export function Styleguide(): JSX.Element { - + + Zoom out + + + + Box (B) - - + + + + + Zoom in +
@@ -270,95 +322,102 @@ export function Styleguide(): JSX.Element { title="Fields" description="Radix labels; every control carries Nova's own focus ring." > -
-
- - - Unique per workspace, case-insensitively. -
-
- - - A hint, under a field that needs one. -
- {/* - The two-line option. Here because it is a primitive variant - rather than one screen's styling: an option that is an identifier - plus the facts about it stacks them, and the trigger shows the same - two lines the list does because Radix renders the selected item's - own text into it. - */} -
- - - - The id at the label role, what it costs beneath it. Nothing truncates. - -
-
- -