@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/tailwind.css CHANGED
@@ -252,23 +252,23 @@
252
252
  silent (the border simply stops existing) and has shipped before. */
253
253
  .light{
254
254
  color-scheme:light;
255
- --background:#f7f7f7;
255
+ --background:#ffffff;
256
256
  --foreground:#0a0a0a;
257
257
  /* A ladder, not four names for #f5f5f5. Light lifts by the same tiny steps
258
258
  dark does — ~2% per rung — so --surface-0..3 mean something in both themes. */
259
- --card:#f2f2f2;
259
+ --card:#fafafa;
260
260
  --card-foreground:#0a0a0a;
261
- --popover:#fbfbfb;
261
+ --popover:#ffffff;
262
262
  --popover-foreground:#0a0a0a;
263
263
  --primary:#0a0a0a;
264
264
  --primary-hover:#262626;
265
265
  --primary-foreground:#fafafa;
266
- --secondary:#e4e4e4;
266
+ --secondary:#ededed;
267
267
  --secondary-hover:#e0e0e0;
268
268
  --secondary-foreground:#0a0a0a;
269
- --muted:#ededed;
269
+ --muted:#f5f5f5;
270
270
  --muted-foreground:#525252;
271
- --accent:#e4e4e4;
271
+ --accent:#ededed;
272
272
  --accent-foreground:#0a0a0a;
273
273
  --destructive:var(--state-error);
274
274
  --destructive-hover:#dc2626;
@@ -293,9 +293,9 @@
293
293
  --selection:rgb(0 0 0 / .16);
294
294
  --glass:rgb(0 0 0 / .04);
295
295
  --glass-strong:rgb(0 0 0 / .07);
296
- --surface-card:#f2f2f2;
297
- --surface-card-emphasis:#fdfdfd;
298
- --surface-card-quiet:#f5f5f5;
296
+ --surface-card:#fafafa;
297
+ --surface-card-emphasis:#ffffff;
298
+ --surface-card-quiet:#fcfcfc;
299
299
  --surface-overlay:rgb(255 255 255 / .95);
300
300
  --surface-header:rgb(255 255 255 / .8);
301
301
  --surface-scrim:rgb(0 0 0 / .5);
@@ -313,25 +313,19 @@
313
313
  — the two are the SAME scale, mirrored. A surface/tenant overrides any --text-*
314
314
  on :root to retune density on demand. */
315
315
  :root{
316
- /* The type knob. 1 is the published scale; every rung below multiplies by
317
- it, so a surface or a person retunes the WHOLE ramp by setting one
318
- number and no rung can drift out of relation with the others.
319
- @hanzo/design's `vars({type})` emits exactly this name. */
320
- --type-scale:1;
321
-
322
- --text-xs:calc(0.6875rem * var(--type-scale, 1)); --leading-xs:calc(1rem * var(--type-scale, 1)); /* 11px — eyebrows / section labels */
323
- --text-sm:calc(0.8125rem * var(--type-scale, 1)); --leading-sm:calc(1.15rem * var(--type-scale, 1)); /* 13px — nav labels, dense body */
324
- --text-base:calc(0.875rem * var(--type-scale, 1)); --leading-base:calc(1.35rem * var(--type-scale, 1)); /* 14px — base app text (was 16px) */
325
- --text-lg:calc(0.9375rem * var(--type-scale, 1)); --leading-lg:calc(1.4rem * var(--type-scale, 1)); /* 15px */
326
- --text-xl:calc(1.0625rem * var(--type-scale, 1)); --leading-xl:calc(1.55rem * var(--type-scale, 1)); /* 17px */
327
- --text-2xl:calc(1.3125rem * var(--type-scale, 1)); --leading-2xl:calc(1.7rem * var(--type-scale, 1)); /* 21px */
328
- --text-3xl:calc(1.625rem * var(--type-scale, 1)); --leading-3xl:calc(1.95rem * var(--type-scale, 1)); /* 26px */
329
- --text-4xl:calc(2rem * var(--type-scale, 1)); --leading-4xl:calc(2.25rem * var(--type-scale, 1)); /* 32px */
330
- --text-5xl:calc(2.5rem * var(--type-scale, 1)); --leading-5xl:1.05; /* 40px */
331
- --text-6xl:calc(3.25rem * var(--type-scale, 1)); --leading-6xl:1; /* 52px */
332
- --text-7xl:calc(4rem * var(--type-scale, 1)); --leading-7xl:1; /* 64px */
333
- --text-8xl:calc(5.25rem * var(--type-scale, 1)); --leading-8xl:1; /* 84px */
334
- --text-9xl:calc(7rem * var(--type-scale, 1)); --leading-9xl:1; /* 112px */
316
+ --text-xs:0.6875rem; --leading-xs:1rem; /* 11px eyebrows / section labels */
317
+ --text-sm:0.8125rem; --leading-sm:1.15rem; /* 13px nav labels, dense body */
318
+ --text-base:0.875rem; --leading-base:1.35rem; /* 14px base app text (was 16px) */
319
+ --text-lg:0.9375rem; --leading-lg:1.4rem; /* 15px */
320
+ --text-xl:1.0625rem; --leading-xl:1.55rem; /* 17px */
321
+ --text-2xl:1.3125rem; --leading-2xl:1.7rem; /* 21px */
322
+ --text-3xl:1.625rem; --leading-3xl:1.95rem; /* 26px */
323
+ --text-4xl:2rem; --leading-4xl:2.25rem; /* 32px */
324
+ --text-5xl:2.5rem; --leading-5xl:1.05; /* 40px */
325
+ --text-6xl:3.25rem; --leading-6xl:1; /* 52px */
326
+ --text-7xl:4rem; --leading-7xl:1; /* 64px */
327
+ --text-8xl:5.25rem; --leading-8xl:1; /* 84px */
328
+ --text-9xl:7rem; --leading-9xl:1; /* 112px */
335
329
 
336
330
  /* The SAME scale under @hanzo/brand's spelling. @hanzo/gui's shell theme and
337
331
  Hanzo Studio address the ramp as --font-size-*; both names are one value, so
@@ -396,7 +390,7 @@
396
390
  form feels broken. pointer:coarse is the real signal — a desktop mouse keeps
397
391
  the compact 13px field. */
398
392
  @media (pointer:coarse){
399
- :root{--text-control:calc(1rem * var(--type-scale, 1))}
393
+ :root{--text-control:1rem}
400
394
  }
401
395
 
402
396
  /* ── tokens/spacing.css ─────────────────────────────────────── */
@@ -404,27 +398,21 @@
404
398
  declared in hanzo.ai's tailwind.config.ts (legacy v3 config, kept for
405
399
  reference) — use it for editorial layouts, not for component padding. */
406
400
  :root{
407
- /* The density knob. 1 is the published spacing; every --space-* rung below
408
- multiplies by it, and --grid-gap-* reference those rungs rather than
409
- restating them, so gaps, padding and section rhythm move together.
410
- @hanzo/design's `vars({density})` emits exactly this name. */
411
- --density:1;
412
-
413
401
  --space-0:0;
414
- --space-1:calc(0.25rem * var(--density, 1));
415
- --space-2:calc(0.5rem * var(--density, 1));
416
- --space-3:calc(0.75rem * var(--density, 1));
417
- --space-4:calc(1rem * var(--density, 1));
418
- --space-5:calc(1.25rem * var(--density, 1));
419
- --space-6:calc(1.5rem * var(--density, 1));
420
- --space-8:calc(2rem * var(--density, 1));
421
- --space-10:calc(2.5rem * var(--density, 1));
422
- --space-12:calc(3rem * var(--density, 1));
423
- --space-14:calc(3.5rem * var(--density, 1));
424
- --space-16:calc(4rem * var(--density, 1));
425
- --space-20:calc(5rem * var(--density, 1));
426
- --space-24:calc(6rem * var(--density, 1));
427
- --space-32:calc(8rem * var(--density, 1));
402
+ --space-1:0.25rem;
403
+ --space-2:0.5rem;
404
+ --space-3:0.75rem;
405
+ --space-4:1rem;
406
+ --space-5:1.25rem;
407
+ --space-6:1.5rem;
408
+ --space-8:2rem;
409
+ --space-10:2.5rem;
410
+ --space-12:3rem;
411
+ --space-14:3.5rem;
412
+ --space-16:4rem;
413
+ --space-20:5rem;
414
+ --space-24:6rem;
415
+ --space-32:8rem;
428
416
 
429
417
  /* golden ramp (φ) — hanzo.ai tailwind.config.ts */
430
418
  --golden-1:0.25rem;
@@ -869,26 +857,19 @@
869
857
  padding:0 var(--space-3);
870
858
  }
871
859
  :where(textarea){padding:var(--space-2) var(--space-3);resize:vertical}
872
- /* A field has NO focus rule of its own — see the ring at the bottom of this
873
- layer, which is the one focus indicator for everything.
874
-
875
- There used to be one here: `outline:none` plus a brightened edge (.15 ->
876
- .22) plus a soft halo, the composer look. It was removed because it could
877
- not do the job in either of the two ways that matter.
878
-
879
- It was never VISIBLE ENOUGH. Composited on --background the brightened edge
880
- measures 1.91:1 and the halo 1.25:1, against the 3:1 that WCAG 1.4.11 asks
881
- of a focus indicator and that this package already gates --ring on. The
882
- budget was documented as being spent entirely on --ring "because that is
883
- what a keyboard user navigates by" true for a button, and false for a
884
- field for exactly as long as this rule told fields not to use it.
885
-
886
- And it was SUPPRESSIBLE. It carried the indicator on `border-color`, so any
887
- app that states `border` on its own fields overrode it — @hanzo/id does,
888
- unlayered, which beats this layer whatever its specificity, and its focused
889
- fields sat at the resting .15 while both files read as correct. An outline
890
- is not a border: nothing in an app's field styling reaches it, so the ring
891
- paints whether or not the app has opinions about edges. */
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. The generic `outline` is suppressed here
865
+ precisely because it would draw a second, harder box around this one. */
866
+ :where(input,select,textarea):focus-visible{
867
+ outline:none;
868
+ border-color:var(--border-focus);
869
+ box-shadow:var(--ring-focus);
870
+ }
871
+ /* A hovered field lifts its surface a rung. Nudging its EDGE from .15 to .16
872
+ is a state nobody can see. */
892
873
  :where(input,select,textarea):hover:not(:focus-visible):not(:disabled){background:var(--surface-3)}
893
874
  :where(input,textarea)::placeholder{color:var(--text-disabled)}
894
875
  :where(input,select,textarea,button):disabled{opacity:.5;cursor:not-allowed}
@@ -928,19 +909,6 @@
928
909
  }
929
910
  }
930
911
 
931
- /* THE focus indicator. One rule, every focusable thing, no exceptions — a
932
- button, a link, a summary, a field. 2px at --ring is 3.77:1 on the darkest
933
- canvas and clears the 2px perimeter WCAG 2.4.13 asks for; the gate in
934
- check-tokens holds --ring to that and nothing else here may weaken it.
935
-
936
- There were two rules until 0.4.9, and they collided invisibly. Both computed
937
- to (0,1,0) — :where() zeroes whatever it wraps, leaving one pseudo-class on
938
- each side — so the cascade fell through to SOURCE ORDER inside this layer,
939
- this rule was written later, and it overrode the `outline:none` the field
940
- rule stated expressly to prevent it. Every focused input on every consumer
941
- drew BOTH the ring and the edge+halo. Each rule read as correct alone, which
942
- is why it survived review in both files; the defect existed only in their
943
- order. One rule cannot disagree with itself. */
944
912
  :focus-visible{outline:2px solid var(--ring);outline-offset:2px}
945
913
  /* --white-20 is white-on-white in the light theme, so selection reads through
946
914
  --selection, which BOTH themes define. */
package/tokens/base.css CHANGED
@@ -99,26 +99,19 @@
99
99
  padding:0 var(--space-3);
100
100
  }
101
101
  :where(textarea){padding:var(--space-2) var(--space-3);resize:vertical}
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. */
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. The generic `outline` is suppressed here
107
+ precisely because it would draw a second, harder box around this one. */
108
+ :where(input,select,textarea):focus-visible{
109
+ outline:none;
110
+ border-color:var(--border-focus);
111
+ box-shadow:var(--ring-focus);
112
+ }
113
+ /* A hovered field lifts its surface a rung. Nudging its EDGE from .15 to .16
114
+ is a state nobody can see. */
122
115
  :where(input,select,textarea):hover:not(:focus-visible):not(:disabled){background:var(--surface-3)}
123
116
  :where(input,textarea)::placeholder{color:var(--text-disabled)}
124
117
  :where(input,select,textarea,button):disabled{opacity:.5;cursor:not-allowed}
@@ -158,19 +151,6 @@
158
151
  }
159
152
  }
160
153
 
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
154
  :focus-visible{outline:2px solid var(--ring);outline-offset:2px}
175
155
  /* --white-20 is white-on-white in the light theme, so selection reads through
176
156
  --selection, which BOTH themes define. */
package/tokens/colors.css CHANGED
@@ -214,23 +214,23 @@
214
214
  silent (the border simply stops existing) and has shipped before. */
215
215
  .light{
216
216
  color-scheme:light;
217
- --background:#f7f7f7;
217
+ --background:#ffffff;
218
218
  --foreground:#0a0a0a;
219
219
  /* A ladder, not four names for #f5f5f5. Light lifts by the same tiny steps
220
220
  dark does — ~2% per rung — so --surface-0..3 mean something in both themes. */
221
- --card:#f2f2f2;
221
+ --card:#fafafa;
222
222
  --card-foreground:#0a0a0a;
223
- --popover:#fbfbfb;
223
+ --popover:#ffffff;
224
224
  --popover-foreground:#0a0a0a;
225
225
  --primary:#0a0a0a;
226
226
  --primary-hover:#262626;
227
227
  --primary-foreground:#fafafa;
228
- --secondary:#e4e4e4;
228
+ --secondary:#ededed;
229
229
  --secondary-hover:#e0e0e0;
230
230
  --secondary-foreground:#0a0a0a;
231
- --muted:#ededed;
231
+ --muted:#f5f5f5;
232
232
  --muted-foreground:#525252;
233
- --accent:#e4e4e4;
233
+ --accent:#ededed;
234
234
  --accent-foreground:#0a0a0a;
235
235
  --destructive:var(--state-error);
236
236
  --destructive-hover:#dc2626;
@@ -255,9 +255,9 @@
255
255
  --selection:rgb(0 0 0 / .16);
256
256
  --glass:rgb(0 0 0 / .04);
257
257
  --glass-strong:rgb(0 0 0 / .07);
258
- --surface-card:#f2f2f2;
259
- --surface-card-emphasis:#fdfdfd;
260
- --surface-card-quiet:#f5f5f5;
258
+ --surface-card:#fafafa;
259
+ --surface-card-emphasis:#ffffff;
260
+ --surface-card-quiet:#fcfcfc;
261
261
  --surface-overlay:rgb(255 255 255 / .95);
262
262
  --surface-header:rgb(255 255 255 / .8);
263
263
  --surface-scrim:rgb(0 0 0 / .5);
@@ -2,27 +2,21 @@
2
2
  declared in hanzo.ai's tailwind.config.ts (legacy v3 config, kept for
3
3
  reference) — use it for editorial layouts, not for component padding. */
4
4
  :root{
5
- /* The density knob. 1 is the published spacing; every --space-* rung below
6
- multiplies by it, and --grid-gap-* reference those rungs rather than
7
- restating them, so gaps, padding and section rhythm move together.
8
- @hanzo/design's `vars({density})` emits exactly this name. */
9
- --density:1;
10
-
11
5
  --space-0:0;
12
- --space-1:calc(0.25rem * var(--density, 1));
13
- --space-2:calc(0.5rem * var(--density, 1));
14
- --space-3:calc(0.75rem * var(--density, 1));
15
- --space-4:calc(1rem * var(--density, 1));
16
- --space-5:calc(1.25rem * var(--density, 1));
17
- --space-6:calc(1.5rem * var(--density, 1));
18
- --space-8:calc(2rem * var(--density, 1));
19
- --space-10:calc(2.5rem * var(--density, 1));
20
- --space-12:calc(3rem * var(--density, 1));
21
- --space-14:calc(3.5rem * var(--density, 1));
22
- --space-16:calc(4rem * var(--density, 1));
23
- --space-20:calc(5rem * var(--density, 1));
24
- --space-24:calc(6rem * var(--density, 1));
25
- --space-32:calc(8rem * var(--density, 1));
6
+ --space-1:0.25rem;
7
+ --space-2:0.5rem;
8
+ --space-3:0.75rem;
9
+ --space-4:1rem;
10
+ --space-5:1.25rem;
11
+ --space-6:1.5rem;
12
+ --space-8:2rem;
13
+ --space-10:2.5rem;
14
+ --space-12:3rem;
15
+ --space-14:3.5rem;
16
+ --space-16:4rem;
17
+ --space-20:5rem;
18
+ --space-24:6rem;
19
+ --space-32:8rem;
26
20
 
27
21
  /* golden ramp (φ) — hanzo.ai tailwind.config.ts */
28
22
  --golden-1:0.25rem;
@@ -5,25 +5,19 @@
5
5
  — the two are the SAME scale, mirrored. A surface/tenant overrides any --text-*
6
6
  on :root to retune density on demand. */
7
7
  :root{
8
- /* The type knob. 1 is the published scale; every rung below multiplies by
9
- it, so a surface or a person retunes the WHOLE ramp by setting one
10
- number and no rung can drift out of relation with the others.
11
- @hanzo/design's `vars({type})` emits exactly this name. */
12
- --type-scale:1;
13
-
14
- --text-xs:calc(0.6875rem * var(--type-scale, 1)); --leading-xs:calc(1rem * var(--type-scale, 1)); /* 11px — eyebrows / section labels */
15
- --text-sm:calc(0.8125rem * var(--type-scale, 1)); --leading-sm:calc(1.15rem * var(--type-scale, 1)); /* 13px — nav labels, dense body */
16
- --text-base:calc(0.875rem * var(--type-scale, 1)); --leading-base:calc(1.35rem * var(--type-scale, 1)); /* 14px — base app text (was 16px) */
17
- --text-lg:calc(0.9375rem * var(--type-scale, 1)); --leading-lg:calc(1.4rem * var(--type-scale, 1)); /* 15px */
18
- --text-xl:calc(1.0625rem * var(--type-scale, 1)); --leading-xl:calc(1.55rem * var(--type-scale, 1)); /* 17px */
19
- --text-2xl:calc(1.3125rem * var(--type-scale, 1)); --leading-2xl:calc(1.7rem * var(--type-scale, 1)); /* 21px */
20
- --text-3xl:calc(1.625rem * var(--type-scale, 1)); --leading-3xl:calc(1.95rem * var(--type-scale, 1)); /* 26px */
21
- --text-4xl:calc(2rem * var(--type-scale, 1)); --leading-4xl:calc(2.25rem * var(--type-scale, 1)); /* 32px */
22
- --text-5xl:calc(2.5rem * var(--type-scale, 1)); --leading-5xl:1.05; /* 40px */
23
- --text-6xl:calc(3.25rem * var(--type-scale, 1)); --leading-6xl:1; /* 52px */
24
- --text-7xl:calc(4rem * var(--type-scale, 1)); --leading-7xl:1; /* 64px */
25
- --text-8xl:calc(5.25rem * var(--type-scale, 1)); --leading-8xl:1; /* 84px */
26
- --text-9xl:calc(7rem * var(--type-scale, 1)); --leading-9xl:1; /* 112px */
8
+ --text-xs:0.6875rem; --leading-xs:1rem; /* 11px eyebrows / section labels */
9
+ --text-sm:0.8125rem; --leading-sm:1.15rem; /* 13px nav labels, dense body */
10
+ --text-base:0.875rem; --leading-base:1.35rem; /* 14px base app text (was 16px) */
11
+ --text-lg:0.9375rem; --leading-lg:1.4rem; /* 15px */
12
+ --text-xl:1.0625rem; --leading-xl:1.55rem; /* 17px */
13
+ --text-2xl:1.3125rem; --leading-2xl:1.7rem; /* 21px */
14
+ --text-3xl:1.625rem; --leading-3xl:1.95rem; /* 26px */
15
+ --text-4xl:2rem; --leading-4xl:2.25rem; /* 32px */
16
+ --text-5xl:2.5rem; --leading-5xl:1.05; /* 40px */
17
+ --text-6xl:3.25rem; --leading-6xl:1; /* 52px */
18
+ --text-7xl:4rem; --leading-7xl:1; /* 64px */
19
+ --text-8xl:5.25rem; --leading-8xl:1; /* 84px */
20
+ --text-9xl:7rem; --leading-9xl:1; /* 112px */
27
21
 
28
22
  /* The SAME scale under @hanzo/brand's spelling. @hanzo/gui's shell theme and
29
23
  Hanzo Studio address the ramp as --font-size-*; both names are one value, so
@@ -88,5 +82,5 @@
88
82
  form feels broken. pointer:coarse is the real signal — a desktop mouse keeps
89
83
  the compact 13px field. */
90
84
  @media (pointer:coarse){
91
- :root{--text-control:calc(1rem * var(--type-scale, 1))}
85
+ :root{--text-control:1rem}
92
86
  }
@@ -1,65 +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
- export type Density = "compact" | "default" | "comfortable";
29
- export interface Preference {
30
- /** Multiplier on the type ramp. 1 is the published scale. */
31
- type?: number;
32
- density?: Density;
33
- /** A CSS colour for --primary / --accent. Rejected unless it is one. */
34
- accent?: string;
35
- }
36
- /**
37
- * The type multiplier is CLAMPED, and the bounds are not arbitrary.
38
- *
39
- * Below 0.85 the smallest rung (--text-xs, 11px) drops under 9.4px, which stops
40
- * being small and starts being unreadable — and a preference that lets someone
41
- * render their own tools illegible is a trap, not a choice. Above 1.4 the
42
- * chrome stops fitting its own containers: this app's builder header already
43
- * overlaps its actions below 1440px at scale 1.
44
- */
45
- export declare const TYPE_MIN = 0.85;
46
- export declare const TYPE_MAX = 1.4;
47
- /**
48
- * Is this a colour, or is it something being smuggled into a style attribute?
49
- *
50
- * A preference is user input and its destination is CSS. `#fff`, `rgb(...)`,
51
- * `oklch(...)` and the bare keywords are colours; anything carrying a `;`, a
52
- * `}`, or a `url(` is trying to be a second declaration, and the answer is to
53
- * drop the axis rather than to sanitise a string into something plausible.
54
- */
55
- export declare function isColor(v: string): boolean;
56
- /**
57
- * The custom properties a preference produces.
58
- *
59
- * Only the axes actually set appear, so an app can spread the result over
60
- * whatever it already has without a default silently overriding a brand.
61
- */
62
- export declare function vars(p: Preference): Record<string, string>;
63
- /** `vars()` as a declaration block, for a <style> tag or an SSR inline. */
64
- export declare function css(p: Preference, selector?: string): string;
65
- //# sourceMappingURL=preference.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"preference.d.ts","sourceRoot":"","sources":["../src/preference.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;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;AAqB5B;;;;;;;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,CAmB1D;AAED,2EAA2E;AAC3E,wBAAgB,GAAG,CAAC,CAAC,EAAE,UAAU,EAAE,QAAQ,SAAc,GAAG,MAAM,CAKjE"}
@@ -1,102 +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
- * The type multiplier is CLAMPED, and the bounds are not arbitrary.
30
- *
31
- * Below 0.85 the smallest rung (--text-xs, 11px) drops under 9.4px, which stops
32
- * being small and starts being unreadable — and a preference that lets someone
33
- * render their own tools illegible is a trap, not a choice. Above 1.4 the
34
- * chrome stops fitting its own containers: this app's builder header already
35
- * overlaps its actions below 1440px at scale 1.
36
- */
37
- export const TYPE_MIN = 0.85;
38
- export const TYPE_MAX = 1.4;
39
- /**
40
- * Density moves SPACING only, and its range is much tighter than type's.
41
- *
42
- * Spacing compounds: a page nests padding inside gap inside margin, so a 0.75
43
- * multiplier is already three-quarters of every one of those in sequence. Below
44
- * that, touch targets fall under the 44px floor `base.css` sets for coarse
45
- * pointers, and the control that promised comfort takes it away.
46
- */
47
- const DENSITY = {
48
- compact: 0.85,
49
- default: 1,
50
- comfortable: 1.15,
51
- };
52
- const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
53
- /** Trim to 4dp so a multiplier cannot emit a 17-digit float into a stylesheet. */
54
- const round = (n) => String(Math.round(n * 10000) / 10000);
55
- /**
56
- * Is this a colour, or is it something being smuggled into a style attribute?
57
- *
58
- * A preference is user input and its destination is CSS. `#fff`, `rgb(...)`,
59
- * `oklch(...)` and the bare keywords are colours; anything carrying a `;`, a
60
- * `}`, or a `url(` is trying to be a second declaration, and the answer is to
61
- * drop the axis rather than to sanitise a string into something plausible.
62
- */
63
- export function isColor(v) {
64
- const s = v.trim();
65
- if (!s || s.length > 64)
66
- return false;
67
- if (/[;{}()]/.test(s) && !/^(rgb|rgba|hsl|hsla|oklch|oklab|lab|lch|color)\([^;{}]*\)$/i.test(s))
68
- return false;
69
- return (/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(s) ||
70
- /^(rgb|rgba|hsl|hsla|oklch|oklab|lab|lch|color)\([^;{}]*\)$/i.test(s) ||
71
- /^[a-z]{3,20}$/i.test(s));
72
- }
73
- /**
74
- * The custom properties a preference produces.
75
- *
76
- * Only the axes actually set appear, so an app can spread the result over
77
- * whatever it already has without a default silently overriding a brand.
78
- */
79
- export function vars(p) {
80
- const out = {};
81
- if (typeof p.type === "number" && Number.isFinite(p.type)) {
82
- out["--type-scale"] = round(clamp(p.type, TYPE_MIN, TYPE_MAX));
83
- }
84
- if (p.density && p.density in DENSITY) {
85
- out["--density"] = round(DENSITY[p.density]);
86
- }
87
- if (p.accent && isColor(p.accent)) {
88
- // Both names, because the ramp uses --primary for action surfaces and
89
- // --accent for selection. One hue, stated once, landing on both.
90
- out["--primary"] = p.accent.trim();
91
- out["--accent"] = p.accent.trim();
92
- }
93
- return out;
94
- }
95
- /** `vars()` as a declaration block, for a <style> tag or an SSR inline. */
96
- export function css(p, selector = "html:root") {
97
- const v = vars(p);
98
- const keys = Object.keys(v);
99
- if (!keys.length)
100
- return "";
101
- return `${selector}{${keys.map((k) => `${k}:${v[k]}`).join(";")}}`;
102
- }
@@ -1,81 +0,0 @@
1
- /**
2
- * Import the BUILT package the way a consumer does.
3
- *
4
- * Everything else in this repo checks the sources: the token files, the
5
- * generator's output, the preference contract. All of it passed while
6
- * `dist/index.js` was unimportable — 0.4.10 and 0.4.11 both shipped a barrel
7
- * that re-exported `'./preference'` with no extension, which TypeScript emits
8
- * verbatim and Node ESM refuses to resolve. `pnpm build` was green, `pnpm test`
9
- * was green, and `import '@hanzo/design'` threw ERR_MODULE_NOT_FOUND for every
10
- * consumer.
11
- *
12
- * A test that reads source cannot see that. This one loads the artifact.
13
- */
14
- import { readFileSync, existsSync } from 'node:fs'
15
- import { fileURLToPath, pathToFileURL } from 'node:url'
16
- import { dirname, join } from 'node:path'
17
-
18
- const here = dirname(fileURLToPath(import.meta.url))
19
- const root = join(here, '..')
20
-
21
- let failed = 0
22
- const check = async (name, fn) => {
23
- try {
24
- await fn()
25
- console.log(` ok ${name}`)
26
- } catch (e) {
27
- failed++
28
- console.error(` FAIL ${name}\n ${e.message}`)
29
- }
30
- }
31
-
32
- console.log('dist:')
33
-
34
- const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
35
-
36
- await check('every export the package advertises actually exists on disk', () => {
37
- const missing = []
38
- for (const [name, entry] of Object.entries(pkg.exports ?? {})) {
39
- for (const target of typeof entry === 'string' ? [entry] : Object.values(entry)) {
40
- if (typeof target !== 'string' || target.includes('*')) continue
41
- if (!existsSync(join(root, target))) missing.push(`${name} -> ${target}`)
42
- }
43
- }
44
- if (missing.length) throw new Error(`exports pointing at nothing:\n ${missing.join('\n ')}`)
45
- })
46
-
47
- await check('the barrel IMPORTS — the check that 0.4.10 and 0.4.11 needed', async () => {
48
- const entry = join(root, 'dist/index.js')
49
- if (!existsSync(entry)) throw new Error('dist/index.js is missing — run the build first')
50
- const mod = await import(pathToFileURL(entry).href)
51
- // The preference API is the part that was unreachable; naming it here means a
52
- // future re-export losing its extension fails by name rather than by silence.
53
- for (const name of ['vars', 'css', 'isColor', 'TYPE_MIN', 'TYPE_MAX', 'cssVar', 'tokenValue']) {
54
- if (mod[name] === undefined) throw new Error(`dist/index.js does not export ${name}`)
55
- }
56
- if (typeof mod.vars !== 'function') throw new Error('vars is not callable from the built package')
57
- const v = mod.vars({ type: 1.2, density: 'compact' })
58
- if (v['--type-scale'] !== '1.2') throw new Error(`built vars() returned ${JSON.stringify(v)}`)
59
- })
60
-
61
- await check('no emitted module uses an extensionless relative specifier', () => {
62
- // The class of bug, not just the one instance: Node ESM resolves relative
63
- // specifiers literally, so a missing `.js` is always a runtime failure.
64
- const bad = []
65
- for (const [, entry] of Object.entries(pkg.exports ?? {})) {
66
- const target = typeof entry === 'string' ? entry : entry.import
67
- if (typeof target !== 'string' || target.includes('*') || !target.endsWith('.js')) continue
68
- const file = join(root, target)
69
- if (!existsSync(file)) continue
70
- for (const m of readFileSync(file, 'utf8').matchAll(/from\s+['"](\.[^'"]*)['"]/g)) {
71
- if (!/\.(js|mjs|cjs|json|css)$/.test(m[1])) bad.push(`${target}: ${m[1]}`)
72
- }
73
- }
74
- if (bad.length) throw new Error(`extensionless relative imports:\n ${bad.join('\n ')}`)
75
- })
76
-
77
- if (failed) {
78
- console.error(`\n${failed} dist check(s) failed`)
79
- process.exit(1)
80
- }
81
- console.log('dist: all checks passed')