@nurix/etna 0.20.13 → 0.20.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,11 +17,15 @@ Tailwind v4 + shadcn-based UI conventions for **web** apps — styling, the `@nu
17
17
  - **`@nurix/components`** is the UI library — import by subpath (`@nurix/components/button`). **Never** `npx shadcn add`, no `components.json` scaffold — consume the prebuilt library (`.tsx`, not `.jsx`). *A recorded deviation from canonical shadcn, where primitives are vendored into `components/ui/` and edited in place: this workspace consumes a published library instead, and vendoring would fork it. Deliberate — the design skill does not "converge" it.*
18
18
  - Vite: `@tailwindcss/vite` + `@vitejs/plugin-react-swc`. Pin `react` / `react-dom` / `react/jsx-runtime` / `react/jsx-dev-runtime` to the app's single copy via `resolve.alias` — `@nurix/*` ship nested react, and two copies means two hook dispatchers.
19
19
  - Tailwind v4 is CSS-only — no `tailwind.config.js`, no PostCSS.
20
- - **The CSS entry is whatever the manifest names**, not a fixed filename — `globals.css`, `index.css` and `styles.css` are each conventional in different framework guides, so resolve it rather than grep for one. Imports in order: `@import "./fonts.css"` (`@fontsource` registrations) → the project's `design.tokens.css` (derived from the repo-base `design.md` by the design skill's validator — **never** hand-edited) → `@import "tailwindcss"` → the library's `styles.css` — then `@theme inline {…}` mapping vars to tokens.
21
- - **Dark mode is the `.dark` class on the root element**, bound as `@custom-variant dark (&:is(.dark *))` in the CSS entry — **never** a `data-theme` attribute. `@nurix/components` compiles `.dark` blocks into its distributed CSS and a consumer cannot re-key them, so an app on the attribute renders half-flipped: its own tokens invert, every library surface stays light. The switcher is three parts — a pre-paint inline script in the HTML shell (a mounted provider runs too late and the page flashes), `color-scheme: light dark`, and the persisted-preference module.
20
+ - **The CSS entry is whatever the manifest names**, not a fixed filename — `globals.css`, `index.css` and `styles.css` are each conventional in different framework guides, so resolve it rather than grep for one. Imports in order: `@import "./fonts.css"` (`@fontsource` registrations) → the project's `design.tokens.css` (derived by the design skill's validator from the `design.md` in the design folder — `packages/common/` at the base of a multi-package workspace, `src/common/` in a single-package repo — **never** hand-edited) → `@import "tailwindcss"` → the library's `styles.css` → the project's `personality.css` (the same validator's `--emit-personality`, compiling the `design.md` traits through `@nurix/components`' own compiler — **never** hand-edited) — then `@theme inline {…}` mapping vars to tokens.
21
+ - **Dark mode is the `.dark` class on the root element**, bound as `@custom-variant dark (&:is(.dark *))` in the CSS entry — **never** a `data-theme` attribute. `@nurix/components` keys its `dark:` variants, its theme toggle, and its toaster on that class and a consumer cannot re-key them, so an app on the attribute renders mixed: its own tokens invert, the library's dark-only chrome stays light. The switcher is three parts — a pre-paint inline script in the HTML shell (a mounted provider runs too late and the page flashes), `color-scheme: light dark`, and the persisted-preference module.
22
22
  - Tokens: raw values in `:root:not(.dark)` and `:root.dark` — **both specificity (0,2,0), and that armor is load-bearing.** Tailwind compiles `@theme` into `@layer theme` and any unlayered declaration beats a layered one, so the library's unlayered `:root`/`.dark` defeats an app's `@theme` outright and *ties* a bare `:root`/`.dark`, leaving the winner to import order. Values are then consumed as semantic Tailwind tokens (`bg-card`, `text-foreground`) through an `@theme inline` mapping layer. The library bundles `class-variance-authority`, `clsx`, `tailwind-merge` — don't re-add them.
23
23
  - **Never retune a Tailwind scale key `@nurix/components` consumes** — `--text-xs`, `--text-sm`, `--text-base`, `--text-lg`, `--text-xl`, `--text-2xl`. Its components carry raw scale utilities and its stylesheet declares no `font-size`, so those compile against *this app's* `@theme`: redefining `--text-sm` moves every button, menu item and table cell in the library at once, with nothing to report it. Change type by changing ramp tokens.
24
- - Spacing in 2/4/6/8; `object-cover`; `truncate`; color through tokens — **never** hardcoded hex in components, and **never a literal font size**. The type ramp and the family rules are the design skill's [`references/type-system.md`](../skills/design/references/type-system.md); `text-sm` is a scale step and is fine, `text-[13px]` is a component overruling the system.
24
+ - Spacing in 2/4/6/8; `object-cover`; `clip` / `clip-<n>` / `clip-fade` (the library's — they carry `min-width: 0` and `overflow-wrap: anywhere`; **never** `truncate` / `line-clamp-*`, which leave the flex-child overflow bug open); color through tokens — **never** hardcoded hex in components, and **never a literal font size**. The type ramp and the family rules are the design skill's [`references/type-system.md`](../skills/design/references/type-system.md); `text-sm` is a scale step and is fine, `text-[13px]` is a component overruling the system.
25
+ - **Feel comes from the traits, never from literals on a control or collection surface.** `personality.css` compiles `density` / `pace` / `edge` / `depth` into `--density-*`, `--motion-duration-*`, `--radius-scale`, `--elevation-*`, and every library component reads those. App code stays inside the same reach: scanned surfaces pad and gap through the tokens (`p-(--density-pad-md)`, `gap-(--density-gap-md)`), a `transition-*` utility carries **no** `duration-*` class (the library's default transition duration retimes it from `pace`), a repeated-row container carries `data-mode="inline"`, and app animation reads `var(--motion-duration-base)`. A `p-3`, a `duration-200`, or a `rounded-lg` on such a surface silently opts out of the product's feel.
26
+ - **Every rendered time is `<Timestamp>`** (`@nurix/components/element/timestamp`, `format="auto"`), and a live label ticks through `useNow` (`@nurix/components/lib/time`) — one shared interval, never a `setInterval` per row or a hand-rolled relative formatter.
27
+ - **Status colour is the role.** `success`, `warning`, `info` are declared in `design.md` in both themes; the app's `@theme inline` maps its utilities onto the same roles (`--color-success: var(--success)`), and a status dot is `<Chip prefix="dot" color="var(--tone-success)">` — one hue per meaning on every surface.
28
+ - **`cn()` keeps only one of a type role and a colour merged together** — its tailwind-merge tables class `text-h1` / `text-body` / `text-dense` as colours, so `cn("text-h1", "text-foreground")` and `cn("text-h1 text-foreground")` are both `text-foreground`. Keep a role and a colour in a `className` string that never goes through `cn`, or put the colour on the parent.
25
29
 
26
30
  ## Component Search Order
27
31
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "fingerprint": "0eb49820003e",
2
+ "fingerprint": "4dd6abe08c97",
3
3
  "harnesses": {
4
4
  "code": {
5
5
  "plugin": "code",
@@ -33,7 +33,7 @@ Three peer modes, then a verification gate and a Completion step — enter where
33
33
 
34
34
  > Brainstorm entry needs a **basis** — a reference document or a one-shot brain dump; drip-sized asks (a bug fix, one button) are too small and get redirected. A **pillar** is a recorded strategic principle from a past brainstorm: in-progress sessions live under `docs/research/`, harvested ones under `docs/history/brainstorms/`, harvested doctrine under `docs/architecture/`. The LLD is the feature doc itself — `docs/features/{feature}.md`, design intent first, extended in place at Completion.
35
35
 
36
- > **Two stops, and only two.** The work between them runs without asking. The **plan pass** ends at a finished plan — written, boundary-checked, amendments folded, both ledgers in place — shown once; a plan that still owes a gate is not finished and is not shown as finished. The **build pass** ends at a discharged gate — the code written, driven, fixed, pinned, every ledger row in its Outcome cell, the plan's documents certified (`Polygraph:` written) — and stops before the freeze. What the user decides is the gated plan (stop 1) and the freeze-and-commit (stop 2); running a gate is never a question put to the user.
36
+ > **Two stops, and only two.** The work between them runs without asking. The **plan pass** ends at a finished plan — written, boundary-checked, amendments folded, both ledgers in place — shown once; a plan that still owes a gate is not finished and is not shown as finished. The **build pass** ends at a discharged gate — the code written, driven, fixed, pinned, every ledger row in its Outcome cell, the plan's documents certified (`Polygraph:` written) — and stops before the freeze. What the user decides is the gated plan (stop 1) and the freeze-and-commit (stop 2); running a gate is never a question put to the user. **Unattended, the same two stops are filed rather than asked** — § Headless Operation.
37
37
 
38
38
  > **A build graph overrides all of this, and most sessions never see one.** When `.claude/state/graph.json` exists, a walker decides which document is produced next and when the run is finished: a write reconciles it, an injected line names the next node, and the turn cannot end while the graph can still advance. Produce the file the line names. Never edit the graph or its state — both are refused — and never rewrite a node that has already landed. No such file ⇒ nothing in this paragraph is running.
39
39
 
@@ -50,6 +50,18 @@ Three peer modes, then a verification gate and a Completion step — enter where
50
50
 
51
51
  ---
52
52
 
53
+ ## Headless Operation
54
+
55
+ **A session is attended or unattended, and only the transport differs.** Attended means a person is present — the local lane on someone's own machine. Unattended means a session the control plane launched into a container, or any session with nobody there to answer. **An attended session never waits on the control plane** — no store lookup, no filed request, no blocked turn; store-mediated stops and fail-closed authority walls are unattended-only. Everything below binds unattended sessions and nothing else.
56
+
57
+ - **Two approval classes, two transports.** The two stops are **plan approval** and **ship approval**, identical in meaning either way. Attended, they are conversational. Unattended, they are store-mediated: the request goes to the control plane, which holds it, delivers it to a person, and resumes this same session with the answer.
58
+ - **File the request, end the turn — never wait in the container.** Any stop an unattended session reaches — a plan approval, a question, a deploy or merge approval, a spend ceiling, work past its mandate — is filed with the control plane as a blocking review item, and the turn ends there. A container has nobody to poll and pays for every minute it sleeps, so waiting burns the budget that would have finished the work.
59
+ - **The mandate is injected, not inferred.** An event- or schedule-triggered session starts with a scoped mandate — what it may change, and how far. **Exceeding the mandate is a stop**, filed like any other and never widened from inside the session. The **attention budget** caps how many stops one session may raise before the control plane pauses it: **3 per session by default**, overridable per lane.
60
+ - **Run caps end a session; they never stretch it.** An unattended session carries a **spend ceiling** and a **wall-clock ceiling**. Reaching either is a stop — file what exists, end as **stopped at budget**, name what remains. A silent continuation past a cap is **never** permitted.
61
+ - **Hygiene fails open, authority fails closed.** Hygiene walls — changelog presence, bookkeeping, ownership — fail open in **every** session, because enforcement is never an outage. Authority walls — approvals, closures, grants — fail closed in unattended sessions: an unreachable store means file and stop, never proceed. A sign-off bound to a commit is read from the control plane before the gated step it guards, through the control plane's own command line (`apollo review check --commit <sha>`: `0` signed, `1` not signed, `2` unreachable or signed out), and `2` is a stop.
62
+
63
+ ---
64
+
53
65
  ## Ownership, Boundaries, Verification & Documentation
54
66
 
55
67
  These four mechanisms work in tandem to keep the repo self-updating and to prevent half-finished changes: **ownership** routes an edit to the canonical doc before the code is touched, the **boundary** check reads both sides of every contract a change participates in so one side never ships alone, **verification** proves the change works in the product before it is called done, and **documentation** updates close the loop so the next session inherits the truth. Skipping any leg breaks the others.
@@ -0,0 +1,101 @@
1
+ ---
2
+ name: verifier
3
+ description: The adversarial second reader of a closed change. Launched by the control plane in a fresh checkout at the exact commit a run reported, on a model other than the one that built it, to re-derive whether the promises held. Reads the plan the commit closed, its Test Obligations and Doc Obligations ledgers, the `Gates:` line, the changelog entry, and the diff against the plan's Proposed Changes; runs the suite cold and re-executes every pin the ledger names with the fix set aside, so a pin is shown to bite rather than merely to be green. Emits one verification report — verdict pass or finding, findings ranked, each carrying what was promised, what the repository does, and re-runnable evidence. Runs on every plan closure and every incident fix. Never edits the repository and never grants. Triggers on "verify this commit", "run the verifier", "independent verification", "/verifier".
4
+ tools: Read, Glob, Grep, Bash
5
+ model: opus
6
+ ---
7
+
8
+ # Verifier — the independent reader of a closed change
9
+
10
+ You are the first party other than the building agent to look at this change. The building agent graded its own gate — a fast loop, and worthless as evidence — so **nothing it recorded about its own work is accepted until you have re-derived it**. Your mandate is adversarial: find where the repository at this commit does not do what it promised. A clean verdict is a valid result, and it is **never a grant** — only a person grants.
11
+
12
+ The control plane launches you in a **fresh checkout** at the exact commit the building run reported, on a **model other than the one that built the change**, because a second opinion drawn from the same model is the same blind spot twice. You run on **every** plan closure and **every** incident fix — there is no sampling policy for you to reason about.
13
+
14
+ **You never write into the repository.** Your report is your final message; the runner delivers it.
15
+
16
+ > **Two launches, one procedure.** A **plan closure** verifies against the plan and its two ledgers. An **incident fix** has no plan — its contract is the incident record the control plane names plus the changelog entry, and its ledger is the single regression pin the bug-fix regime owes; steps 2, 3 and 6 run unchanged, while 1, 4 and 5 read the incident record where they would read the plan.
17
+
18
+ ## Before starting
19
+
20
+ 1. **Prove you are on the reported ref.** `git rev-parse HEAD` equals the commit you were given, and `git status --porcelain` is empty. A different HEAD or a dirty tree means you are not looking at what was reported — that is finding #1, and every later observation is stated as conditional on it.
21
+ 2. **Read the change.** `git show --stat HEAD` for the shape, then the full diff. Split the paths into **source** and **tests** as you read — the pin replay depends on that split, and the diff is its only authority.
22
+ 3. **Find the contract.** In order: the plan the commit message names · the `**Plan:**` link in the changelog entry the commit carries · a plan under `docs/history/plans/` whose Proposed Changes match the diff's paths · the incident record for an incident fix. **A commit with neither a plan nor an incident record is a finding, not a reason to stop quietly** — report it, name what you searched, and end there; there is nothing left to verify against.
23
+ 4. Read the contract whole: Proposed Changes, the **Test Obligations** ledger, the **Doc Obligations** ledger, the `Gates:` line, `Status:`, and the Completed date.
24
+
25
+ ## The procedure
26
+
27
+ Run all six checks. Each is satisfied with evidence or is a finding — a check you could not complete is itself reported, never dropped.
28
+
29
+ ### 1 · The diff against the promise
30
+
31
+ Set the Proposed Changes beside the diff. Three ways they part, all findings: a promised change absent from the diff; a file changed that no Proposed Change names; a change present but doing something other than what the plan says it does. **The plan is the contract, not the diff's own coherence** — a well-made change that is not the promised one is still a finding.
32
+
33
+ ### 2 · The suite, cold
34
+
35
+ Run the repository's canonical test command from a cold shell, as the repo wires it (`package.json` `test`, `cargo test`, whatever the project declares) — never against a hand-started server, never a narrowed selection. **`cmd > log; echo "EXIT=$?"` reports the echo's status, not the command's**, which is the worst direction for a verifier to be wrong in: read the status directly, or drop the trailing echo. A red suite is a finding whatever the ledger says, quoted with the failing test's name and its output.
36
+
37
+ ### 3 · The pins, re-executed with the fix set aside
38
+
39
+ A green pin proves nothing — it may pass over the unfixed code just as happily. For **each pin the Test Obligations ledger names**:
40
+
41
+ ```sh
42
+ git stash push -- <the plan's source paths> # or: git checkout <parent> -- <the plan's source paths>
43
+ <the pin's own command> # expected: red
44
+ git stash pop # or: git checkout HEAD -- <the plan's source paths>
45
+ ```
46
+
47
+ - **Set aside the source side only — never the test files.** The pin must run exactly as written; reverting it alongside the fix makes the replay tautological.
48
+ - **The parent is the parent of the plan's own range**, not blindly `HEAD~1` — a plan spanning several commits has its fix spread across them.
49
+ - **Restore before the next pin, and prove it**: `git status --porcelain` empty again. An unrestored tree poisons every check after it.
50
+ - **A pin that stays green with the fix set aside is a finding** — it does not bite, so it guards nothing.
51
+ - **Red because nothing compiled is not a biting pin.** Separate an assertion failure from a build or import error; where the set-aside breaks the build, narrow it to the file the pin actually exercises and say in the report that you did.
52
+
53
+ ### 4 · The ledgers
54
+
55
+ - **Test Obligations** — every row carries an **Outcome**. An empty cell is a finding; a filled cell your replay contradicts is a worse one, and the report names both the cell and the command that disagreed with it.
56
+ - **Doc Obligations** — every row is discharged into exactly one outcome (**updated** · **owes nothing** with its reason · **blocked** with its backlog item). For a row discharged **updated**, open the doc and check it states what the code at this commit does — a discharged row over an unchanged doc is the commonest way a closure is faked.
57
+
58
+ ### 5 · The gates line
59
+
60
+ The plan's `Gates:` line reads **passed** for both `Boundary:` and `Polygraph:` (or, for polygraph, `owes nothing` with its reason). A segment still reading `pending` on a plan at `Status: Complete` is a finding on its own — the freeze outran the gate.
61
+
62
+ ### 6 · The changelog
63
+
64
+ An entry exists under `docs/history/changelog/` for this commit, it names the plan, and its account matches the diff. A missing entry, an entry naming no plan, and an entry describing work the diff does not contain are three separate findings.
65
+
66
+ ## The report
67
+
68
+ One report, and it is your final message — the runner delivers it to the control plane. No file is written.
69
+
70
+ ```
71
+ Kind: verification
72
+ Repository: {repo} · Commit: {sha}
73
+ Plan: {path or "incident {id}"} · Verdict: pass | finding
74
+ Checked: {N} obligation rows · {M} pins re-executed · {D} doc rows · suite {green|red}
75
+
76
+ | # | Severity | Promised | Observed | Evidence |
77
+ | --- | --- | --- | --- | --- |
78
+ | 1 | HIGH | {what the plan promised} | {what the code or the ledger does} | {file:line, or the command and its output} |
79
+ ```
80
+
81
+ - **Rank by severity**, highest first: HIGH — a promise the repository does not keep, or a pin that does not bite · MEDIUM — a ledger or document that misstates what shipped · LOW — a traceability gap that misleads the next reader.
82
+ - **Every finding carries evidence a reader can re-run** — a `file:line`, or the exact command and the lines of output that decide it. What you cannot evidence is not reported as a finding; it is reported as a check you could not complete.
83
+ - **Print the denominator even when clean** — "verified" with no count is indistinguishable from "scanned nothing".
84
+ - **A pass is recorded, never granted.** State the verdict and stop: a finding reopens the run on the control plane's side, and the grant is a person's act bound to this same commit.
85
+
86
+ ## Never
87
+
88
+ - **Never edit the repository** — no fix, no doc correction, no ledger cell, no test, not even an obvious typo. A finding reopens the session; the party that would fix it is the party whose work is under review.
89
+ - **Never weaken, skip, delete, or regenerate a test.** Regeneration re-derives the assertion from the code as it now is, which turns the defect you are hunting into the asserted truth with everything green.
90
+ - **Never accept an Outcome cell, a `passed` segment, or a "suite green" claim as evidence.** They are the self-report you exist to check; evidence is what your own command printed.
91
+ - **Never grant, approve, sign off, or close a run.** Your output is a verdict; the run's state belongs to the control plane.
92
+ - **Never delegate the verdict.** The replay, the reading, and the ranking stay with you — what you hand back is what you saw.
93
+
94
+ ## Stopping early
95
+
96
+ | Condition | What you do |
97
+ | --- | --- |
98
+ | No plan and no incident record for the commit | A HIGH finding naming what you searched, verdict `finding`, and stop — **silence is the one forbidden response** |
99
+ | HEAD is not the reported commit, or the tree is dirty | Finding #1; continue only if the reported ref is reachable, and mark every later row conditional |
100
+ | The plan carries no Test Obligations section | A HIGH finding — the plan was frozen without the gate that fills it; verify the remaining five checks anyway |
101
+ | The suite cannot run (missing toolchain, unbuildable checkout) | A HIGH finding carrying the command and its error — never improvise a substitute runner, which would verify something nobody ships |
@@ -0,0 +1,100 @@
1
+ # dropped Claude tools: Read, Glob, Grep, Bash
2
+ name = "verifier"
3
+ description = "The adversarial second reader of a closed change. Launched by the control plane in a fresh checkout at the exact commit a run reported, on a model other than the one that built it, to re-derive whether the promises held. Reads the plan the commit closed, its Test Obligations and Doc Obligations ledgers, the `Gates:` line, the changelog entry, and the diff against the plan's Proposed Changes; runs the suite cold and re-executes every pin the ledger names with the fix set aside, so a pin is shown to bite rather than merely to be green. Emits one verification report — verdict pass or finding, findings ranked, each carrying what was promised, what the repository does, and re-runnable evidence. Runs on every plan closure and every incident fix. Never edits the repository and never grants. Triggers on \"verify this commit\", \"run the verifier\", \"independent verification\", \"/verifier\"."
4
+ developer_instructions = '''
5
+
6
+ # Verifier — the independent reader of a closed change
7
+
8
+ You are the first party other than the building agent to look at this change. The building agent graded its own gate — a fast loop, and worthless as evidence — so **nothing it recorded about its own work is accepted until you have re-derived it**. Your mandate is adversarial: find where the repository at this commit does not do what it promised. A clean verdict is a valid result, and it is **never a grant** — only a person grants.
9
+
10
+ The control plane launches you in a **fresh checkout** at the exact commit the building run reported, on a **model other than the one that built the change**, because a second opinion drawn from the same model is the same blind spot twice. You run on **every** plan closure and **every** incident fix — there is no sampling policy for you to reason about.
11
+
12
+ **You never write into the repository.** Your report is your final message; the runner delivers it.
13
+
14
+ > **Two launches, one procedure.** A **plan closure** verifies against the plan and its two ledgers. An **incident fix** has no plan — its contract is the incident record the control plane names plus the changelog entry, and its ledger is the single regression pin the bug-fix regime owes; steps 2, 3 and 6 run unchanged, while 1, 4 and 5 read the incident record where they would read the plan.
15
+
16
+ ## Before starting
17
+
18
+ 1. **Prove you are on the reported ref.** `git rev-parse HEAD` equals the commit you were given, and `git status --porcelain` is empty. A different HEAD or a dirty tree means you are not looking at what was reported — that is finding #1, and every later observation is stated as conditional on it.
19
+ 2. **Read the change.** `git show --stat HEAD` for the shape, then the full diff. Split the paths into **source** and **tests** as you read — the pin replay depends on that split, and the diff is its only authority.
20
+ 3. **Find the contract.** In order: the plan the commit message names · the `**Plan:**` link in the changelog entry the commit carries · a plan under `docs/history/plans/` whose Proposed Changes match the diff's paths · the incident record for an incident fix. **A commit with neither a plan nor an incident record is a finding, not a reason to stop quietly** — report it, name what you searched, and end there; there is nothing left to verify against.
21
+ 4. Read the contract whole: Proposed Changes, the **Test Obligations** ledger, the **Doc Obligations** ledger, the `Gates:` line, `Status:`, and the Completed date.
22
+
23
+ ## The procedure
24
+
25
+ Run all six checks. Each is satisfied with evidence or is a finding — a check you could not complete is itself reported, never dropped.
26
+
27
+ ### 1 · The diff against the promise
28
+
29
+ Set the Proposed Changes beside the diff. Three ways they part, all findings: a promised change absent from the diff; a file changed that no Proposed Change names; a change present but doing something other than what the plan says it does. **The plan is the contract, not the diff's own coherence** — a well-made change that is not the promised one is still a finding.
30
+
31
+ ### 2 · The suite, cold
32
+
33
+ Run the repository's canonical test command from a cold shell, as the repo wires it (`package.json` `test`, `cargo test`, whatever the project declares) — never against a hand-started server, never a narrowed selection. **`cmd > log; echo "EXIT=$?"` reports the echo's status, not the command's**, which is the worst direction for a verifier to be wrong in: read the status directly, or drop the trailing echo. A red suite is a finding whatever the ledger says, quoted with the failing test's name and its output.
34
+
35
+ ### 3 · The pins, re-executed with the fix set aside
36
+
37
+ A green pin proves nothing — it may pass over the unfixed code just as happily. For **each pin the Test Obligations ledger names**:
38
+
39
+ ```sh
40
+ git stash push -- <the plan's source paths> # or: git checkout <parent> -- <the plan's source paths>
41
+ <the pin's own command> # expected: red
42
+ git stash pop # or: git checkout HEAD -- <the plan's source paths>
43
+ ```
44
+
45
+ - **Set aside the source side only — never the test files.** The pin must run exactly as written; reverting it alongside the fix makes the replay tautological.
46
+ - **The parent is the parent of the plan's own range**, not blindly `HEAD~1` — a plan spanning several commits has its fix spread across them.
47
+ - **Restore before the next pin, and prove it**: `git status --porcelain` empty again. An unrestored tree poisons every check after it.
48
+ - **A pin that stays green with the fix set aside is a finding** — it does not bite, so it guards nothing.
49
+ - **Red because nothing compiled is not a biting pin.** Separate an assertion failure from a build or import error; where the set-aside breaks the build, narrow it to the file the pin actually exercises and say in the report that you did.
50
+
51
+ ### 4 · The ledgers
52
+
53
+ - **Test Obligations** — every row carries an **Outcome**. An empty cell is a finding; a filled cell your replay contradicts is a worse one, and the report names both the cell and the command that disagreed with it.
54
+ - **Doc Obligations** — every row is discharged into exactly one outcome (**updated** · **owes nothing** with its reason · **blocked** with its backlog item). For a row discharged **updated**, open the doc and check it states what the code at this commit does — a discharged row over an unchanged doc is the commonest way a closure is faked.
55
+
56
+ ### 5 · The gates line
57
+
58
+ The plan's `Gates:` line reads **passed** for both `Boundary:` and `Polygraph:` (or, for polygraph, `owes nothing` with its reason). A segment still reading `pending` on a plan at `Status: Complete` is a finding on its own — the freeze outran the gate.
59
+
60
+ ### 6 · The changelog
61
+
62
+ An entry exists under `docs/history/changelog/` for this commit, it names the plan, and its account matches the diff. A missing entry, an entry naming no plan, and an entry describing work the diff does not contain are three separate findings.
63
+
64
+ ## The report
65
+
66
+ One report, and it is your final message — the runner delivers it to the control plane. No file is written.
67
+
68
+ ```
69
+ Kind: verification
70
+ Repository: {repo} · Commit: {sha}
71
+ Plan: {path or "incident {id}"} · Verdict: pass | finding
72
+ Checked: {N} obligation rows · {M} pins re-executed · {D} doc rows · suite {green|red}
73
+
74
+ | # | Severity | Promised | Observed | Evidence |
75
+ | --- | --- | --- | --- | --- |
76
+ | 1 | HIGH | {what the plan promised} | {what the code or the ledger does} | {file:line, or the command and its output} |
77
+ ```
78
+
79
+ - **Rank by severity**, highest first: HIGH — a promise the repository does not keep, or a pin that does not bite · MEDIUM — a ledger or document that misstates what shipped · LOW — a traceability gap that misleads the next reader.
80
+ - **Every finding carries evidence a reader can re-run** — a `file:line`, or the exact command and the lines of output that decide it. What you cannot evidence is not reported as a finding; it is reported as a check you could not complete.
81
+ - **Print the denominator even when clean** — "verified" with no count is indistinguishable from "scanned nothing".
82
+ - **A pass is recorded, never granted.** State the verdict and stop: a finding reopens the run on the control plane's side, and the grant is a person's act bound to this same commit.
83
+
84
+ ## Never
85
+
86
+ - **Never edit the repository** — no fix, no doc correction, no ledger cell, no test, not even an obvious typo. A finding reopens the session; the party that would fix it is the party whose work is under review.
87
+ - **Never weaken, skip, delete, or regenerate a test.** Regeneration re-derives the assertion from the code as it now is, which turns the defect you are hunting into the asserted truth with everything green.
88
+ - **Never accept an Outcome cell, a `passed` segment, or a "suite green" claim as evidence.** They are the self-report you exist to check; evidence is what your own command printed.
89
+ - **Never grant, approve, sign off, or close a run.** Your output is a verdict; the run's state belongs to the control plane.
90
+ - **Never delegate the verdict.** The replay, the reading, and the ranking stay with you — what you hand back is what you saw.
91
+
92
+ ## Stopping early
93
+
94
+ | Condition | What you do |
95
+ | --- | --- |
96
+ | No plan and no incident record for the commit | A HIGH finding naming what you searched, verdict `finding`, and stop — **silence is the one forbidden response** |
97
+ | HEAD is not the reported commit, or the tree is dirty | Finding #1; continue only if the reported ref is reachable, and mark every later row conditional |
98
+ | The plan carries no Test Obligations section | A HIGH finding — the plan was frozen without the gate that fills it; verify the remaining five checks anyway |
99
+ | The suite cannot run (missing toolchain, unbuildable checkout) | A HIGH finding carrying the command and its error — never improvise a substitute runner, which would verify something nobody ships |
100
+ '''
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "harness": "code",
3
3
  "name": "code",
4
- "version": "3.16.1",
4
+ "version": "3.16.3",
5
5
  "files": [
6
6
  ".prettierignore",
7
7
  ".prettierrc.json",
@@ -25,5 +25,5 @@
25
25
  }
26
26
  ],
27
27
  "docsRoot": "docs",
28
- "digest": "3a8ab65bd90f"
28
+ "digest": "7a8f67e4b74f"
29
29
  }
@@ -17,11 +17,15 @@ Tailwind v4 + shadcn-based UI conventions for **web** apps — styling, the `@nu
17
17
  - **`@nurix/components`** is the UI library — import by subpath (`@nurix/components/button`). **Never** `npx shadcn add`, no `components.json` scaffold — consume the prebuilt library (`.tsx`, not `.jsx`). *A recorded deviation from canonical shadcn, where primitives are vendored into `components/ui/` and edited in place: this workspace consumes a published library instead, and vendoring would fork it. Deliberate — the design skill does not "converge" it.*
18
18
  - Vite: `@tailwindcss/vite` + `@vitejs/plugin-react-swc`. Pin `react` / `react-dom` / `react/jsx-runtime` / `react/jsx-dev-runtime` to the app's single copy via `resolve.alias` — `@nurix/*` ship nested react, and two copies means two hook dispatchers.
19
19
  - Tailwind v4 is CSS-only — no `tailwind.config.js`, no PostCSS.
20
- - **The CSS entry is whatever the manifest names**, not a fixed filename — `globals.css`, `index.css` and `styles.css` are each conventional in different framework guides, so resolve it rather than grep for one. Imports in order: `@import "./fonts.css"` (`@fontsource` registrations) → the project's `design.tokens.css` (derived from the repo-base `design.md` by the design skill's validator — **never** hand-edited) → `@import "tailwindcss"` → the library's `styles.css` — then `@theme inline {…}` mapping vars to tokens.
21
- - **Dark mode is the `.dark` class on the root element**, bound as `@custom-variant dark (&:is(.dark *))` in the CSS entry — **never** a `data-theme` attribute. `@nurix/components` compiles `.dark` blocks into its distributed CSS and a consumer cannot re-key them, so an app on the attribute renders half-flipped: its own tokens invert, every library surface stays light. The switcher is three parts — a pre-paint inline script in the HTML shell (a mounted provider runs too late and the page flashes), `color-scheme: light dark`, and the persisted-preference module.
20
+ - **The CSS entry is whatever the manifest names**, not a fixed filename — `globals.css`, `index.css` and `styles.css` are each conventional in different framework guides, so resolve it rather than grep for one. Imports in order: `@import "./fonts.css"` (`@fontsource` registrations) → the project's `design.tokens.css` (derived by the design skill's validator from the `design.md` in the design folder — `packages/common/` at the base of a multi-package workspace, `src/common/` in a single-package repo — **never** hand-edited) → `@import "tailwindcss"` → the library's `styles.css` → the project's `personality.css` (the same validator's `--emit-personality`, compiling the `design.md` traits through `@nurix/components`' own compiler — **never** hand-edited) — then `@theme inline {…}` mapping vars to tokens.
21
+ - **Dark mode is the `.dark` class on the root element**, bound as `@custom-variant dark (&:is(.dark *))` in the CSS entry — **never** a `data-theme` attribute. `@nurix/components` keys its `dark:` variants, its theme toggle, and its toaster on that class and a consumer cannot re-key them, so an app on the attribute renders mixed: its own tokens invert, the library's dark-only chrome stays light. The switcher is three parts — a pre-paint inline script in the HTML shell (a mounted provider runs too late and the page flashes), `color-scheme: light dark`, and the persisted-preference module.
22
22
  - Tokens: raw values in `:root:not(.dark)` and `:root.dark` — **both specificity (0,2,0), and that armor is load-bearing.** Tailwind compiles `@theme` into `@layer theme` and any unlayered declaration beats a layered one, so the library's unlayered `:root`/`.dark` defeats an app's `@theme` outright and *ties* a bare `:root`/`.dark`, leaving the winner to import order. Values are then consumed as semantic Tailwind tokens (`bg-card`, `text-foreground`) through an `@theme inline` mapping layer. The library bundles `class-variance-authority`, `clsx`, `tailwind-merge` — don't re-add them.
23
23
  - **Never retune a Tailwind scale key `@nurix/components` consumes** — `--text-xs`, `--text-sm`, `--text-base`, `--text-lg`, `--text-xl`, `--text-2xl`. Its components carry raw scale utilities and its stylesheet declares no `font-size`, so those compile against *this app's* `@theme`: redefining `--text-sm` moves every button, menu item and table cell in the library at once, with nothing to report it. Change type by changing ramp tokens.
24
- - Spacing in 2/4/6/8; `object-cover`; `truncate`; color through tokens — **never** hardcoded hex in components, and **never a literal font size**. The type ramp and the family rules are the design skill's [`references/type-system.md`](../skills/design/references/type-system.md); `text-sm` is a scale step and is fine, `text-[13px]` is a component overruling the system.
24
+ - Spacing in 2/4/6/8; `object-cover`; `clip` / `clip-<n>` / `clip-fade` (the library's — they carry `min-width: 0` and `overflow-wrap: anywhere`; **never** `truncate` / `line-clamp-*`, which leave the flex-child overflow bug open); color through tokens — **never** hardcoded hex in components, and **never a literal font size**. The type ramp and the family rules are the design skill's [`references/type-system.md`](../skills/design/references/type-system.md); `text-sm` is a scale step and is fine, `text-[13px]` is a component overruling the system.
25
+ - **Feel comes from the traits, never from literals on a control or collection surface.** `personality.css` compiles `density` / `pace` / `edge` / `depth` into `--density-*`, `--motion-duration-*`, `--radius-scale`, `--elevation-*`, and every library component reads those. App code stays inside the same reach: scanned surfaces pad and gap through the tokens (`p-(--density-pad-md)`, `gap-(--density-gap-md)`), a `transition-*` utility carries **no** `duration-*` class (the library's default transition duration retimes it from `pace`), a repeated-row container carries `data-mode="inline"`, and app animation reads `var(--motion-duration-base)`. A `p-3`, a `duration-200`, or a `rounded-lg` on such a surface silently opts out of the product's feel.
26
+ - **Every rendered time is `<Timestamp>`** (`@nurix/components/element/timestamp`, `format="auto"`), and a live label ticks through `useNow` (`@nurix/components/lib/time`) — one shared interval, never a `setInterval` per row or a hand-rolled relative formatter.
27
+ - **Status colour is the role.** `success`, `warning`, `info` are declared in `design.md` in both themes; the app's `@theme inline` maps its utilities onto the same roles (`--color-success: var(--success)`), and a status dot is `<Chip prefix="dot" color="var(--tone-success)">` — one hue per meaning on every surface.
28
+ - **`cn()` keeps only one of a type role and a colour merged together** — its tailwind-merge tables class `text-h1` / `text-body` / `text-dense` as colours, so `cn("text-h1", "text-foreground")` and `cn("text-h1 text-foreground")` are both `text-foreground`. Keep a role and a colour in a `className` string that never goes through `cn`, or put the colour on the parent.
25
29
 
26
30
  ## Component Search Order
27
31
 
@@ -57,7 +57,7 @@ The component dictionary spans **two provenances**: the internal `@nurix/compone
57
57
 
58
58
  The project repo keeps exactly what a build needs offline and reproducibly — all editable by anyone, no jails; the canonical memory is the workspace's preference rows on the server:
59
59
 
60
- - **`design.md`** (repo base) — the working copy of the workspace's look: the resolved fork of the chosen package with provenance line, brief bindings, the primitives switch, and every recorded deviation with its one-line reason. The token artifact (`design.tokens.css` beside it) is derived from it, never hand-edited. After any local edit that survives the ship gate, push the new text via `record`; at session start compare the repo copy against `get_preferences` — on mismatch, tell the user which is newer and ask; never silently overwrite either.
60
+ - **`design.md`** (in the design folder — `packages/common/` at the base of a multi-package workspace, `src/common/` in a single-package repo) — the working copy of the workspace's look: the resolved fork of the chosen package with provenance line, brief bindings, the primitives switch, and every recorded deviation with its one-line reason. The artifacts beside it (`design.tokens.css`, and `personality.css` under `nurix`) are derived from it, never hand-edited. **Locate it mechanically, never by search:** `node scripts/validate-design-md.mjs --project .` resolves the one path from the repo's shape and fails closed on a copy anywhere else, naming where it belongs; a repo where it resolves to nothing has no fork yet, and first intake writes it at that path. After any local edit that survives the ship gate, push the new text via `record`; at session start compare the repo copy against `get_preferences` — on mismatch, tell the user which is newer and ask; never silently overwrite either.
61
61
  - **`product.md`** — users and jobs, roles, action frequency, risk, accessibility commitments, brand anti-references. Package choice, UX decisions, motion intensity, and demo data all cite it.
62
62
  - **The `workspaceId`** — one committed line (or ambient Apollo context); what binds the repo to its workspace's memory.
63
63
 
@@ -89,7 +89,7 @@ What enters context, when. Nothing loads whole except where this table says so.
89
89
 
90
90
  Two scripts at `scripts/` — ESM, Node ≥ 22, zero dependencies. You drive them; nothing hooks them.
91
91
 
92
- - `validate-design-md.mjs <file|folder> [--mode admission|build|ship] [--json] [--emit]` — the entire mechanical apparatus of the design.md system: schema, coverage, contrast, index integrity fail closed; taste smells warn. `--emit` compiles the token artifact; `--mode ship` runs the drift check. Runs at build on the project's fork before token compilation and at the ship gate.
92
+ - `validate-design-md.mjs (<file|folder> | --project [dir]) [--mode admission|build|ship] [--json] [--emit] [--emit-personality] [--write] [--lib <folder>]` — the entire mechanical apparatus of the design.md system: schema, coverage, contrast, index integrity fail closed; taste smells warn. `--project .` resolves the project's fork from the repo shape; `--emit --write` compiles the token artifact beside it and `--emit-personality --write` the personality artifact; `--mode ship` runs both drift checks. Runs at build on the project's fork before token compilation and at the ship gate.
93
93
  - `select-chart.mjs <signature.json>` — the chart-selection engine: you author the data signature (the judgment half); it computes elimination, scoring, ranking, and refusal (the mechanical half) over the census that ships beside it — engine and census are one designed artifact. One graph question per call; disagreement with its answer is recorded, never silent.
94
94
 
95
95
  ## What this skill never does
@@ -21,3 +21,21 @@ Recipes are starting points, not jails: restyle or extend them in place when the
21
21
  - **No ownership jails.** Any file may be edited at any time, including generated ones and library compositions.
22
22
  - **No paper-trail bureaucracy.** Decision-log lines only, through `record`; attention goes to pixels.
23
23
  - **A finishing pass with authority over everything is part of this step** — walk the whole canvas once at the end: rhythm, alignment, density, identity-consistency across surfaces, the one-accent discipline.
24
+
25
+ ## Library reach — under `primitives: nurix`
26
+
27
+ The library carries behavior stock shadcn does not, and each item below is reachable only when the composition routes to it; a hand-rolled equivalent is a defect, not a style choice.
28
+
29
+ - **Density is the trait.** Control height and padding come from `personality.density` through `--density-control-*`; container padding and gaps on scanned surfaces (cards in a grid, list rows, panel bodies) read `--density-pad-xs|md|lg` and `--density-gap-md` — `p-(--density-pad-md)`, `gap-(--density-gap-md)` — so one trait re-spaces the product. A `p-3` on a data surface is the per-surface mood foundations forbid; structural spacing (a divider, an icon's optical nudge) stays literal.
30
+ - **Motion rides the seam.** A `transition-*` utility with no `duration-*` class inherits `--motion-duration-fast`, which `pace`, the reduced-motion block, and the inline clamp all reach; a `duration-200` opts that element out of all three. App-owned animation reads `var(--motion-duration-base)` and `var(--motion-ease-standard)`, never a literal; the library's `effect/*` primitives already read them, so an identity moment composes from those before a bespoke variant.
31
+ - **Collections are inline.** Any container of repeated rows the operator scans — a table body, a list, a feed, a board column — carries `data-mode="inline"`: every DOM-descendant transition collapses to instant (portaled tooltips escape) while loaders keep breathing on the ambient duration. Only `<Cell mode="inline">` stamps it, on the cell alone; `block/table` and `block/list` stamp nothing on their containers, so the app stamps it on the collection — including ones built on those blocks.
32
+ - **Every time on screen is `<Timestamp>`** (`element/timestamp`): `format="auto"` reads relative inside `thresholdMs` and absolute beyond it, the full absolute stamp sits on the native `title` by default, `live` ticks on one shared interval. A custom live label subscribes through `useNow` (`lib/time`) — one ticker per interval, never a timer per row.
33
+ - **Typing is not saving.** Input (commits on blur), Textarea (blur, Cmd/Ctrl+Enter), NumberInput (blur, Enter, the steppers), Slider (release), Combobox (close), `DatePicker` and `DatePicker.Range` (the pick) carry `onChange` for the draft and `onSave` for the commit; `InlineEdit` carries `onSave` alone. Mutations wire to `onSave`; `onChange` never reaches the server. A form with a Save button is the one exception — a transaction the button commits whole.
34
+ - **Remote and long option lists are the Combobox's** (`compound/combobox`): `onSearch` returns a page for a query (debounced by `debounceMs`), `onLoadMore` appends the next page, `maxSelections` caps a multi-select. A fetch-in-effect feeding a filtered `<Select>` is the pattern this replaces.
35
+ - **Time-range filters are `DatePicker.Range` with `presets`** (`{ label, from, to }`; its preset column shows by default, `DatePicker`'s needs `showPresets`), `weekStartsOn` from the locale decision, `fromYear`/`toYear` where the domain bounds them; a single date in a table cell is `DatePicker` with `inline` and `compact`. A bespoke window picker is a defect.
36
+ - **Uploads reject before they upload.** `FileUpload` (`compound/file-picker`) takes `accept`, `maxSize`, `maxFiles`, and `onFileValidate` (return the rejection message, or nothing to accept); a rejected file never enters the list and `onFileReject(file, message)` receives it. The dropzone only flashes an invalid border — the composition surfaces the message (a field error or a toast).
37
+ - **Shell affordances come from `block/shell-affordances`**: `Freshness` for "as of" (`staleAfterMs`, `onRefresh`), `GateAffordance` for an action a gate blocks, `SystemBanner` for a system-wide notice. A bespoke freshness pill or banner is a defect.
38
+ - **Status colour is the role.** The package's `success` / `warning` / `info` reach the library as `--tone-success|warning|info` (library fallbacks when undeclared): a status dot is `<Chip prefix="dot" color="var(--tone-success)">`, never a raw hue, and app utilities map onto the same roles (`--color-success: var(--success)` in the app's `@theme inline`), so a chart legend and a chip dot never disagree.
39
+ - **Targets under 24px grow through `hit-area-*`** (`hit-area-2` adds 0.5rem of pointer box on every side; `hit-area-x-*` / `-y-*` / `-t-*` / `-b-*` / `-l-*` / `-r-*` where a neighbour would overlap) — the pointer box grows, the paint box does not. `hit-area-debug` shows the box while composing.
40
+ - **Clipping is `clip`.** `clip` for one line, `clip-<n>` for n lines, `clip-fade` where prose is expected to overflow — each carries `min-width: 0` and `overflow-wrap: anywhere`, which Tailwind's `truncate` and `line-clamp-*` lack (the flex-child overflow bug those leave open).
41
+ - **Type roles are utilities** — `text-display`, `text-title`, `text-h1`, `text-body`, `text-dense`, `text-meta`, … from the library's `@theme` — and `cn()` (tailwind-merge tables) classifies `text-<role>` as a colour: a role and a colour passed through `cn`, together or apart, keep only the later one. Keep both in a `className` string that never goes through `cn`, or put the colour on the parent.
@@ -1,21 +1,47 @@
1
- # Package emission — design.md → token artifact
1
+ # Package emission — design.md → the token artifacts
2
2
 
3
- One source, one derivation: the project's `design.md` is canonical; the token artifact is compiled from it and **never hand-edited**. Emit with `node scripts/validate-design-md.mjs <project design.md> --emit > design.tokens.css` (the artifact lives beside `design.md` at the repo base; the app's global stylesheet imports it). Re-emit on every design.md change; the ship gate's drift check byte-compares a fresh emission against the committed artifact.
3
+ One source, two derivations: the project's `design.md` is canonical; the artifacts are compiled from it and **never hand-edited**. Both live beside `design.md` in the design folder — `packages/common/` at the base of a multi-package workspace, `src/common/` in a single-package repo, the path `--project .` resolves and `--write` emits into — and the app's global stylesheet imports both. Re-emit on every design.md change; the ship gate's drift checks byte-compare a fresh emission against each committed artifact.
4
+
5
+ | Artifact | Emit with | Carries | Compiled by |
6
+ | --- | --- | --- | --- |
7
+ | `design.tokens.css` | `node scripts/validate-design-md.mjs --project . --emit --write` | the color roles in both themes, the type ramp, elevation, the motion base, layout | the validator |
8
+ | `personality.css` — `primitives: nurix` only | `node scripts/validate-design-md.mjs --project . --emit-personality --write` | the six traits compiled into the token families every `@nurix/components` component reads: `--density-*`, `--motion-duration-*`, `--radius-scale`, `--elevation-*`, `--texture-*`, `--accent`/`--ring` | `compileToCss` from `@nurix/components/lib/personality`, reached through the validator from the project's own install |
4
9
 
5
10
  ## Coverage
6
11
 
7
- The artifact is complete by construction — the validator fails the design.md before emission otherwise: every one of the 36 color roles in **both** themes, the full type ramp, the elevation system, the motion base, layout dimensions. Composition consumes tokens and never invents values; switching packages re-skins the whole app.
12
+ `design.tokens.css` is complete by construction — the validator fails the design.md before emission otherwise: every one of the 36 color roles in **both** themes, the full type ramp, the elevation system, the motion base, layout dimensions. Composition consumes tokens and never invents values; switching packages re-skins the whole app.
13
+
14
+ ## The personality block
15
+
16
+ `@nurix/components` derives feel — control geometry, spacing, timing, radius, depth, grain, accent — from six traits and reads nothing else for it: a component's transition is `var(--motion-duration-base)`, a control's height is `var(--density-control-height)`. The design.md states the traits once, in its design-values block, and the library derives every value:
17
+
18
+ ```yaml
19
+ personality:
20
+ density: compact # compact | regular | airy — control height and padding, container padding, gaps
21
+ pace: 0.3 # 0 still · 0.5 the library's neutral timing · 1 lively
22
+ edge: sharp # sharp | regular | soft — the radius scale
23
+ depth: flat # flat | layered | elevated — the elevation ladder
24
+ texture: clean # clean | grainy — grain opacity on effect surfaces
25
+ tone: neutral # neutral | expressive — whether the accent re-anchors on the primary
26
+ ```
27
+
28
+ - **§4–§7 are read into the traits, never into pixels.** A §6 of "flat, hairline-separated" is `depth: flat`; a §7 of "utilitarian, nothing animates twice a day" is `pace` at 0.2–0.35; an operator §4 is `density: compact`. Declare only what differs from neutral (regular · 0.5 · regular · layered · grainy · neutral). An absent block ships the library's defaults and the validator warns (`dmw-05`) — a project that never declared its feel is how these families go unused.
29
+ - **`motion.duration` and `motion.easing` are the app's own vocabulary** (`--duration-panel`, `--ease-standard`, read by app-owned animation); the library's components never read them. `pace` is what retimes the library — and, through the library's default transition duration, every plain Tailwind `transition-*` utility in the app. State both from the same §7 reading.
30
+ - **The block is closed-vocabulary and fails closed** (`dmv-12`): an unknown trait, an out-of-range pace, or a block under `primitives: native` (nothing there reads it) fails validation.
31
+ - **The compiler is the project's own install.** `--emit-personality` resolves `@nurix/components` by walking up from the design.md, then one and two levels into the workspace (`packages/<app>`), because a pnpm root hoists nothing. Several installed versions is an ambiguity the app package resolves: `--project . --emit-personality --write --lib packages/<app>`. The artifact's header names the library version that compiled it, so a library upgrade re-emits by drift, never by hand.
32
+ - **The artifact is committed, never injected.** `personality.css` is the product's artifact — inspectable, diffable, drift-checked at the ship gate — imported after the library's `styles.css` so its `:root` wins on order. `<PersonalityStyle>` from the same subpath serves a runtime switch (a density toggle, a preview) and never replaces the artifact.
8
33
 
9
34
  ## Emission constraints
10
35
 
11
36
  Each prevents a known silent-failure class:
12
37
 
13
- - **Specificity armor.** Theme blocks are `:root, :root[data-theme="light"]` and `:root[data-theme="dark"]` — both specificity (0,2,0) — so a component library's later bare `:root` block cannot silently defeat a theme.
14
- - **Font binding by derivation.** Font stacks bind to installed packages under **registered family names** (`"Inter Variable"`, not `"Inter"`), each with its declared fallback stack — otherwise the flagship font silently never renders. Install the font packages the design.md declares (e.g. Fontsource) and verify the name matches what the package registers.
15
- - **Every authored token group reaches a named consumer.** An authored ramp with no emitter vanishes without a trace — wire each group (colors → theme classes/utilities, ramp → text styles, elevation → radius/shadow utilities, motion → duration/easing vars) and let the ship gate's reach check prove it.
38
+ - **Specificity armor.** Theme blocks are `:root:not(.dark)` and `:root.dark` — both specificity (0,2,0) — keyed on the `.dark` class the library compiles into its own stylesheet. A `data-theme` attribute keys none of the library's `dark:` variants, its theme toggle, or its toaster: an app on the attribute inverts its tokens and renders mixed library chrome.
39
+ - **The tone trio.** `success`, `warning`, and `info` reach the library as `--tone-success|warning|info` — what a status dot (`<Chip prefix="dot" color="var(--tone-success)">`) and the copy button's check read, falling back to the library's own hue when a role is absent. Declare all three, in both themes — a missing `info` (`dmw-06`) puts the library's blue beside the package's green and amber.
40
+ - **Font binding by derivation.** Font stacks bind to installed packages under **registered family names** (`"Inter Variable"`, not `"Inter"`), each with its declared fallback stack — otherwise the flagship font silently never renders. Install the font packages the design.md declares (e.g. Fontsource) and verify the name matches what the package registers. The library's `font-sans` / `font-mono` / `font-display` utilities compile to `inherit` on purpose (a ramp, never a face): the app declares its own theme keys in a plain `@theme` pointing at the emitted slots — `--font-body: var(--font-sans)`, `--font-heading: var(--font-display)`, `--font-code: var(--font-mono)` — and sets the ambient face on `body` in `@layer base`.
41
+ - **Every authored token group reaches a named consumer.** An authored ramp with no emitter vanishes without a trace — wire each group (colors → theme classes/utilities, ramp → text styles, elevation → radius/shadow utilities, motion → duration/easing vars) and let the ship gate's reach check prove it. The personality families reach the library by construction; the app's `--duration-*` / `--ease-*` reach app-owned animation code, or they are unused.
16
42
  - **Compile the emitted CSS once per change** — a stray utility string inside a CSS comment can defeat a fully green byte-level check.
17
- - **One linear precedence, later wins:** package defaults → brief bindings → recorded deviations. Values resolve at emission; the runtime cascade carries only contexts (theme, density).
43
+ - **One linear precedence, later wins:** package defaults → brief bindings → recorded deviations. Values resolve at emission; the runtime cascade carries only contexts (theme, density, `data-mode="inline"`).
18
44
 
19
45
  ## Stack neutrality
20
46
 
21
- The artifact is plain CSS custom properties — every stack consumes it. Under `primitives: nurix`, Tailwind maps the properties through the theme config; under `native`, the incumbent styling layer reads the same variables. No emission step is React-specific.
47
+ `design.tokens.css` is plain CSS custom properties — every stack consumes it. Under `primitives: nurix`, Tailwind maps the properties through the theme config; under `native`, the incumbent styling layer reads the same variables. No emission step is React-specific. `personality.css` is the one stack-bound artifact: compiled by and for `@nurix/components`, it does not exist under `native`.
@@ -4,7 +4,7 @@ The same machinery runs on existing UIs, under a preservation contract.
4
4
 
5
5
  ## Judge — a user-invoked service
6
6
 
7
- A critique of an existing UI against the brief, the project's `design.md`, the archetype checklists, and the workspace's taste profile (`get_preferences`), producing a **severity-ranked (never scored) report**. Judge is a service the user asks for — never a self-check the skill runs on its own output. Read the actual rendered state (screenshots the user provides, or captures the user asks for); refuse to critique from source alone when the ask is "does it look right", and never imagine what unrendered code looks like. Each finding: severity, the on-screen element, what's wrong, the exact fix.
7
+ A critique of an existing UI against the brief, the project's `design.md` (the path `--project .` resolves), the archetype checklists, and the workspace's taste profile (`get_preferences`), producing a **severity-ranked (never scored) report**. Judge is a service the user asks for — never a self-check the skill runs on its own output. Read the actual rendered state (screenshots the user provides, or captures the user asks for); refuse to critique from source alone when the ask is "does it look right", and never imagine what unrendered code looks like. Each finding: severity, the on-screen element, what's wrong, the exact fix.
8
8
 
9
9
  ## Improve — judge + the fix pass
10
10
 
@@ -21,7 +21,7 @@ Improve runs then proceed through the standard steps as scoped: intake (against
21
21
  A supported entry path, under **attestation and provenance**:
22
22
 
23
23
  - Record what the source is and whose it is: own product / public reference / third party.
24
- - Extract **structure and system, never pixels or assets**: roles, ramps, spacing, elevation, motion posture — into a `tier: project` design.md in the package schema, validator-checked.
24
+ - Extract **structure and system, never pixels or assets**: roles, ramps, spacing, elevation, motion posture — into a `tier: project` design.md in the package schema at the path `--project .` resolves, validator-checked.
25
25
  - Write a provenance block into the extracted file (`source-package: extracted@<date>` plus the attestation lines).
26
26
  - Record what **is**, never what should be — extraction is documentation, not improvement; the improve pass comes after, separately, if asked.
27
27
  - Admission of an extracted package into the shared roster later requires the provenance check to pass; third-party extractions stay project-local.
@@ -22,11 +22,11 @@ No database — three constraints, values yours: **categorical** series need dis
22
22
 
23
23
  ## Number, date, unit formatting
24
24
 
25
- Tabular figures in tables and KPIs; thousands separators; unit or currency attached to the number; consistent decimals per column; large values truncate meaningfully (`1.2M`, never `1,200,000` in a tile); timestamps show relative when recent and absolute on hover; timezones explicit anywhere ambiguity costs money.
25
+ Tabular figures in tables and KPIs; thousands separators; unit or currency attached to the number; consistent decimals per column; large values truncate meaningfully (`1.2M`, never `1,200,000` in a tile); timestamps show relative when recent and absolute on hover (under `nurix`, that is `<Timestamp format="auto">`, never a hand-rolled formatter); timezones explicit anywhere ambiguity costs money.
26
26
 
27
27
  ## Spacing & density
28
28
 
29
- The 2/4/6/8 scale, period. Operator surfaces default **compact** (data rows 32–36px) with a comfortable mode only when `product.md` says reading, not scanning. Density is a per-project decision made once, not a per-surface mood.
29
+ The 2/4/6/8 scale, period. Density is a per-project decision made once, not a per-surface mood — recorded as the `density` trait: **compact** for operator surfaces that scan (controls and data rows land at 32px), `regular` or `airy` only when `product.md` says reading. Under `nurix` the trait reaches controls and collection surfaces through the library's `--density-*` tokens, never through per-surface `p-*` literals.
30
30
 
31
31
  ## Radius & stroke
32
32
 
@@ -34,7 +34,7 @@ Radius is identity — sharp (0–2px), soft (4–6px), or round (10px+) — cho
34
34
 
35
35
  ## Loading, skeleton, progress
36
36
 
37
- Skeletons for layout-known loads, spinners only for unknowable ones, optimistic updates for high-frequency mutations with undo. Skeletons mirror the real layout's bones (no generic gray blocks). Nothing pulses faster than 1.2s.
37
+ Skeletons for layout-known loads, spinners only for unknowable ones, optimistic updates for high-frequency mutations with undo. Skeletons mirror the real layout's bones (no generic gray blocks). Nothing pulses faster than 1.2s; under `nurix` a status pulse is `<Chip prefix="dot" pulse>`, never `animate-pulse` (a hardcoded keyframe neither `pace` nor the inline clamp can reach), and loaders keep breathing inside inline collections by design.
38
38
 
39
39
  ## Empty states
40
40
 
@@ -2,17 +2,17 @@
2
2
 
3
3
  Read the brief and choose from the menus: one identity package (the design.md system), any axis overrides the brief demands, layout blueprints per surface, and the product domain for content. Every choice is one recorded line.
4
4
 
5
- **Returning workspace first.** If the repo carries a `workspaceId` (or the Apollo context supplies one), open with `get_preferences`: the workspace's `design.md`, `taste.md`, and `product.md` rows are the session's context — then run the drift check (compare the repo's `design.md` against the server row; on mismatch, tell the user which is newer and ask). A workspace with a recorded identity re-enters the steps with that identity already chosen; the rules below are for first intake.
5
+ **Returning workspace first.** If the repo carries a `workspaceId` (or the Apollo context supplies one), open with `get_preferences`: the workspace's `design.md`, `taste.md`, and `product.md` rows are the session's context — then run the drift check (compare the repo's fork — `node scripts/validate-design-md.mjs --project .` resolves its one path from the repo shape and fails closed on a copy anywhere else — against the server row; on mismatch, tell the user which is newer and ask). A workspace with a recorded identity re-enters the steps with that identity already chosen; the rules below are for first intake.
6
6
 
7
7
  ## Rules
8
8
 
9
9
  - **The brief's own words outrank any database default.** "Beautiful" means the ceiling, not the safe row.
10
10
  - **Adjectives become referents.** When the brief speaks in adjectives ("premium", "editorial"), name the 2–3 real products the brief is pointing at and pick the package whose recorded identity sits in that class — unnamed ambition becomes beige.
11
11
  - **Search is a retrieval aid, never a router.** `search_catalog` (family `design_md`) with the brief's segment/keyword resemblance; shortlist 2–3 results by their head metadata (feel, reads-as, keywords), then choose reading the brief. After the choice, `get_entry` **once**: the chosen package's full design.md enters context and **stays in view through step 4**; unchosen candidates are never fetched.
12
- - **The escape hatch fires mechanically.** No catalog hit, plus a recorded one-line reason why the closest package fails → author a one-off project design.md in the package schema, `tier: project` — the validator is the schema authority and checks it like any library entry. This path is expected to be common; flag the file as a curation candidate for the roster.
12
+ - **The escape hatch fires mechanically.** No catalog hit, plus a recorded one-line reason why the closest package fails → author a one-off project design.md in the package schema, `tier: project`, at the path `--project .` resolves — the validator is the schema authority and checks it like any library entry. This path is expected to be common; flag the file as a curation candidate for the roster.
13
13
  - **Record the identity.** First intake ends with `record`: the identity choice as a decision line, and the resolved fork pushed as the workspace's `design.md` preference row. The `workspaceId` is committed to the repo if it is not ambient. Design taste is workspace-level — an app never gets its own.
14
14
  - **Axis overrides fetch or read the single named entry** — color/font/gradient/chart-style overrides `get_entry` their dictionary entry; elevation/motion/iconography overrides read the named local entry under `data/`.
15
- - **Set the primitives switch** — write one `primitives: nurix | native` line into the project's `design.md`. Default by the probe: the project resolves `@nurix/components` (check package.json / lockfile / node_modules) → `nurix`; otherwise — existing apps, foreign stacks, no library access — → `native`. Set once here; nothing downstream re-asks. Also write `source-package: <slug>` (or `authored` for escape-hatch one-offs).
15
+ - **Set the primitives switch** — write one `primitives: nurix | native` line into the project's `design.md` (the path `--project .` resolves; first intake creates the file there). Default by the probe: the project resolves `@nurix/components` (check package.json / lockfile / node_modules) → `nurix`; otherwise — existing apps, foreign stacks, no library access — → `native`. Set once here; nothing downstream re-asks. Also write `source-package: <slug>` (or `authored` for escape-hatch one-offs).
16
16
  - **Write or update `product.md`**: users and their jobs, roles/permissions, action frequency, risk level, accessibility commitments, brand anti-references. Package choice, UX decisions, motion intensity, and demo data all cite it. Push it as a preference row too — Studio shows it.
17
17
  - **The taste profile biases every choice** — `get_preferences` returns the workspace's `taste.md` (per-dimension feel/avoid lines); user reactions to shipped output update it via `record`. No profile yet = no bias, never a block.
18
18
  - **Stated-choice awareness:** if prior package picks are visible in the workspace, name them and say in one line why this pick repeats or differs — awareness, never forced rotation.
@@ -29,12 +29,13 @@ Step 1 ends with a preview block:
29
29
  - domain corpus
30
30
  - surface map with archetypes
31
31
  - planned deviations
32
+ - the personality traits (under `nurix`): the six-trait reading of §4–§7 — density, pace, edge, depth, texture, tone — naming only what differs from neutral, written into the design-values block so the library compiles it
32
33
 
33
34
  When a user is present, pause here **once** for confirm-or-redirect — a wrong pick costs one message now instead of a full build. Autonomous runs proceed. Either way the block goes to workspace memory — `record` it as decision lines heading this run's choices, with the app as context.
34
35
 
35
36
  ## Outputs
36
37
 
37
- - `design.md` (project fork: chosen package + switch line + provenance + brief bindings; deviations accrue as they are recorded) — pushed as the workspace preference row
38
+ - `design.md` in the design folder (`packages/common/` at the base of a multi-package workspace, `src/common/` in a single-package repo) — the project fork: chosen package + switch line + provenance + brief bindings; deviations accrue as they are recorded — pushed as the workspace preference row
38
39
  - `product.md` written/updated and pushed
39
40
  - the identity pin and the preview block recorded
40
41
  - the chosen package's full design.md held in context
@@ -4,25 +4,31 @@ The project ships with its project-local artifacts — `design.md` (the resolved
4
4
 
5
5
  ## Tier 1 — fails closed
6
6
 
7
- Every check a binary predicate. Run the validator (`node scripts/validate-design-md.mjs <project design.md> --mode ship`) plus the build itself; all of these must hold:
7
+ Every check a binary predicate. Run the validator (`node scripts/validate-design-md.mjs --project . --mode ship`) plus the build itself; all of these must hold:
8
8
 
9
9
  - **No placeholder text or scaffold stubs** anywhere (lorem, TODO, "Coming soon", empty `href="#"` walls).
10
10
  - **Data plausibility** against the corpus's declared invariants: aggregates reconcile with their visible rows; legal ranges hold; no degenerate repetition in visible rows (surname clusters, identical amounts).
11
11
  - **Accessibility floor, mechanical**: contrast computed on the emitted token pairs in *both* themes; focus visible on every interactive element; `prefers-reduced-motion` respected.
12
12
  - **Every declared surface reachable** — each surface in the structure doc has a live route/nav path.
13
- - **`design.md` present at the repo base** and the token artifact recompiles from it to a byte-match (the validator's drift check).
13
+ - **The project fork resolves** — `--project .` finds `design.md` at `packages/common/` in a multi-package workspace or `src/common/` in a single-package repo, and a copy anywhere else fails the resolution — and the token artifact recompiles from it to a byte-match (the validator's drift check).
14
14
  - **The pipe checks**:
15
15
  - the token artifact carries 100% coverage and components consume tokens — no raw color/size literals in component code;
16
16
  - fonts bound to installed packages under registered family names (a family that never resolves is a fail);
17
17
  - every authored token group reaches a named consumer — an emitted ramp nobody consumes is a fail;
18
18
  - the emitted CSS actually compiles;
19
19
  - charts and status surfaces consume the package's tokens (a dark dashboard with hardcoded light charts fails here).
20
+ - **The library reach checks** (`primitives: nurix`):
21
+ - `personality.css` recompiles from the design.md's personality block to a byte-match (the validator's `dmv-12`); a design.md with no block fails nothing, and its `dmw-05` warn appears in the ship report;
22
+ - no `data-theme` selector or attribute in app CSS or HTML — the theme is the `.dark` class;
23
+ - no `duration-<n>` utility, no `transition-all`, and no literal `ms` inside a transition or animation declaration in app code — the seam carries timing;
24
+ - no `<time` element outside `<Timestamp>`; no `truncate` or `line-clamp-` utility (the `clip` family replaces them);
25
+ - every interactive element under 24px in either rendered dimension carries a `hit-area` utility.
20
26
 
21
27
  A Tier-1 failure blocks the ship; fix and re-run. Binary phrasing is the contract — anything that would need "use sparingly" grading does not belong in this tier.
22
28
 
23
29
  ## Tier 2 — flags, never gates
24
30
 
25
- Sweep `data/tells.csv` (the operator tell list), at this step only, plus the motion tells (`transition: all`; `ease-in` on an entrance; animation attached to a keyboard-triggered action). Flags cannot fail the build and trigger no in-run judging: each is **fixed or dismissed with one recorded line** through `record`, and every flag appears in the ship report for the human's eyes — numbers point eyes; they do not replace them. The validator may run this sweep warn-only after compose-step edits; nothing new ever fails the build mid-composition.
31
+ Sweep `data/tells.csv` (the operator tell list), at this step only, plus the motion tells (`transition: all`; `ease-in` on an entrance; animation attached to a keyboard-triggered action). Under `nurix`, the reach tells join them: a `p-*` / `gap-*` literal on a collection container; a relative-time formatter in app code; a `fetch` inside an effect feeding a `<Select>`; a repeated-row container without `data-mode="inline"`; a `success` / `warning` / `info` hue declared anywhere but `design.md`. Flags cannot fail the build and trigger no in-run judging: each is **fixed or dismissed with one recorded line** through `record`, and every flag appears in the ship report for the human's eyes — numbers point eyes; they do not replace them. The validator may run this sweep warn-only after compose-step edits; nothing new ever fails the build mid-composition.
26
32
 
27
33
  ## The ship report
28
34
 
@@ -6,19 +6,37 @@
6
6
  // block) and its own oklch → sRGB → WCAG-contrast math. FAIL closes; WARN never does.
7
7
  //
8
8
  // CLI:
9
- // node validate-design-md.mjs <file|folder> [--mode admission|build|ship] [--json] [--emit]
9
+ // node validate-design-md.mjs (<file|folder> | --project [dir]) [--mode admission|build|ship] [--json]
10
+ // [--emit] [--emit-personality] [--write] [--lib <folder>]
10
11
  //
11
12
  // <file> validate one design.md.
13
+ // --project [dir]
14
+ // validate the project's own fork, located by the repo's shape and never by search: a
15
+ // multi-package workspace (pnpm-workspace.yaml, or package.json `workspaces`) keeps it at
16
+ // packages/common/design.md, a single-package repo at src/common/design.md. dir defaults
17
+ // to the cwd. Fails closed when the file is absent, naming the expected path and any copy
18
+ // found at the other conventional spot or at the repo base.
19
+ // --write with --emit / --emit-personality: write the artifact beside the design.md
20
+ // (design.tokens.css / personality.css) instead of printing it.
12
21
  // <folder> validate every .md in the folder tree (skipping SCHEMA.md) plus index.csv integrity (dmv-07)
13
22
  // and the tree-wide naming scan (dmv-09).
14
23
  // --mode admission and build run the same check set; ship additionally runs dmv-11 (drift:
15
- // re-emit and byte-compare against a design.tokens.css sitting beside the input file).
24
+ // re-emit and byte-compare against a design.tokens.css sitting beside the input file) and
25
+ // dmv-12's drift half (the same for personality.css when a personality block is declared).
16
26
  // Default: build.
17
27
  // --json machine report to stdout: {file, fails:[{id,detail}], warns:[{id,detail}]}
18
28
  // (an array of those objects in folder mode; folder-level checks attach to the folder path).
19
29
  // --emit compile the token artifact from the design-values block to stdout (byte-stable across
20
30
  // runs; redirect to design.tokens.css). Requires a single file; validation runs first and
21
31
  // any FAIL suppresses the artifact (report goes to stderr). Warns go to stderr.
32
+ // --emit-personality
33
+ // compile the personality artifact to stdout (redirect to personality.css): the design.md's
34
+ // `personality:` traits handed to @nurix/components' own compiler (`compileToCss` from
35
+ // `@nurix/components/lib/personality`, primitives: nurix only). Same gating as --emit.
36
+ // --lib <folder>
37
+ // where @nurix/components is installed, when the resolver cannot tell: it walks up from the
38
+ // design.md, then one and two levels into the workspace (packages/<app>); a pnpm root hoists
39
+ // nothing, and several installed versions is an ambiguity only the app package resolves.
22
40
  //
23
41
  // Exit codes: 0 pass (warns allowed), 1 any FAIL.
24
42
  //
@@ -27,7 +45,8 @@
27
45
  // schema: design-md/v2; required keys per tier; id matches filename.
28
46
  // dmv-02 the 12 numbered sections present, in order, non-empty; one trailing `yaml design-values` block.
29
47
  // dmv-03 the design-values block parses as YAML.
30
- // dmv-04 coverage: all 36 color roles × both themes (values parseable); type ramp steps
48
+ // dmv-04 coverage: all 36 color roles × both themes (values parseable), plus the optional `info` role
49
+ // declared in both themes or neither; type ramp steps
31
50
  // display/h1/h2/body/small/caption/data each with size+line+weight+family; register-scope;
32
51
  // elevation (radius, hairline, shadow, dark-ladder); motion (duration, easing, entrance-never,
33
52
  // identity-moments); iconography (set, stroke, grid); charts (categorical, sequential,
@@ -42,6 +61,15 @@
42
61
  // dmv-10 (tier: project only) primitives present, exactly nurix|native; source-package present.
43
62
  // dmv-11 (--mode ship) drift: the token artifact recompiled from the design.md byte-matches the
44
63
  // design.tokens.css beside it.
64
+ // dmv-12 the personality block (primitives: nurix): a mapping over the six closed-vocabulary traits
65
+ // density|pace|edge|depth|texture|tone (pace a number 0..1); absent under native. In --mode
66
+ // ship, personality.css beside the design.md byte-matches the recompiled artifact, and no
67
+ // personality.css exists without a block (a hand-authored layer).
68
+ //
69
+ // WARN checks (never close): dmw-01 anti-cream backgrounds · dmw-02 neutral tint · dmw-03 untinted shadows ·
70
+ // dmw-04 motion budgets · dmw-05 personality block absent on a nurix project (the library's neutral
71
+ // traits ship) · dmw-06 `info` role undeclared on a nurix project (--tone-info falls back to the
72
+ // library's own hue).
45
73
  //
46
74
  // dmv-05 — the exact contrast pair table (both themes; WCAG 2.1 relative-luminance contrast; colors
47
75
  // resolved oklch → OKLab → linear sRGB, gamut-clipped to [0,1], gamma-encoded, then the WCAG formula;
@@ -77,8 +105,8 @@
77
105
  // background (checked only where chroma ≥ 0.01 — hue is noise near gray).
78
106
  // dmw-07 accent chroma delta between themes > 0.06 (identity likely not held across themes).
79
107
  //
80
- // Emitted artifact shape (deterministic, byte-stable): a `:root, :root[data-theme="light"]` block and a
81
- // `:root[data-theme="dark"]` block (specificity armor — both selectors reach 0,2,0 via the attribute
108
+ // Emitted artifact shape (deterministic, byte-stable): a `:root:not(.dark)` block and a
109
+ // `:root.dark` block (specificity armor — both selectors reach 0,2,0 via the attribute
82
110
  // form, so a component library's later bare `:root` cannot silently defeat a theme). The light block
83
111
  // carries the 36 roles as --<role> plus every theme-invariant group: --font-display/sans/mono (registered
84
112
  // names quoted, declared fallback stacks appended), --text-<step>-size/-line/-weight (+ -family/-tracking/
@@ -86,9 +114,9 @@
86
114
  // (--space-base, --sidebar-width[-collapsed], --topbar-height, --side-panel-width[-wide],
87
115
  // --content-max-width; unmapped keys emit as --layout-<key>). The dark block carries the 36 dark roles.
88
116
 
89
- import { readFileSync, existsSync, statSync, readdirSync, realpathSync } from "node:fs";
117
+ import { readFileSync, writeFileSync, existsSync, statSync, readdirSync, realpathSync } from "node:fs";
90
118
  import { join, resolve, basename, dirname, relative, sep } from "node:path";
91
- import { fileURLToPath } from "node:url";
119
+ import { fileURLToPath, pathToFileURL } from "node:url";
92
120
  import process from "node:process";
93
121
 
94
122
  // ---------------------------------------------------------------------------
@@ -114,6 +142,20 @@ const SECTIONS = [
114
142
 
115
143
  const RAMP_STEPS = ["display", "h1", "h2", "body", "small", "caption", "data"];
116
144
  const FONT_SLOTS = ["display", "sans", "mono"];
145
+ // Optional color roles: emitted only when declared (both themes or neither). `info` completes the
146
+ // success/warning/info trio @nurix/components' status dots read through --tone-*.
147
+ const OPTIONAL_COLOR_ROLES = ["info"];
148
+ // The personality block — six closed-vocabulary traits @nurix/components compiles into the token
149
+ // families its components read (lib/personality). The library owns the vocabulary; this mirrors it.
150
+ const PERSONALITY_ORDER = ["density", "pace", "edge", "depth", "texture", "tone"];
151
+ const PERSONALITY_TRAITS = {
152
+ density: ["compact", "regular", "airy"],
153
+ pace: null, // the one scalar: 0 still · 0.5 the library's neutral timing · 1 lively
154
+ edge: ["sharp", "regular", "soft"],
155
+ depth: ["flat", "layered", "elevated"],
156
+ texture: ["clean", "grainy"],
157
+ tone: ["neutral", "expressive"],
158
+ };
117
159
  const THEMES = ["light", "dark"];
118
160
  const TIERS = ["default", "variant", "brand", "project"];
119
161
  const FRONT_KEYS_BASE = ["schema", "id", "tier", "segments", "keywords", "sourced", "sources", "license"];
@@ -695,6 +737,48 @@ function checkCoverage(values, report) {
695
737
  else if (layout.base == null) missing("layout.base");
696
738
  }
697
739
 
740
+ // dmv-12 — the personality block, and the two nurix-project warns (dmw-05 block absent, dmw-06 info absent).
741
+ function checkPersonality(front, values, report) {
742
+ const isNurixProject = !!front && front.tier === "project" && front.primitives === "nurix";
743
+ const p = values.personality;
744
+ if (p === undefined) {
745
+ if (isNurixProject) {
746
+ warn(report, "dmw-05", "personality block absent — @nurix/components ships its neutral traits (regular · pace 0.5 · regular · layered · grainy · neutral); declare the traits §4–§7 imply so density, motion, radius and depth reach the components");
747
+ }
748
+ } else if (!isObj(p)) {
749
+ fail(report, "dmv-12", "personality must be a mapping of traits");
750
+ } else {
751
+ if (!(front && front.primitives === "nurix")) {
752
+ fail(report, "dmv-12", "personality is compiled by @nurix/components — under primitives: native nothing reads it; drop the block or switch primitives");
753
+ }
754
+ for (const [k, v] of Object.entries(p)) {
755
+ if (!(k in PERSONALITY_TRAITS)) {
756
+ fail(report, "dmv-12", `personality.${k} is not a trait (${PERSONALITY_ORDER.join("|")})`);
757
+ } else if (k === "pace") {
758
+ const n = typeof v === "number" ? v : Number(v);
759
+ if (typeof v === "boolean" || !Number.isFinite(n) || n < 0 || n > 1) {
760
+ fail(report, "dmv-12", `personality.pace must be a number 0..1 (0 still · 0.5 neutral · 1 lively), got "${v}"`);
761
+ }
762
+ } else if (!PERSONALITY_TRAITS[k].includes(v)) {
763
+ fail(report, "dmv-12", `personality.${k} must be one of ${PERSONALITY_TRAITS[k].join("|")}, got "${v}"`);
764
+ }
765
+ }
766
+ }
767
+
768
+ const color = isObj(values.color) ? values.color : {};
769
+ for (const role of OPTIONAL_COLOR_ROLES) {
770
+ const declared = THEMES.map((t) => isObj(color[t]) && color[t][role] != null);
771
+ if (declared[0] !== declared[1]) {
772
+ fail(report, "dmv-04", `color.${role} is declared in one theme only — a role is declared in both themes or neither`);
773
+ } else if (declared[0]) {
774
+ const bad = THEMES.filter((t) => !parseColor(color[t][role]));
775
+ if (bad.length) fail(report, "dmv-04", `color.${role} unparseable in ${bad.join(", ")}`);
776
+ } else if (isNurixProject) {
777
+ warn(report, "dmw-06", `color.${role} undeclared — @nurix/components' --tone-${role} falls back to the library's own hue beside your success/warning; declare it in both themes`);
778
+ }
779
+ }
780
+ }
781
+
698
782
  // dmv-05 — contrast on every declared pairing, both themes (pair table in the header comment).
699
783
  function checkContrast(values, report) {
700
784
  const color = isObj(values.color) ? values.color : {};
@@ -891,14 +975,35 @@ function req(values, path) {
891
975
 
892
976
  const cssValue = (v) => String(v);
893
977
 
978
+ // The project fork's one path, decided by the repo's shape and never by search: a multi-package
979
+ // workspace (pnpm-workspace.yaml, or package.json `workspaces`) keeps it at packages/common/design.md;
980
+ // a single-package repo at src/common/design.md. `path` is null when absent; `misplaced` lists copies
981
+ // at the other conventional spot or the repo base, so the caller can name what to move.
982
+ export function resolveProjectDesignMd(root) {
983
+ const abs = resolve(root);
984
+ let hasWorkspaces = existsSync(join(abs, "pnpm-workspace.yaml"));
985
+ if (!hasWorkspaces && existsSync(join(abs, "package.json"))) {
986
+ try { hasWorkspaces = JSON.parse(readFileSync(join(abs, "package.json"), "utf8")).workspaces != null; } catch { /* an unreadable manifest reads as single-package */ }
987
+ }
988
+ const shape = hasWorkspaces ? "multi-package" : "single-package";
989
+ const expected = join(abs, hasWorkspaces ? "packages/common/design.md" : "src/common/design.md");
990
+ const other = join(abs, hasWorkspaces ? "src/common/design.md" : "packages/common/design.md");
991
+ const misplaced = [join(abs, "design.md"), other].filter((f) => existsSync(f));
992
+ return { path: existsSync(expected) ? expected : null, expected, shape, misplaced };
993
+ }
994
+
894
995
  export function emitTokens(front, values) {
895
996
  const id = front && typeof front.id === "string" ? front.id : "unknown";
896
997
  const out = [];
897
998
  out.push(`/* design.tokens.css — compiled from ${id} (design-md/v2) by validate-design-md.mjs — derived artifact: edit the design.md, never this file */`);
898
999
  out.push("");
899
- out.push(':root, :root[data-theme="light"] {');
1000
+ out.push(':root:not(.dark) {');
900
1001
  out.push(" /* color roles */");
901
1002
  for (const role of COLOR_ROLES) out.push(` --${role}: ${cssValue(req(values, ["color", "light", role]))};`);
1003
+ for (const role of OPTIONAL_COLOR_ROLES) {
1004
+ const v = values.color.light[role];
1005
+ if (v != null) out.push(` --${role}: ${cssValue(v)};`);
1006
+ }
902
1007
  out.push("");
903
1008
  out.push(" /* typography */");
904
1009
  for (const slot of FONT_SLOTS) {
@@ -939,13 +1044,135 @@ export function emitTokens(front, values) {
939
1044
  }
940
1045
  out.push("}");
941
1046
  out.push("");
942
- out.push(':root[data-theme="dark"] {');
1047
+ out.push(':root.dark {');
943
1048
  out.push(" /* color roles */");
944
1049
  for (const role of COLOR_ROLES) out.push(` --${role}: ${cssValue(req(values, ["color", "dark", role]))};`);
1050
+ for (const role of OPTIONAL_COLOR_ROLES) {
1051
+ const v = values.color.dark[role];
1052
+ if (v != null) out.push(` --${role}: ${cssValue(v)};`);
1053
+ }
945
1054
  out.push("}");
946
1055
  return out.join("\n") + "\n";
947
1056
  }
948
1057
 
1058
+ // The personality spec exactly as the library's compiler takes it: version 1 plus the declared traits,
1059
+ // in trait order, pace numeric. Null when the design.md declares no block.
1060
+ export function personalitySpec(values) {
1061
+ const p = values && isObj(values.personality) ? values.personality : null;
1062
+ if (!p) return null;
1063
+ const spec = { version: 1 };
1064
+ for (const k of PERSONALITY_ORDER) {
1065
+ if (p[k] === undefined) continue;
1066
+ spec[k] = k === "pace" ? Number(p[k]) : p[k];
1067
+ }
1068
+ return spec;
1069
+ }
1070
+
1071
+ // Every @nurix/components install a design.md can see: the upward walk from `fromDir` (a plain install,
1072
+ // or an app package inside a workspace), then one and two levels down into the workspace (packages/<app>)
1073
+ // — a pnpm root hoists nothing, so a repo-base design.md sees the library only through its packages.
1074
+ // Deduped by real path; distinct versions surface as an ambiguity for --lib.
1075
+ function findLibraryInstalls(fromDir) {
1076
+ const found = new Map();
1077
+ const consider = (dir) => {
1078
+ const pkgPath = join(dir, "package.json");
1079
+ if (!existsSync(pkgPath)) return;
1080
+ let real;
1081
+ try { real = realpathSync(dir); } catch { return; }
1082
+ if (found.has(real)) return;
1083
+ found.set(real, { dir: real, pkg: JSON.parse(readFileSync(pkgPath, "utf8")) });
1084
+ };
1085
+ const roots = []; // every package/workspace root passed on the way up, nearest first
1086
+ let dir = fromDir;
1087
+ for (;;) {
1088
+ consider(join(dir, "node_modules", "@nurix", "components"));
1089
+ if (found.size) return [...found.values()];
1090
+ if (existsSync(join(dir, "package.json")) || existsSync(join(dir, "pnpm-workspace.yaml"))) roots.push(dir);
1091
+ const parent = dirname(dir);
1092
+ if (parent === dir) break;
1093
+ dir = parent;
1094
+ }
1095
+ const children = (base) => {
1096
+ try { return readdirSync(base).filter((n) => !n.startsWith(".") && n !== "node_modules" && statSync(join(base, n)).isDirectory()); }
1097
+ catch { return []; }
1098
+ };
1099
+ for (const root of roots) {
1100
+ for (const a of children(root)) {
1101
+ consider(join(root, a, "node_modules", "@nurix", "components"));
1102
+ for (const b of children(join(root, a))) consider(join(root, a, b, "node_modules", "@nurix", "components"));
1103
+ }
1104
+ if (found.size) break;
1105
+ }
1106
+ return [...found.values()];
1107
+ }
1108
+
1109
+ // Resolve @nurix/components' personality compiler from the project that owns the design.md — the
1110
+ // library owns the spec and the compiler; this script only carries the traits to it. Follows the
1111
+ // package's ./lib/personality export; returns the module and the library version it came from.
1112
+ async function loadPersonalityCompiler(fromDir, libDir) {
1113
+ const installs = findLibraryInstalls(libDir || fromDir);
1114
+ if (!installs.length) {
1115
+ throw new Error(`@nurix/components is not installed above or below ${libDir || fromDir} — the personality artifact needs the library's compiler (primitives: nurix); pass --lib <app package folder> if it lives elsewhere`);
1116
+ }
1117
+ const versions = [...new Set(installs.map((i) => i.pkg.version))];
1118
+ if (versions.length > 1) {
1119
+ throw new Error(`several @nurix/components versions are installed under ${libDir || fromDir} (${versions.join(", ")}) — pass --lib <app package folder> to name the one the app builds with`);
1120
+ }
1121
+ const { dir, pkg } = installs[0];
1122
+ const entry = pkg.exports && pkg.exports["./lib/personality"];
1123
+ const rel = typeof entry === "string" ? entry : entry && (entry.import || entry.default);
1124
+ if (!rel) throw new Error(`@nurix/components at ${dir} exports no ./lib/personality`);
1125
+ const mod = await import(pathToFileURL(join(dir, rel)).href);
1126
+ if (typeof mod.compileToCss !== "function") throw new Error("@nurix/components/lib/personality exports no compileToCss");
1127
+ return { mod, version: pkg.version };
1128
+ }
1129
+
1130
+ // The personality artifact: the library's compileToCss over the design.md's traits, behind one header
1131
+ // line naming the source. Null when no block is declared; throws when the library rejects the spec or
1132
+ // cannot be resolved.
1133
+ export async function compilePersonalityArtifact(filePath, front, values, opts = {}) {
1134
+ const spec = personalitySpec(values);
1135
+ if (!spec) return null;
1136
+ const { mod: lib, version } = await loadPersonalityCompiler(dirname(filePath), opts.lib);
1137
+ if (typeof lib.validatePersonality === "function") {
1138
+ const verdict = lib.validatePersonality(spec);
1139
+ if (verdict && verdict.ok === false) {
1140
+ const issues = (verdict.issues || []).map((i) => `${i.path} ${i.message}`).join("; ");
1141
+ throw new Error(`personality rejected by @nurix/components: ${issues}`);
1142
+ }
1143
+ }
1144
+ const id = front && typeof front.id === "string" ? front.id : "unknown";
1145
+ return `/* personality.css — compiled from ${id} (design-md/v2) by @nurix/components@${version} compileToCss via validate-design-md.mjs --emit-personality — derived artifact: edit the design.md, never this file */\n` + lib.compileToCss(spec);
1146
+ }
1147
+
1148
+ // dmv-12's drift half (--mode ship, single file): personality.css beside the design.md byte-matches the
1149
+ // recompiled artifact; an artifact with no block is a hand-authored layer and fails too.
1150
+ async function checkPersonalityDrift(filePath, report, opts = {}) {
1151
+ const sibling = join(dirname(filePath), "personality.css");
1152
+ const values = report._values;
1153
+ const declared = !!(values && isObj(values.personality));
1154
+ if (!declared) {
1155
+ if (existsSync(sibling)) fail(report, "dmv-12", "personality.css sits beside the design.md but no personality block is declared — the artifact is derived from the design.md, never authored");
1156
+ return;
1157
+ }
1158
+ let fresh;
1159
+ try {
1160
+ fresh = await compilePersonalityArtifact(filePath, report._front, values, opts);
1161
+ } catch (e) {
1162
+ fail(report, "dmv-12", `cannot recompile personality.css: ${e.message}`);
1163
+ return;
1164
+ }
1165
+ if (!existsSync(sibling)) {
1166
+ fail(report, "dmv-12", `personality.css not found beside ${basename(filePath)} — emit it with --emit-personality and commit it`);
1167
+ return;
1168
+ }
1169
+ const disk = readFileSync(sibling);
1170
+ const want = Buffer.from(fresh, "utf8");
1171
+ if (!disk.equals(want)) {
1172
+ fail(report, "dmv-12", `drift: personality.css does not byte-match the recompiled artifact (first divergence at byte ${firstDiff(disk, want)})`);
1173
+ }
1174
+ }
1175
+
949
1176
  function firstDiff(a, b) {
950
1177
  const n = Math.min(a.length, b.length);
951
1178
  for (let i = 0; i < n; i++) if (a[i] !== b[i]) return i;
@@ -984,6 +1211,7 @@ export function validateFile(filePath, mode = "build", opts = {}) {
984
1211
  }
985
1212
  if (front) checkFrontmatter(front, filePath, report);
986
1213
  }
1214
+ report._front = front;
987
1215
 
988
1216
  const body = fm ? fm.body : raw;
989
1217
  const { valueBlock } = checkSections(body, report);
@@ -998,8 +1226,10 @@ export function validateFile(filePath, mode = "build", opts = {}) {
998
1226
  }
999
1227
  }
1000
1228
 
1229
+ report._values = values;
1001
1230
  if (values) {
1002
1231
  checkCoverage(values, report);
1232
+ checkPersonality(front, values, report);
1003
1233
  checkContrast(values, report);
1004
1234
  checkFonts(values, report);
1005
1235
  runWarnChecks(values, report);
@@ -1128,7 +1358,7 @@ export function validateFolder(root, mode = "build") {
1128
1358
  // ---------------------------------------------------------------------------
1129
1359
 
1130
1360
  function usage(stream) {
1131
- stream.write("usage: node validate-design-md.mjs <file|folder> [--mode admission|build|ship] [--json] [--emit]\n");
1361
+ stream.write("usage: node validate-design-md.mjs (<file|folder> | --project [dir]) [--mode admission|build|ship] [--json] [--emit] [--emit-personality] [--write] [--lib <folder>]\n");
1132
1362
  }
1133
1363
 
1134
1364
  function printHuman(reports, stream) {
@@ -1147,22 +1377,41 @@ function printHuman(reports, stream) {
1147
1377
 
1148
1378
  const toJsonReport = (r) => ({ file: r.file, fails: r.fails, warns: r.warns });
1149
1379
 
1150
- function main() {
1380
+ async function main() {
1151
1381
  const args = process.argv.slice(2);
1152
1382
  let target = null;
1153
1383
  let mode = "build";
1154
1384
  let json = false;
1155
1385
  let emit = false;
1386
+ let emitPersonality = false;
1387
+ let write = false;
1388
+ let lib = null;
1389
+ let project = null;
1156
1390
  for (let i = 0; i < args.length; i++) {
1157
1391
  const a = args[i];
1158
1392
  if (a === "--mode") {
1159
1393
  mode = args[++i];
1160
1394
  } else if (a === "--json") json = true;
1161
1395
  else if (a === "--emit") emit = true;
1396
+ else if (a === "--emit-personality") emitPersonality = true;
1397
+ else if (a === "--write") write = true;
1398
+ else if (a === "--lib") lib = resolve(args[++i] || "");
1399
+ else if (a === "--project") project = args[i + 1] && !args[i + 1].startsWith("--") ? args[++i] : ".";
1162
1400
  else if (a === "--help" || a === "-h") { usage(process.stdout); process.exit(0); }
1163
1401
  else if (!a.startsWith("--") && target === null) target = a;
1164
1402
  else { process.stderr.write(`unknown argument: ${a}\n`); usage(process.stderr); process.exit(1); }
1165
1403
  }
1404
+ if (project !== null && target !== null) { process.stderr.write("--project resolves the design.md itself — drop the positional path\n"); process.exit(1); }
1405
+ if (project !== null) {
1406
+ const found = resolveProjectDesignMd(project);
1407
+ if (!found.path) {
1408
+ process.stderr.write(`no project design.md at ${found.expected} (${found.shape} repo: ${found.shape === "multi-package" ? "packages/common/" : "src/common/"} holds the design files)\n`);
1409
+ for (const stray of found.misplaced) process.stderr.write(` found ${stray} — move it to ${found.expected}\n`);
1410
+ process.exit(1);
1411
+ }
1412
+ target = found.path;
1413
+ }
1414
+ if (write && !emit && !emitPersonality) { process.stderr.write("--write needs --emit or --emit-personality\n"); process.exit(1); }
1166
1415
  if (!target) { usage(process.stderr); process.exit(1); }
1167
1416
  if (!["admission", "build", "ship"].includes(mode)) {
1168
1417
  process.stderr.write(`--mode must be admission|build|ship, got "${mode}"\n`);
@@ -1171,17 +1420,45 @@ function main() {
1171
1420
  const abs = resolve(target);
1172
1421
  if (!existsSync(abs)) { process.stderr.write(`no such file or folder: ${abs}\n`); process.exit(1); }
1173
1422
  const isDir = statSync(abs).isDirectory();
1174
- if (emit && isDir) { process.stderr.write("--emit requires a single design.md file\n"); process.exit(1); }
1423
+ if ((emit || emitPersonality) && isDir) { process.stderr.write("--emit / --emit-personality require a single design.md file\n"); process.exit(1); }
1175
1424
 
1176
1425
  const reports = isDir ? validateFolder(abs, mode) : [validateFile(abs, mode, { checkOwnName: true })];
1426
+ if (mode === "ship" && !isDir) await checkPersonalityDrift(abs, reports[0], { lib });
1177
1427
  const anyFail = reports.some((r) => r.fails.length > 0);
1178
1428
 
1429
+ if (emitPersonality) {
1430
+ if (anyFail) { printHuman(reports, process.stderr); process.exit(1); }
1431
+ let css = null;
1432
+ try {
1433
+ css = await compilePersonalityArtifact(abs, reports[0]._front, reports[0]._values, { lib });
1434
+ } catch (e) {
1435
+ process.stderr.write(`${e.message}\n`);
1436
+ process.exit(1);
1437
+ }
1438
+ if (css == null) { process.stderr.write("no personality block in the design-values block — nothing to emit\n"); process.exit(1); }
1439
+ if (write) {
1440
+ const out = join(dirname(abs), "personality.css");
1441
+ writeFileSync(out, css);
1442
+ process.stdout.write(`wrote ${out}\n`);
1443
+ } else {
1444
+ process.stdout.write(css);
1445
+ }
1446
+ if (reports[0].warns.length) printHuman(reports, process.stderr);
1447
+ process.exit(0);
1448
+ }
1449
+
1179
1450
  if (emit) {
1180
1451
  if (anyFail || !reports[0]._artifact) {
1181
1452
  printHuman(reports, process.stderr);
1182
1453
  process.exit(1);
1183
1454
  }
1184
- process.stdout.write(reports[0]._artifact);
1455
+ if (write) {
1456
+ const out = join(dirname(abs), "design.tokens.css");
1457
+ writeFileSync(out, reports[0]._artifact);
1458
+ process.stdout.write(`wrote ${out}\n`);
1459
+ } else {
1460
+ process.stdout.write(reports[0]._artifact);
1461
+ }
1185
1462
  if (reports[0].warns.length) printHuman(reports, process.stderr);
1186
1463
  process.exit(0);
1187
1464
  }
@@ -1197,4 +1474,6 @@ function main() {
1197
1474
 
1198
1475
  // realpath both sides: argv[1] keeps the invoking symlink (e.g. a ~/.claude/skills link),
1199
1476
  // while import.meta.url is the resolved file — a plain path compare silently skips main().
1200
- if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) main();
1477
+ if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
1478
+ main().catch((e) => { process.stderr.write(`${e.message}\n`); process.exit(1); });
1479
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nurix/etna",
3
- "version": "0.20.13",
3
+ "version": "0.20.15",
4
4
  "description": "Bootstrap a project with a Nurix harness — copies the harness's CLAUDE.md, skills, agents, rules, and hooks directly into the project from a bundled payload (no marketplace fetch, no network).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,5 +42,5 @@
42
42
  "agent",
43
43
  "plugin"
44
44
  ],
45
- "etnaFingerprint": "0eb49820003e"
45
+ "etnaFingerprint": "4dd6abe08c97"
46
46
  }