@sondalab/ui-kit 0.2.0 → 0.4.0

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.
@@ -0,0 +1,168 @@
1
+ # Sondalab Design Language — Family-Resemblance Spec
2
+
3
+ > **What this file is:** the family-resemblance rules that sit *above* the token contract — the shared grammar of layout, graphic motifs, type treatment, and editorial voice that makes every Sondalab surface (spexr, introspect, daos, future products) read as one package. Where `design-system-spec.md` is the machine contract (the canonical `--sl-*`/terminal values and their generators), this file is the human contract (how to *use* those values so products look related without looking identical).
4
+ > **Audience:** engineers and designers building or migrating any Sondalab UI surface; reviewers checking cross-product coherence.
5
+ > **Owner:** marcellobarile.
6
+ > **Companion files:** `design-system-spec.md` (token contract — the values this language spends), `design-system-plan.md` (token pipeline build sequence), the identity board artifact `https://claude.ai/code/artifact/abbebb7f-5778-4af6-a9b4-2b3f169ae20f` (visual thesis). The per-product application of this language is tracked in a separate implementation plan (writing-plans), not here.
7
+
8
+ ## Legend (status vocabulary)
9
+
10
+ - **Shipped / Delivered** — merged to the surface's production codebase, available to users.
11
+ - **PoC implemented (not delivered)** — built as a prototype, not merged/released.
12
+ - **Placeholder (not delivered)** — stub or scaffold only.
13
+
14
+ At authoring time (2026-08-22): spexr consumes the token architecture (delivered); introspect has adopted the shared type (delivered, type-only); daos landing and the sondalab-ui showcase are **not delivered** (this language spec is design only until the pass-1 plan lands).
15
+
16
+ ## Why this exists
17
+
18
+ Colour and type harmonisation alone (phase 2 tokens) does not make products feel like one suite. introspect after its type swap still "looks the same" because the values converged but the *grammar* did not: the same accent and font can carry two unrelated layouts. This document codifies the grammar — the reusable editorial decisions — so a new product (or a reviewer) can achieve family resemblance without re-deriving it.
19
+
20
+ The reference implementation is **daos landing** (`daos/docs/daos-landing.html`): it already expresses the intended editorial modernism (hairline structure, mono structural labels, oversized display type, inversion bands, real content over marketing copy). This spec extracts daos's vocabulary and generalises it; daos is the *source*, not the first target.
21
+
22
+ ## Decision 1 — Theme selector policy (two tiers)
23
+
24
+ Products reach Sondalab colour through one of two blessed patterns. This was previously decided twice, inconsistently (spexr uses `data-sl-theme`; introspect and daos use `[data-theme]`); it is now a policy keyed on substrate, not an ad-hoc per-product call.
25
+
26
+ - **Tier A — full theme adoption.** Set `data-sl-theme="light|dark|high-contrast"` on the root and consume the generated theme layer (`themes/*.css`) wholesale. Best for greenfield surfaces and chrome-heavy hosts that need every neutral. **Adopter:** spexr (its Theia chrome needs the full neutral set; see `spexr-theme-contribution.ts`).
27
+ - **Tier B — shared-root adoption.** Vendor only the shared `:root` layer of the token CSS (`--sl-*` type/space/radius/motion/etc., theme-independent). The product keeps its own `[data-theme]` colour layer, but **every colour value in that layer must resolve to a Sondalab value** (a product token equals an `--sl-*` value or an accent-registry value; no free-floating hex). Best for surfaces with an existing editorial palette. **Adopters:** introspect, daos.
28
+
29
+ **Why not one tier:** the generated theme layer is scoped `[data-sl-theme]`; a product on `[data-theme]` can never match that selector, so forcing Tier A on introspect/daos would mean rewriting their working colour layers for no visual gain. Tier B keeps their selector and pins their *values*.
30
+
31
+ **Compliance test (both tiers):** open the surface, sample any surface/text/accent colour, and confirm it equals a value in `sondalab.tokens.json` or the accent registry below. Tier is an implementation detail; value-equality is the invariant.
32
+
33
+ **Migration note (the loser):** Tier B is the default for products with a pre-existing palette. A Tier-B product should migrate to Tier A only if it later drops its own colour layer entirely (unlikely for introspect/daos). No forced migration.
34
+
35
+ ## Decision 2 — Product accent registry
36
+
37
+ Shared grammar, per-product accent. The brand default is amber (with a teal *depth* companion); each product may override the accent hue only. Everything else (neutrals, type, spacing, motion) is shared.
38
+
39
+ | Product | Accent (light / dark) | Source | Note |
40
+ |---|---|---|---|
41
+ | **Sondalab default / daos** | `#B56A0C` / `#F5A83C` (depth `#14606C` / `#35A0B4`) | `sondalab.tokens.json` `accent.*.default` | daos re-baselines from its near-amber `#9a6a15`/`#d29a44` to the exact token values. |
42
+ | **spexr** | `#5b6cff` / `#8b96ff` | `spexr-overrides.css` | Delivered. |
43
+ | **introspect** | `#2ee6c0` (single) | introspect `--cy` | ⚠ Differs from the Sondalab teal depth `#35A0B4`. **Resolved:** introspect's brighter mint-cyan is registered as its product accent (kept); it is not required to match `depth`. `depth` is the *default* product's teal, not a constraint on cyan-accented products. |
44
+
45
+ Machine values of this table: `packages/ui-kit/agent/accent-registry.json` (consumed by the compliance audit). The table above stays authoritative for the reasoning; `scripts/accent-registry.test.mjs` asserts product-set parity between the two.
46
+
47
+ Rule: a product picks **one** accent hue and derives hover/active/subtle from it; it never introduces a second chromatic accent. **Derivation is concrete:** the generator emits each accent step as a `color-mix()` off `--sl-accent-default` (e.g. `--sl-accent-hover: color-mix(in srgb, var(--sl-accent-default) 89%, black)`), so a product override sets **only the hue** and the family re-derives. A baked step *literal* is allowed only as an explicit per-(theme, step) override in `sondalab.tokens.json` `accent.*` carrying a reason — the escape hatch for the few steps a straight mix can't reproduce (e.g. dark-active's slight hue rotation). This is enforced advisorily today (a judgement note); the audit gains a mechanical derivation check in a following release (see `packages/ui-kit/agent/HANDOFF.md`). Semantic status colours (ok/warn/err) are separate from the accent and shared — but the `--sl-status-*` tokens are tuned for **on-canvas** use. On an inverted surface (a block whose background is the strongest neutral, e.g. an ink-on-paper output panel) they lose contrast; there, derive an on-ink status pair locally rather than using the canvas tokens (daos `.artifact .ok`/`.no` are a correct instance of this — kept as light-on-ink greens/reds, not swapped to the darker canvas tokens).
48
+
49
+ ## The family-resemblance layer
50
+
51
+ Each rule below carries a **compliance level per substrate** (see the matrix). Layout rules are expressed as **invariants** (a spacing scale, a rule weight, a measure, a type-scale relationship) — never as fixed templates — so a data-grid app with hard pixel columns can satisfy them as readily as a marketing page.
52
+
53
+ ### L1 — Type treatment
54
+
55
+ - **Three roles, fixed pairing.** Display/serif = `--sl-font-display` (Fraunces); body/UI sans = `--sl-font-sans` (Instrument Sans); mono = `--sl-font-mono` (Martian Mono). A surface may substitute the *mono* face for a narrower one where fixed pixel grids demand it (introspect keeps IBM Plex Mono; Martian is materially wider and overflows `ch`-clamped columns) — this is an `adapt`, not a violation, provided the substitute stays monospace and the sans/display roles are unchanged.
56
+ - **Structural labels are mono, uppercase, wide-tracked.** Eyebrows, section indices, meta, wordmark, badges, tags: `--sl-font-mono`, `text-transform: uppercase`, `letter-spacing` in the `0.14em–0.22em` band, size `--sl-text-xs`. This mono-label motif is the single strongest family signal — a surface reads as Sondalab primarily through it.
57
+ - **Headings.** `font-weight: 600`, `text-wrap: balance`, `letter-spacing: -0.02em`, `line-height: --sl-line-tight`. Sizes step off the shared scale (`--sl-text-xl`/`--sl-text-2xl`) for in-flow headings.
58
+ - **Oversized display is a structural element, used sparingly.** Hero/anchor headings scale beyond the fixed token scale via a fluid clamp (pattern: `clamp(2.7rem, 1.6rem + 5vw, 5.4rem)`), not a token — the size is fluid, so it is a documented pattern, not a `--sl-text-*` addition. At most one oversized anchor per screen.
59
+ - **Body measure.** Running text is capped at ~`63ch` (convention var `--sl-measure: 63ch`, defined locally — not a token, it is layout-level). Never let prose run full-bleed.
60
+
61
+ ### L2 — Structure & spacing
62
+
63
+ - **Spacing rhythm off the scale.** All gaps/padding step off `--sl-space-*` (0.25rem base). No arbitrary spacing values in new work.
64
+ - **Hairline structure.** Structural separation is a single `1px` rule in the theme's line colour — section tops, header/footer borders, card edges, gutter rulers. Weight is always `1px`; no heavy borders, no drop-shadow-as-separator (shadows are reserved for genuinely floating surfaces via `--sl-shadow-*`).
65
+ - **Hairline grid technique.** Card grids are formed by a `1px` gap over a line-colour background (`display: grid; gap: 1px; background: <line>`), cells painting their own surface — producing crisp shared rules between cells instead of doubled borders. This is a signature; prefer it over bordered-card grids (which the house style otherwise avoids).
66
+ - **Gutter & ruler.** Content sits in a max-width wrap (`~78rem`) with a fluid gutter (`clamp(1.25rem, 5vw, 6rem)`, convention var `--sl-gutter`); on wide viewports a `1px` vertical ruler at the gutter edge is an optional editorial accent.
67
+ - **Sections lead with an index.** A section opens with a mono `sec-idx` label (numbered where the content is genuinely a sequence — see the structure-is-information rule) beside its heading, separated from the previous section by a `1px` top rule.
68
+
69
+ ### L3 — Signature components
70
+
71
+ These recurring parts carry the family look. A surface need not use all, but any it does use should match:
72
+
73
+ - **Wordmark (official logo)** — the brand is typographic, no pictorial mark. Primary: `sondalab.` set in `--sl-font-display` (Fraunces) weight 500, `letter-spacing: -0.05em`, with the trailing period in the accent (`.dot { color: accent }`). Compact lockup (headers, tight spots): `sonda·lab` in `--sl-font-mono`, uppercase, tracked `0.2em`, with the middle dot in the accent. Source of truth: the identity board artifact `https://claude.ai/code/artifact/abbebb7f-5778-4af6-a9b4-2b3f169ae20f`; reference implementation: `sondalab-ui/showcase/index.html` (`.brand-word` hero + `.wordmark` header).
74
+ - **Eyebrow** — mono uppercase micro-label above a heading (`--sl-text-xs`, tracked `0.22em`, muted).
75
+ - **Tag / tier marker** — inline mono uppercase pill: `1px` border in a soft accent tint, small dot or square glyph before the label, accent-coloured text. Used for status/classification.
76
+ - **Inversion band** — a full-width block flipped to `text-on-*` on the strongest neutral (ink background, paper text), used to punctuate rhythm and mark the thesis or primary call-to-action. The recurring inversion is a core motif; at least one per long page is idiomatic.
77
+ - **Sticky translucent header** — `position: sticky`, background `color-mix(paper 86%, transparent)` + `backdrop-filter: blur()`, `1px` bottom rule; brand wordmark (mono, tracked) left, mono meta/controls right.
78
+ - **Real-content blocks** — where a surface shows output (JSON, terminal, code, data rows), show *actual* content in mono on the ink surface, not illustrative filler. This is both an editorial-voice rule and a visual one.
79
+
80
+ **Canonical recipe — eyebrow + tag.** These two are the strongest family signal, so their values are fixed here (a convention, like `--sl-measure`; not a token, because products bind them to their own local colour vars). Every implementation cites this recipe rather than re-deriving it; a product substitutes its own token names for the roles in brackets.
81
+
82
+ ```css
83
+ /* eyebrow — mono uppercase micro-label */
84
+ .eyebrow {
85
+ font-family: var(--sl-font-mono); /* or the product's mono face */
86
+ font-size: var(--sl-text-xs); /* ~9.5–11px in px-scaled UIs */
87
+ letter-spacing: 0.2em; /* band 0.18–0.22em */
88
+ text-transform: uppercase;
89
+ color: [text-muted];
90
+ }
91
+ /* tag / tier marker — accent-tinted pill with a leading dot */
92
+ .tag {
93
+ display: inline-flex; align-items: center; gap: 6px;
94
+ font-family: var(--sl-font-mono);
95
+ font-size: var(--sl-text-xs); font-weight: 500;
96
+ letter-spacing: 0.16em; text-transform: uppercase;
97
+ color: [accent];
98
+ border: 1px solid [accent-subtle];
99
+ background: [accent-subtle];
100
+ padding: 3px 7px; line-height: 1;
101
+ }
102
+ .tag::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: [accent]; flex: none; }
103
+ ```
104
+
105
+ Current citations: introspect `.sl-eyebrow`/`.sl-tag` in `introspect/web/src/theme.css`; spexr `.spexr-agent-chat__label`, `.spexr-spec-resources__caption`, and `.spexr-df-card__status` in `packages/theia-extensions/src/browser/style/spexr.css` — these adopt the **eyebrow** half of the recipe (mono uppercase tracked, via spexr's `--sl-font-code`). spexr has **not** adopted the tag/pill half; its existing status pills `.spexr-df-pill--attn/--error` are untouched. In spexr the eyebrow face is the design mono `--sl-font-code`, deliberately distinct from code-content runs (`.spexr-df-card__branch`/`__trail`, which mirror the editor font `--theia-code-font-family`): labels are design-layer, code content mirrors the editor.
106
+
107
+ ### L4 — Motion
108
+
109
+ - **One easing, three speeds.** All transitions use `--sl-motion-ease` with `--sl-motion-fast|mid|slow`. Entrance reveals use a small rise (`translateY(~6px)` + opacity) on `--sl-motion-mid|slow`.
110
+ - **Stagger, don't fade-everything.** Where multiple related elements enter, stagger them (nth-child delays) rather than fading the whole screen at once.
111
+ - **Respect reduced motion.** The token layer already zeroes durations under `prefers-reduced-motion`; never re-introduce motion that bypasses the tokens.
112
+ - **Interaction, not opacity.** Hover/focus states change colour/border/background meaningfully; `hover:opacity` alone is not a state.
113
+
114
+ ### L5 — Editorial voice (copy)
115
+
116
+ - **Precise and technical, never marketing.** Name things by what they are and do. Prefer a real measured figure or executed output to an adjective.
117
+ - **Active voice; controls state their effect.** A button says what happens; its confirmation says it happened.
118
+ - **Structure encodes truth.** Numbered markers, eyebrows, dividers appear only where they reflect something real (an actual sequence, a real classification) — not as decoration.
119
+ - **English for product copy and all identifiers**, matching the suite.
120
+
121
+ ## Per-substrate compliance matrix
122
+
123
+ Substrates: **IDE** (fixed-chrome host, e.g. spexr in Theia), **App** (data-grid web app, e.g. introspect), **Page** (marketing/landing, e.g. daos), **Term** (terminal/CLI, e.g. smartctx). Levels: **R** required · **A** adapt (honour the intent within substrate limits) · **N/A** not applicable.
124
+
125
+ | Rule | IDE | App | Page | Term |
126
+ |---|---|---|---|---|
127
+ | Theme selector (Tier A/B) | R (A) | R (B) | R (B) | N/A |
128
+ | Accent registry (one hue) | R | R | R | R (roles) |
129
+ | L1 type roles (display/sans/mono) | R | A (mono sub allowed) | R | A (mono only) |
130
+ | L1 mono uppercase structural labels | R | R | R | A (dim/caps where ANSI allows) |
131
+ | L1 heading treatment | A (host headings limited) | R | R | N/A |
132
+ | L1 oversized display anchor | N/A | A (where a hero exists) | R | N/A |
133
+ | L1 body measure ~63ch | A | R | R | R (wrap width) |
134
+ | L2 spacing off `--sl-space-*` | R | R | R | A (char cells) |
135
+ | L2 hairline `1px` structure | A (host chrome fixed) | R | R | A (box-drawing rules) |
136
+ | L2 hairline-grid technique | N/A | R | R | N/A |
137
+ | L2 gutter/ruler | N/A | A | R | N/A |
138
+ | L2 section index | N/A | A | R | N/A |
139
+ | L3 eyebrow / tag / tier | A | R | R | A |
140
+ | L3 inversion band | N/A | A | R | A (inverse video attribute) |
141
+ | L3 sticky translucent header | N/A | R | R | N/A |
142
+ | L3 real-content blocks | R | R | R | R |
143
+ | L4 motion (ease/speeds/stagger) | A | R | R | N/A |
144
+ | L4 reduced-motion respect | R | R | R | N/A |
145
+ | L5 editorial voice | R | R | R | R |
146
+
147
+ ## Reconciling shared grammar with "one unexpected layout decision per screen"
148
+
149
+ The house aesthetic (personal `CLAUDE.md`, UI Aesthetics) requires ≥1 unexpected, intentional layout decision per screen. That does **not** conflict with this shared grammar: the grammar fixes the *materials* (type roles, spacing scale, rule weight, motifs, motion, voice), while the surprise is spent on *composition* (an asymmetric split, an oversized anchor, a full-bleed inversion, an unexpected adjacency). Family resemblance comes from shared materials; distinctiveness comes from per-screen composition. A screen that satisfies the grammar but reads as a generic centred card grid has failed the house rule; a screen that invents a new type scale or a second accent to be "surprising" has failed the grammar. Spend boldness on layout, keep the materials shared.
150
+
151
+ ## New-token policy
152
+
153
+ Express the language in **existing** `--sl-*` tokens. A new token forces regenerating `packages/`, satisfying `check-drift.yml`, and bumping **both** `packages/ui-kit/package.json` and `packages/palette-py/pyproject.toml` in the same PR (see `AGENTS.md`). Therefore:
154
+
155
+ - **Kept as conventions, not tokens** (layout-level, fluid, or product-local): `--sl-measure` (~63ch), `--sl-gutter` (fluid clamp), the oversized-display clamp pattern. Products define these locally with the canonical values above.
156
+ - **A new token is justified only** for a genuine shared, fixed, theme-independent value that recurs across ≥2 products and cannot be a convention — and then it goes through the full regen + dual-bump flow.
157
+
158
+ ## Pass-1 application (scope & order)
159
+
160
+ Full task breakdown lives in the implementation plan (writing-plans). Order (daos is the source, so it is extracted-from first and re-based later, once the language is written):
161
+
162
+ 1. **Extract from daos** → this spec. *(Done at authoring.)*
163
+ 2. **Write this spec** (`design-language.md`). *(This file.)*
164
+ 3. **sondalab-ui showcase** — a page that renders the tokens + L3 signature components against this spec. Proves the language at zero user-facing risk. Status target: **PoC implemented (not delivered)**.
165
+ 4. **Re-baseline daos landing** — swap its local palette vars onto Sondalab values (Tier B), align accent to the registry, mono-label/measure already conformant. Low risk; aesthetics already correct.
166
+ 5. **introspect** — apply beyond the type swap: mono structural labels, hairline structure, section indices, signature header, editorial voice. Most work, most regression surface; done last.
167
+
168
+ spexr is out of pass 1 (token + chrome already delivered); its remaining margin is L3 signature components inside SPEXR panels, tracked separately.
@@ -0,0 +1,400 @@
1
+ #!/usr/bin/env node
2
+ // sl-audit — deterministic Sondalab design-system compliance audit.
3
+ // Checks only what is decidable from CSS text (colour value-equality, tier
4
+ // consistency). Judgement-level rules (missing labels, editorial voice,
5
+ // layout composition) are out of scope by design — they belong to the
6
+ // sl-ds-auditor subagent. See design-system-agentic-spec.md.
7
+ //
8
+ // Ships inside @sondalab/ui-kit as the `sl-audit` bin. It resolves the colour
9
+ // allowlist from the package's own token CSS (tokens.css + themes/*.css), so it
10
+ // works identically in-repo and when installed in a consumer's node_modules.
11
+ // Surface globs resolve against the caller's cwd.
12
+
13
+ import { readFileSync, globSync } from "node:fs";
14
+ import { fileURLToPath } from "node:url";
15
+ import { dirname, resolve, relative } from "node:path";
16
+
17
+ const HERE = dirname(fileURLToPath(import.meta.url)); // packages/ui-kit/agent
18
+ const PKG_DIR = resolve(HERE, ".."); // packages/ui-kit — holds tokens.css + themes/
19
+ const TOKEN_CSS = ["tokens.css", "themes/light.css", "themes/dark.css", "themes/high-contrast.css"];
20
+
21
+ // --- colour normalization -------------------------------------------------
22
+
23
+ /** Canonicalize a CSS colour literal to lowercase `#rrggbb` or `#rrggbbaa`.
24
+ * Returns null for anything that is not a hex/rgb literal (var(), keywords). */
25
+ export function normalizeColor(raw) {
26
+ if (typeof raw !== "string") return null;
27
+ const s = raw.trim().toLowerCase();
28
+
29
+ const hex = /^#([0-9a-f]{3,8})$/.exec(s);
30
+ if (hex) {
31
+ const h = hex[1];
32
+ if (h.length === 3) return "#" + [...h].map((c) => c + c).join("");
33
+ if (h.length === 4) return "#" + [...h].map((c) => c + c).join("");
34
+ if (h.length === 6) return "#" + h;
35
+ if (h.length === 8) return "#" + h;
36
+ return null; // 5 or 7 digits: not a valid colour
37
+ }
38
+
39
+ const rgb = /^rgba?\(([^)]*)\)$/.exec(s);
40
+ if (rgb) {
41
+ const body = rgb[1].trim();
42
+ let rgbPart = body;
43
+ let alpha = null;
44
+ if (body.includes("/")) {
45
+ const [lhs, rhs] = body.split("/");
46
+ rgbPart = lhs.trim();
47
+ alpha = rhs.trim();
48
+ }
49
+ const parts = rgbPart.split(/[\s,]+/).filter(Boolean);
50
+ if (parts.length < 3) return null;
51
+ if (alpha === null && parts.length >= 4) alpha = parts[3];
52
+ const [r, g, b] = parts.slice(0, 3).map((n) => channelToHex(n));
53
+ if (r === null || g === null || b === null) return null;
54
+ let out = "#" + r + g + b;
55
+ if (alpha !== null) {
56
+ const a = alphaToHex(alpha);
57
+ if (a !== null && a !== "ff") out += a;
58
+ }
59
+ return out;
60
+ }
61
+
62
+ return null;
63
+ }
64
+
65
+ function channelToHex(n) {
66
+ let v;
67
+ if (n.endsWith("%")) v = Math.round((parseFloat(n) / 100) * 255);
68
+ else v = Math.round(parseFloat(n));
69
+ if (!Number.isFinite(v)) return null;
70
+ v = Math.max(0, Math.min(255, v));
71
+ return v.toString(16).padStart(2, "0");
72
+ }
73
+
74
+ function alphaToHex(a) {
75
+ const v = parseFloat(a);
76
+ if (!Number.isFinite(v)) return null;
77
+ return Math.round(Math.max(0, Math.min(1, v)) * 255)
78
+ .toString(16)
79
+ .padStart(2, "0");
80
+ }
81
+
82
+ /** The opaque base (`#rrggbb`) of a normalized colour, dropping any alpha. */
83
+ export function colorBase(normalized) {
84
+ return normalized.slice(0, 7);
85
+ }
86
+
87
+ // --- extraction -----------------------------------------------------------
88
+
89
+ const COLOR_RE = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})\b|rgba?\([^)]*\)/g;
90
+
91
+ /** Every hex/rgb literal in a CSS string, with 1-based line numbers.
92
+ * var() references are intentionally ignored — they resolve to tokens. */
93
+ export function extractColors(css) {
94
+ const out = [];
95
+ for (const m of css.matchAll(COLOR_RE)) {
96
+ const line = css.slice(0, m.index).split("\n").length;
97
+ out.push({ value: m[0], line });
98
+ }
99
+ return out;
100
+ }
101
+
102
+ function collectStrings(node, acc) {
103
+ if (typeof node === "string") acc.push(node);
104
+ else if (Array.isArray(node)) for (const v of node) collectStrings(v, acc);
105
+ else if (node && typeof node === "object")
106
+ for (const v of Object.values(node)) collectStrings(v, acc);
107
+ return acc;
108
+ }
109
+
110
+ /** Set of opaque colour bases (`#rrggbb`) appearing anywhere in a tokens.json
111
+ * object. Kept for unit tests and callers that hold the token object. */
112
+ export function buildAllowlistBases(tokens) {
113
+ const bases = new Set();
114
+ for (const s of collectStrings(tokens, [])) {
115
+ for (const { value } of extractColors(s)) {
116
+ const norm = normalizeColor(value);
117
+ if (norm) bases.add(colorBase(norm));
118
+ }
119
+ }
120
+ return bases;
121
+ }
122
+
123
+ /** Set of opaque colour bases extracted from token CSS text (tokens.css +
124
+ * themes/*.css). This is the shipped, drift-proof allowlist source — the CSS is
125
+ * generated from sondalab.tokens.json and always ships with the package. */
126
+ export function buildAllowlistBasesFromCss(cssTexts) {
127
+ const bases = new Set();
128
+ for (const css of cssTexts) {
129
+ for (const { value } of extractColors(css)) {
130
+ const norm = normalizeColor(value);
131
+ if (norm) bases.add(colorBase(norm));
132
+ }
133
+ }
134
+ return bases;
135
+ }
136
+
137
+ function accentColorStrings(accentEntry) {
138
+ return collectStrings(accentEntry, []).filter((s) => normalizeColor(s));
139
+ }
140
+
141
+ // --- tier detection -------------------------------------------------------
142
+
143
+ /** 'A' (data-sl-theme), 'B' (data-theme), or null if neither appears. */
144
+ export function detectTier(css) {
145
+ if (/\[data-sl-theme/.test(css)) return "A";
146
+ if (/\[data-theme/.test(css)) return "B";
147
+ return null;
148
+ }
149
+
150
+ // --- selector context -----------------------------------------------------
151
+
152
+ /** For each 1-based line, a context string = the enclosing selector plus the
153
+ * current declaration (property + value accumulated across lines, reset at
154
+ * `;`/`{`/`}`). The declaration part carries the property name onto every line
155
+ * of a multi-line value, so blessed suppression keyed on a custom-property name
156
+ * (e.g. `--bg-grad`) reaches its continuation lines. Not a full CSS parser. */
157
+ function contextByLine(css) {
158
+ const lines = css.split("\n");
159
+ const map = new Array(lines.length + 1).fill("");
160
+ let selector = "";
161
+ let selPending = "";
162
+ let decl = "";
163
+ lines.forEach((line, i) => {
164
+ // snapshot the state entering this line, then include the raw line so a
165
+ // selector or property declared on this same line is matchable too.
166
+ map[i + 1] = selector + " " + decl + " " + line;
167
+ for (const ch of line) {
168
+ if (ch === "{") {
169
+ selector = selPending.trim();
170
+ selPending = "";
171
+ decl = "";
172
+ } else if (ch === "}") {
173
+ selector = "";
174
+ selPending = "";
175
+ decl = "";
176
+ } else if (ch === ";") {
177
+ decl = "";
178
+ selPending += ch;
179
+ } else {
180
+ selPending += ch;
181
+ decl += ch;
182
+ }
183
+ }
184
+ });
185
+ return map;
186
+ }
187
+
188
+ /** Whether a blessed selector appears in a context string, delimited so a
189
+ * short name does not match a longer one (`--rule` must not match `--rule-2`
190
+ * or `--ruler`). Empty selectors never match. */
191
+ export function matchesSelector(context, selector) {
192
+ const sel = selector.trim();
193
+ if (!sel) return false;
194
+ const esc = sel.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
195
+ return new RegExp(`(?<![\\w-])${esc}(?![\\w-])`).test(context);
196
+ }
197
+
198
+ // --- audit ----------------------------------------------------------------
199
+
200
+ function levelFor(matrix, rule, substrate) {
201
+ if (!matrix || !substrate) return "R";
202
+ const row = matrix.rules?.[rule];
203
+ return row?.[substrate] ?? "R";
204
+ }
205
+
206
+ /** Audit one CSS string. Pure — no I/O. Provide either `allowlistBases` (a Set
207
+ * of `#rrggbb` bases, the shipped path) or `tokens` (a tokens.json object, the
208
+ * test path); `allowlistBases` wins when both are given. */
209
+ export function auditCss({
210
+ css,
211
+ tokens,
212
+ allowlistBases,
213
+ accentEntry = {},
214
+ blessed = { deviations: [] },
215
+ declaredTier = null,
216
+ substrate = null,
217
+ matrix = null,
218
+ file = "",
219
+ }) {
220
+ const findings = [];
221
+ const bases = allowlistBases ? new Set(allowlistBases) : buildAllowlistBases(tokens);
222
+ for (const s of accentColorStrings(accentEntry)) bases.add(colorBase(normalizeColor(s)));
223
+
224
+ const colorDeviations = (blessed.deviations || []).filter((d) => d.kind === "color");
225
+ const blessedColorSelectors = colorDeviations
226
+ .flatMap((d) => d.selectors || [])
227
+ .map((s) => s.trim())
228
+ .filter(Boolean); // an empty selector would match every context — drop it
229
+ // value-based blessing — the honest mechanism for demo data / literals that
230
+ // do not live under a distinctive selector (e.g. a JS swatch object).
231
+ const blessedValueBases = new Set(
232
+ colorDeviations
233
+ .flatMap((d) => d.values || [])
234
+ .map((v) => normalizeColor(v))
235
+ .filter(Boolean)
236
+ .map(colorBase),
237
+ );
238
+
239
+ // check 2 — tier consistency
240
+ const detected = detectTier(css);
241
+ if (declaredTier && detected && detected !== declaredTier) {
242
+ const level = levelFor(matrix, "theme-selector", substrate);
243
+ if (level !== "N/A")
244
+ findings.push({
245
+ rule: "theme-selector",
246
+ level,
247
+ file,
248
+ line: css.slice(0, css.indexOf(detected === "A" ? "[data-sl-theme" : "[data-theme")).split("\n").length,
249
+ message: `selector implies Tier ${detected} but product declared Tier ${declaredTier}`,
250
+ });
251
+ }
252
+
253
+ // check 1 — colour value-equality
254
+ const ctxMap = contextByLine(css);
255
+ for (const { value, line } of extractColors(css)) {
256
+ const norm = normalizeColor(value);
257
+ if (!norm) continue;
258
+ if (bases.has(colorBase(norm))) continue;
259
+ if (blessedValueBases.has(colorBase(norm))) continue;
260
+ const context = ctxMap[line] || "";
261
+ const blessedHit = blessedColorSelectors.some((sel) => matchesSelector(context, sel));
262
+ if (blessedHit) continue;
263
+ const level = levelFor(matrix, "accent-one-hue", substrate);
264
+ if (level === "N/A") continue;
265
+ findings.push({
266
+ rule: "accent-one-hue",
267
+ level,
268
+ file,
269
+ line,
270
+ message: `colour ${value} does not resolve to a token, product accent, or blessed value`,
271
+ });
272
+ }
273
+
274
+ return {
275
+ file,
276
+ substrate,
277
+ tier: { declared: declaredTier, detected },
278
+ findings,
279
+ };
280
+ }
281
+
282
+ // --- registry / config resolution ----------------------------------------
283
+
284
+ export function resolveAccentEntry(registry, product) {
285
+ const seen = new Set();
286
+ let name = product;
287
+ while (name) {
288
+ if (seen.has(name)) throw new Error(`accent-registry: circular inherits at "${name}"`);
289
+ seen.add(name);
290
+ const entry = registry.products?.[name];
291
+ if (!entry) return {};
292
+ if (entry.inherits) {
293
+ name = entry.inherits;
294
+ continue;
295
+ }
296
+ return entry;
297
+ }
298
+ return {};
299
+ }
300
+
301
+ function loadJson(path) {
302
+ return JSON.parse(readFileSync(path, "utf8"));
303
+ }
304
+
305
+ function loadTokenCss() {
306
+ const texts = [];
307
+ for (const rel of TOKEN_CSS) {
308
+ try {
309
+ texts.push(readFileSync(resolve(PKG_DIR, rel), "utf8"));
310
+ } catch {
311
+ /* a theme file may be absent in a trimmed install — skip it */
312
+ }
313
+ }
314
+ return texts;
315
+ }
316
+
317
+ // --- CLI ------------------------------------------------------------------
318
+
319
+ function parseArgs(argv) {
320
+ const args = {};
321
+ for (let i = 0; i < argv.length; i++) {
322
+ const a = argv[i];
323
+ if (a.startsWith("--")) args[a.slice(2)] = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : true;
324
+ }
325
+ return args;
326
+ }
327
+
328
+ function group(findings, level) {
329
+ return findings.filter((f) => f.level === level);
330
+ }
331
+
332
+ function main() {
333
+ const args = parseArgs(process.argv.slice(2));
334
+ if (!args.surface || !args.product) {
335
+ console.error("usage: sl-audit --surface <glob> --product <name> [--substrate IDE|App|Page|Term] [--fail-on R]");
336
+ process.exit(2);
337
+ }
338
+
339
+ const allowlistBases = buildAllowlistBasesFromCss(loadTokenCss());
340
+ const registry = loadJson(resolve(HERE, "accent-registry.json"));
341
+ const matrix = loadJson(resolve(HERE, "substrate-matrix.json"));
342
+ let blessed = { deviations: [] };
343
+ try {
344
+ blessed = loadJson(resolve(HERE, `blessed/${args.product}.json`));
345
+ } catch {
346
+ /* no blessed file yet — treat as none */
347
+ }
348
+
349
+ const substrate = args.substrate || matrix.products?.[args.product] || blessed.substrate || null;
350
+ const declaredTier = args.tier || matrix.tiers?.[args.product] || blessed.tier || null;
351
+ if (!declaredTier && substrate && substrate !== "Term")
352
+ console.warn(`warning: no tier declared for "${args.product}" (add it to substrate-matrix.json "tiers") — tier-consistency check disabled`);
353
+ const accentEntry = resolveAccentEntry(registry, args.product);
354
+
355
+ const cwd = process.cwd();
356
+ const files = globSync(args.surface, { cwd }).map((f) => resolve(cwd, f));
357
+ const all = [];
358
+ for (const file of files) {
359
+ const css = readFileSync(file, "utf8");
360
+ const res = auditCss({
361
+ css,
362
+ allowlistBases,
363
+ accentEntry,
364
+ blessed,
365
+ declaredTier,
366
+ substrate,
367
+ matrix,
368
+ file: relative(cwd, file),
369
+ });
370
+ all.push(...res.findings);
371
+ }
372
+
373
+ const R = group(all, "R");
374
+ const A = group(all, "A");
375
+ const naCount = matrix.rules
376
+ ? Object.values(matrix.rules).filter((r) => r[substrate] === "N/A").length
377
+ : 0;
378
+
379
+ console.log(`\n${args.product} (${substrate}, Tier ${declaredTier ?? "?"}) — sl-audit`);
380
+ console.log(` ${files.length} file(s) scanned\n`);
381
+ printBucket("R-violation", R);
382
+ printBucket("A-deviation", A);
383
+ console.log(`N/A: ${naCount} rules skipped for substrate ${substrate}\n`);
384
+
385
+ if (args["fail-on"] === "R" && R.length > 0) process.exit(1);
386
+ }
387
+
388
+ function printBucket(label, findings) {
389
+ if (findings.length === 0) {
390
+ console.log(`${label} (0)\n`);
391
+ return;
392
+ }
393
+ console.log(`${label} (${findings.length})`);
394
+ for (const f of findings) console.log(` ${f.file}:${f.line} ${f.message}`);
395
+ console.log("");
396
+ }
397
+
398
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
399
+ main();
400
+ }
@@ -0,0 +1,38 @@
1
+ {
2
+ "$note": "Machine source of the per-substrate compliance matrix in design-language.md. level ∈ R (required) | A (adapt) | N/A (not applicable). Adaptation nuance (the parentheticals in the doc) stays in design-language.md; this file carries only the level per (rule, substrate) so the audit can decide what is blocking.",
3
+ "products": {
4
+ "spexr": "IDE",
5
+ "introspect": "App",
6
+ "daos": "Page",
7
+ "default": "Page",
8
+ "smartctx": "Term"
9
+ },
10
+ "tiers": {
11
+ "$note": "Theme-selector tier per product (design-language.md §Decision 1). Authoritative machine source for the audit's tier-consistency check. Term products have no tier (no theme selector).",
12
+ "spexr": "A",
13
+ "introspect": "B",
14
+ "daos": "B",
15
+ "default": "A"
16
+ },
17
+ "rules": {
18
+ "theme-selector": { "IDE": "R", "App": "R", "Page": "R", "Term": "N/A" },
19
+ "accent-one-hue": { "IDE": "R", "App": "R", "Page": "R", "Term": "R" },
20
+ "l1-type-roles": { "IDE": "R", "App": "A", "Page": "R", "Term": "A" },
21
+ "l1-mono-labels": { "IDE": "R", "App": "R", "Page": "R", "Term": "A" },
22
+ "l1-heading": { "IDE": "A", "App": "R", "Page": "R", "Term": "N/A" },
23
+ "l1-oversized-anchor":{ "IDE": "N/A", "App": "A", "Page": "R", "Term": "N/A" },
24
+ "l1-body-measure": { "IDE": "A", "App": "R", "Page": "R", "Term": "R" },
25
+ "l2-spacing-scale": { "IDE": "R", "App": "R", "Page": "R", "Term": "A" },
26
+ "l2-hairline-1px": { "IDE": "A", "App": "R", "Page": "R", "Term": "A" },
27
+ "l2-hairline-grid": { "IDE": "N/A", "App": "R", "Page": "R", "Term": "N/A" },
28
+ "l2-gutter-ruler": { "IDE": "N/A", "App": "A", "Page": "R", "Term": "N/A" },
29
+ "l2-section-index": { "IDE": "N/A", "App": "A", "Page": "R", "Term": "N/A" },
30
+ "l3-eyebrow-tag": { "IDE": "A", "App": "R", "Page": "R", "Term": "A" },
31
+ "l3-inversion-band": { "IDE": "N/A", "App": "A", "Page": "R", "Term": "A" },
32
+ "l3-sticky-header": { "IDE": "N/A", "App": "R", "Page": "R", "Term": "N/A" },
33
+ "l3-real-content": { "IDE": "R", "App": "R", "Page": "R", "Term": "R" },
34
+ "l4-motion": { "IDE": "A", "App": "R", "Page": "R", "Term": "N/A" },
35
+ "l4-reduced-motion": { "IDE": "R", "App": "R", "Page": "R", "Term": "N/A" },
36
+ "l5-voice": { "IDE": "R", "App": "R", "Page": "R", "Term": "R" }
37
+ }
38
+ }
@@ -0,0 +1,35 @@
1
+ # Workflow — Adopt a single Sondalab component
2
+
3
+ > **What this file is:** the ordered procedure for pulling **one** Sondalab component or signature pattern into an existing app **without** a full migration — the lightest of the three workflows. Companion to `migrate.md` (whole-surface compliance) and `author.md` (greenfield).
4
+ > **Reads:** `catalog.md` + `catalog.json` (the component), `../design-language.md` (grammar), `substrate-matrix.json` (substrate applicability), `sl-audit` (verdict, scoped to the touched file).
5
+ > **Invariant:** adopt touches only what it adds. The rest of the app is out of scope — do not audit or "fix" surrounding code. The change is small and reversible; the gate is showing the diff, not a full migration plan.
6
+
7
+ ## When to use this (vs migrate)
8
+
9
+ Use adopt when the ask is "add a Sondalab eyebrow/tag/inversion band here", "give this header the house look", "use the DS button on this one screen" — a taste of the family look, not a wholesale adoption. If the ask is "bring this app onto the DS", that is `migrate.md`.
10
+
11
+ ## Steps
12
+
13
+ ### 0 — Identify the component and the host
14
+ Pick the target from `catalog.md` (a primitive like `sl-tag`, or a signature pattern like the inversion band). Note the host app's **substrate** and **tier** — an adopted component must honour the host's tier: on a Tier-B app you bind the component's `--slc-*` role-vars to the app's local colour layer; you do not switch the app to `data-sl-theme`.
15
+
16
+ ### 1 — Bind, don't fork
17
+ Copy the component's markup from `catalog.md`. Resolve its `--slc-*`/`--sl-*` role-vars to the host's existing values — every one must resolve to a Sondalab token or the host's registered accent (Tier-B rule). If the host lacks a role-var the component needs, define it once, mapped to a Sondalab value. Never bind the component to a raw literal.
18
+
19
+ ### 2 — Insert
20
+ Place the component in the host markup. Keep the change minimal and localised — one component, the files it touches, nothing else. Match the host's existing structure and naming.
21
+
22
+ ### 3 — Audit the touched file only
23
+ ```sh
24
+ npx sl-audit --surface "<the one file you edited>" --product <host> --substrate <host substrate>
25
+ ```
26
+ The **added** component must contribute zero R-violations. Pre-existing findings elsewhere in the file are **not** yours to fix in an adopt — note them (they are migrate's job) but leave them. Populate `blessed/<host>.json` only for a deviation your added component legitimately needs.
27
+
28
+ ### 4 — Judgement check (the one component)
29
+ Against `design-language.md`, scoped to just what you added: does the eyebrow/tag read as mono-uppercase-tracked? Does an adopted inversion band avoid an alpha-on-canvas hairline inside it? Is the copy in the component precise/active (L5)? See `HANDOFF.md` for judgement vs mechanical.
30
+
31
+ ### 5 — Show the diff
32
+ Present the diff (it is small). No full migration plan — the change is localised and reversible. Render the touched screen across the host's themes to confirm the component sits right.
33
+
34
+ ## Output
35
+ One Sondalab component living correctly in the host app, bound to the host's colour layer, auditing clean for what it added — the rest of the app untouched.