@celestia-island/hikari 0.55.8 → 0.55.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.8",
3
+ "version": "0.55.10",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -5,6 +5,11 @@
5
5
  .hk-checkbox {
6
6
  display: inline-flex;
7
7
  align-items: center;
8
+ /* When the control sits INLINE among text (not in a host flex row), the
9
+ * default `vertical-align: baseline` parks the inline-flex's bottom edge
10
+ * on the text baseline and rides the box ~4px above the text's visual
11
+ * center. `middle` is the house parity value (HkSwitch/HkRadio). */
12
+ vertical-align: middle;
8
13
  gap: var(--space-8, 0.5rem);
9
14
  cursor: pointer;
10
15
  user-select: none;
@@ -28,9 +33,34 @@
28
33
  background: var(--hi-color-surface, rgba(240,244,248,0.95));
29
34
  transition:
30
35
  border-color var(--duration-normal, 0.3s) ease,
31
- background var(--duration-normal, 0.3s) ease;
36
+ background var(--duration-normal, 0.3s) ease,
37
+ scale var(--duration-short, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1));
32
38
  flex-shrink: 0;
33
39
  position: relative;
40
+
41
+ /* Box scales with the size modifier — it previously ignored `size`
42
+ * entirely (an lg row carried an md box, floating 2px high next to
43
+ * 16px text). sm/md/lg now match HkRadio's box scale. */
44
+ .hk-checkbox-sm & {
45
+ width: 0.875rem;
46
+ height: 0.875rem;
47
+ }
48
+
49
+ .hk-checkbox-lg & {
50
+ width: 1.375rem;
51
+ height: 1.375rem;
52
+ }
53
+ }
54
+
55
+ /* Press feedback (house parity: HkSwitch thumb squish). */
56
+ .hk-checkbox:not([data-disabled]):active .hk-checkbox-box {
57
+ scale: 0.9;
58
+ }
59
+
60
+ /* Hover parity with HkRadio: an unchecked box picks up the primary edge
61
+ under the pointer. Checked/indeterminate keep their solid fill. */
62
+ .hk-checkbox:not([data-disabled]):hover .hk-checkbox-box:not([data-checked]):not([data-indeterminate]) {
63
+ border-color: var(--hi-color-primary, #7aa2f7);
34
64
  }
35
65
 
36
66
  .hk-checkbox[data-type="radio"] .hk-checkbox-box {
@@ -74,6 +104,16 @@
74
104
  color: rgb(var(--color-on-solid-icon, 255 255 255));
75
105
  width: 14px;
76
106
  height: 14px;
107
+
108
+ .hk-checkbox-sm & {
109
+ width: 11px;
110
+ height: 11px;
111
+ }
112
+
113
+ .hk-checkbox-lg & {
114
+ width: 16px;
115
+ height: 16px;
116
+ }
77
117
  }
78
118
 
79
119
  .hk-checkbox-dot {
@@ -82,6 +122,16 @@
82
122
  border-radius: 50%;
83
123
  background: rgb(var(--color-on-solid-icon, 255 255 255));
84
124
  z-index: var(--hi-z-above, 1);
125
+
126
+ .hk-checkbox-sm & {
127
+ width: 5px;
128
+ height: 5px;
129
+ }
130
+
131
+ .hk-checkbox-lg & {
132
+ width: 8px;
133
+ height: 8px;
134
+ }
85
135
  }
86
136
 
87
137
  .hk-checkbox-indeterminate {
@@ -90,6 +140,14 @@
90
140
  border-radius: 1px;
91
141
  background: rgb(var(--color-on-solid-icon, 255 255 255));
92
142
  z-index: var(--hi-z-above, 1);
143
+
144
+ .hk-checkbox-sm & {
145
+ width: 6px;
146
+ }
147
+
148
+ .hk-checkbox-lg & {
149
+ width: 10px;
150
+ }
93
151
  }
94
152
 
95
153
  /* ---------- label ---------- */
@@ -97,3 +155,11 @@
97
155
  .hk-checkbox[data-animating] .hk-checkbox-box {
98
156
  will-change: border-color, background;
99
157
  }
158
+
159
+ /* Reduced motion: the press squish snaps instead of tweening (house
160
+ pattern: HkSwitch.scss / HkSlider.scss). */
161
+ @media (prefers-reduced-motion: reduce) {
162
+ .hk-checkbox-box {
163
+ transition: none;
164
+ }
165
+ }
@@ -23,6 +23,10 @@
23
23
  .hk-radio {
24
24
  display: inline-flex;
25
25
  align-items: center;
26
+ /* House parity (HkCheckbox/HkSwitch): keeps the box on the text's
27
+ * visual midline when a radio row is lifted out of the group into an
28
+ * inline text context. No-op inside the flex group. */
29
+ vertical-align: middle;
26
30
  gap: var(--space-8, 0.5rem);
27
31
  cursor: pointer;
28
32
  user-select: none;
@@ -44,7 +48,8 @@
44
48
  background: var(--hi-color-surface);
45
49
  transition:
46
50
  border-color var(--duration-normal, 0.3s) ease,
47
- background var(--duration-normal, 0.3s) ease;
51
+ background var(--duration-normal, 0.3s) ease,
52
+ scale var(--duration-short, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1));
48
53
  flex-shrink: 0;
49
54
  position: relative;
50
55
 
@@ -73,6 +78,11 @@
73
78
  border-color: var(--hi-color-primary);
74
79
  }
75
80
 
81
+ /* Press feedback (house parity: HkCheckbox box / HkSwitch thumb). */
82
+ .hk-radio:not([data-disabled]):active .hk-radio-box {
83
+ scale: 0.9;
84
+ }
85
+
76
86
  /* --- Hidden input --- */
77
87
 
78
88
  .hk-radio-input {
@@ -111,3 +121,11 @@
111
121
  height: 5px;
112
122
  }
113
123
  }
124
+
125
+ /* Reduced motion: the press squish snaps instead of tweening (house
126
+ pattern: HkSwitch.scss / HkSlider.scss). */
127
+ @media (prefers-reduced-motion: reduce) {
128
+ .hk-radio-box {
129
+ transition: none;
130
+ }
131
+ }
@@ -0,0 +1,283 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { compile } from "sass";
6
+ import { describe, expect, it } from "vitest";
7
+
8
+ /**
9
+ * Compiled-stylesheet guard for the three selection controls
10
+ * (HkCheckbox / HkRadio / HkSwitch).
11
+ *
12
+ * Vitest stubs CSS, so the behavioral suites can never catch a geometry or
13
+ * alignment fact drifting. This guard compiles the three sheets and pins
14
+ * the facts the 2026-09 alignment/size fix introduced:
15
+ *
16
+ * 1. inline-in-text alignment: every control root declares
17
+ * `vertical-align: middle` (baseline parked the inline-flex's bottom
18
+ * edge on the text baseline, riding the box ~4–7px above the text's
19
+ * visual center — measured in headless Chrome).
20
+ * 2. checkbox box scale: the box follows the sm/md/lg size modifiers
21
+ * (it previously ignored `size` entirely and rendered an md box in
22
+ * lg rows), matching HkRadio's box scale.
23
+ * 3. switch geometry: the track/thumb grew (md 44×24 track, 20px thumb)
24
+ * and the checked travel stays exactly `track − 2×inset − thumb` per
25
+ * size, so the thumb can never detach from either pill edge.
26
+ * 4. interaction faces: hover/press feedback exists on all three
27
+ * (checkbox hover was missing entirely; radio hover had no press
28
+ * twin) and the off track is a filled neutral, not a bare surface.
29
+ *
30
+ * The rules below are parsed out of the COMPILED css (not the .scss
31
+ * source) so a rule that fails to compile can never count as present.
32
+ */
33
+
34
+ const componentDir = resolve(dirname(fileURLToPath(import.meta.url)));
35
+
36
+ interface Rule {
37
+ selector: string;
38
+ body: string;
39
+ }
40
+
41
+ /** Flat-rule splitter for the expanded output of these leaf sheets. */
42
+ function rules(css: string): Rule[] {
43
+ // Expanded output keeps loud comments, and sheets containing non-ASCII
44
+ // text get a braille-less `@charset "UTF-8";` statement — both would glue
45
+ // onto the next selector and shift every rule pair, so strip them first.
46
+ const bare = css
47
+ .replace(/\/\*[\s\S]*?\*\//g, "")
48
+ .replace(/@charset[^;]*;/g, "")
49
+ .trim();
50
+ const out: Rule[] = [];
51
+ for (const match of bare.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
52
+ const selector = match[1]!.trim();
53
+ // Keep control rules; the rtl mirrors lead with an attribute selector.
54
+ if (!selector.startsWith(".hk-") && !selector.startsWith("[dir=")) continue;
55
+ out.push({ selector, body: match[2]! });
56
+ }
57
+ return out;
58
+ }
59
+
60
+ function find(all: Rule[], selector: string): Rule {
61
+ const hit = all.find((r) => r.selector === selector);
62
+ expect(hit, `compiled rule for \`${selector}\``).toBeDefined();
63
+ return hit!;
64
+ }
65
+
66
+ /**
67
+ * The R2 adversarial round smuggled a `.hk-switch-md &` override past the
68
+ * per-size assertions (the render picked it up, the tests kept reading the
69
+ * base rule — a 0,2,0 selector beats the 0,1,0 base at runtime). Any
70
+ * geometry face pinned here must therefore pin its WHOLE selector set:
71
+ * a reader can only trust the values below when no unlisted, more
72
+ * specific rule also touches the class.
73
+ */
74
+ function expectSelectorSet(all: Rule[], classSuffix: string, expected: string[]) {
75
+ const present = all
76
+ .map((r) => r.selector)
77
+ .filter((s) => {
78
+ const last = s.split(/\s+>/).pop()!.trim().split(" ").pop()!;
79
+ return last === classSuffix && !s.startsWith("[dir=");
80
+ })
81
+ .sort();
82
+ expect(present, `selector set touching \`${classSuffix}\``).toEqual(expected.sort());
83
+ }
84
+
85
+ /** `2.25rem` / `14px` / `-1rem` → signed px number (1rem = 16px; bare numbers = rem). */
86
+ function toPx(decl: string): number {
87
+ const m = decl.match(/(-?[\d.]+)(rem|px)?$/);
88
+ expect(m, `length in "${decl}"`).not.toBeNull();
89
+ const value = parseFloat(m![1]!);
90
+ return m![2] === "px" ? value : value * 16;
91
+ }
92
+
93
+ function valueOf(body: string, prop: string): string {
94
+ const m = body.match(new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*([^;]+)`));
95
+ expect(m, `declaration \`${prop}\``).not.toBeNull();
96
+ return m![1]!.trim();
97
+ }
98
+
99
+ const checkbox = rules(compile(resolve(componentDir, "HkCheckbox.scss"), { style: "expanded" }).css);
100
+ const radio = rules(compile(resolve(componentDir, "HkRadio.scss"), { style: "expanded" }).css);
101
+ const sw = rules(compile(resolve(componentDir, "HkSwitch.scss"), { style: "expanded" }).css);
102
+
103
+ describe("selection controls inline alignment", () => {
104
+ it.each([
105
+ ["HkCheckbox", checkbox, ".hk-checkbox"],
106
+ ["HkRadio", radio, ".hk-radio"],
107
+ ["HkSwitch", sw, ".hk-switch"],
108
+ ])("%s root declares vertical-align: middle", (_name, all, selector) => {
109
+ expect(valueOf(find(all, selector).body, "vertical-align")).toBe("middle");
110
+ });
111
+ });
112
+
113
+ describe("HkCheckbox box scale follows the size modifier", () => {
114
+ const cases: Array<[string, string, number]> = [
115
+ ["base (md)", ".hk-checkbox-box", 18],
116
+ ["sm", ".hk-checkbox-sm .hk-checkbox-box", 14],
117
+ ["lg", ".hk-checkbox-lg .hk-checkbox-box", 22],
118
+ ];
119
+ it.each(cases)("%s box is %ipx", (_name, selector, px) => {
120
+ const body = find(checkbox, selector).body;
121
+ expect(toPx(valueOf(body, "width"))).toBe(px);
122
+ expect(toPx(valueOf(body, "height"))).toBe(px);
123
+ });
124
+
125
+ it.each([
126
+ ["sm", ".hk-checkbox-sm .hk-checkbox-icon", 11],
127
+ ["md base", ".hk-checkbox-icon", 14],
128
+ ["lg", ".hk-checkbox-lg .hk-checkbox-icon", 16],
129
+ ])("%s check icon is %ipx", (_name, selector, px) => {
130
+ const body = find(checkbox, selector).body;
131
+ expect(toPx(valueOf(body, "width"))).toBe(px);
132
+ });
133
+
134
+ it("hover paints the primary edge on an unchecked box (HkRadio parity)", () => {
135
+ const body = find(
136
+ checkbox,
137
+ ".hk-checkbox:not([data-disabled]):hover .hk-checkbox-box:not([data-checked]):not([data-indeterminate])",
138
+ ).body;
139
+ expect(valueOf(body, "border-color")).toContain("--hi-color-primary");
140
+ });
141
+
142
+ it("press squish on the box via the independent scale property", () => {
143
+ const body = find(checkbox, ".hk-checkbox:not([data-disabled]):active .hk-checkbox-box").body;
144
+ expect(valueOf(body, "scale")).toBe("0.9");
145
+ // The squish only composes cleanly if the box transitions `scale`.
146
+ expect(valueOf(find(checkbox, ".hk-checkbox-box").body, "transition")).toContain("scale");
147
+ });
148
+ });
149
+
150
+ describe("HkRadio interaction parity", () => {
151
+ it("press squish on the box via the independent scale property", () => {
152
+ const body = find(radio, ".hk-radio:not([data-disabled]):active .hk-radio-box").body;
153
+ expect(valueOf(body, "scale")).toBe("0.9");
154
+ expect(valueOf(find(radio, ".hk-radio-box").body, "transition")).toContain("scale");
155
+ });
156
+ });
157
+
158
+ describe("HkSwitch geometry", () => {
159
+ const geometry: Array<[string, number, number, number]> = [
160
+ // [size, track width, track height, thumb width]
161
+ ["sm", 36, 20, 16],
162
+ ["md", 44, 24, 20],
163
+ ["lg", 52, 28, 24],
164
+ ];
165
+
166
+ it.each(geometry)("%s track is %ipx wide", (size, w, h) => {
167
+ const body = find(sw, `.hk-switch-${size} .hk-switch-track`).body;
168
+ expect(toPx(valueOf(body, "width"))).toBe(w);
169
+ expect(toPx(valueOf(body, "height"))).toBe(h);
170
+ });
171
+
172
+ it.each(geometry)("%s thumb is %ipx", (size, _w, _h, thumb) => {
173
+ const body = find(sw, `.hk-switch-${size} .hk-switch-thumb`).body;
174
+ expect(toPx(valueOf(body, "width"))).toBe(thumb);
175
+ expect(toPx(valueOf(body, "height"))).toBe(thumb);
176
+ });
177
+
178
+ it.each(geometry)(
179
+ "%s checked travel keeps the thumb inside both pill edges",
180
+ (size, trackW, _trackH, thumb) => {
181
+ const body = find(sw, `.hk-switch-${size}[data-checked] .hk-switch-thumb`).body;
182
+ const inset = toPx(valueOf(find(sw, ".hk-switch-thumb").body, "inset-inline-start"));
183
+ const border = toPx(valueOf(find(sw, ".hk-switch-track").body, "border").split(" ")[0]!);
184
+ const travel = toPx(valueOf(body, "transform").replace("translateX(", "").replace(")", ""));
185
+ // travel = track − 2×(border + inset) − thumb: inset-inline-start
186
+ // resolves against the padding box, so the 1px border counts on BOTH
187
+ // sides — the checked gap then equals the resting gap (3px) exactly.
188
+ expect(travel).toBe(trackW - 2 * (border + inset) - thumb);
189
+ },
190
+ );
191
+
192
+ it.each(geometry)(
193
+ "%s off/on content anchors clear of the covering thumb",
194
+ (size, trackW, _trackH, thumb) => {
195
+ // md carries the base declaration; sm/lg get size overrides.
196
+ const scoped = (base: string) =>
197
+ find(sw, `.hk-switch-${size} ${base}`.replace(".hk-switch-md ", "")).body;
198
+ const offBody = scoped(".hk-switch-content-off");
199
+ const onBody = scoped(".hk-switch-content-on");
200
+ // off face: starts after the resting thumb (2px inset + thumb + 2px gap).
201
+ expect(toPx(valueOf(offBody, "inset-inline-start"))).toBe(2 + thumb + 2);
202
+ // on face: ends before the checked thumb — the checked thumb starts
203
+ // `2 + travel` from the padding-box start, i.e. (track−2−travel) from
204
+ // the end, plus the 2px gap.
205
+ const travel = toPx(
206
+ valueOf(find(sw, `.hk-switch-${size}[data-checked] .hk-switch-thumb`).body, "transform")
207
+ .replace("translateX(", "").replace(")", ""),
208
+ );
209
+ expect(toPx(valueOf(onBody, "inset-inline-end"))).toBe(trackW - 2 - travel);
210
+ },
211
+ );
212
+
213
+ it("pins the whole selector set of each caption face (R2 M7 blind spot)", () => {
214
+ // A more specific override (e.g. `.hk-switch-md .hk-switch-content-off`)
215
+ // wins at runtime but is invisible to the per-size assertions above,
216
+ // which read the base rule — the ONLY sanctioned scoped faces are the
217
+ // sm/lg ones; anything else must fail here first.
218
+ for (const face of [".hk-switch-content-off", ".hk-switch-content-on"]) {
219
+ expectSelectorSet(sw, face, [
220
+ face,
221
+ `.hk-switch-sm ${face}`,
222
+ `.hk-switch-lg ${face}`,
223
+ ]);
224
+ }
225
+ });
226
+
227
+ it("resting off track is a filled neutral, not a bare surface", () => {
228
+ const body = find(sw, ".hk-switch-track").body;
229
+ expect(valueOf(body, "background")).toContain("color-mix");
230
+ expect(valueOf(body, "border")).toContain("color-mix");
231
+ });
232
+
233
+ it("hover recolors the off track and brightens the checked track", () => {
234
+ expect(
235
+ valueOf(
236
+ find(sw, ".hk-switch:not([data-disabled]):not([data-checked]):hover .hk-switch-track").body,
237
+ "background",
238
+ ),
239
+ ).toContain("color-mix");
240
+ expect(
241
+ valueOf(find(sw, ".hk-switch:not([data-disabled])[data-checked]:hover .hk-switch-track").body, "filter"),
242
+ ).toContain("brightness");
243
+ });
244
+
245
+ it("press squish on the thumb via the independent scale property", () => {
246
+ const body = find(sw, ".hk-switch:not([data-disabled]):active .hk-switch-thumb").body;
247
+ expect(valueOf(body, "scale")).toBe("0.88");
248
+ expect(valueOf(find(sw, ".hk-switch-thumb").body, "transition")).toContain("scale");
249
+ });
250
+
251
+ it("rtl mirrors the checked slide for every size", () => {
252
+ for (const size of ["sm", "md", "lg"]) {
253
+ const ltr = toPx(
254
+ valueOf(find(sw, `.hk-switch-${size}[data-checked] .hk-switch-thumb`).body, "transform").replace(
255
+ "translateX(",
256
+ "",
257
+ ).replace(")", ""),
258
+ );
259
+ const rtl = find(sw, `[dir=rtl] .hk-switch-${size}[data-checked] .hk-switch-thumb`).body;
260
+ expect(toPx(valueOf(rtl, "transform").replace("translateX(", "").replace(")", ""))).toBe(-ltr);
261
+ }
262
+ });
263
+
264
+ it.each([
265
+ ["HkCheckbox", "HkCheckbox", ".hk-checkbox-box"],
266
+ ["HkRadio", "HkRadio", ".hk-radio-box"],
267
+ ["HkSwitch", "HkSwitch", ".hk-switch-thumb"],
268
+ ])("%s press feedback is disabled under prefers-reduced-motion", (_name, sheet, selector) => {
269
+ // Parse ONLY the reduced-motion block (the flat splitter would read the
270
+ // base rule and make this pass vacuously).
271
+ const css = compile(resolve(componentDir, `${sheet}.scss`), { style: "expanded" }).css;
272
+ const marker = css.match(/@media[^{]*prefers-reduced-motion[^{]*{/);
273
+ expect(marker, `reduced-motion block in ${sheet}.scss`).not.toBeNull();
274
+ const blockRules = rules(css.slice(marker!.index! + marker![0]!.length));
275
+ // The switch block groups `.hk-switch-thumb, .hk-switch-content` in one
276
+ // rule — compare per selector in the list, whitespace-normalized.
277
+ const hit = blockRules.find((r) =>
278
+ r.selector.replace(/\s+/g, " ").split(", ").includes(selector),
279
+ );
280
+ expect(hit, `${selector} inside the reduced-motion block`).toBeDefined();
281
+ expect(valueOf(hit!.body, "transition")).toBe("none");
282
+ });
283
+ });
@@ -1,6 +1,11 @@
1
1
  .hk-switch {
2
2
  display: inline-flex;
3
3
  align-items: center;
4
+ /* When the switch sits INLINE among text (not in a host flex row), the
5
+ * default `vertical-align: baseline` parks the inline-flex's bottom edge
6
+ * on the text baseline and rides the track ~5px above the text's visual
7
+ * center. `middle` is the house parity value (HkCheckbox/HkRadio). */
8
+ vertical-align: middle;
4
9
  gap: var(--space-8, 0.5rem);
5
10
  cursor: pointer;
6
11
  user-select: none;
@@ -24,8 +29,8 @@
24
29
  the label; house pattern: HkButton double ring). */
25
30
  .hk-switch-input:focus-visible ~ .hk-switch-track {
26
31
  box-shadow:
27
- 0 0 0 2px rgb(var(--color-surface)),
28
- 0 0 0 4px rgb(var(--color-primary));
32
+ 0 0 0 2px var(--hi-color-surface, rgba(240, 244, 248, 0.95)),
33
+ 0 0 0 4px var(--hi-color-primary, #7aa2f7);
29
34
  }
30
35
 
31
36
  /* --- Track --- */
@@ -35,27 +40,32 @@
35
40
  display: flex;
36
41
  align-items: center;
37
42
  border-radius: 9999px;
38
- border: 1px solid var(--hi-color-border);
39
- background: var(--hi-color-surface);
43
+ /* Resting state is a softly FILLED neutral track (iOS-style) rather
44
+ * than a bare surface sliver: the off state must read as a control
45
+ * even on a same-colored card. The border rides one step ahead of the
46
+ * fill so the pill keeps its edge on tinted host surfaces too. */
47
+ border: 1px solid color-mix(in srgb, var(--hi-color-text-secondary, rgb(120 130 150)) 26%, transparent);
48
+ background: color-mix(in srgb, var(--hi-color-text-secondary, rgb(120 130 150)) 14%, transparent);
40
49
  transition:
41
- background-color 0.2s ease,
42
- border-color 0.2s ease;
50
+ background-color var(--duration-short, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1)),
51
+ border-color var(--duration-short, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1)),
52
+ filter var(--duration-short, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1));
43
53
  flex-shrink: 0;
44
54
  overflow: hidden;
45
55
 
46
56
  .hk-switch-sm & {
47
- width: 2rem;
48
- height: 1.125rem;
57
+ width: 2.25rem;
58
+ height: 1.25rem;
49
59
  }
50
60
 
51
61
  .hk-switch-md & {
52
- width: 2.5rem;
53
- height: 1.375rem;
62
+ width: 2.75rem;
63
+ height: 1.5rem;
54
64
  }
55
65
 
56
66
  .hk-switch-lg & {
57
- width: 3rem;
58
- height: 1.625rem;
67
+ width: 3.25rem;
68
+ height: 1.75rem;
59
69
  }
60
70
 
61
71
  .hk-switch-primary[data-checked] & {
@@ -79,6 +89,19 @@
79
89
  }
80
90
  }
81
91
 
92
+ /* Hover / press feedback (house parity: HkRadio box hover). The checked
93
+ track brightens a touch instead of recoloring, so the four state colors
94
+ keep their exact resting identity. Note the state markers live on the
95
+ ROOT label, not the track, so the :not() rides there too. */
96
+ .hk-switch:not([data-disabled]):not([data-checked]):hover .hk-switch-track {
97
+ background: color-mix(in srgb, var(--hi-color-text-secondary, rgb(120 130 150)) 22%, transparent);
98
+ border-color: color-mix(in srgb, var(--hi-color-text-secondary, rgb(120 130 150)) 34%, transparent);
99
+ }
100
+
101
+ .hk-switch:not([data-disabled])[data-checked]:hover .hk-switch-track {
102
+ filter: brightness(1.07) saturate(1.05);
103
+ }
104
+
82
105
  /* --- Thumb --- */
83
106
 
84
107
  .hk-switch-thumb {
@@ -91,31 +114,46 @@
91
114
  the luminance-computed on-primary that went near-black on bright
92
115
  primaries (synthwave pink) while HkButton labels stayed white. */
93
116
  background: rgb(var(--color-on-solid-icon, 255 255 255));
94
- box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
95
- transition: transform 0.2s var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1));
117
+ box-shadow:
118
+ 0 1px 2px rgba(0, 0, 0, 0.2),
119
+ 0 2px 8px rgba(0, 0, 0, 0.12);
120
+ transition:
121
+ transform var(--duration-short, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1)),
122
+ scale var(--duration-short, 0.15s) var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1));
96
123
  z-index: 1;
97
124
 
98
125
  .hk-switch-sm & {
99
- width: 0.75rem;
100
- height: 0.75rem;
126
+ width: 1rem;
127
+ height: 1rem;
101
128
  }
102
129
 
103
130
  .hk-switch-md & {
104
- width: 0.9375rem;
105
- height: 0.9375rem;
131
+ width: 1.25rem;
132
+ height: 1.25rem;
106
133
  }
107
134
 
108
135
  .hk-switch-lg & {
109
- width: 1.125rem;
110
- height: 1.125rem;
136
+ width: 1.5rem;
137
+ height: 1.5rem;
138
+ }
139
+
140
+ /* Press squish composes with the slide via the independent `scale`
141
+ property (transform owns the translation only). */
142
+ .hk-switch:not([data-disabled]):active & {
143
+ scale: 0.88;
111
144
  }
112
145
 
146
+ /* Travel = track − 2×(1px border + 2px inset) − thumb width, so the
147
+ checked thumb clears the border by the same 3px as the resting one
148
+ (inset-inline-start resolves against the padding box — the 1px border
149
+ must count twice or the two gaps render asymmetric):
150
+ sm 36−6−16=14 · md 44−6−20=18 · lg 52−6−24=22. */
113
151
  .hk-switch-sm[data-checked] & {
114
- transform: translateX(0.813rem);
152
+ transform: translateX(0.875rem);
115
153
  }
116
154
 
117
155
  .hk-switch-md[data-checked] & {
118
- transform: translateX(1.12rem);
156
+ transform: translateX(1.125rem);
119
157
  }
120
158
 
121
159
  .hk-switch-lg[data-checked] & {
@@ -125,11 +163,11 @@
125
163
  /* RTL: the checked slide mirrors — the thumb travels back toward the
126
164
  inline-start (physically leftward) edge. */
127
165
  [dir="rtl"] .hk-switch-sm[data-checked] & {
128
- transform: translateX(-0.813rem);
166
+ transform: translateX(-0.875rem);
129
167
  }
130
168
 
131
169
  [dir="rtl"] .hk-switch-md[data-checked] & {
132
- transform: translateX(-1.12rem);
170
+ transform: translateX(-1.125rem);
133
171
  }
134
172
 
135
173
  [dir="rtl"] .hk-switch-lg[data-checked] & {
@@ -141,7 +179,7 @@
141
179
 
142
180
  .hk-switch-content {
143
181
  position: absolute;
144
- font-size: 0.5625rem;
182
+ font-size: 0.625rem;
145
183
  font-weight: 700;
146
184
  line-height: 1;
147
185
  white-space: nowrap;
@@ -149,21 +187,46 @@
149
187
  transition: opacity 0.2s ease;
150
188
 
151
189
  .hk-switch-sm & {
152
- font-size: 0.4375rem;
190
+ font-size: 0.5rem;
153
191
  }
154
192
 
155
193
  .hk-switch-lg & {
156
- font-size: 0.625rem;
157
- }
158
-
194
+ font-size: 0.6875rem;
195
+ }
196
+
197
+ /* Both faces anchor AWAY from the thumb that would cover them (content
198
+ * sits at z-index 0, the thumb at 1): the off face starts clear of the
199
+ * resting thumb (2px inset + thumb + 2px gap = 20/24/28), the on face
200
+ * ends clear of the checked one (thumb + 4 = 20/24/28: with travel =
201
+ * track − 2×(border+inset) − thumb, the checked thumb starts
202
+ * thumb + 4 from the padding-box end). A label longer
203
+ * than its free zone clips at the far seam instead of sliding under the
204
+ * thumb — the R1 verification measured 3–5px of coverage with the old
205
+ * far-edge anchors once the track grew. */
159
206
  &-off {
160
- inset-inline-end: 4px;
207
+ inset-inline-start: 24px;
161
208
  color: var(--hi-color-text-secondary);
209
+
210
+ .hk-switch-sm & {
211
+ inset-inline-start: 20px;
212
+ }
213
+
214
+ .hk-switch-lg & {
215
+ inset-inline-start: 28px;
216
+ }
162
217
  }
163
218
 
164
219
  &-on {
165
- inset-inline-start: 4px;
220
+ inset-inline-end: 24px;
166
221
  color: rgb(var(--color-on-solid-text, 255 255 255));
222
+
223
+ .hk-switch-sm & {
224
+ inset-inline-end: 20px;
225
+ }
226
+
227
+ .hk-switch-lg & {
228
+ inset-inline-end: 28px;
229
+ }
167
230
  }
168
231
  }
169
232