@hanzo/design 0.4.7 → 0.4.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanzo/design",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
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",
@@ -140,6 +140,40 @@ 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. 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.
152
+ //
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.
157
+ {
158
+ const base = strip(read(join(tokensDir, 'base.css')))
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)]
163
+ .map(([, sel, body]) => ({ sel: sel.trim().replace(/\s+/g, ' '), body }))
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`))
175
+ }
176
+
143
177
  // ── 2. every var() used inside the token layer must resolve ──────────────
144
178
  {
145
179
  const declared = new Set()
package/styles.css CHANGED
@@ -834,19 +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. The generic `outline` is suppressed here
842
- precisely because it would draw a second, harder box around this one. */
843
- :where(input,select,textarea):focus-visible{
844
- outline:none;
845
- border-color:var(--border-focus);
846
- box-shadow:var(--ring-focus);
847
- }
848
- /* A hovered field lifts its surface a rung. Nudging its EDGE from .15 to .16
849
- 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. */
850
857
  :where(input,select,textarea):hover:not(:focus-visible):not(:disabled){background:var(--surface-3)}
851
858
  :where(input,textarea)::placeholder{color:var(--text-disabled)}
852
859
  :where(input,select,textarea,button):disabled{opacity:.5;cursor:not-allowed}
@@ -886,6 +893,19 @@
886
893
  }
887
894
  }
888
895
 
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. */
889
909
  :focus-visible{outline:2px solid var(--ring);outline-offset:2px}
890
910
  /* --white-20 is white-on-white in the light theme, so selection reads through
891
911
  --selection, which BOTH themes define. */
package/tailwind.css CHANGED
@@ -857,19 +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. 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. */
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. */
873
880
  :where(input,select,textarea):hover:not(:focus-visible):not(:disabled){background:var(--surface-3)}
874
881
  :where(input,textarea)::placeholder{color:var(--text-disabled)}
875
882
  :where(input,select,textarea,button):disabled{opacity:.5;cursor:not-allowed}
@@ -909,6 +916,19 @@
909
916
  }
910
917
  }
911
918
 
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. */
912
932
  :focus-visible{outline:2px solid var(--ring);outline-offset:2px}
913
933
  /* --white-20 is white-on-white in the light theme, so selection reads through
914
934
  --selection, which BOTH themes define. */
package/tokens/base.css CHANGED
@@ -99,19 +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. 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. */
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. */
115
122
  :where(input,select,textarea):hover:not(:focus-visible):not(:disabled){background:var(--surface-3)}
116
123
  :where(input,textarea)::placeholder{color:var(--text-disabled)}
117
124
  :where(input,select,textarea,button):disabled{opacity:.5;cursor:not-allowed}
@@ -151,6 +158,19 @@
151
158
  }
152
159
  }
153
160
 
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. */
154
174
  :focus-visible{outline:2px solid var(--ring);outline-offset:2px}
155
175
  /* --white-20 is white-on-white in the light theme, so selection reads through
156
176
  --selection, which BOTH themes define. */