diff --git a/DESIGN.md b/DESIGN.md index 62705e72..80e47a0f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,432 +1,43 @@ # VisionSet design foundations -## Purpose and Ownership +The design system — the shadcn Nova foundation, its tokens, the primitive governance, the +status palette, and the gates that hold all of it — lives in +[Robomous/ui-core](https://github.com/Robomous/ui-core), consumed here as `@robomous/ui-core`. +That repository's `DESIGN.md` is the governing document; what follows is only what is +VisionSet's own: the extension registry, where the brand paints, and which gate holds each +rule in this repo. -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)), or frontend architecture -([`docs/content/architecture/frontend/`](docs/content/architecture/frontend/README.md)). +## VisionSet extensions -## Source of Truth +Everything VisionSet owns above the foundation, each following shadcn's extension convention — +a value in `:root`, a dark counterpart in `.dark`, exposure through `@theme inline` — in +`frontend/ui-core/src/styles.css`, mirrored by `src/tokens.ts`: -Six layers decide the visual result. **When two disagree, the earlier one wins.** - -| # | 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 | -| --- | --- | --- | -| `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). +| Extension | Rule | +| --- | --- | +| `stage` | The annotator's surround — the neutral a photograph is judged against. Its own role: not `muted`'s subtle fill, not `card`'s surface, distinguishable from `background` so a white asset edge still shows where it ends. Usage: [`docs/content/ui/annotator.md`](docs/content/ui/annotator.md#the-stage) | +| `origin-hub` / `origin-custom` / `origin-robomous` | A model's provenance, as a card's accent edge. A mark: never a surface, never ink, and an origin is a kind rather than a state, so these never stand in for a status. Theme-stable, like the chart palette | +| `--spacing-sidebar` / `--spacing-sidebar-collapsed` | 240px and 48px, consumed by `AppShell`, its collapse toggle and the content offset, which must agree or the layout jumps on collapse. The collapsed width is the preset's own icon-sidebar width, so with the rail's `p-2` it holds exactly one `size-8` control per row | +| `--spacing-project-nav` / `--container-page` | 180px project-nav column; the 96rem page cap | -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. +An extension that turns out to be universal is a candidate to move into the package — that is +a design decision and a PR against Robomous/ui-core, with the justification written into its +`DESIGN.md`. -### Where the brand is +## 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 | -| --- | --- | -| `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 | - -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 ` - - )} - - - ) -} - -function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function DialogFooter({ - className, - showCloseButton = false, - children, - ...props -}: React.ComponentProps<"div"> & { - showCloseButton?: boolean -}) { - return ( -
- {children} - {showCloseButton && ( - - - - )} -
- ) -} - -function DialogTitle({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function DialogDescription({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -export { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogOverlay, - DialogPortal, - DialogTitle, - DialogTrigger, -} diff --git a/frontend/ui-core/shadcn/dropdown-menu.tsx b/frontend/ui-core/shadcn/dropdown-menu.tsx deleted file mode 100644 index 98f8a975..00000000 --- a/frontend/ui-core/shadcn/dropdown-menu.tsx +++ /dev/null @@ -1,267 +0,0 @@ -import * as React from "react" -import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui" - -import { cn } from "@/lib/cn" -import { CheckIcon, ChevronRightIcon } from "lucide-react" - -function DropdownMenu({ - ...props -}: React.ComponentProps) { - return -} - -function DropdownMenuPortal({ - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function DropdownMenuTrigger({ - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function DropdownMenuContent({ - className, - align = "start", - sideOffset = 4, - ...props -}: React.ComponentProps) { - return ( - - - - ) -} - -function DropdownMenuGroup({ - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function DropdownMenuItem({ - className, - inset, - variant = "default", - ...props -}: React.ComponentProps & { - inset?: boolean - variant?: "default" | "destructive" -}) { - return ( - - ) -} - -function DropdownMenuCheckboxItem({ - className, - children, - checked, - inset, - ...props -}: React.ComponentProps & { - inset?: boolean -}) { - return ( - - - - - - - {children} - - ) -} - -function DropdownMenuRadioGroup({ - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function DropdownMenuRadioItem({ - className, - children, - inset, - ...props -}: React.ComponentProps & { - inset?: boolean -}) { - return ( - - - - - - - {children} - - ) -} - -function DropdownMenuLabel({ - className, - inset, - ...props -}: React.ComponentProps & { - inset?: boolean -}) { - return ( - - ) -} - -function DropdownMenuSeparator({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function DropdownMenuShortcut({ - className, - ...props -}: React.ComponentProps<"span">) { - return ( - - ) -} - -function DropdownMenuSub({ - ...props -}: React.ComponentProps) { - return -} - -function DropdownMenuSubTrigger({ - className, - inset, - children, - ...props -}: React.ComponentProps & { - inset?: boolean -}) { - return ( - - {children} - - - ) -} - -function DropdownMenuSubContent({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -export { - DropdownMenu, - DropdownMenuPortal, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuLabel, - DropdownMenuItem, - DropdownMenuCheckboxItem, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuSub, - DropdownMenuSubTrigger, - DropdownMenuSubContent, -} diff --git a/frontend/ui-core/shadcn/field.tsx b/frontend/ui-core/shadcn/field.tsx deleted file mode 100644 index af7e4441..00000000 --- a/frontend/ui-core/shadcn/field.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import { useMemo } from "react" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/cn" -import { Label } from "@/primitives/label" -import { Separator } from "@/primitives/separator" - -function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { - return ( -
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", - className - )} - {...props} - /> - ) -} - -function FieldLegend({ - className, - variant = "legend", - ...props -}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { - return ( - - ) -} - -function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -const fieldVariants = cva( - "group/field flex w-full gap-2 data-[invalid=true]:text-destructive", - { - variants: { - orientation: { - vertical: "flex-col *:w-full [&>.sr-only]:w-auto", - horizontal: - "flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", - responsive: - "flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", - }, - }, - defaultVariants: { - orientation: "vertical", - }, - } -) - -function Field({ - className, - orientation = "vertical", - ...props -}: React.ComponentProps<"div"> & VariantProps) { - return ( -
- ) -} - -function FieldContent({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ) -} - -function FieldLabel({ - className, - ...props -}: React.ComponentProps) { - return ( -