@celestia-island/hikari 0.55.9 → 0.55.11
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 +1 -1
- package/src/components/HkCheckbox.scss +67 -1
- package/src/components/HkRadio.scss +19 -1
- package/src/components/HkSelectionControls.styles.test.ts +283 -0
- package/src/components/HkSwitch.scss +94 -31
- package/src/components/HkTimeline.scss +0 -1
- package/src/components/_menu-item.scss +1 -1
- package/src/styles/admin-tokens.scss +31 -31
- package/src/styles/channelFallbacks.test.ts +190 -0
- package/src/styles/reset.scss +1 -1
- package/src/styles/sheetCompile.test.ts +74 -0
- package/src/styles/theme/channels.scss +2 -2
- package/src/tokens.scss +2 -2
package/package.json
CHANGED
|
@@ -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
|
|
28
|
-
0 0 0 4px
|
|
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
|
-
|
|
39
|
-
|
|
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.
|
|
42
|
-
border-color 0.
|
|
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:
|
|
48
|
-
height: 1.
|
|
57
|
+
width: 2.25rem;
|
|
58
|
+
height: 1.25rem;
|
|
49
59
|
}
|
|
50
60
|
|
|
51
61
|
.hk-switch-md & {
|
|
52
|
-
width: 2.
|
|
53
|
-
height: 1.
|
|
62
|
+
width: 2.75rem;
|
|
63
|
+
height: 1.5rem;
|
|
54
64
|
}
|
|
55
65
|
|
|
56
66
|
.hk-switch-lg & {
|
|
57
|
-
width:
|
|
58
|
-
height: 1.
|
|
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:
|
|
95
|
-
|
|
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:
|
|
100
|
-
height:
|
|
126
|
+
width: 1rem;
|
|
127
|
+
height: 1rem;
|
|
101
128
|
}
|
|
102
129
|
|
|
103
130
|
.hk-switch-md & {
|
|
104
|
-
width:
|
|
105
|
-
height:
|
|
131
|
+
width: 1.25rem;
|
|
132
|
+
height: 1.25rem;
|
|
106
133
|
}
|
|
107
134
|
|
|
108
135
|
.hk-switch-lg & {
|
|
109
|
-
width: 1.
|
|
110
|
-
height: 1.
|
|
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.
|
|
152
|
+
transform: translateX(0.875rem);
|
|
115
153
|
}
|
|
116
154
|
|
|
117
155
|
.hk-switch-md[data-checked] & {
|
|
118
|
-
transform: translateX(1.
|
|
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.
|
|
166
|
+
transform: translateX(-0.875rem);
|
|
129
167
|
}
|
|
130
168
|
|
|
131
169
|
[dir="rtl"] .hk-switch-md[data-checked] & {
|
|
132
|
-
transform: translateX(-1.
|
|
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.
|
|
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.
|
|
190
|
+
font-size: 0.5rem;
|
|
153
191
|
}
|
|
154
192
|
|
|
155
193
|
.hk-switch-lg & {
|
|
156
|
-
font-size: 0.
|
|
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-
|
|
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-
|
|
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
|
|
|
@@ -213,6 +213,6 @@
|
|
|
213
213
|
height: 1px;
|
|
214
214
|
margin: var(--hk-menu-item-pad-y, 6px) auto;
|
|
215
215
|
width: calc(100% - 2 * var(--hk-menu-item-pad-x, 12px));
|
|
216
|
-
background: rgb(var(--color-border,
|
|
216
|
+
background: rgb(var(--color-border, 100 100 100) / 18%);
|
|
217
217
|
border: none;
|
|
218
218
|
}
|
|
@@ -15,34 +15,34 @@
|
|
|
15
15
|
* loads before this sheet. Only plana-specific tokens remain below. */
|
|
16
16
|
|
|
17
17
|
/* ── Shared color aliases ─ */
|
|
18
|
-
--c-primary: rgb(var(--color-primary,
|
|
19
|
-
--c-primary-light: rgb(var(--color-primary,
|
|
20
|
-
--c-primary-subtle: rgb(var(--color-primary,
|
|
21
|
-
--c-primary-overlay: rgb(var(--color-primary,
|
|
18
|
+
--c-primary: rgb(var(--color-primary, 122 162 247));
|
|
19
|
+
--c-primary-light: rgb(var(--color-primary, 122 162 247) / 15%);
|
|
20
|
+
--c-primary-subtle: rgb(var(--color-primary, 122 162 247) / 8%);
|
|
21
|
+
--c-primary-overlay: rgb(var(--color-primary, 122 162 247) / 15%);
|
|
22
22
|
|
|
23
23
|
/* ── Hikari color bridge ─ */
|
|
24
|
-
--hi-color-primary: rgb(var(--color-primary,
|
|
25
|
-
--hi-color-primary-rgb: var(--color-primary,
|
|
26
|
-
--hi-color-surface: rgb(var(--color-surface,
|
|
24
|
+
--hi-color-primary: rgb(var(--color-primary, 122 162 247));
|
|
25
|
+
--hi-color-primary-rgb: var(--color-primary, 122 162 247);
|
|
26
|
+
--hi-color-surface: rgb(var(--color-surface, 240 244 248));
|
|
27
27
|
--hi-color-border: rgb(var(--color-border, 100 100 100) / 15%);
|
|
28
|
-
--hi-color-text-primary: rgb(var(--color-text,
|
|
28
|
+
--hi-color-text-primary: rgb(var(--color-text, 30 30 30));
|
|
29
29
|
--hi-color-text-on-primary: rgb(var(--color-on-primary, 255 255 255)); /* deprecated in-tree: prefer the on-solid slots */
|
|
30
30
|
--color-on-solid: 255 255 255;
|
|
31
31
|
--color-on-solid-text: 255 255 255;
|
|
32
32
|
--color-on-solid-icon: 255 255 255;
|
|
33
33
|
--hi-color-text-on-solid: rgb(var(--color-on-solid-text, 255 255 255));
|
|
34
34
|
--hi-color-icon-on-solid: rgb(var(--color-on-solid-icon, 255 255 255));
|
|
35
|
-
--hi-color-text-secondary: color-mix(in srgb, rgb(var(--color-text,
|
|
36
|
-
--hi-color-muted: rgb(var(--color-muted,
|
|
37
|
-
--hi-color-background: rgb(var(--color-background,
|
|
38
|
-
--hi-color-bg-canvas: rgb(var(--color-background,
|
|
39
|
-
--hi-color-bg-elevated: rgb(var(--color-surface,
|
|
40
|
-
--hi-color-bg-subtle: rgb(var(--color-surface,
|
|
41
|
-
--hi-secondary-bg: rgb(var(--color-surface,
|
|
42
|
-
--hi-color-danger: rgb(var(--color-error,
|
|
43
|
-
--hi-color-success: rgb(var(--color-success,
|
|
44
|
-
--hi-color-warning: rgb(var(--color-warning,
|
|
45
|
-
--hi-color-info: rgb(var(--color-info,
|
|
35
|
+
--hi-color-text-secondary: color-mix(in srgb, rgb(var(--color-text, 30 30 30)) 60%, transparent);
|
|
36
|
+
--hi-color-muted: rgb(var(--color-muted, 108 108 108));
|
|
37
|
+
--hi-color-background: rgb(var(--color-background, 214 236 240));
|
|
38
|
+
--hi-color-bg-canvas: rgb(var(--color-background, 214 236 240));
|
|
39
|
+
--hi-color-bg-elevated: rgb(var(--color-surface, 240 244 248));
|
|
40
|
+
--hi-color-bg-subtle: rgb(var(--color-surface, 240 244 248) / 0.5);
|
|
41
|
+
--hi-secondary-bg: rgb(var(--color-surface, 240 244 248) / 0.5);
|
|
42
|
+
--hi-color-danger: rgb(var(--color-error, 247 118 142));
|
|
43
|
+
--hi-color-success: rgb(var(--color-success, 14 184 64));
|
|
44
|
+
--hi-color-warning: rgb(var(--color-warning, 238 214 119));
|
|
45
|
+
--hi-color-info: rgb(var(--color-info, 106 190 214));
|
|
46
46
|
--hi-color-black-10: rgb(0 0 0 / 0.1);
|
|
47
47
|
--hi-color-white-5: rgb(255 255 255 / 0.05);
|
|
48
48
|
--hi-color-white-10: rgb(255 255 255 / 0.1);
|
|
@@ -58,8 +58,8 @@
|
|
|
58
58
|
* by load order. (--hk-surface's only in-tree consumer — the retired
|
|
59
59
|
* HTabs tail overlay — is gone; the token stays published for
|
|
60
60
|
* external plana-ui readers.) */
|
|
61
|
-
--hk-surface: var(--color-surface,
|
|
62
|
-
--hk-primary: var(--color-primary,
|
|
61
|
+
--hk-surface: var(--color-surface, 240 244 248);
|
|
62
|
+
--hk-primary: var(--color-primary, 122 162 247);
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/* Global reset (opt-in: import this explicitly) */
|
|
@@ -545,7 +545,7 @@
|
|
|
545
545
|
height: 1px;
|
|
546
546
|
margin: var(--hk-menu-item-pad-y, 6px) auto;
|
|
547
547
|
width: calc(100% - 2 * var(--hk-menu-item-pad-x, 12px));
|
|
548
|
-
background: rgb(var(--color-border,
|
|
548
|
+
background: rgb(var(--color-border, 100 100 100) / 18%);
|
|
549
549
|
border: none;
|
|
550
550
|
}
|
|
551
551
|
|
|
@@ -754,7 +754,7 @@
|
|
|
754
754
|
depend on the host having loaded the reset sheet (without it a 1px
|
|
755
755
|
border pushes the box 2px past its max-width and off-centre). */
|
|
756
756
|
box-sizing: border-box;
|
|
757
|
-
background: color-mix(in srgb, rgb(var(--color-surface,
|
|
757
|
+
background: color-mix(in srgb, rgb(var(--color-surface, 240 244 248)) 70%, transparent);
|
|
758
758
|
border: 1px solid color-mix(in srgb, rgb(var(--color-text, 30 30 30)) 12%, transparent);
|
|
759
759
|
border-radius: var(--hi-radius-sm, 4px);
|
|
760
760
|
box-shadow: 0 2px 16px rgb(0 0 0 / 8%);
|
|
@@ -879,8 +879,8 @@
|
|
|
879
879
|
gap: var(--space-8, 0.5rem);
|
|
880
880
|
font-size: var(--text-xs, 0.75rem);
|
|
881
881
|
color: var(--hi-color-danger, #f85149);
|
|
882
|
-
background: color-mix(in srgb, var(--hi-color-danger,
|
|
883
|
-
border: 1px solid color-mix(in srgb, var(--hi-color-danger,
|
|
882
|
+
background: color-mix(in srgb, var(--hi-color-danger, rgb(247 118 142)) 10%, transparent);
|
|
883
|
+
border: 1px solid color-mix(in srgb, var(--hi-color-danger, rgb(247 118 142)) 20%, transparent);
|
|
884
884
|
border-radius: 6px;
|
|
885
885
|
padding: var(--space-8, 0.5rem) var(--space-12, 0.75rem);
|
|
886
886
|
}
|
|
@@ -967,8 +967,8 @@
|
|
|
967
967
|
|
|
968
968
|
.s-auth-invite-banner.expired {
|
|
969
969
|
color: var(--hi-color-danger, #f85149);
|
|
970
|
-
background: color-mix(in srgb, var(--hi-color-danger,
|
|
971
|
-
border-color: color-mix(in srgb, var(--hi-color-danger,
|
|
970
|
+
background: color-mix(in srgb, var(--hi-color-danger, rgb(247 118 142)) 10%, transparent);
|
|
971
|
+
border-color: color-mix(in srgb, var(--hi-color-danger, rgb(247 118 142)) 20%, transparent);
|
|
972
972
|
}
|
|
973
973
|
|
|
974
974
|
@media (max-width: 480px) {
|
|
@@ -992,7 +992,7 @@
|
|
|
992
992
|
}
|
|
993
993
|
|
|
994
994
|
.a-text-danger {
|
|
995
|
-
color: var(--hi-color-danger,
|
|
995
|
+
color: var(--hi-color-danger, rgb(247 118 142));
|
|
996
996
|
}
|
|
997
997
|
|
|
998
998
|
.a-icon-inline {
|
|
@@ -1000,11 +1000,11 @@
|
|
|
1000
1000
|
}
|
|
1001
1001
|
|
|
1002
1002
|
.a-icon-success {
|
|
1003
|
-
color: var(--color-success,
|
|
1003
|
+
color: var(--color-success, rgb(14 184 64));
|
|
1004
1004
|
}
|
|
1005
1005
|
|
|
1006
1006
|
.a-icon-warning {
|
|
1007
|
-
color: var(--color-warning,
|
|
1007
|
+
color: var(--color-warning, rgb(238 214 119));
|
|
1008
1008
|
}
|
|
1009
1009
|
|
|
1010
1010
|
.a-bg-secondary {
|
|
@@ -1022,7 +1022,7 @@
|
|
|
1022
1022
|
|
|
1023
1023
|
.a-error-banner {
|
|
1024
1024
|
background: var(--hi-color-danger, rgba(239, 68, 68, 0.1));
|
|
1025
|
-
border: 1px solid color-mix(in srgb, var(--hi-color-danger,
|
|
1025
|
+
border: 1px solid color-mix(in srgb, var(--hi-color-danger, rgb(247 118 142)) 20%, transparent);
|
|
1026
1026
|
}
|
|
1027
1027
|
|
|
1028
1028
|
.a-tabular-nums {
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Channel-fallback drift guard (2026-09-16).
|
|
9
|
+
*
|
|
10
|
+
* The sheets carry two layers of colour: the STATIC SEED
|
|
11
|
+
* (`styles/theme/channels.scss`, a byte-identical copy of `src/tokens.scss`
|
|
12
|
+
* — see vendoredSync.test.ts) which "renders correctly even without
|
|
13
|
+
* initTheme()", and the alias fallbacks in `styles/admin-tokens.scss`,
|
|
14
|
+
* `styles/reset.scss`, `components/_menu-item.scss` plus the package-local
|
|
15
|
+
* ladders in `packages/theme/styles`, which apply when a host never themes
|
|
16
|
+
* — or, in the legacy ladders, as plain values.
|
|
17
|
+
*
|
|
18
|
+
* Failure class: a fallback is a SECOND COPY of a palette decision, and
|
|
19
|
+
* nothing kept it in step with the seed. Six of them still named
|
|
20
|
+
* synthwave84's pink (`rgb(255 107 157)`) long after the seed had moved to
|
|
21
|
+
* its own blue, so a consumer resolving the alias sheet without the seed got
|
|
22
|
+
* the retired default — a downstream panel's standalone error page shipped
|
|
23
|
+
* exactly that pink, the legacy focus ring glowed pink, and the button
|
|
24
|
+
* ladders still carried the pink/salmon trio. A fallback must resolve to
|
|
25
|
+
* what the seeded composition already declares, and no shipped sheet may
|
|
26
|
+
* carry the retired family at all.
|
|
27
|
+
*
|
|
28
|
+
* Slots with no seed are listed in UNSEEDED rather than skipped silently:
|
|
29
|
+
* `--color-bg-secondary` / `--color-bg-tertiary` are retired spellings
|
|
30
|
+
* (their `#18181b` / `#27272a` fallbacks always win) and `--color-danger` is
|
|
31
|
+
* defined nowhere, so there is no seed value to compare against — recorded
|
|
32
|
+
* debt, not a passing check.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const stylesDir = resolve(dirname(fileURLToPath(import.meta.url)));
|
|
36
|
+
const packageDir = resolve(stylesDir, "..", "..");
|
|
37
|
+
const legacyDir = resolve(packageDir, "..", "theme", "styles");
|
|
38
|
+
|
|
39
|
+
/** Slots this guard cannot hold to the seed, with the reason above. */
|
|
40
|
+
const UNSEEDED = ["--color-bg-secondary", "--color-bg-tertiary", "--color-danger"];
|
|
41
|
+
|
|
42
|
+
function read(path: string): string {
|
|
43
|
+
return readFileSync(path, "utf8");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `--color-SLOT: R G B;` declarations of the static seed. */
|
|
47
|
+
function seedTriplets(source: string): Record<string, string> {
|
|
48
|
+
const out: Record<string, string> = {};
|
|
49
|
+
for (const match of source.matchAll(/--color-([a-z-]+):\s*(\d+ \d+ \d+);/g)) {
|
|
50
|
+
out[match[1]] = match[2];
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Normalize a colour literal to `R G B`: bare/space/comma triplets,
|
|
56
|
+
* `rgb()`/`rgba()` wrappers, an alpha term, or hex — every spelling the
|
|
57
|
+
* sheets actually use. */
|
|
58
|
+
function triplet(value: string): string | null {
|
|
59
|
+
const v = value
|
|
60
|
+
.trim()
|
|
61
|
+
.replace(/^(?:rgba?)\(/i, "")
|
|
62
|
+
.replace(/\)$/, "")
|
|
63
|
+
.replace(/\/[^/]*$/, "")
|
|
64
|
+
.trim();
|
|
65
|
+
const parts = v.split(/[\s,]+/).filter(Boolean);
|
|
66
|
+
if (parts.length >= 3 && parts.slice(0, 3).every((p) => /^\d+$/.test(p))) {
|
|
67
|
+
return parts.slice(0, 3).join(" ");
|
|
68
|
+
}
|
|
69
|
+
const hex = /^#([0-9a-f]{6})$/i.exec(v);
|
|
70
|
+
if (hex) {
|
|
71
|
+
const n = Number.parseInt(hex[1], 16);
|
|
72
|
+
return `${(n >> 16) & 255} ${(n >> 8) & 255} ${n & 255}`;
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Typed fallbacks, whitespace-tolerant: `var( --color-x, v )` must not slip
|
|
78
|
+
* past the scan (a single occurrence of that spelling was proven to hide a
|
|
79
|
+
* drifted value while every counter stayed green). */
|
|
80
|
+
function fallbacks(source: string): { slot: string; value: string; line: number }[] {
|
|
81
|
+
const out: { slot: string; value: string; line: number }[] = [];
|
|
82
|
+
const re = /var\(\s*--color-([a-z-]+)\s*,\s*((?:[^()]|\([^()]*\))+?)\s*\)/g;
|
|
83
|
+
let match: RegExpExecArray | null;
|
|
84
|
+
while ((match = re.exec(source)) !== null) {
|
|
85
|
+
out.push({
|
|
86
|
+
slot: match[1],
|
|
87
|
+
value: match[2],
|
|
88
|
+
line: source.slice(0, match.index).split("\n").length,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The retired pink/salmon family in EVERY spelling: flatten whitespace and
|
|
95
|
+
* commas so `rgba(238, 162, 164, 0.8)` is caught next to `238 162 164` (the
|
|
96
|
+
* space-only needles let the comma form through, proven by mutation). */
|
|
97
|
+
function retiredHits(source: string): string[] {
|
|
98
|
+
const flat = source.toLowerCase().replace(/[\s,]+/g, "");
|
|
99
|
+
return [
|
|
100
|
+
// synthwave84's pink pair
|
|
101
|
+
"#ff6b9d",
|
|
102
|
+
"#d63384",
|
|
103
|
+
"255107157",
|
|
104
|
+
"21451132",
|
|
105
|
+
// the Gen-1 peony brand and its tints/salmon form
|
|
106
|
+
"#eea2a4",
|
|
107
|
+
"#f4c4c5",
|
|
108
|
+
"#d88b8e",
|
|
109
|
+
"238162164",
|
|
110
|
+
"244196197",
|
|
111
|
+
"216139142",
|
|
112
|
+
// NOT listed: the Gen-1 vermilion danger family (#FF4C00 / #FF7A33 /
|
|
113
|
+
// #CC3D00 / rgba(255, 76, 0, …) — 26 sites across the component sheets
|
|
114
|
+
// and base.scss's danger slot). That is a deliberate Gen-1 palette, not
|
|
115
|
+
// a leftover of the pink brand line, so moving it is a design decision
|
|
116
|
+
// of its own; recorded in the PR instead of smuggled into this guard.
|
|
117
|
+
].filter((needle) => flat.includes(needle));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
describe("channel fallbacks mirror the static seed", () => {
|
|
121
|
+
const seedSource = read(resolve(packageDir, "src", "tokens.scss"));
|
|
122
|
+
const seed = seedTriplets(seedSource);
|
|
123
|
+
|
|
124
|
+
/** Sheets that carry typed `var(--color-SLOT, …)` fallbacks. */
|
|
125
|
+
const FALLBACK_SHEETS: [string, string][] = [
|
|
126
|
+
["src/tokens.scss", seedSource],
|
|
127
|
+
["src/styles/theme/channels.scss", read(resolve(stylesDir, "theme", "channels.scss"))],
|
|
128
|
+
["src/styles/admin-tokens.scss", read(resolve(stylesDir, "admin-tokens.scss"))],
|
|
129
|
+
["src/styles/reset.scss", read(resolve(stylesDir, "reset.scss"))],
|
|
130
|
+
["src/components/_menu-item.scss", read(resolve(packageDir, "src", "components", "_menu-item.scss"))],
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
/** Every shipped sheet, for the retired-family sweep. */
|
|
134
|
+
const ALL_SHEETS: [string, string][] = [
|
|
135
|
+
...FALLBACK_SHEETS,
|
|
136
|
+
["src/styles/theme/scale.scss", read(resolve(stylesDir, "theme", "scale.scss"))],
|
|
137
|
+
["src/styles/theme/scrollbar.scss", read(resolve(stylesDir, "theme", "scrollbar.scss"))],
|
|
138
|
+
...[
|
|
139
|
+
"_tokens.scss",
|
|
140
|
+
"base.scss",
|
|
141
|
+
"foundation.scss",
|
|
142
|
+
"themes.scss",
|
|
143
|
+
"variables.scss",
|
|
144
|
+
"_layout.scss",
|
|
145
|
+
"_glass.scss",
|
|
146
|
+
"mixins.scss",
|
|
147
|
+
"_scrollbar.scss",
|
|
148
|
+
].map((name) => [`packages/theme/styles/${name}`, read(resolve(legacyDir, name))] as [string, string]),
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
it("finds both the seed and the fallbacks (positive control)", () => {
|
|
152
|
+
// An empty or mis-pathed read would make every check below pass
|
|
153
|
+
// vacuously, so each scan must prove it saw real content.
|
|
154
|
+
expect(Object.keys(seed).length).toBeGreaterThanOrEqual(15);
|
|
155
|
+
const typed = FALLBACK_SHEETS.flatMap(([, source]) => fallbacks(source));
|
|
156
|
+
expect(typed.length).toBeGreaterThanOrEqual(20);
|
|
157
|
+
for (const [label, source] of ALL_SHEETS) {
|
|
158
|
+
expect(source.length, `${label} read empty`).toBeGreaterThan(200);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("declares every typed fallback with the seed value for its slot", () => {
|
|
163
|
+
const checked: string[] = [];
|
|
164
|
+
for (const [label, source] of FALLBACK_SHEETS) {
|
|
165
|
+
for (const row of fallbacks(source)) {
|
|
166
|
+
const slot = `--color-${row.slot}`;
|
|
167
|
+
if (UNSEEDED.includes(slot)) continue;
|
|
168
|
+
expect(seed[row.slot], `${label}:${row.line} ${slot} has no seed slot`).toBeDefined();
|
|
169
|
+
checked.push(`${label}:${row.line}`);
|
|
170
|
+
expect(
|
|
171
|
+
triplet(row.value),
|
|
172
|
+
`${label}:${row.line} var(${slot}, ${row.value})`,
|
|
173
|
+
).toBe(seed[row.slot]);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// The scan must actually reach the sheets (guards against a regex that
|
|
177
|
+
// silently matches nothing after an edit).
|
|
178
|
+
expect(checked.length).toBeGreaterThanOrEqual(25);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("keeps the retired synthwave pink family out of every shipped sheet", () => {
|
|
182
|
+
for (const [label, source] of ALL_SHEETS) {
|
|
183
|
+
expect(retiredHits(source), `${label} reintroduced a retired colour`).toEqual([]);
|
|
184
|
+
}
|
|
185
|
+
// Positive control: the detector must fire on the known-bad spellings.
|
|
186
|
+
expect(retiredHits("--x: rgba(238, 162, 164, 0.8);")).toContain("238162164");
|
|
187
|
+
expect(retiredHits("--x: #F4C4C5;")).toContain("#f4c4c5");
|
|
188
|
+
expect(retiredHits("--x: rgb(255 107 157 / 12%);")).toContain("255107157");
|
|
189
|
+
});
|
|
190
|
+
});
|
package/src/styles/reset.scss
CHANGED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import * as sass from "sass";
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Every shipped sheet must compile (2026-09-17).
|
|
10
|
+
*
|
|
11
|
+
* Regression: hikari #564 ("Highlight the timeline step on hover instead of
|
|
12
|
+
* scaling it") left a stray `}` at the end of `HkTimeline.scss`. The sheet
|
|
13
|
+
* was uncompilable from the moment it merged and nothing noticed, because
|
|
14
|
+
* · the entry sheet (`styles/index.scss`) does not `@use` component sheets,
|
|
15
|
+
* · component tests import the TSX, never the SCSS,
|
|
16
|
+
* · and the one contract that DOES compile sheets
|
|
17
|
+
* (`components/textClip.contract.test.ts`) only compiles the ones it
|
|
18
|
+
* reviews — HkTimeline is not among them.
|
|
19
|
+
* So the first thing to compile it would have been a downstream app.
|
|
20
|
+
*
|
|
21
|
+
* This guard compiles every `.scss` under `src/` with the same sass a
|
|
22
|
+
* consumer uses (144 sheets, ~1s), which is cheap enough to run always.
|
|
23
|
+
* A failure names the file and sass's own message.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const srcDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
27
|
+
|
|
28
|
+
function sheetsIn(dir: string): string[] {
|
|
29
|
+
const out: string[] = [];
|
|
30
|
+
for (const entry of readdirSync(dir)) {
|
|
31
|
+
if (entry === "node_modules") continue;
|
|
32
|
+
const path = join(dir, entry);
|
|
33
|
+
if (statSync(path).isDirectory()) out.push(...sheetsIn(path));
|
|
34
|
+
else if (entry.endsWith(".scss")) out.push(path);
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function compile(file: string): string | null {
|
|
40
|
+
try {
|
|
41
|
+
sass.compile(file, {
|
|
42
|
+
loadPaths: [srcDir, join(srcDir, "styles"), join(srcDir, "styles", "theme")],
|
|
43
|
+
// `@import` and the JS-API bridge are deprecated, not errors: the
|
|
44
|
+
// legacy Gen-1 sheets still use them and that is not this guard's job.
|
|
45
|
+
silenceDeprecations: ["import", "global-builtin", "legacy-js-api"],
|
|
46
|
+
});
|
|
47
|
+
return null;
|
|
48
|
+
} catch (error) {
|
|
49
|
+
return String((error as Error).message).split("\n")[0];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("every shipped stylesheet compiles", () => {
|
|
54
|
+
const sheets = sheetsIn(srcDir);
|
|
55
|
+
|
|
56
|
+
it("walks the whole source tree (positive control)", () => {
|
|
57
|
+
// A mis-rooted walk would make the sweep below pass vacuously.
|
|
58
|
+
expect(sheets.length, "sheets found under src/").toBeGreaterThan(100);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("reports failures (instrument self-test)", () => {
|
|
62
|
+
expect(compile(resolve(srcDir, "styles", "index.scss")), "the entry sheet compiles").toBeNull();
|
|
63
|
+
// The instrument must be able to see the defect class it guards: sass
|
|
64
|
+
// rejects an unbalanced block, and this is exactly the error #564 shipped.
|
|
65
|
+
expect(() => sass.compileString(".hk-probe { color: red;\n}\n}")).toThrow(/unmatched/i);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("compiles every sheet, naming the ones that fail", () => {
|
|
69
|
+
const failures = sheets
|
|
70
|
+
.map((file) => ({ file: relative(srcDir, file), error: compile(file) }))
|
|
71
|
+
.filter((row): row is { file: string; error: string } => row.error !== null);
|
|
72
|
+
expect(failures, failures.map((f) => `${f.file}: ${f.error}`).join("\n")).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -69,8 +69,8 @@
|
|
|
69
69
|
--hi-bg-surface-dark: rgb(var(--color-muted, 108 108 108) / 12%);
|
|
70
70
|
--hi-color-primary-dark: color-mix(in srgb, rgb(var(--color-primary, 122 162 247)) 82%, #000);
|
|
71
71
|
--hi-color-secondary-dark: color-mix(in srgb, rgb(var(--color-secondary, 81 154 115)) 82%, #000);
|
|
72
|
-
--hi-color-danger-dark: color-mix(in srgb, rgb(var(--color-error,
|
|
73
|
-
--hi-color-success-dark: color-mix(in srgb, rgb(var(--color-success,
|
|
72
|
+
--hi-color-danger-dark: color-mix(in srgb, rgb(var(--color-error, 247 118 142)) 82%, #000);
|
|
73
|
+
--hi-color-success-dark: color-mix(in srgb, rgb(var(--color-success, 14 184 64)) 82%, #000);
|
|
74
74
|
--hi-color-text-on-secondary: rgb(var(--color-on-solid, 255 255 255));
|
|
75
75
|
--hi-color-text-on-danger: rgb(var(--color-on-solid, 255 255 255));
|
|
76
76
|
--hi-color-text-on-success: rgb(var(--color-on-solid, 255 255 255));
|
package/src/tokens.scss
CHANGED
|
@@ -62,8 +62,8 @@
|
|
|
62
62
|
--hi-bg-surface-dark: rgb(var(--color-muted, 108 108 108) / 12%);
|
|
63
63
|
--hi-color-primary-dark: color-mix(in srgb, rgb(var(--color-primary, 122 162 247)) 82%, #000);
|
|
64
64
|
--hi-color-secondary-dark: color-mix(in srgb, rgb(var(--color-secondary, 81 154 115)) 82%, #000);
|
|
65
|
-
--hi-color-danger-dark: color-mix(in srgb, rgb(var(--color-error,
|
|
66
|
-
--hi-color-success-dark: color-mix(in srgb, rgb(var(--color-success,
|
|
65
|
+
--hi-color-danger-dark: color-mix(in srgb, rgb(var(--color-error, 247 118 142)) 82%, #000);
|
|
66
|
+
--hi-color-success-dark: color-mix(in srgb, rgb(var(--color-success, 14 184 64)) 82%, #000);
|
|
67
67
|
--hi-color-text-on-secondary: rgb(var(--color-on-solid, 255 255 255));
|
|
68
68
|
--hi-color-text-on-danger: rgb(var(--color-on-solid, 255 255 255));
|
|
69
69
|
--hi-color-text-on-success: rgb(var(--color-on-solid, 255 255 255));
|