@hanzo/design 0.4.9 → 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 +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/preference.d.ts +59 -0
- package/dist/preference.d.ts.map +1 -0
- package/dist/preference.js +104 -0
- package/package.json +2 -2
- package/scripts/check-preference.mjs +135 -0
- package/src/index.ts +6 -0
- package/src/preference.ts +124 -0
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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.
|
|
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");
|
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
|
+
}
|