@hanzo/design 0.4.8 → 0.4.10

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/dist/index.d.ts CHANGED
@@ -33,4 +33,6 @@ export declare function tokenValue(name: CssVarName): string | undefined;
33
33
  * bare. Pass a URL you serve.
34
34
  */
35
35
  export declare function injectDesignCss(href: string): void;
36
+ export { vars, css, isColor, TYPE_MIN, TYPE_MAX } from './preference';
37
+ export type { Preference, Density } from './preference';
36
38
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAWA,cAAc,iBAAiB,CAAA;AAC/B,OAAO,EAAW,KAAK,UAAU,EAAE,MAAM,iBAAiB,CAAA;AAE1D,uFAAuF;AACvF,MAAM,MAAM,SAAS,GAAG,UAAU,SAAS,KAAK,MAAM,CAAC,EAAE,GAAG,CAAC,GAAG,KAAK,CAAA;AAErE;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,MAAM,CAAC,IAAI,EAAE,UAAU,GAAG,SAAS,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAI9E;AAED,oFAAoF;AACpF,wBAAgB,UAAU,CAAC,IAAI,EAAE,UAAU,GAAG,MAAM,GAAG,SAAS,CAE/D;AAED;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAQlD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAWA,cAAc,iBAAiB,CAAA;AAC/B,OAAO,EAAW,KAAK,UAAU,EAAE,MAAM,iBAAiB,CAAA;AAE1D,uFAAuF;AACvF,MAAM,MAAM,SAAS,GAAG,UAAU,SAAS,KAAK,MAAM,CAAC,EAAE,GAAG,CAAC,GAAG,KAAK,CAAA;AAErE;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,MAAM,CAAC,IAAI,EAAE,UAAU,GAAG,SAAS,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAI9E;AAED,oFAAoF;AACpF,wBAAgB,UAAU,CAAC,IAAI,EAAE,UAAU,GAAG,MAAM,GAAG,SAAS,CAE/D;AAED;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAQlD;AAKD,OAAO,EAAE,IAAI,EAAE,GAAG,EAAE,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,cAAc,CAAC;AACtE,YAAY,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC"}
package/dist/index.js CHANGED
@@ -58,3 +58,7 @@ export function injectDesignCss(href) {
58
58
  l.setAttribute('data-hanzo-design', '');
59
59
  document.head.appendChild(l);
60
60
  }
61
+ // A person's own reading of the system — type size, density, accent — as CSS
62
+ // custom properties. Pure: it maps a preference to variables and returns them,
63
+ // so an app, an embedded preview and a server render all apply it the same way.
64
+ export { vars, css, isColor, TYPE_MIN, TYPE_MAX } from './preference';
@@ -0,0 +1,59 @@
1
+ /**
2
+ * A person's own reading of the system: type size, density, accent.
3
+ *
4
+ * The ramps in `tokens/*.css` are the SHAPE — the relationships between sizes,
5
+ * gaps and hues that make a surface read as one thing. A preference does not
6
+ * replace them and cannot reach inside them. It applies ONE transform to a whole
7
+ * axis, so every size still stands in the same relation to every other size and
8
+ * a customised UI is the same design at a different setting, not a different
9
+ * design.
10
+ *
11
+ * It is a pure function on purpose. `vars()` maps a preference to the custom
12
+ * properties that carry it, and returns them; nothing here touches a document.
13
+ * That is what lets it be tested without a browser and reused by every surface —
14
+ * an app, an embedded builder preview, a server render that inlines the result.
15
+ *
16
+ * Three axes, because those are the three the token files already separate:
17
+ *
18
+ * type scales the --text-* ramp
19
+ * density scales the --grid-gap-* ramp (the spacing between things)
20
+ * accent sets --primary / --accent (the one hue the monochrome brand allows)
21
+ */
22
+ export type Density = "compact" | "default" | "comfortable";
23
+ export interface Preference {
24
+ /** Multiplier on the type ramp. 1 is the published scale. */
25
+ type?: number;
26
+ density?: Density;
27
+ /** A CSS colour for --primary / --accent. Rejected unless it is one. */
28
+ accent?: string;
29
+ }
30
+ /**
31
+ * The type multiplier is CLAMPED, and the bounds are not arbitrary.
32
+ *
33
+ * Below 0.85 the smallest rung (--text-xs, 11px) drops under 9.4px, which stops
34
+ * being small and starts being unreadable — and a preference that lets someone
35
+ * render their own tools illegible is a trap, not a choice. Above 1.4 the
36
+ * chrome stops fitting its own containers: this app's builder header already
37
+ * overlaps its actions below 1440px at scale 1.
38
+ */
39
+ export declare const TYPE_MIN = 0.85;
40
+ export declare const TYPE_MAX = 1.4;
41
+ /**
42
+ * Is this a colour, or is it something being smuggled into a style attribute?
43
+ *
44
+ * A preference is user input and its destination is CSS. `#fff`, `rgb(...)`,
45
+ * `oklch(...)` and the bare keywords are colours; anything carrying a `;`, a
46
+ * `}`, or a `url(` is trying to be a second declaration, and the answer is to
47
+ * drop the axis rather than to sanitise a string into something plausible.
48
+ */
49
+ export declare function isColor(v: string): boolean;
50
+ /**
51
+ * The custom properties a preference produces.
52
+ *
53
+ * Only the axes actually set appear, so an app can spread the result over
54
+ * whatever it already has without a default silently overriding a brand.
55
+ */
56
+ export declare function vars(p: Preference): Record<string, string>;
57
+ /** `vars()` as a declaration block, for a <style> tag or an SSR inline. */
58
+ export declare function css(p: Preference, selector?: string): string;
59
+ //# sourceMappingURL=preference.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"preference.d.ts","sourceRoot":"","sources":["../src/preference.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,MAAM,MAAM,OAAO,GAAG,SAAS,GAAG,SAAS,GAAG,aAAa,CAAC;AAE5D,MAAM,WAAW,UAAU;IACzB,6DAA6D;IAC7D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,wEAAwE;IACxE,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,QAAQ,OAAO,CAAC;AAC7B,eAAO,MAAM,QAAQ,MAAM,CAAC;AA2B5B;;;;;;;GAOG;AACH,wBAAgB,OAAO,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAS1C;AAED;;;;;GAKG;AACH,wBAAgB,IAAI,CAAC,CAAC,EAAE,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAqB1D;AAED,2EAA2E;AAC3E,wBAAgB,GAAG,CAAC,CAAC,EAAE,UAAU,EAAE,QAAQ,SAAc,GAAG,MAAM,CAKjE"}
@@ -0,0 +1,104 @@
1
+ /**
2
+ * A person's own reading of the system: type size, density, accent.
3
+ *
4
+ * The ramps in `tokens/*.css` are the SHAPE — the relationships between sizes,
5
+ * gaps and hues that make a surface read as one thing. A preference does not
6
+ * replace them and cannot reach inside them. It applies ONE transform to a whole
7
+ * axis, so every size still stands in the same relation to every other size and
8
+ * a customised UI is the same design at a different setting, not a different
9
+ * design.
10
+ *
11
+ * It is a pure function on purpose. `vars()` maps a preference to the custom
12
+ * properties that carry it, and returns them; nothing here touches a document.
13
+ * That is what lets it be tested without a browser and reused by every surface —
14
+ * an app, an embedded builder preview, a server render that inlines the result.
15
+ *
16
+ * Three axes, because those are the three the token files already separate:
17
+ *
18
+ * type scales the --text-* ramp
19
+ * density scales the --grid-gap-* ramp (the spacing between things)
20
+ * accent sets --primary / --accent (the one hue the monochrome brand allows)
21
+ */
22
+ /**
23
+ * The type multiplier is CLAMPED, and the bounds are not arbitrary.
24
+ *
25
+ * Below 0.85 the smallest rung (--text-xs, 11px) drops under 9.4px, which stops
26
+ * being small and starts being unreadable — and a preference that lets someone
27
+ * render their own tools illegible is a trap, not a choice. Above 1.4 the
28
+ * chrome stops fitting its own containers: this app's builder header already
29
+ * overlaps its actions below 1440px at scale 1.
30
+ */
31
+ export const TYPE_MIN = 0.85;
32
+ export const TYPE_MAX = 1.4;
33
+ const DENSITY_SCALE = {
34
+ compact: 0.75,
35
+ default: 1,
36
+ comfortable: 1.35,
37
+ };
38
+ /** The type ramp, by name, in rem at a 16px root — mirrors tokens/typography.css. */
39
+ const TEXT = {
40
+ xs: 0.6875, sm: 0.8125, base: 0.875, lg: 1, xl: 1.125,
41
+ "2xl": 1.3125, "3xl": 1.625, "4xl": 2, "5xl": 2.5,
42
+ "6xl": 3.25, "7xl": 4, "8xl": 5.25, "9xl": 7,
43
+ };
44
+ /** The gap ramp, in rem — mirrors tokens/grid.css. */
45
+ const GAP = {
46
+ "grid-gap-tight": 0.5,
47
+ "grid-gap": 1,
48
+ "grid-gap-loose": 1.5,
49
+ };
50
+ const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
51
+ /** Trim to 4dp so a multiplier cannot emit a 17-digit float into a stylesheet. */
52
+ const rem = (n) => `${Math.round(n * 10000) / 10000}rem`;
53
+ /**
54
+ * Is this a colour, or is it something being smuggled into a style attribute?
55
+ *
56
+ * A preference is user input and its destination is CSS. `#fff`, `rgb(...)`,
57
+ * `oklch(...)` and the bare keywords are colours; anything carrying a `;`, a
58
+ * `}`, or a `url(` is trying to be a second declaration, and the answer is to
59
+ * drop the axis rather than to sanitise a string into something plausible.
60
+ */
61
+ export function isColor(v) {
62
+ const s = v.trim();
63
+ if (!s || s.length > 64)
64
+ return false;
65
+ if (/[;{}()]/.test(s) && !/^(rgb|rgba|hsl|hsla|oklch|oklab|lab|lch|color)\([^;{}]*\)$/i.test(s))
66
+ return false;
67
+ return (/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(s) ||
68
+ /^(rgb|rgba|hsl|hsla|oklch|oklab|lab|lch|color)\([^;{}]*\)$/i.test(s) ||
69
+ /^[a-z]{3,20}$/i.test(s));
70
+ }
71
+ /**
72
+ * The custom properties a preference produces.
73
+ *
74
+ * Only the axes actually set appear, so an app can spread the result over
75
+ * whatever it already has without a default silently overriding a brand.
76
+ */
77
+ export function vars(p) {
78
+ const out = {};
79
+ if (typeof p.type === "number" && Number.isFinite(p.type)) {
80
+ const k = clamp(p.type, TYPE_MIN, TYPE_MAX);
81
+ for (const [name, size] of Object.entries(TEXT))
82
+ out[`--text-${name}`] = rem(size * k);
83
+ }
84
+ if (p.density && p.density in DENSITY_SCALE) {
85
+ const k = DENSITY_SCALE[p.density];
86
+ for (const [name, size] of Object.entries(GAP))
87
+ out[`--${name}`] = rem(size * k);
88
+ }
89
+ if (p.accent && isColor(p.accent)) {
90
+ // Both names, because the ramp uses --primary for action surfaces and
91
+ // --accent for selection. One hue, stated once, landing on both.
92
+ out["--primary"] = p.accent.trim();
93
+ out["--accent"] = p.accent.trim();
94
+ }
95
+ return out;
96
+ }
97
+ /** `vars()` as a declaration block, for a <style> tag or an SSR inline. */
98
+ export function css(p, selector = "html:root") {
99
+ const v = vars(p);
100
+ const keys = Object.keys(v);
101
+ if (!keys.length)
102
+ return "";
103
+ return `${selector}{${keys.map((k) => `${k}:${v[k]}`).join(";")}}`;
104
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/design",
3
- "version": "0.4.8",
3
+ "version": "0.4.10",
4
4
  "packageManager": "pnpm@11.17.0",
5
5
  "description": "Hanzo Design System \u2014 monochrome, dark-default tokens + components + brand assets, the single source of truth for every Hanzo surface. CSS + typed programmatic tokens.",
6
6
  "license": "MIT OR Apache-2.0",
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "scripts": {
34
34
  "gen": "node scripts/gen-tokens.mjs",
35
- "test": "node scripts/check-tokens.mjs && node scripts/lint.mjs .",
35
+ "test": "node scripts/check-tokens.mjs && node scripts/check-preference.mjs && node scripts/lint.mjs .",
36
36
  "build": "pnpm gen && pnpm test && tsc -p tsconfig.json",
37
37
  "prepublishOnly": "pnpm build",
38
38
  "lint": "node scripts/lint.mjs"
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Preference contract, checked the way this package already checks tokens:
3
+ * a plain node script, no framework, run by `npm test`.
4
+ *
5
+ * The three things worth pinning are the three that would hurt: a preference
6
+ * that renders someone's own tools illegible, one that lets a colour field
7
+ * carry a second declaration into a stylesheet, and one that emits names no
8
+ * token file reads.
9
+ */
10
+ import { readFileSync } from "node:fs";
11
+ import { fileURLToPath } from "node:url";
12
+ import { dirname, join } from "node:path";
13
+
14
+ const here = dirname(fileURLToPath(import.meta.url));
15
+ const root = join(here, "..");
16
+
17
+ // Load the source directly — this package ships tokens, and the preference
18
+ // module is small enough to evaluate without a build step in the check.
19
+ const src = readFileSync(join(root, "src/preference.ts"), "utf8");
20
+ const js = src
21
+ .replace(/^import[^\n]*\n/gm, "")
22
+ .replace(/export (type|interface) [\s\S]*?\n}\n/g, "")
23
+ .replace(/export type [^\n]*\n/g, "")
24
+ .replace(/: Record<[^>]*>/g, "")
25
+ .replace(/: Preference/g, "")
26
+ .replace(/: Density/g, "")
27
+ .replace(/: string/g, "")
28
+ .replace(/: number/g, "")
29
+ .replace(/: boolean/g, "")
30
+ .replace(/export /g, "");
31
+
32
+ const mod = new Function(`${js}; return { vars, css, isColor, TYPE_MIN, TYPE_MAX };`)();
33
+ const { vars, css, isColor, TYPE_MIN, TYPE_MAX } = mod;
34
+
35
+ let failed = 0;
36
+ const check = (name, fn) => {
37
+ try {
38
+ fn();
39
+ console.log(` ok ${name}`);
40
+ } catch (e) {
41
+ failed++;
42
+ console.error(` FAIL ${name}\n ${e.message}`);
43
+ }
44
+ };
45
+ const eq = (a, b, m) => {
46
+ const A = JSON.stringify(a), B = JSON.stringify(b);
47
+ if (A !== B) throw new Error(`${m || ""} got ${A} want ${B}`);
48
+ };
49
+ const ok = (v, m) => { if (!v) throw new Error(m || "expected truthy"); };
50
+
51
+ console.log("preference:");
52
+
53
+ check("an unset preference changes nothing", () => {
54
+ eq(vars({}), {});
55
+ eq(css({}), "");
56
+ });
57
+
58
+ check("type scales the WHOLE ramp, keeping every relation", () => {
59
+ const v = vars({ type: 2 }); // clamps to TYPE_MAX
60
+ const base = parseFloat(v["--text-base"]);
61
+ const xs = parseFloat(v["--text-xs"]);
62
+ // 0.875 / 0.6875 must survive the transform
63
+ const ratio = base / xs;
64
+ ok(Math.abs(ratio - 0.875 / 0.6875) < 1e-9, `ratio drifted: ${ratio}`);
65
+ });
66
+
67
+ check("type is CLAMPED — a preference cannot make the UI illegible", () => {
68
+ const tiny = vars({ type: 0.1 });
69
+ eq(tiny["--text-base"], `${Math.round(0.875 * TYPE_MIN * 10000) / 10000}rem`, "min");
70
+ const huge = vars({ type: 99 });
71
+ eq(huge["--text-base"], `${Math.round(0.875 * TYPE_MAX * 10000) / 10000}rem`, "max");
72
+ // the smallest rung must stay readable at the floor
73
+ ok(parseFloat(tiny["--text-xs"]) * 16 >= 9, "xs fell below 9px");
74
+ });
75
+
76
+ check("density moves the gap ramp, not the type ramp", () => {
77
+ const v = vars({ density: "compact" });
78
+ ok(v["--grid-gap"], "no gap emitted");
79
+ ok(!v["--text-base"], "density must not touch type");
80
+ ok(parseFloat(v["--grid-gap"]) < 1, "compact should tighten");
81
+ ok(parseFloat(vars({ density: "comfortable" })["--grid-gap"]) > 1, "comfortable should loosen");
82
+ });
83
+
84
+ check("a colour lands on both --primary and --accent", () => {
85
+ const v = vars({ accent: "#808000" });
86
+ eq(v["--primary"], "#808000");
87
+ eq(v["--accent"], "#808000");
88
+ });
89
+
90
+ check("a NON-colour is dropped, never sanitised into something plausible", () => {
91
+ for (const bad of [
92
+ "red; background-image:url(//evil/x)",
93
+ "}html{display:none",
94
+ "url(//evil/x)",
95
+ "expression(alert(1))",
96
+ "",
97
+ " ",
98
+ ]) {
99
+ const v = vars({ accent: bad });
100
+ ok(!("--primary" in v), `accepted ${JSON.stringify(bad)}`);
101
+ ok(!isColor(bad), `isColor said yes to ${JSON.stringify(bad)}`);
102
+ }
103
+ });
104
+
105
+ check("real colour notations are accepted", () => {
106
+ for (const good of ["#fff", "#ffffff", "#ffffffff", "rgb(1 2 3)", "oklch(.7 .1 120)", "rebeccapurple"]) {
107
+ ok(isColor(good), `isColor said no to ${good}`);
108
+ }
109
+ });
110
+
111
+ check("css() emits ONE block for the selector it is given", () => {
112
+ const out = css({ type: 1.1, density: "compact", accent: "#fff" }, ":root");
113
+ ok(out.startsWith(":root{") && out.endsWith("}"), out.slice(0, 40));
114
+ ok(!out.includes("}"+"{"), "emitted more than one block");
115
+ });
116
+
117
+ check("every emitted name is one the token files actually declare", () => {
118
+ // A variable nothing reads is a write into another document — the exact
119
+ // mistake this package exists to prevent.
120
+ const declared = new Set();
121
+ for (const f of ["tokens/typography.css", "tokens/grid.css", "tokens/colors.css"]) {
122
+ for (const m of readFileSync(join(root, f), "utf8").matchAll(/^\s*(--[a-z0-9-]+)\s*:/gm)) {
123
+ declared.add(m[1]);
124
+ }
125
+ }
126
+ const emitted = Object.keys(vars({ type: 1.1, density: "compact", accent: "#fff" }));
127
+ const orphans = emitted.filter((k) => !declared.has(k));
128
+ eq(orphans, [], "emitted names no token file declares:");
129
+ });
130
+
131
+ if (failed) {
132
+ console.error(`\n${failed} preference check(s) failed`);
133
+ process.exit(1);
134
+ }
135
+ console.log("preference: all checks passed");
@@ -140,30 +140,38 @@ const pass = (msg) => console.log(` ok ${msg}`)
140
140
  : fail('base.css is UNLAYERED — its element rules outrank every utility an app writes')
141
141
  }
142
142
 
143
- // ── 1d. a focused element gets exactly ONE indicator ─────────────────────
144
- // The field rule and the generic ring rule both compute to (0,1,0) :where()
145
- // zeroes its contents and each side keeps one pseudo-class so the cascade
146
- // falls through to SOURCE ORDER inside @layer base. The generic rule was
147
- // written later, so it overrode the `outline:none` that the field rule states
148
- // expressly to prevent it, and every focused input on every consumer drew the
149
- // 2px ring AND the edge+halo. Both rules read as correct in isolation; only
150
- // their order was wrong, which is why nobody saw it in either file.
143
+ // ── 1d. exactly ONE rule decides focus ───────────────────────────────────
144
+ // Until 0.4.9 there were two: a field rule that suppressed the outline and drew
145
+ // a brightened edge + halo, and the generic ring. Both computed to (0,1,0)
146
+ // :where() zeroes its contents and each side keeps one pseudo-class so the
147
+ // cascade fell through to SOURCE ORDER inside @layer base, the generic rule was
148
+ // written later, and it overrode the `outline:none` the field rule stated
149
+ // expressly to prevent it. Every focused input on every consumer drew BOTH.
150
+ // Each rule read as correct alone; the defect existed only in their order,
151
+ // which is why it survived review in both files.
151
152
  //
152
- // Order is not testable as intent, so this tests the property instead: a rule
153
- // that paints an outline on focus must not be able to land on a field.
153
+ // Order is not testable as intent, so this tests the property that replaced it:
154
+ // one rule paints the indicator, and nothing else touches focus. A second rule
155
+ // is how they disagree, `outline:none` is how an indicator disappears, and
156
+ // box-shadow is how a second one appears — none of the three can return quietly.
154
157
  {
155
158
  const base = strip(read(join(tokensDir, 'base.css')))
156
- const FIELDS = 'input,select,textarea'
157
- // `outline:none` disarms; anything else paints. `outline-offset` is a
158
- // different property and never matches — the colon must follow `outline`.
159
- const paints = (body) => /(?:^|[;{\s])outline\s*:\s*(?!none\b)[^;}]+/.test(body)
160
- const scoped = (sel) => sel.includes(`:where(${FIELDS})`) || sel.includes(`:not(${FIELDS})`)
161
- const doubled = [...base.matchAll(/([^{}]+)\{([^{}]*)\}/g)]
159
+ // `outline-offset` is a different property and never matches: the colon must
160
+ // follow `outline` itself.
161
+ const OUTLINE = /(?:^|[;{\s])outline\s*:\s*([^;}]+)/
162
+ const rules = [...base.matchAll(/([^{}]+)\{([^{}]*)\}/g)]
162
163
  .map(([, sel, body]) => ({ sel: sel.trim().replace(/\s+/g, ' '), body }))
163
- .filter(({ sel, body }) => sel.includes(':focus-visible') && paints(body) && !scoped(sel))
164
- doubled.length
165
- ? doubled.forEach(({ sel }) => fail(`\`${sel}\` paints an outline on a field that already draws its own edge + halo — two focus indicators`))
166
- : pass('one focus indicator per element: fields brighten their edge, everything else rings')
164
+ .filter(({ sel }) => sel.includes(':focus-visible'))
165
+
166
+ const paints = rules.filter(({ body }) => { const m = body.match(OUTLINE); return m && m[1].trim() !== 'none' })
167
+ const mutes = rules.filter(({ body }) => { const m = body.match(OUTLINE); return m && m[1].trim() === 'none' })
168
+ const halos = rules.filter(({ body }) => /(?:^|[;{\s])box-shadow\s*:/.test(body))
169
+
170
+ paints.length === 1
171
+ ? pass(`one focus indicator, one rule: \`${paints[0].sel}\``)
172
+ : fail(`${paints.length} rules paint a focus outline (${paints.map((r) => r.sel).join(' / ')}) — equal specificity inside @layer base, so source order decides which one a user actually sees`)
173
+ mutes.forEach(({ sel }) => fail(`\`${sel}\` sets outline:none — it removes the focus indicator instead of replacing it`))
174
+ halos.forEach(({ sel }) => fail(`\`${sel}\` adds a box-shadow on focus — a second indicator beside the ring`))
167
175
  }
168
176
 
169
177
  // ── 2. every var() used inside the token layer must resolve ──────────────
package/src/index.ts CHANGED
@@ -62,3 +62,9 @@ export function injectDesignCss(href: string): void {
62
62
  l.setAttribute('data-hanzo-design', '')
63
63
  document.head.appendChild(l)
64
64
  }
65
+
66
+ // A person's own reading of the system — type size, density, accent — as CSS
67
+ // custom properties. Pure: it maps a preference to variables and returns them,
68
+ // so an app, an embedded preview and a server render all apply it the same way.
69
+ export { vars, css, isColor, TYPE_MIN, TYPE_MAX } from './preference';
70
+ export type { Preference, Density } from './preference';
@@ -0,0 +1,124 @@
1
+ /**
2
+ * A person's own reading of the system: type size, density, accent.
3
+ *
4
+ * The ramps in `tokens/*.css` are the SHAPE — the relationships between sizes,
5
+ * gaps and hues that make a surface read as one thing. A preference does not
6
+ * replace them and cannot reach inside them. It applies ONE transform to a whole
7
+ * axis, so every size still stands in the same relation to every other size and
8
+ * a customised UI is the same design at a different setting, not a different
9
+ * design.
10
+ *
11
+ * It is a pure function on purpose. `vars()` maps a preference to the custom
12
+ * properties that carry it, and returns them; nothing here touches a document.
13
+ * That is what lets it be tested without a browser and reused by every surface —
14
+ * an app, an embedded builder preview, a server render that inlines the result.
15
+ *
16
+ * Three axes, because those are the three the token files already separate:
17
+ *
18
+ * type scales the --text-* ramp
19
+ * density scales the --grid-gap-* ramp (the spacing between things)
20
+ * accent sets --primary / --accent (the one hue the monochrome brand allows)
21
+ */
22
+
23
+ export type Density = "compact" | "default" | "comfortable";
24
+
25
+ export interface Preference {
26
+ /** Multiplier on the type ramp. 1 is the published scale. */
27
+ type?: number;
28
+ density?: Density;
29
+ /** A CSS colour for --primary / --accent. Rejected unless it is one. */
30
+ accent?: string;
31
+ }
32
+
33
+ /**
34
+ * The type multiplier is CLAMPED, and the bounds are not arbitrary.
35
+ *
36
+ * Below 0.85 the smallest rung (--text-xs, 11px) drops under 9.4px, which stops
37
+ * being small and starts being unreadable — and a preference that lets someone
38
+ * render their own tools illegible is a trap, not a choice. Above 1.4 the
39
+ * chrome stops fitting its own containers: this app's builder header already
40
+ * overlaps its actions below 1440px at scale 1.
41
+ */
42
+ export const TYPE_MIN = 0.85;
43
+ export const TYPE_MAX = 1.4;
44
+
45
+ const DENSITY_SCALE: Record<Density, number> = {
46
+ compact: 0.75,
47
+ default: 1,
48
+ comfortable: 1.35,
49
+ };
50
+
51
+ /** The type ramp, by name, in rem at a 16px root — mirrors tokens/typography.css. */
52
+ const TEXT: Record<string, number> = {
53
+ xs: 0.6875, sm: 0.8125, base: 0.875, lg: 1, xl: 1.125,
54
+ "2xl": 1.3125, "3xl": 1.625, "4xl": 2, "5xl": 2.5,
55
+ "6xl": 3.25, "7xl": 4, "8xl": 5.25, "9xl": 7,
56
+ };
57
+
58
+ /** The gap ramp, in rem — mirrors tokens/grid.css. */
59
+ const GAP: Record<string, number> = {
60
+ "grid-gap-tight": 0.5,
61
+ "grid-gap": 1,
62
+ "grid-gap-loose": 1.5,
63
+ };
64
+
65
+ const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
66
+
67
+ /** Trim to 4dp so a multiplier cannot emit a 17-digit float into a stylesheet. */
68
+ const rem = (n: number) => `${Math.round(n * 10000) / 10000}rem`;
69
+
70
+ /**
71
+ * Is this a colour, or is it something being smuggled into a style attribute?
72
+ *
73
+ * A preference is user input and its destination is CSS. `#fff`, `rgb(...)`,
74
+ * `oklch(...)` and the bare keywords are colours; anything carrying a `;`, a
75
+ * `}`, or a `url(` is trying to be a second declaration, and the answer is to
76
+ * drop the axis rather than to sanitise a string into something plausible.
77
+ */
78
+ export function isColor(v: string): boolean {
79
+ const s = v.trim();
80
+ if (!s || s.length > 64) return false;
81
+ if (/[;{}()]/.test(s) && !/^(rgb|rgba|hsl|hsla|oklch|oklab|lab|lch|color)\([^;{}]*\)$/i.test(s)) return false;
82
+ return (
83
+ /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(s) ||
84
+ /^(rgb|rgba|hsl|hsla|oklch|oklab|lab|lch|color)\([^;{}]*\)$/i.test(s) ||
85
+ /^[a-z]{3,20}$/i.test(s)
86
+ );
87
+ }
88
+
89
+ /**
90
+ * The custom properties a preference produces.
91
+ *
92
+ * Only the axes actually set appear, so an app can spread the result over
93
+ * whatever it already has without a default silently overriding a brand.
94
+ */
95
+ export function vars(p: Preference): Record<string, string> {
96
+ const out: Record<string, string> = {};
97
+
98
+ if (typeof p.type === "number" && Number.isFinite(p.type)) {
99
+ const k = clamp(p.type, TYPE_MIN, TYPE_MAX);
100
+ for (const [name, size] of Object.entries(TEXT)) out[`--text-${name}`] = rem(size * k);
101
+ }
102
+
103
+ if (p.density && p.density in DENSITY_SCALE) {
104
+ const k = DENSITY_SCALE[p.density];
105
+ for (const [name, size] of Object.entries(GAP)) out[`--${name}`] = rem(size * k);
106
+ }
107
+
108
+ if (p.accent && isColor(p.accent)) {
109
+ // Both names, because the ramp uses --primary for action surfaces and
110
+ // --accent for selection. One hue, stated once, landing on both.
111
+ out["--primary"] = p.accent.trim();
112
+ out["--accent"] = p.accent.trim();
113
+ }
114
+
115
+ return out;
116
+ }
117
+
118
+ /** `vars()` as a declaration block, for a <style> tag or an SSR inline. */
119
+ export function css(p: Preference, selector = "html:root"): string {
120
+ const v = vars(p);
121
+ const keys = Object.keys(v);
122
+ if (!keys.length) return "";
123
+ return `${selector}{${keys.map((k) => `${k}:${v[k]}`).join(";")}}`;
124
+ }
package/styles.css CHANGED
@@ -834,20 +834,26 @@
834
834
  padding:0 var(--space-3);
835
835
  }
836
836
  :where(textarea){padding:var(--space-2) var(--space-3);resize:vertical}
837
- /* ——— focus, on a control ———
838
- Not the generic ring. A field already HAS an edge, so focus brightens that
839
- edge (.15 -> .22) and adds a soft halo just outside it — which is what the
840
- reference does (.composer-box:focus-within) and what separates a focused
841
- field from a browser default. `outline:none` drops the UA's own focus ring,
842
- which would otherwise draw a second, harder box around this one; the
843
- generic ring below excludes fields for the same reason. */
844
- :where(input,select,textarea):focus-visible{
845
- outline:none;
846
- border-color:var(--border-focus);
847
- box-shadow:var(--ring-focus);
848
- }
849
- /* A hovered field lifts its surface a rung. Nudging its EDGE from .15 to .16
850
- is a state nobody can see. */
837
+ /* A field has NO focus rule of its own — see the ring at the bottom of this
838
+ layer, which is the one focus indicator for everything.
839
+
840
+ There used to be one here: `outline:none` plus a brightened edge (.15 ->
841
+ .22) plus a soft halo, the composer look. It was removed because it could
842
+ not do the job in either of the two ways that matter.
843
+
844
+ It was never VISIBLE ENOUGH. Composited on --background the brightened edge
845
+ measures 1.91:1 and the halo 1.25:1, against the 3:1 that WCAG 1.4.11 asks
846
+ of a focus indicator and that this package already gates --ring on. The
847
+ budget was documented as being spent entirely on --ring "because that is
848
+ what a keyboard user navigates by" — true for a button, and false for a
849
+ field for exactly as long as this rule told fields not to use it.
850
+
851
+ And it was SUPPRESSIBLE. It carried the indicator on `border-color`, so any
852
+ app that states `border` on its own fields overrode it — @hanzo/id does,
853
+ unlayered, which beats this layer whatever its specificity, and its focused
854
+ fields sat at the resting .15 while both files read as correct. An outline
855
+ is not a border: nothing in an app's field styling reaches it, so the ring
856
+ paints whether or not the app has opinions about edges. */
851
857
  :where(input,select,textarea):hover:not(:focus-visible):not(:disabled){background:var(--surface-3)}
852
858
  :where(input,textarea)::placeholder{color:var(--text-disabled)}
853
859
  :where(input,select,textarea,button):disabled{opacity:.5;cursor:not-allowed}
@@ -887,17 +893,20 @@
887
893
  }
888
894
  }
889
895
 
890
- /* The generic ring for everything that is NOT a field. The exclusion is
891
- load-bearing, not tidiness. This rule and the control rule above both
892
- compute to (0,1,0): :where() zeroes whatever it wraps, leaving one
893
- pseudo-class on each side. Equal specificity inside one layer falls through
894
- to source order, this rule sits 46 lines LATER, so it overrode the
895
- `outline:none` written above to prevent precisely this and every focused
896
- input, select and textarea on every consumer drew BOTH the 2px ring and the
897
- edge+halo. Two indicators, from the two rules that each say there is one.
898
- Stating the exclusion in the selector fixes it by MEANING rather than by
899
- position, so reordering either rule cannot bring it back. */
900
- :where(:not(input,select,textarea)):focus-visible{outline:2px solid var(--ring);outline-offset:2px}
896
+ /* THE focus indicator. One rule, every focusable thing, no exceptions a
897
+ button, a link, a summary, a field. 2px at --ring is 3.77:1 on the darkest
898
+ canvas and clears the 2px perimeter WCAG 2.4.13 asks for; the gate in
899
+ check-tokens holds --ring to that and nothing else here may weaken it.
900
+
901
+ There were two rules until 0.4.9, and they collided invisibly. Both computed
902
+ to (0,1,0) :where() zeroes whatever it wraps, leaving one pseudo-class on
903
+ each side so the cascade fell through to SOURCE ORDER inside this layer,
904
+ this rule was written later, and it overrode the `outline:none` the field
905
+ rule stated expressly to prevent it. Every focused input on every consumer
906
+ drew BOTH the ring and the edge+halo. Each rule read as correct alone, which
907
+ is why it survived review in both files; the defect existed only in their
908
+ order. One rule cannot disagree with itself. */
909
+ :focus-visible{outline:2px solid var(--ring);outline-offset:2px}
901
910
  /* --white-20 is white-on-white in the light theme, so selection reads through
902
911
  --selection, which BOTH themes define. */
903
912
  ::selection{background:var(--selection);color:var(--text-primary)}
package/tailwind.css CHANGED
@@ -857,20 +857,26 @@
857
857
  padding:0 var(--space-3);
858
858
  }
859
859
  :where(textarea){padding:var(--space-2) var(--space-3);resize:vertical}
860
- /* ——— focus, on a control ———
861
- Not the generic ring. A field already HAS an edge, so focus brightens that
862
- edge (.15 -> .22) and adds a soft halo just outside it — which is what the
863
- reference does (.composer-box:focus-within) and what separates a focused
864
- field from a browser default. `outline:none` drops the UA's own focus ring,
865
- which would otherwise draw a second, harder box around this one; the
866
- generic ring below excludes fields for the same reason. */
867
- :where(input,select,textarea):focus-visible{
868
- outline:none;
869
- border-color:var(--border-focus);
870
- box-shadow:var(--ring-focus);
871
- }
872
- /* A hovered field lifts its surface a rung. Nudging its EDGE from .15 to .16
873
- is a state nobody can see. */
860
+ /* A field has NO focus rule of its own — see the ring at the bottom of this
861
+ layer, which is the one focus indicator for everything.
862
+
863
+ There used to be one here: `outline:none` plus a brightened edge (.15 ->
864
+ .22) plus a soft halo, the composer look. It was removed because it could
865
+ not do the job in either of the two ways that matter.
866
+
867
+ It was never VISIBLE ENOUGH. Composited on --background the brightened edge
868
+ measures 1.91:1 and the halo 1.25:1, against the 3:1 that WCAG 1.4.11 asks
869
+ of a focus indicator and that this package already gates --ring on. The
870
+ budget was documented as being spent entirely on --ring "because that is
871
+ what a keyboard user navigates by" — true for a button, and false for a
872
+ field for exactly as long as this rule told fields not to use it.
873
+
874
+ And it was SUPPRESSIBLE. It carried the indicator on `border-color`, so any
875
+ app that states `border` on its own fields overrode it — @hanzo/id does,
876
+ unlayered, which beats this layer whatever its specificity, and its focused
877
+ fields sat at the resting .15 while both files read as correct. An outline
878
+ is not a border: nothing in an app's field styling reaches it, so the ring
879
+ paints whether or not the app has opinions about edges. */
874
880
  :where(input,select,textarea):hover:not(:focus-visible):not(:disabled){background:var(--surface-3)}
875
881
  :where(input,textarea)::placeholder{color:var(--text-disabled)}
876
882
  :where(input,select,textarea,button):disabled{opacity:.5;cursor:not-allowed}
@@ -910,17 +916,20 @@
910
916
  }
911
917
  }
912
918
 
913
- /* The generic ring for everything that is NOT a field. The exclusion is
914
- load-bearing, not tidiness. This rule and the control rule above both
915
- compute to (0,1,0): :where() zeroes whatever it wraps, leaving one
916
- pseudo-class on each side. Equal specificity inside one layer falls through
917
- to source order, this rule sits 46 lines LATER, so it overrode the
918
- `outline:none` written above to prevent precisely this and every focused
919
- input, select and textarea on every consumer drew BOTH the 2px ring and the
920
- edge+halo. Two indicators, from the two rules that each say there is one.
921
- Stating the exclusion in the selector fixes it by MEANING rather than by
922
- position, so reordering either rule cannot bring it back. */
923
- :where(:not(input,select,textarea)):focus-visible{outline:2px solid var(--ring);outline-offset:2px}
919
+ /* THE focus indicator. One rule, every focusable thing, no exceptions a
920
+ button, a link, a summary, a field. 2px at --ring is 3.77:1 on the darkest
921
+ canvas and clears the 2px perimeter WCAG 2.4.13 asks for; the gate in
922
+ check-tokens holds --ring to that and nothing else here may weaken it.
923
+
924
+ There were two rules until 0.4.9, and they collided invisibly. Both computed
925
+ to (0,1,0) :where() zeroes whatever it wraps, leaving one pseudo-class on
926
+ each side so the cascade fell through to SOURCE ORDER inside this layer,
927
+ this rule was written later, and it overrode the `outline:none` the field
928
+ rule stated expressly to prevent it. Every focused input on every consumer
929
+ drew BOTH the ring and the edge+halo. Each rule read as correct alone, which
930
+ is why it survived review in both files; the defect existed only in their
931
+ order. One rule cannot disagree with itself. */
932
+ :focus-visible{outline:2px solid var(--ring);outline-offset:2px}
924
933
  /* --white-20 is white-on-white in the light theme, so selection reads through
925
934
  --selection, which BOTH themes define. */
926
935
  ::selection{background:var(--selection);color:var(--text-primary)}
package/tokens/base.css CHANGED
@@ -99,20 +99,26 @@
99
99
  padding:0 var(--space-3);
100
100
  }
101
101
  :where(textarea){padding:var(--space-2) var(--space-3);resize:vertical}
102
- /* ——— focus, on a control ———
103
- Not the generic ring. A field already HAS an edge, so focus brightens that
104
- edge (.15 -> .22) and adds a soft halo just outside it — which is what the
105
- reference does (.composer-box:focus-within) and what separates a focused
106
- field from a browser default. `outline:none` drops the UA's own focus ring,
107
- which would otherwise draw a second, harder box around this one; the
108
- generic ring below excludes fields for the same reason. */
109
- :where(input,select,textarea):focus-visible{
110
- outline:none;
111
- border-color:var(--border-focus);
112
- box-shadow:var(--ring-focus);
113
- }
114
- /* A hovered field lifts its surface a rung. Nudging its EDGE from .15 to .16
115
- is a state nobody can see. */
102
+ /* A field has NO focus rule of its own — see the ring at the bottom of this
103
+ layer, which is the one focus indicator for everything.
104
+
105
+ There used to be one here: `outline:none` plus a brightened edge (.15 ->
106
+ .22) plus a soft halo, the composer look. It was removed because it could
107
+ not do the job in either of the two ways that matter.
108
+
109
+ It was never VISIBLE ENOUGH. Composited on --background the brightened edge
110
+ measures 1.91:1 and the halo 1.25:1, against the 3:1 that WCAG 1.4.11 asks
111
+ of a focus indicator and that this package already gates --ring on. The
112
+ budget was documented as being spent entirely on --ring "because that is
113
+ what a keyboard user navigates by" — true for a button, and false for a
114
+ field for exactly as long as this rule told fields not to use it.
115
+
116
+ And it was SUPPRESSIBLE. It carried the indicator on `border-color`, so any
117
+ app that states `border` on its own fields overrode it — @hanzo/id does,
118
+ unlayered, which beats this layer whatever its specificity, and its focused
119
+ fields sat at the resting .15 while both files read as correct. An outline
120
+ is not a border: nothing in an app's field styling reaches it, so the ring
121
+ paints whether or not the app has opinions about edges. */
116
122
  :where(input,select,textarea):hover:not(:focus-visible):not(:disabled){background:var(--surface-3)}
117
123
  :where(input,textarea)::placeholder{color:var(--text-disabled)}
118
124
  :where(input,select,textarea,button):disabled{opacity:.5;cursor:not-allowed}
@@ -152,17 +158,20 @@
152
158
  }
153
159
  }
154
160
 
155
- /* The generic ring for everything that is NOT a field. The exclusion is
156
- load-bearing, not tidiness. This rule and the control rule above both
157
- compute to (0,1,0): :where() zeroes whatever it wraps, leaving one
158
- pseudo-class on each side. Equal specificity inside one layer falls through
159
- to source order, this rule sits 46 lines LATER, so it overrode the
160
- `outline:none` written above to prevent precisely this and every focused
161
- input, select and textarea on every consumer drew BOTH the 2px ring and the
162
- edge+halo. Two indicators, from the two rules that each say there is one.
163
- Stating the exclusion in the selector fixes it by MEANING rather than by
164
- position, so reordering either rule cannot bring it back. */
165
- :where(:not(input,select,textarea)):focus-visible{outline:2px solid var(--ring);outline-offset:2px}
161
+ /* THE focus indicator. One rule, every focusable thing, no exceptions a
162
+ button, a link, a summary, a field. 2px at --ring is 3.77:1 on the darkest
163
+ canvas and clears the 2px perimeter WCAG 2.4.13 asks for; the gate in
164
+ check-tokens holds --ring to that and nothing else here may weaken it.
165
+
166
+ There were two rules until 0.4.9, and they collided invisibly. Both computed
167
+ to (0,1,0) :where() zeroes whatever it wraps, leaving one pseudo-class on
168
+ each side so the cascade fell through to SOURCE ORDER inside this layer,
169
+ this rule was written later, and it overrode the `outline:none` the field
170
+ rule stated expressly to prevent it. Every focused input on every consumer
171
+ drew BOTH the ring and the edge+halo. Each rule read as correct alone, which
172
+ is why it survived review in both files; the defect existed only in their
173
+ order. One rule cannot disagree with itself. */
174
+ :focus-visible{outline:2px solid var(--ring);outline-offset:2px}
166
175
  /* --white-20 is white-on-white in the light theme, so selection reads through
167
176
  --selection, which BOTH themes define. */
168
177
  ::selection{background:var(--selection);color:var(--text-primary)}