@assure-one/design-system 1.32.0 → 1.33.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.
package/README.md CHANGED
@@ -11,6 +11,7 @@ The single source of truth for visual design across all Assure products.
11
11
  - [**`CHANGELOG.md`**](./CHANGELOG.md) — release-by-release log of what shipped.
12
12
  - [**`CONTRIBUTING.md`**](./CONTRIBUTING.md) — dev loop, release flow, versioning, pitfalls.
13
13
  - [**`CLAUDE.md`**](./CLAUDE.md) — invariants and conventions for AI assistants working in this repo.
14
+ - [**`docs/integration/css.md`**](./docs/integration/css.md) — how an application loads our CSS: layer order, import sequence, the compatibility preset, removing `@source`, runtime brand theming.
14
15
  - [**`docs/testing.md`**](./docs/testing.md) — rendering the real package in your app's Jest tests (`@assure-one/design-system/testing`, experimental).
15
16
  - [**`claude-skills/`**](./claude-skills) — drop-in Claude Code skill for consuming projects.
16
17
 
@@ -53,15 +54,35 @@ no component reads them, and importing them changes nothing on its own.
53
54
  | `./css/tailwind.css` | A Tailwind `@theme` bridge, so `bg-surface/40`, `hover:text-fg-3` and `rounded-control` compile in *your* build instead of silently producing nothing. |
54
55
  | `./css/shadcn.css` | The app-vocabulary bridge: `background`, `foreground`, `primary`, `muted`, `destructive`, the radius scale and the rest, each reading one `--app-*` input you can override. |
55
56
  | `./css/base.css` | Optional document defaults: body background, ink, font and `color-scheme`. Ships no preflight, no element rules and no font import. |
56
-
57
- Import order, once the mode is supported end to end, is
58
- `tokens.css` `tailwind.css` → `shadcn.css` (optional) → `base.css`
59
- (optional), after your own `@import "tailwindcss"`. The integration guide with
60
- the full sequence, the layer statement and the per-app preset lands with the
61
- rest of Wave 1; until then the reasoning lives in
62
- [ADR-004](./docs/adr/004-css-delivery-cascade.md) and
57
+ | `./css/components.css` | The component utilities compiled with Tailwind's `ds` prefix (`ds:flex`, `ds:bg-surface`), inside `@layer ds` and bound to the `--ds-*` tokens. **Unused until the class-vocabulary flip (W1-11):** no component emits a prefixed class yet, so importing it styles nothing. Published so the file, its size and its hygiene can be reviewed before the flip. |
58
+
59
+ Import order is `tokens.css` → `legacy-aliases.css` (optional) → `tailwind.css`
60
+ → `shadcn.css` (optional) `base.css` (optional) `components.css`, after your own
61
+ `@import "tailwindcss"` and under the layer statement
62
+ `@layer theme, base, ds, components, utilities;`.
63
+ [**`docs/integration/css.md`**](./docs/integration/css.md) has the full
64
+ sequence, where the per-app preset goes, when the `@source` into our package may
65
+ be removed, and the fixture run behind each of those claims. The reasoning lives
66
+ in [ADR-004](./docs/adr/004-css-delivery-cascade.md) and
63
67
  [ADR-005](./docs/adr/005-app-vocabulary-bridge.md).
64
68
 
69
+ ### Runtime brand theming (experimental)
70
+
71
+ For a tenant colour known only at runtime, `@assure-one/design-system/tokens`
72
+ exports `createBrandTheme()`: a validated seed becomes the custom properties a
73
+ built-in brand scope declares, ready to spread into a `style` attribute.
74
+
75
+ ```tsx
76
+ import { createBrandTheme } from "@assure-one/design-system/tokens";
77
+
78
+ <div style={{ ...createBrandTheme({ brand: "#2258d8", accent: "#f59e0b" }) }}>…</div>;
79
+ ```
80
+
81
+ It is pure and SSR-deterministic, clamps a fill too pale to read against the
82
+ canvas, and guarantees WCAG AA for every foreground it emits. The seed shape is
83
+ gated by decision D7 and may change while that decision is open — see
84
+ [`docs/integration/css.md`](./docs/integration/css.md#5-runtime-brand-theming).
85
+
65
86
  ## Develop
66
87
 
67
88
  ```bash
@@ -76,14 +97,24 @@ See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for the full dev loop, including iter
76
97
 
77
98
  ## Architecture
78
99
 
79
- 3-tier token system (W3C Design Tokens spec compatible):
100
+ 3-tier token system, authored as DTCG JSON in [`tokens/src`](./tokens/README.md)
101
+ and generated into `src/tokens/` and the `./css/*` entries (ADR-003):
80
102
 
81
- ```
82
- reference → system (semantic) → component
83
- color.navy.700 → color.action.primary.bg → button.primary.bg
84
- ```
103
+ | Tier | Example (namespaced name → value) | Reads |
104
+ | --- | --- | --- |
105
+ | reference | `--ds-ref-brand-pro-fg: #6c42f8` | a literal; the raw ramps, one per brand |
106
+ | system (semantic) | `--ds-color-action-brand-bg: var(--ds-ref-brand-pro-fg)` | the reference tier; **this is the public contract** |
107
+ | component | `--ds-menu-item-hover-bg: var(--ds-color-canvas-sunken)` | the system tier; only where a component must be themable on its own |
108
+
109
+ Components consume **system tokens**, never reference values directly. Themes
110
+ (colour scheme, per-product brand, a runtime brand from `createBrandTheme()`)
111
+ re-map system → reference without touching component code.
85
112
 
86
- Components consume **system tokens**, never reference values directly. Themes (light, dark, per-product accent) re-map system → reference without touching component code.
113
+ Every token has two published spellings: the namespaced `--ds-*` name above and
114
+ the legacy name it ships as today (`--color-brand`, `--color-pro-fg`,
115
+ `--color-menu-item-hover-bg`). `css/legacy-aliases.css` declares
116
+ `<legacy>: var(<--ds-* name>)` in the same scopes, so both resolve to the same
117
+ value — proved by `pnpm tokens:ds-check`.
87
118
 
88
119
  ## Releases
89
120
 
@@ -102,12 +102,72 @@ the list a human has to pick up — spread props, dynamic expressions, conflicti
102
102
 
103
103
  | Id | Class | What it does |
104
104
  | ----- | ----- | ----------------------------------------------------------------------------------------------------------------------------------- |
105
+ | CM-14 | X | hidden-input mirror finder: hidden `<input name>` a consumer added because a design-system control posts nothing |
105
106
  | CM-15 | X | DOM-selector finder: consumer code that depends on the internal DOM of design-system components, mapped to registry `C-DOM-*` ids |
106
107
  | CM-16 | X | `globals.css` analyser: `@source` into the package, duplicate preflight, colliding `@theme` keys, unlayered globals, legacy `var()` |
108
+ | CM-20 | X | Select sentinel finder: option values standing in for "no value", and the line where each is converted back |
107
109
 
108
110
  The remaining ids of plan §29 land with the waves that ship their replacement
109
111
  APIs.
110
112
 
113
+ ### CM-14 — hidden-input mirror finder
114
+
115
+ Report-only, **for ever**: deleting a mirror changes what the server receives,
116
+ and plan §29 never automates that. It is the input to W4-04 (native form
117
+ participation) and to the registry contract `C-HIDDEN-MIRRORS`.
118
+
119
+ The applications do not put a mirror next to its control — they collect the
120
+ mirrors at the top of the `<form>` and bind the control far below ([CU §18]).
121
+ "Next to" is therefore read as _bound to the same state_:
122
+
123
+ | Rule | Contract | Found |
124
+ | --------------------------- | ---------------- | ------------------------------------------------------------------------------ |
125
+ | `mirror-shared-binding` | C-HIDDEN-MIRRORS | the hidden input's `value` reads a binding a design-system control is bound to |
126
+ | `mirror-in-form` | C-HIDDEN-MIRRORS | same `<form>` as a control that posts nothing today; nothing ties the two |
127
+ | `unregistered-hidden-input` | (unregistered) | no control to mirror — a value the server supplied (a token, an id) |
128
+
129
+ Each finding names the control, the field name and whether that control takes
130
+ `name` **today** (`yes`) or only after Wave 4 (`planned`); severity is `high`
131
+ when the control takes `name` and the call site already passes it, because the
132
+ form then posts the field twice.
133
+
134
+ The third rule is why the report reconciles with the audit's census of every
135
+ hidden `<input>` ([CU §24]): CM-14 reports the same population, classified,
136
+ rather than a smaller number with no explanation. It does not look across
137
+ files — a mirror whose control lives elsewhere lands in `mirror-in-form` or
138
+ `unregistered-hidden-input` and says so. An `<input type={expr}>` whose type is
139
+ computed appears in "could not be transformed".
140
+
141
+ The `name` capability table lives in the transform, and
142
+ `tests/codemods/cm-14.test.mjs` checks it against the design system's own
143
+ sources so it cannot describe components this version does not ship.
144
+
145
+ ### CM-20 — Select sentinel finder
146
+
147
+ Report-only, **for ever**: replacing a sentinel with `null` changes what the
148
+ server receives. It is the input to W4-08 (`Select value: string | null`) and
149
+ to the registry contract `C-SELECT-EMPTY`.
150
+
151
+ The spellings are **found, not assumed**. The registry names `"none"` and
152
+ `"__unassigned__"`; the applications use far more, and `"none"` is also a real
153
+ domain value. CM-20 reports a spelling only where the file itself proves it
154
+ stands in for nothing:
155
+
156
+ | Rule | Contract | Found |
157
+ | --------------------- | -------------- | ----------------------------------------------------------------- |
158
+ | `empty-string-value` | C-SELECT-EMPTY | `value=""` on a Select-family control or an option |
159
+ | `sentinel-dunder` | C-SELECT-EMPTY | a `__…__` spelling used as a Select value |
160
+ | `sentinel-constant` | C-SELECT-EMPTY | a no-value-named constant used as a Select value |
161
+ | `sentinel-word` | C-SELECT-EMPTY | `none`/`all`/… **with** a no-value label or a round trip |
162
+ | `sentinel-discovered` | C-SELECT-EMPTY | any other spelling the file converts to nothing |
163
+ | `sentinel-conversion` | C-SELECT-EMPTY | the line that maps a reported sentinel to `null`/`undefined`/`""` |
164
+
165
+ A known word with no evidence at all is **not** reported: CM-20 would rather
166
+ miss a sentinel than invite someone to change a value the backend depends on.
167
+ Sentinels outside the Select family — grouping keys, tab ids, route segments —
168
+ are out of scope even when the spelling matches, and nothing is followed across
169
+ files.
170
+
111
171
  ### CM-15 — DOM-selector finder
112
172
 
113
173
  Report-only. It never modifies a file; the runner throws if it tries. What it
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Form-shaped source facts, shared by the two report-only finders of the
3
+ * forms wave (plan §16): CM-14 (hidden-input mirrors) and CM-20 (Select
4
+ * sentinels).
5
+ *
6
+ * `lib/jsx.mjs` answers "which class tokens sit on which design-system
7
+ * component" and therefore only records elements that carry a class
8
+ * attribute. A hidden `<input type="hidden" name="x" value={x} />` carries
9
+ * none, and a sentinel lives in a prop value, not in a class token — so the
10
+ * forms codemods need a different view of the same tree:
11
+ *
12
+ * - **every** JSX element, design-system and raw alike, in document order;
13
+ * - each element's attributes, with the string literals and the identifiers
14
+ * of the expression behind them (the identifiers are what tie a mirror to
15
+ * the control it mirrors);
16
+ * - the nearest `<form>` ancestor, because the applications do not put a
17
+ * mirror next to its control — they collect the mirrors at the top of the
18
+ * form ([CU §18]);
19
+ * - top-level `const NAME = "literal"` declarations, because the sentinels
20
+ * are named constants (`const NONE_VALUE = "__none__"`).
21
+ *
22
+ * As everywhere in `codemods/lib`, the TypeScript module is passed in: a
23
+ * codemod never imports a parser itself (`lib/environment.mjs`).
24
+ */
25
+ import { DS_PACKAGE, createSourceFile } from "./jsx.mjs";
26
+
27
+ export { DS_PACKAGE };
28
+
29
+ const isDsSpecifier = (s) => s === DS_PACKAGE || s.startsWith(`${DS_PACKAGE}/`);
30
+
31
+ /** Attributes whose value is the control's current value, in binding order. */
32
+ export const VALUE_PROPS = ["value", "defaultValue", "checked", "defaultChecked", "selected"];
33
+
34
+ /** Attributes through which a control writes its value back. */
35
+ export const CHANGE_PROPS = [
36
+ "onValueChange",
37
+ "onCheckedChange",
38
+ "onChange",
39
+ "onSelect",
40
+ "onRemove",
41
+ "onSelectionChange",
42
+ ];
43
+
44
+ /**
45
+ * Parses one file into form facts.
46
+ *
47
+ * Returns:
48
+ * - `sf`, `lineOf` — the source file and a node → line helper
49
+ * - `imports` — design-system local name → exported name
50
+ * - `namespaces` — local names of `import * as X from the package`
51
+ * - `elements` — every JSX element, in document order, as described below
52
+ * - `constants` — top-level `const` name → `{ text, line }` for string consts
53
+ * - `componentsUsed` — the design-system base names used as JSX
54
+ * - `parseErrors`
55
+ *
56
+ * An element is
57
+ * `{ index, parent, tag, base, component, isDs, line, props, spread, form }`
58
+ * where `props` is a Map of attribute name → `{ literals, identifiers, text,
59
+ * line, expression }` and `form` is the index of the nearest `<form>`
60
+ * ancestor (or `null`).
61
+ */
62
+ export function analyseForms(ts, text, rel) {
63
+ const sf = createSourceFile(ts, text, rel);
64
+ const lineOf = (node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
65
+
66
+ const imports = new Map();
67
+ const namespaces = new Set();
68
+ for (const stmt of sf.statements) {
69
+ if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier)) continue;
70
+ if (!isDsSpecifier(stmt.moduleSpecifier.text)) continue;
71
+ const clause = stmt.importClause;
72
+ if (!clause || clause.isTypeOnly) continue;
73
+ const bindings = clause.namedBindings;
74
+ if (bindings && ts.isNamespaceImport(bindings)) namespaces.add(bindings.name.text);
75
+ else if (bindings && ts.isNamedImports(bindings)) {
76
+ for (const el of bindings.elements) {
77
+ if (!el.isTypeOnly) imports.set(el.name.text, (el.propertyName ?? el.name).text);
78
+ }
79
+ }
80
+ }
81
+
82
+ /** Top-level `const NAME = "text"`, the shape the sentinels are declared in. */
83
+ const constants = new Map();
84
+ for (const stmt of sf.statements) {
85
+ const list = ts.isVariableStatement(stmt) ? stmt.declarationList : null;
86
+ if (!list || !(list.flags & ts.NodeFlags.Const)) continue;
87
+ for (const decl of list.declarations) {
88
+ if (!ts.isIdentifier(decl.name) || !decl.initializer) continue;
89
+ const init = decl.initializer;
90
+ if (ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init)) {
91
+ constants.set(decl.name.text, { text: init.text, line: lineOf(init) });
92
+ }
93
+ }
94
+ }
95
+
96
+ /** String literals inside an expression, at any depth below JSX. */
97
+ const literalsIn = (node, out = []) => {
98
+ if (!node) return out;
99
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
100
+ out.push(node.text);
101
+ return out;
102
+ }
103
+ if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) {
104
+ return out;
105
+ }
106
+ // A block body: `forEachChild` stops at the first truthy return, and the
107
+ // accumulator is always truthy.
108
+ ts.forEachChild(node, (child) => {
109
+ literalsIn(child, out);
110
+ });
111
+ return out;
112
+ };
113
+
114
+ /**
115
+ * Identifier names an expression reads. Member names (`a.b`), object keys
116
+ * and JSX subtrees are excluded: what matters is which bindings the value
117
+ * depends on, so that two attributes reading `entityType` can be recognised
118
+ * as two views of the same state.
119
+ */
120
+ const identifiersIn = (node, out = new Set()) => {
121
+ if (!node) return out;
122
+ if (ts.isIdentifier(node)) {
123
+ const parent = node.parent;
124
+ const isMemberName = parent && ts.isPropertyAccessExpression(parent) && parent.name === node;
125
+ const isKey =
126
+ parent &&
127
+ (ts.isPropertyAssignment(parent) || ts.isPropertySignature(parent)) &&
128
+ parent.name === node;
129
+ if (!isMemberName && !isKey) out.add(node.text);
130
+ return out;
131
+ }
132
+ if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) {
133
+ return out;
134
+ }
135
+ ts.forEachChild(node, (child) => {
136
+ identifiersIn(child, out);
137
+ });
138
+ return out;
139
+ };
140
+
141
+ const resolveTag = (tag) => {
142
+ const members = [];
143
+ let current = tag;
144
+ while (ts.isPropertyAccessExpression(current)) {
145
+ members.unshift(current.name.text);
146
+ current = current.expression;
147
+ }
148
+ if (!ts.isIdentifier(current)) return null;
149
+ const root = current.text;
150
+ if (namespaces.has(root) && members.length) {
151
+ return { base: members[0], component: members.join(".") };
152
+ }
153
+ const base = imports.get(root);
154
+ if (!base) return null;
155
+ return { base, component: [base, ...members].join(".") };
156
+ };
157
+
158
+ const elements = [];
159
+ const openingOf = (node) => (ts.isJsxElement(node) ? node.openingElement : node);
160
+
161
+ const record = (node, parent, form) => {
162
+ const opening = openingOf(node);
163
+ const info = resolveTag(opening.tagName);
164
+ const tag = opening.tagName.getText(sf);
165
+ const props = new Map();
166
+ let spread = false;
167
+ for (const attr of opening.attributes.properties) {
168
+ if (ts.isJsxSpreadAttribute(attr)) {
169
+ spread = true;
170
+ continue;
171
+ }
172
+ if (!ts.isJsxAttribute(attr)) continue;
173
+ const name = attr.name.getText(sf);
174
+ const init = attr.initializer;
175
+ if (init === undefined) {
176
+ // A bare attribute (`required`) is the boolean `true`.
177
+ props.set(name, {
178
+ literals: [],
179
+ identifiers: [],
180
+ text: "true",
181
+ line: lineOf(attr),
182
+ expression: false,
183
+ });
184
+ continue;
185
+ }
186
+ const inner = ts.isJsxExpression(init) ? (init.expression ?? null) : init;
187
+ const isLiteral =
188
+ inner !== null && (ts.isStringLiteral(inner) || ts.isNoSubstitutionTemplateLiteral(inner));
189
+ props.set(name, {
190
+ literals: inner ? literalsIn(inner) : [],
191
+ identifiers: inner ? [...identifiersIn(inner)] : [],
192
+ text: inner ? inner.getText(sf) : null,
193
+ line: lineOf(attr),
194
+ expression: Boolean(inner) && !isLiteral,
195
+ });
196
+ }
197
+ const entry = {
198
+ index: elements.length,
199
+ parent,
200
+ tag,
201
+ base: info?.base ?? null,
202
+ component: info?.component ?? null,
203
+ isDs: Boolean(info),
204
+ line: lineOf(opening),
205
+ props,
206
+ spread,
207
+ form,
208
+ node,
209
+ };
210
+ elements.push(entry);
211
+ return entry;
212
+ };
213
+
214
+ const walk = (node, parent, form) => {
215
+ if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
216
+ const entry = record(node, parent, form);
217
+ const nextForm = /^(?:form|Form)$/.test(entry.tag) ? entry.index : form;
218
+ ts.forEachChild(node, (child) => walk(child, entry.index, nextForm));
219
+ return;
220
+ }
221
+ ts.forEachChild(node, (child) => walk(child, parent, form));
222
+ };
223
+ walk(sf, null, null);
224
+
225
+ const parseErrors = (sf.parseDiagnostics ?? []).map((d) => ({
226
+ line: d.start === undefined ? null : sf.getLineAndCharacterOfPosition(d.start).line + 1,
227
+ message: ts.flattenDiagnosticMessageText(d.messageText, " "),
228
+ }));
229
+
230
+ return {
231
+ sf,
232
+ lineOf,
233
+ literalsIn,
234
+ identifiersIn: (node) => [...identifiersIn(node)],
235
+ imports,
236
+ namespaces,
237
+ elements,
238
+ constants,
239
+ componentsUsed: new Set(elements.filter((e) => e.isDs).map((e) => e.base)),
240
+ parseErrors,
241
+ };
242
+ }
243
+
244
+ /** The literal value of an attribute when it is a single string, else `null`. */
245
+ export const literalValue = (prop) =>
246
+ prop && !prop.expression && prop.literals.length === 1 ? prop.literals[0] : null;
247
+
248
+ /** Whether `inner` is `outer` or sits below it in the element tree. */
249
+ export function isWithin(elements, inner, outer) {
250
+ if (outer === null || inner === null) return false;
251
+ for (let i = inner; i !== null; i = elements[i].parent) if (i === outer) return true;
252
+ return false;
253
+ }
Binary file
@@ -3,8 +3,10 @@
3
3
  * codemods listed here can be run by id; the sequence is the plan's.
4
4
  */
5
5
  export const CODEMODS = [
6
+ { id: "CM-14", module: "../transforms/cm-14-hidden-mirrors.mjs" },
6
7
  { id: "CM-15", module: "../transforms/cm-15-dom-selectors.mjs" },
7
8
  { id: "CM-16", module: "../transforms/cm-16-globals-css.mjs" },
9
+ { id: "CM-20", module: "../transforms/cm-20-select-sentinels.mjs" },
8
10
  ];
9
11
 
10
12
  export const findCodemod = (id) =>