From f201ef4bcaa74530fd156f5c30d6e96f4916a6a1 Mon Sep 17 00:00:00 2001 From: YaelAnaya Date: Mon, 24 Aug 2026 01:25:40 -0700 Subject: [PATCH 1/3] docs(design): the preset's radius lives in the stylesheet, and components.json carries only the fields shadcn's schema defines The foundation's source-of-truth table called components.json "the decoded preset configuration", which reads as a promise that every decoded property is represented there. It is not: shadcn's config schema is strict, and a property it has no field for is rejected rather than ignored, so the file cannot restate the whole preset even as documentation. The decoded `radius: medium` therefore has exactly one home, `--radius: 0.625rem` in styles.css, and its absence from components.json is the design working rather than drift. DESIGN.md now separates the three layers - preset intent, runtime realization, schema-supported configuration - with the radius as the worked example, and the Radius section names the stylesheet as the single place the medium step is spelled. The Lucide anti-pattern was also stale in a way that misdirected: it placed the remaining debt at screen level, where no file imports lucide-react any more. Every remaining import sits under frontend/ui-core/src/annotator/, which is what the rule now names. The architecture note picks up the same two corrections, and its count of the VisionSet extensions catches up with the five roles that exist. --- DESIGN.md | 44 ++++++++++++++----- docs/content/architecture/frontend/ui-core.md | 21 +++++---- 2 files changed, 46 insertions(+), 19 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index e92896e2..46dce52c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -31,16 +31,32 @@ CLI's own output. | File | Role | | --- | --- | -| [`frontend/ui-core/components.json`](frontend/ui-core/components.json) | The decoded preset configuration shadcn's own tools read | +| [`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 | +| --- | --- | --- | +| 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, 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. +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. @@ -184,8 +200,14 @@ this document sanctions, and only where the content's own height is the point. ## Radius -`--radius: 0.625rem` (10px) is the one constant; every other radius step is derived from -it in `@theme inline`, verbatim: +The preset's `radius: medium` materialises here and nowhere else: `--radius: 0.625rem` +(10px) in `styles.css` is the authoritative runtime value, mirrored by `tokens.ts` and +pinned by `tokens.test.ts`. `components.json` holds no radius field — shadcn's config +schema defines none, and being strict it rejects one. This stylesheet is the single place +the medium step is spelled. + +`--radius` is the one constant; every other radius step is derived from it in +`@theme inline`, verbatim: | Step | Formula | Result | | --- | --- | --- | @@ -360,12 +382,12 @@ First-class, and part of every rule above rather than a section to satisfy after - **Ad-hoc geometry that fights Nova.** A control's height, padding, or radius is not a per-screen decision; reaching past the geometry table above for a bespoke size is a design decision to make in this document, not in a component diff. -- **Mixing icon sets in new code.** Tabler is the set, and inside the primitives it is - already the only one: every icon `frontend/ui-core/src/primitives/` draws is - `@tabler/icons-react` — the select's chevrons and check, the dialog's close — and no - file there imports `lucide-react` at all. Lucide survives at *screen* level, in views - this rewrite did not open, and that is the whole of the remaining debt: a screen still - importing it is a file to migrate, not a precedent to follow. +- **Mixing icon sets in new code.** Tabler is the set, and across the primitives *and* + the screens it is already the only one: every icon `frontend/ui-core/src/primitives/` + and `frontend/ui-core/src/screens/` draws is `@tabler/icons-react`, and neither + directory imports `lucide-react` at all. Lucide survives in exactly one place — + `frontend/ui-core/src/annotator/` — and that is the whole of the remaining debt: a file + there still importing it is a file to migrate, not a precedent to follow. - **Brand in a functional control.** Robomous coral is identity — the wordmark and its styleguide swatch, nothing else. A functional control reaching for `brand` is a semantic-colour violation regardless of how many other sites already use it correctly. diff --git a/docs/content/architecture/frontend/ui-core.md b/docs/content/architecture/frontend/ui-core.md index 303999b7..8205df2d 100644 --- a/docs/content/architecture/frontend/ui-core.md +++ b/docs/content/architecture/frontend/ui-core.md @@ -76,11 +76,15 @@ whenever its asserted type is and `tsc` cannot see the mismatch. `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 -generated output, transcribed verbatim, plus four VisionSet extensions -(`stage`, `brand`, `success`, `warning`) added through shadcn's own extension -convention. `components.json` (`style: "radix-nova"`, `iconLibrary: "tabler"`, -`menuColor: "inverted"`, `menuAccent: "subtle"`) is the decoded configuration -those tools read. `tokens.ts` is the TypeScript mirror for a caller that +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"`, +`iconLibrary: "tabler"`, `menuColor: "inverted"`, `menuAccent: "subtle"`) holds +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. `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 @@ -89,8 +93,9 @@ repository and there must not be one - the tokens would acquire a second home. Three gates hold this: `tokens.test.ts` asserts `styles.css` and `tokens.ts` agree, declaration for declaration, and that no retired token has returned; `tests/scripts/design_tokens.test.mjs` scans every tracked frontend file for a -raw colour in a class string, refuses a second `tailwind.config.js`, and -confines `brand` to its two identity sites; `tests/scripts/docs_links.test.mjs` +raw colour in a class string, refuses a second `tailwind.config.js`, confines +`brand` to its two identity sites, and holds `components.json` to the +schema-supported field set; `tests/scripts/docs_links.test.mjs` keeps [`DESIGN.md`](../../../../DESIGN.md)'s own cross-references honest. ## Libraries @@ -101,7 +106,7 @@ visual-design rule. The current choices: | Concern | Choice | | --- | --- | | UI primitives | Radix (+ shadcn-style composition with `cva` and `cn`) - the open-code shadcn maintenance model is the direction: a primitive is VisionSet-owned source in `frontend/ui-core/src/primitives/`, edited directly, not a package dependency upgraded blindly | -| Icons | `@tabler/icons-react` is the target for new and touched components; `lucide-react` remains in components this rewrite did not touch, as migration debt | +| Icons | `@tabler/icons-react` is the set: the primitives and the screens draw nothing else. `lucide-react` remains only in `src/annotator/`, as migration debt | | Styling | Tailwind v4, CSS-first `@theme`, on the shadcn preset `b3bXyyPdWj` - no `tailwind.config.js`, ever | | Toasts | sonner | | Component tests | vitest + jsdom + @testing-library/react | From f71252d8daf35dd75a3b71f265a142e57d110630 Mon Sep 17 00:00:00 2001 From: YaelAnaya Date: Mon, 24 Aug 2026 01:25:50 -0700 Subject: [PATCH 2/3] chore: the frontend stack reads as Tabler-iconed wherever it is described Three places still described the primitives as "Radix + lucide": the ui-core package description, the stack table in the agent guidelines, and the frontend setup skill's instruction to contributors. Tabler is the icon set, and inside the primitives and the screens it is already the only one, so the instruction was pointing new code at the wrong library. Each now says what is true, and the skill adds where the exception lives, so a contributor who finds a lucide-react import under src/annotator/ reads it as debt rather than as the house style. --- .agents/skills/frontend/nodejs-setup/SKILL.md | 5 +++-- AGENTS.md | 2 +- frontend/ui-core/package.json | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.agents/skills/frontend/nodejs-setup/SKILL.md b/.agents/skills/frontend/nodejs-setup/SKILL.md index dec7c6c0..b6c20430 100644 --- a/.agents/skills/frontend/nodejs-setup/SKILL.md +++ b/.agents/skills/frontend/nodejs-setup/SKILL.md @@ -63,8 +63,9 @@ pnpm add -w -D # root tooling only - Cross-package references use the workspace protocol: `"@visionset/annotator": "workspace:*"`. - `@visionset/annotator` keeps `react` as an **optional peer dependency** — never promote it to a hard dependency; the core must stay usable without React. -- `@visionset/ui-core` UI primitives are **Radix + lucide only**. Do not add another component - library. +- `@visionset/ui-core` UI primitives are **Radix behaviour + shadcn Nova styling, iconed with + Tabler**. Do not add another component library, and do not reach for `lucide-react` in new + code — it survives only in `src/annotator/`, as migration debt. - `@visionset/app` is `private: true` — never published to npm; its bundle ships inside the Python wheel. diff --git a/AGENTS.md b/AGENTS.md index 19da2040..1e391beb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,7 +64,7 @@ client of the same SDK, and the release artifact is a plain `pip` package. | Component | Location | Stack | | --- | --- | --- | | Python distribution | `src/visionset/` | Python 3.12+, pydantic v2, FastAPI, Typer, MCP, SQLAlchemy, uv | -| Frontend workspace | `frontend/` | Node 24, pnpm, TypeScript, React 19, Vite, vitest, Radix + lucide | +| Frontend workspace | `frontend/` | Node 24, pnpm, TypeScript, React 19, Vite, vitest, Radix + Tabler | | Dev infra | `docker/` | Docker Compose (dev only) | See `README.md` for the monorepo map and `CONTRIBUTING.md` for the full check list. diff --git a/frontend/ui-core/package.json b/frontend/ui-core/package.json index e5998638..9dcd4a77 100644 --- a/frontend/ui-core/package.json +++ b/frontend/ui-core/package.json @@ -1,7 +1,7 @@ { "name": "@visionset/ui-core", "version": "0.0.1-beta.2", - "description": "VisionSet domain components, design tokens, and the generated typed API client. UI primitives: Radix + lucide only.", + "description": "VisionSet domain components, design tokens, and the generated typed API client. UI primitives: Radix behaviour under shadcn Nova styling, with Tabler icons.", "license": "Apache-2.0", "type": "module", "main": "./dist/index.js", From efe9101c506caf6bc027d59ca5caaecb16444a56 Mon Sep 17 00:00:00 2001 From: YaelAnaya Date: Mon, 24 Aug 2026 01:26:12 -0700 Subject: [PATCH 3/3] test(scripts): components.json is held to the fields shadcn's schema defines Nothing in the repository read components.json, so the one repair the radius question keeps inviting - writing `radius: medium` into it because the preset decodes to that - had no gate in front of it. The field would not be ignored: the schema answers `unrecognized_keys`, which breaks every shadcn invocation that reads the file. The gate asserts the key set rather than a count, so a failure names what moved, and it pins the preset values the schema does carry. It lives beside the other design invariants, and its failure message points at styles.css, where a decoded property with no field belongs. --- tests/scripts/design_tokens.test.mjs | 49 ++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/scripts/design_tokens.test.mjs b/tests/scripts/design_tokens.test.mjs index f8b26d8d..142a523f 100644 --- a/tests/scripts/design_tokens.test.mjs +++ b/tests/scripts/design_tokens.test.mjs @@ -269,6 +269,55 @@ test("the retired foundation vocabulary is absent from the stylesheet", () => { ); }); +/** + * `components.json` carries the preset properties shadcn's own tools read, and + * only those: the fields its config schema defines. The schema is **strict** — + * `rawConfigSchema.safeParse` answers `unrecognized_keys` for anything else — so + * a decoded preset property the schema has no field for cannot be added here + * even as documentation. It would not be ignored; it would break every `shadcn` + * invocation that reads the file. + * + * `radius` is the property that keeps inviting the mistake: the preset decodes to + * `radius: medium`, and the obvious repair for "the config does not say so" is to + * write it in. The medium step's one home is `styles.css`'s `--radius: 0.625rem` + * (asserted by `tokens.test.ts`); this test is the other half, refusing the field + * that would look like a second home while doing nothing. Keys rather than a + * count, so a failure names what moved. + */ +const CONFIG_PATH = "frontend/ui-core/components.json"; +const SCHEMA_SUPPORTED_KEYS = [ + "$schema", + "aliases", + "iconLibrary", + "menuAccent", + "menuColor", + "registries", + "rsc", + "rtl", + "style", + "tailwind", + "tsx", +]; + +test("components.json holds the schema-supported preset fields, and no others", () => { + const config = JSON.parse(readFileSync(path.join(REPO, CONFIG_PATH), "utf8")); + assert.deepEqual( + Object.keys(config).sort(), + SCHEMA_SUPPORTED_KEYS, + `${CONFIG_PATH} must carry exactly the fields shadcn's strict config schema defines. ` + + "A decoded preset property with no field here belongs in frontend/ui-core/src/styles.css " + + "as a value — see DESIGN.md 'Source of Truth'", + ); + + // The preset's own values, where the schema does have a field for them. + assert.equal(config.style, "radix-nova"); + assert.equal(config.iconLibrary, "tabler"); + assert.equal(config.menuColor, "inverted"); + assert.equal(config.menuAccent, "subtle"); + assert.equal(config.tailwind.baseColor, "neutral"); + assert.equal(config.tailwind.css, "src/styles.css"); +}); + test("the tokens have exactly one home, and it is the stylesheet", () => { const listed = spawnSync("git", ["ls-files", "-z"], { cwd: REPO, encoding: "utf8" }); assert.equal(listed.status, 0, `git ls-files failed: ${listed.stderr}`);