@hanzo/design 0.4.13 → 0.5.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/LICENSE +24 -10
- package/README.md +1 -1
- package/dist/brand.d.ts +30 -0
- package/dist/brand.d.ts.map +1 -0
- package/dist/brand.js +58 -0
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -4
- package/dist/tokens.gen.d.ts +70 -74
- package/dist/tokens.gen.d.ts.map +1 -1
- package/dist/tokens.gen.js +70 -74
- package/package.json +6 -8
- package/scripts/check-tokens.mjs +0 -34
- package/skills/design-system/SKILL.md +1 -1
- package/src/brand.ts +76 -0
- package/src/index.ts +1 -6
- package/src/tokens.gen.ts +70 -74
- package/styles.css +50 -82
- package/tailwind.css +50 -82
- package/tokens/base.css +13 -33
- package/tokens/colors.css +9 -9
- package/tokens/spacing.css +14 -20
- package/tokens/typography.css +14 -20
- package/dist/preference.d.ts +0 -65
- package/dist/preference.d.ts.map +0 -1
- package/dist/preference.js +0 -102
- package/scripts/check-dist.mjs +0 -81
- package/scripts/check-preference.mjs +0 -168
- package/src/preference.ts +0 -122
|
@@ -1,168 +0,0 @@
|
|
|
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 load-bearing check is the last one. The first version of preference.ts
|
|
6
|
-
* kept its own copy of the type ramp and the copy was WRONG (lg/xl were 16/18
|
|
7
|
-
* against the tokens' 15/17), so a preference of 1 — "leave it alone" — would
|
|
8
|
-
* have resized two rungs of the published design. Nothing caught it, because
|
|
9
|
-
* nothing compared the copy to the source. Now there is no copy: the knobs are
|
|
10
|
-
* multipliers and the ramps live only in tokens/*.css, and check 5 fails if a
|
|
11
|
-
* rung ever stops carrying its multiplier.
|
|
12
|
-
*/
|
|
13
|
-
import { readFileSync } from "node:fs";
|
|
14
|
-
import { fileURLToPath } from "node:url";
|
|
15
|
-
import { dirname, join } from "node:path";
|
|
16
|
-
|
|
17
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
18
|
-
const root = join(here, "..");
|
|
19
|
-
|
|
20
|
-
// Load the source directly — this package ships tokens, and the preference
|
|
21
|
-
// module is small enough to evaluate without a build step in the check.
|
|
22
|
-
const src = readFileSync(join(root, "src/preference.ts"), "utf8");
|
|
23
|
-
const js = src
|
|
24
|
-
.replace(/^import[^\n]*\n/gm, "")
|
|
25
|
-
.replace(/export (type|interface) [\s\S]*?\n}\n/g, "")
|
|
26
|
-
.replace(/export type [^\n]*\n/g, "")
|
|
27
|
-
.replace(/: Record<[^>]*>/g, "")
|
|
28
|
-
.replace(/: Preference/g, "")
|
|
29
|
-
.replace(/: Density/g, "")
|
|
30
|
-
.replace(/: string/g, "")
|
|
31
|
-
.replace(/: number/g, "")
|
|
32
|
-
.replace(/: boolean/g, "")
|
|
33
|
-
.replace(/export /g, "");
|
|
34
|
-
|
|
35
|
-
const mod = new Function(`${js}; return { vars, css, isColor, TYPE_MIN, TYPE_MAX };`)();
|
|
36
|
-
const { vars, css, isColor, TYPE_MIN, TYPE_MAX } = mod;
|
|
37
|
-
|
|
38
|
-
let failed = 0;
|
|
39
|
-
const check = (name, fn) => {
|
|
40
|
-
try {
|
|
41
|
-
fn();
|
|
42
|
-
console.log(` ok ${name}`);
|
|
43
|
-
} catch (e) {
|
|
44
|
-
failed++;
|
|
45
|
-
console.error(` FAIL ${name}\n ${e.message}`);
|
|
46
|
-
}
|
|
47
|
-
};
|
|
48
|
-
const eq = (a, b, m) => {
|
|
49
|
-
const A = JSON.stringify(a), B = JSON.stringify(b);
|
|
50
|
-
if (A !== B) throw new Error(`${m || ""} got ${A} want ${B}`);
|
|
51
|
-
};
|
|
52
|
-
const ok = (v, m) => { if (!v) throw new Error(m || "expected truthy"); };
|
|
53
|
-
|
|
54
|
-
console.log("preference:");
|
|
55
|
-
|
|
56
|
-
check("an unset preference changes nothing", () => {
|
|
57
|
-
eq(vars({}), {});
|
|
58
|
-
eq(css({}), "");
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
check("type is ONE knob, not a restated ramp", () => {
|
|
62
|
-
const v = vars({ type: 1.2 });
|
|
63
|
-
eq(Object.keys(v), ["--type-scale"], "type must emit exactly one name:");
|
|
64
|
-
eq(v["--type-scale"], "1.2");
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
check("type is CLAMPED — a preference cannot make the UI illegible", () => {
|
|
68
|
-
eq(vars({ type: 0.1 })["--type-scale"], String(TYPE_MIN), "min");
|
|
69
|
-
eq(vars({ type: 99 })["--type-scale"], String(TYPE_MAX), "max");
|
|
70
|
-
// the smallest rung must stay readable at the floor: 11px * TYPE_MIN
|
|
71
|
-
ok(11 * TYPE_MIN >= 9, "xs falls below 9px at the floor");
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
check("density moves spacing only, and never touches type", () => {
|
|
75
|
-
const v = vars({ density: "compact" });
|
|
76
|
-
eq(Object.keys(v), ["--density"]);
|
|
77
|
-
ok(Number(v["--density"]) < 1, "compact should tighten");
|
|
78
|
-
ok(Number(vars({ density: "comfortable" })["--density"]) > 1, "comfortable should loosen");
|
|
79
|
-
ok(!("--type-scale" in v), "density must not touch type");
|
|
80
|
-
// Spacing compounds through nesting, so the range stays tight enough that a
|
|
81
|
-
// 44px coarse-pointer target does not fall under the floor.
|
|
82
|
-
ok(Number(vars({ density: "compact" })["--density"]) >= 0.8, "compact is too tight to stay tappable");
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
check("EVERY ramp rung carries its multiplier — no rung can opt out", () => {
|
|
86
|
-
// This is the check that the drifting copy would have failed.
|
|
87
|
-
const type = readFileSync(join(root, "tokens/typography.css"), "utf8");
|
|
88
|
-
const space = readFileSync(join(root, "tokens/spacing.css"), "utf8");
|
|
89
|
-
|
|
90
|
-
const bare = [];
|
|
91
|
-
for (const [file, text, knob, re] of [
|
|
92
|
-
["typography.css", type, "--type-scale", /--text-[a-z0-9]+:\s*([^;]+);/g],
|
|
93
|
-
["spacing.css", space, "--density", /--space-\d+:\s*([^;]+);/g],
|
|
94
|
-
]) {
|
|
95
|
-
for (const m of text.matchAll(re)) {
|
|
96
|
-
const value = m[1].trim();
|
|
97
|
-
if (value === "0") continue; // zero times anything is zero
|
|
98
|
-
if (value.startsWith("var(")) continue; // an alias inherits its target's calc
|
|
99
|
-
if (!value.includes(`var(${knob}`)) bare.push(`${file}: ${m[0].trim()}`);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
eq(bare, [], "rungs that do NOT scale with their knob:");
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
check("a unitless leading stays a RATIO — scaling it would double-apply", () => {
|
|
106
|
-
const type = readFileSync(join(root, "tokens/typography.css"), "utf8");
|
|
107
|
-
const bad = [];
|
|
108
|
-
for (const m of type.matchAll(/--leading-[a-z0-9]+:\s*([^;]+);/g)) {
|
|
109
|
-
const v = m[1].trim();
|
|
110
|
-
// A ratio (`1.05`) already scales with the font size it multiplies.
|
|
111
|
-
if (/^[0-9.]+$/.test(v) === false) continue;
|
|
112
|
-
if (v.includes("var(")) bad.push(m[0].trim());
|
|
113
|
-
}
|
|
114
|
-
eq(bad, [], "unitless leadings must not carry a multiplier:");
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
check("a colour lands on both --primary and --accent", () => {
|
|
118
|
-
const v = vars({ accent: "#808000" });
|
|
119
|
-
eq(v["--primary"], "#808000");
|
|
120
|
-
eq(v["--accent"], "#808000");
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
check("a NON-colour is dropped, never sanitised into something plausible", () => {
|
|
124
|
-
for (const bad of [
|
|
125
|
-
"red; background-image:url(//evil/x)",
|
|
126
|
-
"}html{display:none",
|
|
127
|
-
"url(//evil/x)",
|
|
128
|
-
"expression(alert(1))",
|
|
129
|
-
"",
|
|
130
|
-
" ",
|
|
131
|
-
]) {
|
|
132
|
-
const v = vars({ accent: bad });
|
|
133
|
-
ok(!("--primary" in v), `accepted ${JSON.stringify(bad)}`);
|
|
134
|
-
ok(!isColor(bad), `isColor said yes to ${JSON.stringify(bad)}`);
|
|
135
|
-
}
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
check("real colour notations are accepted", () => {
|
|
139
|
-
for (const good of ["#fff", "#ffffff", "#ffffffff", "rgb(1 2 3)", "oklch(.7 .1 120)", "rebeccapurple"]) {
|
|
140
|
-
ok(isColor(good), `isColor said no to ${good}`);
|
|
141
|
-
}
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
check("css() emits ONE block for the selector it is given", () => {
|
|
145
|
-
const out = css({ type: 1.1, density: "compact", accent: "#fff" }, ":root");
|
|
146
|
-
ok(out.startsWith(":root{") && out.endsWith("}"), out.slice(0, 40));
|
|
147
|
-
ok(!out.includes("}" + "{"), "emitted more than one block");
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
check("every emitted name is one the token files actually read", () => {
|
|
151
|
-
// A variable nothing reads is a write into another document — the exact
|
|
152
|
-
// mistake this package exists to prevent. The knobs are READ by the ramps
|
|
153
|
-
// (as var(--knob, 1)); the colours are DECLARED by colors.css.
|
|
154
|
-
const files = ["tokens/typography.css", "tokens/spacing.css", "tokens/grid.css", "tokens/colors.css"];
|
|
155
|
-
const text = files.map((f) => readFileSync(join(root, f), "utf8")).join("\n");
|
|
156
|
-
const declared = new Set([...text.matchAll(/^\s*(--[a-z0-9-]+)\s*:/gm)].map((m) => m[1]));
|
|
157
|
-
const read = new Set([...text.matchAll(/var\((--[a-z0-9-]+)/g)].map((m) => m[1]));
|
|
158
|
-
|
|
159
|
-
const emitted = Object.keys(vars({ type: 1.1, density: "compact", accent: "#fff" }));
|
|
160
|
-
const orphans = emitted.filter((k) => !declared.has(k) && !read.has(k));
|
|
161
|
-
eq(orphans, [], "emitted names no token file declares or reads:");
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
if (failed) {
|
|
165
|
-
console.error(`\n${failed} preference check(s) failed`);
|
|
166
|
-
process.exit(1);
|
|
167
|
-
}
|
|
168
|
-
console.log("preference: all checks passed");
|
package/src/preference.ts
DELETED
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A person's own reading of the system: type size, density, accent.
|
|
3
|
-
*
|
|
4
|
-
* Three knobs, and each is ONE multiplier on a whole axis — never a restated
|
|
5
|
-
* ramp. The ramps live in `tokens/*.css`, authored once, and each rung carries
|
|
6
|
-
* its own `calc(<base> * var(--type-scale, 1))`. So a preference sets three
|
|
7
|
-
* numbers and every rung follows, including rungs added later and rungs this
|
|
8
|
-
* file has never heard of.
|
|
9
|
-
*
|
|
10
|
-
* That is not a style choice; it is the fix for a real bug. The first version of
|
|
11
|
-
* this module kept its own copy of the type ramp so it could recompute each
|
|
12
|
-
* rung, and the copy was WRONG — it had `lg: 1rem` and `xl: 1.125rem` (16px and
|
|
13
|
-
* 18px) while `tokens/typography.css` says `0.9375rem` and `1.0625rem` (15px and
|
|
14
|
-
* 17px). Setting a preference of 1 — "leave it alone" — would have silently
|
|
15
|
-
* resized two rungs of the published design. A second copy of a value is a
|
|
16
|
-
* second source of truth, and it drifted before anyone used it.
|
|
17
|
-
*
|
|
18
|
-
* Because the knobs are plain multipliers, any OTHER ramp can opt in the same
|
|
19
|
-
* way. @hanzo/gui compiles its own `--f-size-*` scale for the 1600-odd
|
|
20
|
-
* `fontSize="$n"` call sites in the apps; an app that redeclares those as
|
|
21
|
-
* `calc(<its px> * var(--type-scale, 1))` gets the same control with no change
|
|
22
|
-
* at scale 1.
|
|
23
|
-
*
|
|
24
|
-
* It is a pure function on purpose: it maps a preference to custom properties
|
|
25
|
-
* and returns them, touching no document. That is what lets an app, an embedded
|
|
26
|
-
* preview and a server render apply it identically.
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
export type Density = "compact" | "default" | "comfortable";
|
|
30
|
-
|
|
31
|
-
export interface Preference {
|
|
32
|
-
/** Multiplier on the type ramp. 1 is the published scale. */
|
|
33
|
-
type?: number;
|
|
34
|
-
density?: Density;
|
|
35
|
-
/** A CSS colour for --primary / --accent. Rejected unless it is one. */
|
|
36
|
-
accent?: string;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* The type multiplier is CLAMPED, and the bounds are not arbitrary.
|
|
41
|
-
*
|
|
42
|
-
* Below 0.85 the smallest rung (--text-xs, 11px) drops under 9.4px, which stops
|
|
43
|
-
* being small and starts being unreadable — and a preference that lets someone
|
|
44
|
-
* render their own tools illegible is a trap, not a choice. Above 1.4 the
|
|
45
|
-
* chrome stops fitting its own containers: this app's builder header already
|
|
46
|
-
* overlaps its actions below 1440px at scale 1.
|
|
47
|
-
*/
|
|
48
|
-
export const TYPE_MIN = 0.85;
|
|
49
|
-
export const TYPE_MAX = 1.4;
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Density moves SPACING only, and its range is much tighter than type's.
|
|
53
|
-
*
|
|
54
|
-
* Spacing compounds: a page nests padding inside gap inside margin, so a 0.75
|
|
55
|
-
* multiplier is already three-quarters of every one of those in sequence. Below
|
|
56
|
-
* that, touch targets fall under the 44px floor `base.css` sets for coarse
|
|
57
|
-
* pointers, and the control that promised comfort takes it away.
|
|
58
|
-
*/
|
|
59
|
-
const DENSITY: Record<Density, number> = {
|
|
60
|
-
compact: 0.85,
|
|
61
|
-
default: 1,
|
|
62
|
-
comfortable: 1.15,
|
|
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 round = (n: number) => String(Math.round(n * 10000) / 10000);
|
|
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
|
-
out["--type-scale"] = round(clamp(p.type, TYPE_MIN, TYPE_MAX));
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
if (p.density && p.density in DENSITY) {
|
|
103
|
-
out["--density"] = round(DENSITY[p.density]);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
if (p.accent && isColor(p.accent)) {
|
|
107
|
-
// Both names, because the ramp uses --primary for action surfaces and
|
|
108
|
-
// --accent for selection. One hue, stated once, landing on both.
|
|
109
|
-
out["--primary"] = p.accent.trim();
|
|
110
|
-
out["--accent"] = p.accent.trim();
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
return out;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** `vars()` as a declaration block, for a <style> tag or an SSR inline. */
|
|
117
|
-
export function css(p: Preference, selector = "html:root"): string {
|
|
118
|
-
const v = vars(p);
|
|
119
|
-
const keys = Object.keys(v);
|
|
120
|
-
if (!keys.length) return "";
|
|
121
|
-
return `${selector}{${keys.map((k) => `${k}:${v[k]}`).join(";")}}`;
|
|
122
|
-
}
|