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
5 changes: 3 additions & 2 deletions .agents/skills/frontend/nodejs-setup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,9 @@ pnpm add -w -D <pkg> # 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.

Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
44 changes: 33 additions & 11 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 |
| --- | --- | --- |
Expand Down Expand Up @@ -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.
21 changes: 13 additions & 8 deletions docs/content/architecture/frontend/ui-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion frontend/ui-core/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
49 changes: 49 additions & 0 deletions tests/scripts/design_tokens.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down
Loading