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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 37 additions & 27 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,17 @@ the tests and contributor docs.
## Source of Truth

VisionSet's visual language is a **shadcn preset**, not a hand-authored system: preset
code `b3bXyyPdWj`, decoded as `style: nova` (on the Radix base — `radix-nova` in
`components.json`), `baseColor: neutral`, `chart: orange`, `icons: tabler`,
`font: inter`, `heading: geist`, `radius: medium`, `menu: inverted/subtle`. The preset
was generated with shadcn CLI **4.18.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.

**One property deliberately diverges: the icon set.** The preset decodes
`icons: tabler`, and that line above says so because it is what the code decodes to;
the product draws `lucide-react`, and `components.json` says `iconLibrary: lucide`.
The schema accepts the value, so this is a decision rather than drift — but it is the
one place where reading the preset and reading the configuration give different
answers, and it is written here so nobody has to discover that by diffing them.
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.

`@visionset/ui-core` owns the implementation, in exactly three files:

Expand Down Expand Up @@ -140,6 +137,11 @@ picks one of these three; it does not compose a fill from scratch.
- **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.
Expand All @@ -149,12 +151,14 @@ picks one of these three; it does not compose a fill from scratch.

## Typography

- **Body copy: Inter**, via `--font-sans` (`'Inter Variable', sans-serif`).
- **Headings: Geist**, via `--font-heading` (`'Geist Variable', sans-serif`), applied at
the semantic-HTML level — `h1`–`h4` carry it in the base layer, so a screen never has
to remember `font-heading` on every heading it writes.
- Both are bundled offline through `@fontsource-variable/{inter,geist}` — no runtime
fetch to a font host, ever.
- **One family: Geist**, via `--font-sans` (`'Geist Variable', sans-serif`). The preset
sets `--font-heading` to `var(--font-sans)`, so a heading is the same face as body
copy at a different size and weight rather than a second typeface.
- **`font-heading` survives as a hook, not as a difference.** `h1`–`h4` still carry it
in the base layer, so no screen writes it by hand and a later preset that splits the
two families again lands in one declaration rather than at every heading.
- Bundled offline through `@fontsource-variable/geist` — no runtime fetch to a font
host, ever.
- **One justified technical role: `font-mono`.** Tailwind's default monospace stack (no
Geist Mono package is bundled) marks *machine-shaped* content — identifiers, hashes,
model references, measurements. It is never decoration, and prose never wears it; this
Expand Down Expand Up @@ -313,16 +317,22 @@ something a component opts into.

## Charts

The preset's chart palette — five orange steps, **identical in both themes** (a chart
does not restyle when the page switches theme):
The preset's chart palette — five neutral steps from light to dark, **identical in both
themes** (a chart does not restyle when the page switches theme):

| Token | Value |
| --- | --- |
| `chart-1` | `oklch(0.837 0.128 66.29)` |
| `chart-2` | `oklch(0.705 0.213 47.604)` |
| `chart-3` | `oklch(0.646 0.222 41.116)` |
| `chart-4` | `oklch(0.553 0.195 38.402)` |
| `chart-5` | `oklch(0.47 0.157 37.304)` |
| `chart-1` | `oklch(0.87 0 0)` |
| `chart-2` | `oklch(0.556 0 0)` |
| `chart-3` | `oklch(0.439 0 0)` |
| `chart-4` | `oklch(0.371 0 0)` |
| `chart-5` | `oklch(0.269 0 0)` |

They separate by **lightness rather than by hue**, which is the trade this preset makes:
a series stays legible to a reader who cannot separate two hues, and it survives being
printed or screenshotted in greyscale — at the cost of holding fewer series apart before
the steps run together. A chart needing more than five wants a second channel, a shape
or a label, not a sixth colour invented here.

These are **series colours, never status.** A chart never leans on `chart-2` to mean
"warning" — status uses the status tokens below, a chart uses these to tell one series
Expand Down
12 changes: 5 additions & 7 deletions docs/content/architecture/frontend/ui-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,10 @@ whenever its asserted type is and `tsc` cannot see the mismatch.

## The design system is a shadcn preset

`styles.css` is the shadcn preset `b3bXyyPdWj` (style `nova` on the Radix base,
base colour `neutral`, chart palette `orange`, icons `tabler`, Inter body /
Geist heading fonts, radius `medium`, menu `inverted`/`subtle`) - the CLI's own
`styles.css` is the shadcn preset `b2iH` (style `nova` on the Radix base, base
colour `neutral`, chart palette `neutral`, icons `lucide`, Geist throughout with
the heading face inheriting the body's, radius `medium`, menu
`inverted`/`subtle`, pointer cursor on pressable controls) - the CLI's own
generated output, transcribed verbatim, plus five VisionSet extension roles
(`stage`, `brand`, `success`, `warning`, `origin-*`) added through shadcn's own
extension convention. `components.json` (`style: "radix-nova"`,
Expand All @@ -84,10 +85,7 @@ the preset properties shadcn's own tools read - the fields its config schema
defines, and no others. The schema is strict, so the properties it has no field
for - the radius, the fonts, the chart palette, every colour - are values
carried by `styles.css` instead; see [`DESIGN.md`](../../../../DESIGN.md)'s
Source of Truth for the three layers. The icon set is the one place the
configuration and the preset disagree on purpose: the preset decodes `tabler`, the
product draws `lucide-react`, and `iconLibrary` records the product rather than the
preset. `tokens.ts` is the TypeScript mirror for a caller that
Source of Truth for the three layers. `tokens.ts` is the TypeScript mirror for a caller that
cannot read CSS. Both themes - light and dark - are declared in full from the
preset, so `bg-primary` in a component here and `bg-primary` in a screen mean
the same colour by construction. There is no `tailwind.config.js` in this
Expand Down
11 changes: 8 additions & 3 deletions frontend/app/e2e/styleguide.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,10 +175,15 @@ test("the progress fill wears the functional primary colour, not the brand", asy
* `@layer base`, so every screen inherited one rhythm without asking. The
* shadcn preset does not: typography is Tailwind's ordinary scale, applied per
* element (`DESIGN.md`, *Typography* and *Density and Spacing*), and the base
* layer's job shrank to three things — `html` gets `font-sans` (Inter), `h1`–`h4`
* get `font-heading` (Geist) and `body` gets `bg-background`/`text-foreground`.
* layer's job shrank to three things — `html` gets `font-sans`, `h1`–`h4` get
* `font-heading` and `body` gets `bg-background`/`text-foreground`.
* Those three are what this now asserts; a body-wide font-size is no longer
* part of the claim because it is no longer part of the contract.
*
* Both hooks resolve to Geist under `b2iH`, which sets `--font-heading` to
* `--font-sans`. Each is still asserted by name rather than against the other: a
* heading that had fallen back to the browser's default would satisfy "the two
* agree" while proving the hook was never wired.
*/
test("the base layer wires fonts and background, not a per-screen font-size", async ({ page }) => {
const body = page.locator("body");
Expand All @@ -189,7 +194,7 @@ test("the base layer wires fonts and background, not a per-screen font-size", as
const bodyBg = await rgbaOf(page, body, "background-color");

// `html { @apply font-sans }` — every screen, not a class on each page.
expect(bodyFont).toContain("Inter");
expect(bodyFont).toContain("Geist");
// `h1`–`h4` carry `font-heading` in the base layer, so a heading never repeats it.
expect(headingFont).toContain("Geist");
// `body { @apply bg-background }` — the preset's own white, applied once.
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 0 additions & 1 deletion frontend/ui-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@
},
"dependencies": {
"@fontsource-variable/geist": "^5.3.0",
"@fontsource-variable/inter": "^5.3.0",
"@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-dropdown-menu": "^2.1.24",
"@radix-ui/react-label": "^2.1.15",
Expand Down
46 changes: 26 additions & 20 deletions frontend/ui-core/src/styles.css
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
/*
* @visionset/ui-core — the design system's stylesheet.
*
* This is the shadcn preset `b3bXyyPdWj` (style `nova` on the Radix base,
* base colour `neutral`, chart palette `orange`, icons `tabler`, body font
* Inter, heading font Geist, radius medium, menu inverted/subtle) — the CLI's
* exact generated output, transcribed verbatim from a Docker scratch project
* built with `shadcn` 4.18.0. The preset is the source of truth for every
* This is the shadcn preset `b2iH` (style `nova` on the Radix base, base colour
* `neutral`, chart palette `neutral`, icons `lucide`, Geist throughout with the
* heading face inheriting the body's, radius medium, menu inverted/subtle, and
* the pointer cursor on pressable controls) — the CLI's exact generated output,
* transcribed verbatim from a Docker scratch project built with `shadcn` 4.19.0. The preset is the source of truth for every
* name below except nine: `stage`, `brand`, `success`/`success-foreground`,
* `warning`/`warning-foreground` and the three `origin-*` marks are
* VisionSet's own, each justified in
Expand All @@ -26,7 +26,6 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/inter";
@import "@fontsource-variable/geist";

/*
Expand Down Expand Up @@ -57,11 +56,11 @@
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.837 0.128 66.29);
--chart-2: oklch(0.705 0.213 47.604);
--chart-3: oklch(0.646 0.222 41.116);
--chart-4: oklch(0.553 0.195 38.402);
--chart-5: oklch(0.47 0.157 37.304);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
Expand Down Expand Up @@ -126,11 +125,11 @@
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.837 0.128 66.29);
--chart-2: oklch(0.705 0.213 47.604);
--chart-3: oklch(0.646 0.222 41.116);
--chart-4: oklch(0.553 0.195 38.402);
--chart-5: oklch(0.47 0.157 37.304);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
Expand All @@ -152,8 +151,8 @@
}

@theme inline {
--font-sans: 'Inter Variable', sans-serif;
--font-heading: 'Geist Variable', sans-serif;
--font-sans: 'Geist Variable', sans-serif;
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
Expand Down Expand Up @@ -238,11 +237,18 @@
body {
@apply bg-background text-foreground;
}
/* The preset's pointer option. Scoped to what is actually pressable and away
from what is not: a disabled control keeps the arrow, because a hand over
something that will not respond is the cursor telling a lie. */
button:not(:disabled), [role="button"]:not(:disabled) {
cursor: pointer;
}
html {
@apply font-sans;
}
/* The preset's heading font, applied at the semantic-HTML level so a
screen never has to remember `font-heading` on every <h1>. */
/* One family now: the preset sets `--font-heading` to `--font-sans`, so this
rule keeps the semantic hook without changing the face. A later preset that
splits them again lands here without a diff at every heading. */
h1, h2, h3, h4 {
@apply font-heading;
}
Expand Down
19 changes: 10 additions & 9 deletions frontend/ui-core/src/tokens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,11 @@ const EXTENSION_NAMES = [
];

const ORANGE_CHART = {
"chart-1": "oklch(0.837 0.128 66.29)",
"chart-2": "oklch(0.705 0.213 47.604)",
"chart-3": "oklch(0.646 0.222 41.116)",
"chart-4": "oklch(0.553 0.195 38.402)",
"chart-5": "oklch(0.47 0.157 37.304)",
"chart-1": "oklch(0.87 0 0)",
"chart-2": "oklch(0.556 0 0)",
"chart-3": "oklch(0.439 0 0)",
"chart-4": "oklch(0.371 0 0)",
"chart-5": "oklch(0.269 0 0)",
} as const;

describe("EXTENSIONS", () => {
Expand Down Expand Up @@ -159,7 +159,7 @@ describe(":root", () => {
expect(THEME.radius).toBe("0.625rem");
});

it("pins the orange chart palette exactly", () => {
it("pins the neutral chart palette exactly", () => {
for (const [name, value] of Object.entries(ORANGE_CHART)) {
expect(root.get(name)).toBe(value);
}
Expand All @@ -183,7 +183,7 @@ describe(".dark", () => {
expect(Object.keys(DARK_THEME).sort()).toEqual([...dark.keys()].sort());
});

it("pins the orange chart palette exactly, unchanged from light", () => {
it("pins the neutral chart palette exactly, unchanged from light", () => {
for (const [name, value] of Object.entries(ORANGE_CHART)) {
expect(dark.get(name)).toBe(value);
}
Expand Down Expand Up @@ -254,12 +254,13 @@ describe("legacy tokens", () => {
});

describe("structure", () => {
it("declares the dark variant and the five imports, in order", () => {
it("declares the dark variant and the four imports, in order", () => {
const anchors = [
'@import "tailwindcss";',
'@import "tw-animate-css";',
'@import "shadcn/tailwind.css";',
'@import "@fontsource-variable/inter";',
// One family, so one font import: `b2iH` sets the heading face to the body's
// rather than naming a second one.
'@import "@fontsource-variable/geist";',
"@custom-variant dark (&:is(.dark *));",
];
Expand Down
30 changes: 15 additions & 15 deletions frontend/ui-core/src/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
* which parses the stylesheet and asserts the two agree, declaration for
* declaration.
*
* `LIGHT_THEME`/`DARK_THEME` are the shadcn preset (`b3bXyyPdWj` — style
* `nova`, base colour `neutral`, chart palette `orange`) exactly as the CLI
* 4.18.0 scratch generated it, plus VisionSet's justified extensions:
* `LIGHT_THEME`/`DARK_THEME` are the shadcn preset (`b2iH` — style
* `nova`, base colour `neutral`, chart palette `neutral`) exactly as the CLI
* 4.19.0 scratch generated it, plus VisionSet's justified extensions:
* `stage` (the annotator's surround), `brand` (Robomous coral — identity
* only), the `success`/`warning` status pair, each with its own
* `-foreground`, and the three `origin-*` marks a Models card's accent edge
Expand All @@ -36,11 +36,11 @@ export const LIGHT_THEME: Readonly<Record<string, string>> = Object.freeze({
border: "oklch(0.922 0 0)",
input: "oklch(0.922 0 0)",
ring: "oklch(0.708 0 0)",
"chart-1": "oklch(0.837 0.128 66.29)",
"chart-2": "oklch(0.705 0.213 47.604)",
"chart-3": "oklch(0.646 0.222 41.116)",
"chart-4": "oklch(0.553 0.195 38.402)",
"chart-5": "oklch(0.47 0.157 37.304)",
"chart-1": "oklch(0.87 0 0)",
"chart-2": "oklch(0.556 0 0)",
"chart-3": "oklch(0.439 0 0)",
"chart-4": "oklch(0.371 0 0)",
"chart-5": "oklch(0.269 0 0)",
sidebar: "oklch(0.985 0 0)",
"sidebar-foreground": "oklch(0.145 0 0)",
"sidebar-primary": "oklch(0.205 0 0)",
Expand Down Expand Up @@ -89,11 +89,11 @@ export const DARK_THEME: Readonly<Record<string, string>> = Object.freeze({
border: "oklch(1 0 0 / 10%)",
input: "oklch(1 0 0 / 15%)",
ring: "oklch(0.556 0 0)",
"chart-1": "oklch(0.837 0.128 66.29)",
"chart-2": "oklch(0.705 0.213 47.604)",
"chart-3": "oklch(0.646 0.222 41.116)",
"chart-4": "oklch(0.553 0.195 38.402)",
"chart-5": "oklch(0.47 0.157 37.304)",
"chart-1": "oklch(0.87 0 0)",
"chart-2": "oklch(0.556 0 0)",
"chart-3": "oklch(0.439 0 0)",
"chart-4": "oklch(0.371 0 0)",
"chart-5": "oklch(0.269 0 0)",
sidebar: "oklch(0.205 0 0)",
"sidebar-foreground": "oklch(0.985 0 0)",
"sidebar-primary": "oklch(0.488 0.243 264.376)",
Expand All @@ -117,8 +117,8 @@ export const DARK_THEME: Readonly<Record<string, string>> = Object.freeze({
/** The two provenance facts a colour string alone cannot carry. */
export const THEME = {
radius: "0.625rem",
fontSans: "'Inter Variable', sans-serif",
fontHeading: "'Geist Variable', sans-serif",
fontSans: "'Geist Variable', sans-serif",
fontHeading: "var(--font-sans)",
} as const;

/** The nine variable names VisionSet keeps beyond shadcn's own vocabulary. */
Expand Down
8 changes: 0 additions & 8 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 0 additions & 4 deletions tests/scripts/design_tokens.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -311,10 +311,6 @@ test("components.json holds the schema-supported preset fields, and no others",

// The preset's own values, where the schema does have a field for them.
assert.equal(config.style, "radix-nova");
// The one field that no longer matches the decoded preset. `b3bXyyPdWj` decodes
// `iconLibrary: tabler`; the product draws lucide by a later decision, and the
// schema accepts the value, so this is a deliberate divergence rather than drift.
// DESIGN.md's *Source of Truth* records it.
assert.equal(config.iconLibrary, "lucide");
assert.equal(config.menuColor, "inverted");
assert.equal(config.menuAccent, "subtle");
Expand Down
Loading