@celestia-island/hikari 0.55.59 → 0.55.64

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.
Files changed (56) hide show
  1. package/package.json +1 -1
  2. package/src/components/HkColorSchemeDialog.scss +25 -0
  3. package/src/components/HkColorSchemeDialog.tsx +2 -2
  4. package/src/components/HkColorSchemeEditor.initTheme.test.ts +207 -0
  5. package/src/components/HkColorSchemeEditor.test.ts +218 -4
  6. package/src/components/HkColorSchemeEditor.tsx +103 -54
  7. package/src/components/HkModal.scss +12 -0
  8. package/src/components/HkStatusTray.scss +36 -0
  9. package/src/components/HkStatusTray.size.test.ts +218 -0
  10. package/src/components/HkStatusTray.test.tsx +178 -0
  11. package/src/components/HkStatusTray.tsx +187 -0
  12. package/src/components/HkThemeDecor.test.tsx +203 -0
  13. package/src/components/HkThemeDecor.tsx +120 -0
  14. package/src/components/HkThemeToggle.test.ts +10 -1
  15. package/src/components/HkWallpaperBackdrop.scss +68 -0
  16. package/src/components/HkWallpaperBackdrop.ssr.test.ts +45 -0
  17. package/src/components/HkWallpaperBackdrop.styles.contract.test.ts +190 -0
  18. package/src/components/HkWallpaperBackdrop.test.tsx +430 -0
  19. package/src/components/HkWallpaperBackdrop.tsx +447 -0
  20. package/src/components/themeDecorLayout.contract.test.ts +128 -0
  21. package/src/composables/useReducedMotion.ts +7 -0
  22. package/src/composables/useSizeMorph.test.ts +65 -7
  23. package/src/composables/useSizeMorph.ts +61 -19
  24. package/src/demo.ts +1 -1
  25. package/src/index.ts +57 -2
  26. package/src/render.ts +1 -1
  27. package/src/styles/channelFallbacks.test.ts +9 -1
  28. package/src/theme/backdropDecor.test.ts +124 -0
  29. package/src/theme/index.ts +46 -3
  30. package/src/theme/presets.ts +61 -154
  31. package/src/theme/safeStorage.ts +56 -0
  32. package/src/theme/slotKinds.test.ts +478 -0
  33. package/src/theme/standardDecor.test.ts +122 -0
  34. package/src/theme/standardDecor.ts +64 -0
  35. package/src/theme/standardGroups.test.ts +131 -0
  36. package/src/theme/standardGroups.ts +91 -0
  37. package/src/theme/themeDecor.test.ts +175 -0
  38. package/src/theme/themeDecor.ts +179 -0
  39. package/src/theme/themeDecorSurface.test.ts +82 -0
  40. package/src/theme/themeLoaderContract.test.ts +102 -0
  41. package/src/theme/tokenGroups.test.ts +34 -25
  42. package/src/theme/tokenGroups.ts +410 -48
  43. package/src/theme/useBackgroundLuminance.test.ts +232 -0
  44. package/src/theme/useBackgroundLuminance.ts +162 -14
  45. package/src/theme/useTheme.test.ts +42 -23
  46. package/src/theme/useTheme.ts +27 -7
  47. package/src/theme/useWallpaper.test.ts +382 -0
  48. package/src/theme/useWallpaper.ts +440 -0
  49. package/src/theme/wallpaper.ts +362 -0
  50. package/src/theme/wallpaperClock.test.ts +84 -0
  51. package/src/theme/wallpaperCustom.test.ts +281 -0
  52. package/src/theme/wallpaperDisplay.ts +23 -0
  53. package/src/theme/wallpaperNamespace.test.ts +170 -0
  54. package/src/theme/wallpaperSettings.test.ts +119 -0
  55. package/src/theme/wallpaperSingleton.contract.test.ts +139 -0
  56. package/src/theme/wallpaperSsr.test.ts +123 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.59",
3
+ "version": "0.55.64",
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",
@@ -64,6 +64,31 @@
64
64
  padding: var(--space-4, 0.25rem) var(--space-4, 0.25rem) var(--space-8, 0.5rem);
65
65
  }
66
66
 
67
+ /* Non-color slot cell (number slider / enum tab strip). A picker labels
68
+ itself; a slider and a tab strip do not, so the field supplies the slot
69
+ label — and the slider, whose value is otherwise only exposed through
70
+ aria-valuetext, prints it in the unit its cssvar is written with. */
71
+ .s-scheme-group-field {
72
+ display: flex;
73
+ flex-direction: column;
74
+ gap: var(--space-2, 0.125rem);
75
+ min-width: 0;
76
+ }
77
+
78
+ .s-scheme-group-field-label {
79
+ font-size: var(--text-xs, 0.75rem);
80
+ color: rgb(var(--color-muted));
81
+ overflow: hidden;
82
+ text-overflow: ellipsis;
83
+ white-space: nowrap;
84
+ }
85
+
86
+ .s-scheme-group-field-value {
87
+ font-size: var(--text-2xs, 0.625rem);
88
+ color: rgb(var(--color-muted));
89
+ font-variant-numeric: tabular-nums;
90
+ }
91
+
67
92
  @media (max-width: 767px) {
68
93
  .s-scheme-group-grid {
69
94
  grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -28,9 +28,9 @@ export const HColorSchemeDialog = defineComponent({
28
28
  name: "HkColorSchemeDialog",
29
29
  props: {
30
30
  modelValue: { type: Boolean, required: true },
31
- /** Prefill dark tokens; defaults to the hikari synthwave dark scheme. */
31
+ /** Prefill dark tokens; defaults to the stock default preset's dark scheme. */
32
32
  initialDark: { type: Object as PropType<ThemeSchemeTokens>, default: undefined },
33
- /** Prefill light tokens; defaults to the hikari synthwave light scheme. */
33
+ /** Prefill light tokens; defaults to the stock default preset's light scheme. */
34
34
  initialLight: { type: Object as PropType<ThemeSchemeTokens>, default: undefined },
35
35
  /** Prefill extension token groups (per mode); defaults to registry defaults. */
36
36
  initialGroups: { type: Object as PropType<ThemeTokenGroupModes>, default: undefined },
@@ -0,0 +1,207 @@
1
+ /**
2
+ * The end-to-end chain the other two theme suites leave open:
3
+ * `initTheme()` → registry → `HkColorSchemeEditor` → draft → cssvars.
4
+ *
5
+ * - src/theme/standardGroups.test.ts proves initTheme() REGISTERS `shape`.
6
+ * - src/components/HkColorSchemeEditor.test.ts proves the editor RENDERS
7
+ * whatever the registry holds — but it registers its own `editor-kinds`
8
+ * group by hand (registerTokenGroup) and nothing under src/components/
9
+ * calls initTheme() at all.
10
+ *
11
+ * So neither side notices when the halves stop agreeing: a standard group
12
+ * shipping slots the editor cannot render, a registry instance the editor
13
+ * does not share with initTheme(), or a slider edit that never reaches the
14
+ * emitted cssvar. That seam is this file's subject.
15
+ *
16
+ * Every case builds a FRESH module graph (vi.resetModules() + dynamic
17
+ * import) and never calls registerTokenGroup, so initTheme() is the only
18
+ * registrant in it. The reverse-control case below takes the same fresh
19
+ * graph but does NOT call initTheme(), which is what keeps the positive
20
+ * assertions from being vacuously true.
21
+ *
22
+ * Known happy-dom noise: each initTheme() call leaves the environment one
23
+ * pending async task that teardown aborts, so one
24
+ * `DOMException [AbortError]: The operation was aborted` dump is printed per
25
+ * call once the file finishes — and none from the reverse-control case,
26
+ * which never calls initTheme(). The dump is pre-existing teardown noise
27
+ * rather than a defect of this seam: this same branch without this file
28
+ * already prints four of them on a full run. Documented, not silenced.
29
+ */
30
+ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
31
+ import { createApp, h, nextTick, ref, type Ref } from "vue";
32
+
33
+ import type { HCustomTheme } from "./HkColorSchemeEditor";
34
+
35
+ interface EditorExpose {
36
+ getDraft: () => HCustomTheme;
37
+ }
38
+
39
+ type EditorComponent = (typeof import("./HkColorSchemeEditor"))["HkColorSchemeEditor"];
40
+ type ThemeModule = typeof import("../theme");
41
+
42
+ // The editor's first dynamic import drags in the whole src/index.ts barrel
43
+ // (~200 components), and Vite charges that transform to whichever case runs
44
+ // first. Warm the cache here, under a hook budget instead of a case's 5s.
45
+ // The instances the cases use are still the fresh ones taken after
46
+ // vi.resetModules() below; this hook only invites the transform.
47
+ beforeAll(async () => {
48
+ await import("../theme");
49
+ await import("./HkColorSchemeEditor");
50
+ }, 120_000);
51
+
52
+ /** A fresh module graph, with initTheme() the only possible registrant. */
53
+ async function freshGraph(): Promise<{ theme: ThemeModule; editor: EditorComponent }> {
54
+ vi.resetModules();
55
+ const theme = await import("../theme");
56
+ const { HkColorSchemeEditor } = await import("./HkColorSchemeEditor");
57
+ return { theme, editor: HkColorSchemeEditor };
58
+ }
59
+
60
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
61
+
62
+ function mountEditor(editor: EditorComponent): {
63
+ exposed: Ref<EditorExpose | null>;
64
+ container: HTMLElement;
65
+ } {
66
+ const container = document.createElement("div");
67
+ document.body.appendChild(container);
68
+ const exposed = ref<EditorExpose | null>(null);
69
+ const app = createApp({ render: () => h(editor, { ref: exposed }) });
70
+ app.mount(container);
71
+ mounts.push({ app, container });
72
+ return { exposed, container };
73
+ }
74
+
75
+ afterEach(() => {
76
+ for (const { app, container } of mounts.splice(0)) {
77
+ app.unmount();
78
+ container.remove();
79
+ }
80
+ });
81
+
82
+ interface SliderFacts {
83
+ max: string | null;
84
+ label: string | null;
85
+ valueNow: string | null;
86
+ valueText: string | null;
87
+ }
88
+
89
+ /** Every rendered slider of the editor, in DOM order, by aria attribute. */
90
+ function sliders(container: HTMLElement): SliderFacts[] {
91
+ return [...container.querySelectorAll(".hk-slider")].map((slider) => ({
92
+ max: slider.getAttribute("aria-valuemax"),
93
+ label: slider.getAttribute("aria-label"),
94
+ valueNow: slider.getAttribute("aria-valuenow"),
95
+ valueText: slider.getAttribute("aria-valuetext"),
96
+ }));
97
+ }
98
+
99
+ describe("initTheme() → registry → HkColorSchemeEditor", () => {
100
+ it("registers the shape group and renders its four radius sliders", async () => {
101
+ const { theme, editor } = await freshGraph();
102
+ // Fresh graph: the registry is empty until initTheme() runs, which is
103
+ // what makes initTheme() the only registrant of every slot asserted on.
104
+ expect(theme.getTokenGroups()).toEqual([]);
105
+
106
+ theme.initTheme();
107
+ // Pin the editing tab. The editor seeds it from `effectiveMode`, which
108
+ // under the default "system" mode resolves from the SOLAR PERIOD — so
109
+ // without this the assertions below flip with the wall clock (they read
110
+ // the dark side while a daytime run edits the light side). Same pin as
111
+ // the sibling HkColorSchemeEditor.test.ts does in its beforeEach.
112
+ theme.useTheme().setMode("dark");
113
+
114
+ const shape = theme
115
+ .getTokenGroups()
116
+ .find((group) => group.id === theme.STANDARD_SHAPE_GROUP_ID);
117
+ expect(shape, "initTheme() registered the shape group").toBeDefined();
118
+ // The four slots target the existing L2 scale tokens, and their maxima
119
+ // are the aria-valuemax values the mount below is identified by.
120
+ const slotFacts = (shape!.slots ?? []).map((slot) => {
121
+ if (!theme.isNumberSlot(slot)) {
122
+ throw new Error(`shape slot ${slot.key} is not a number slot`);
123
+ }
124
+ return [slot.key, slot.cssVar, slot.max];
125
+ });
126
+ expect(slotFacts).toEqual([
127
+ ["radius-sm", "--radius-sm", 24],
128
+ ["radius-md", "--radius-md", 32],
129
+ ["radius-lg", "--radius-lg", 40],
130
+ ["radius-xl", "--radius-xl", 48],
131
+ ]);
132
+
133
+ const { container } = mountEditor(editor);
134
+ await nextTick();
135
+
136
+ const rendered = sliders(container);
137
+ expect(rendered, "one slider per number slot, and only those").toHaveLength(4);
138
+ expect(rendered.map((s) => s.max)).toEqual(["24", "32", "40", "48"]);
139
+ // aria-label carries the LOCALIZED slot label — never the raw
140
+ // `hikari::theme.groups.<id>.<key>` message key or a stringified label
141
+ // map. The expectation is read BACK FROM THE REGISTRY rather than
142
+ // re-typed here: a second hand-copied list would go red on a pure copy
143
+ // edit while the seam stayed intact, which is the drift class this repo
144
+ // keeps retiring (see styles/channelFallbacks.test.ts). The controls
145
+ // below still pin the seam itself.
146
+ const expectedLabels = (shape!.slots ?? []).map((slot) =>
147
+ theme.resolveLocalizedText(slot.label, "en"),
148
+ );
149
+ expect(rendered.map((s) => s.label)).toEqual(expectedLabels);
150
+ for (const { label } of rendered) {
151
+ expect(label).not.toContain("hikari::");
152
+ expect(label).not.toContain("[object Object]");
153
+ expect(label!.length).toBeGreaterThan(0);
154
+ }
155
+ // Current values, unit-formatted the same way the cssvar is written.
156
+ expect(rendered.map((s) => s.valueNow)).toEqual(["4", "8", "12", "16"]);
157
+ expect(rendered.map((s) => s.valueText)).toEqual(["4px", "8px", "12px", "16px"]);
158
+ });
159
+
160
+ it("routes a slider edit into the dark draft only, and into the cssvars", async () => {
161
+ const { theme, editor } = await freshGraph();
162
+ theme.initTheme();
163
+ // Pin the editing tab. The editor seeds it from `effectiveMode`, which
164
+ // under the default "system" mode resolves from the SOLAR PERIOD — so
165
+ // without this the assertions below flip with the wall clock (they read
166
+ // the dark side while a daytime run edits the light side). Same pin as
167
+ // the sibling HkColorSchemeEditor.test.ts does in its beforeEach.
168
+ theme.useTheme().setMode("dark");
169
+ const { exposed, container } = mountEditor(editor);
170
+ await nextTick();
171
+
172
+ const first = container.querySelector<HTMLElement>(".hk-slider");
173
+ expect(first, "the sm slider rendered").not.toBeNull();
174
+ // The control's own update path (HkSlider's key handler → emit → the
175
+ // editor's updateGroupToken), not a direct draft write.
176
+ first!.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
177
+ await nextTick();
178
+
179
+ const draft = exposed.value!.getDraft();
180
+ expect(draft.groups, "getDraft() carries the initTheme()-registered group").toBeTruthy();
181
+ expect(draft.groups!.dark!.shape["radius-sm"]).toBe(5);
182
+ // Per-mode isolation: the light side keeps the registry default.
183
+ expect(draft.groups!.light!.shape["radius-sm"]).toBe(4);
184
+
185
+ // …and the edit is what the theme layer would emit for the dark mode.
186
+ const darkVars = theme.groupTokensToCSSVars(draft.groups!.dark!);
187
+ expect(darkVars["--radius-sm"]).toBe("5px");
188
+ // Untouched siblings still serialize their defaults.
189
+ expect(darkVars["--radius-md"]).toBe("8px");
190
+ expect(darkVars["--radius-lg"]).toBe("12px");
191
+ expect(darkVars["--radius-xl"]).toBe("16px");
192
+ expect(theme.groupTokensToCSSVars(draft.groups!.light!)["--radius-sm"]).toBe("4px");
193
+ });
194
+
195
+ it("renders no slider at all when initTheme() never runs", async () => {
196
+ // Reverse control: the same fresh graph WITHOUT initTheme(). If the
197
+ // assertions above were true for any reason other than initTheme()
198
+ // registering the group, this case would still show four sliders.
199
+ const { theme, editor } = await freshGraph();
200
+ expect(theme.getTokenGroups()).toEqual([]);
201
+
202
+ const { container } = mountEditor(editor);
203
+ await nextTick();
204
+
205
+ expect(container.querySelectorAll(".hk-slider")).toHaveLength(0);
206
+ });
207
+ });
@@ -1,7 +1,7 @@
1
1
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
2
  import { createApp, h, nextTick, onMounted, ref, type Ref } from "vue";
3
3
 
4
- import { registerTokenGroup, useTheme, type TokenGroupDefinition } from "../theme";
4
+ import { registerTokenGroup, themePresets, useTheme, type ThemeTokenRGB, type TokenGroupDefinition } from "../theme";
5
5
  import { HkColorSchemeEditor, type HCustomTheme } from "./HkColorSchemeEditor";
6
6
 
7
7
  // Fresh file = fresh registry module instance: no groups are registered
@@ -90,6 +90,7 @@ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement
90
90
  function mountEditor(initial?: {
91
91
  initialDark?: HCustomTheme["dark"];
92
92
  initialLight?: HCustomTheme["light"];
93
+ initialGroups?: HCustomTheme["groups"];
93
94
  }): { ref: Ref<EditorExpose | null>; container: HTMLElement } {
94
95
  const container = document.createElement("div");
95
96
  document.body.appendChild(container);
@@ -100,6 +101,7 @@ function mountEditor(initial?: {
100
101
  ref: editorRef,
101
102
  ...(initial?.initialDark ? { initialDark: initial.initialDark } : {}),
102
103
  ...(initial?.initialLight ? { initialLight: initial.initialLight } : {}),
104
+ ...(initial?.initialGroups ? { initialGroups: initial.initialGroups } : {}),
103
105
  }),
104
106
  });
105
107
  app.mount(container);
@@ -238,7 +240,7 @@ describe("HkColorSchemeEditor", () => {
238
240
  b: { r: 7, g: 8, b: 9 },
239
241
  });
240
242
  // Sectioned group slots also land under their group id.
241
- expect(darkGroups["editor-sectioned"].l1.r).toBe(234);
243
+ expect((darkGroups["editor-sectioned"].l1 as ThemeTokenRGB).r).toBe(234);
242
244
  expect(lightGroups["editor-sectioned"].l2).toEqual({ r: 21, g: 128, b: 61 });
243
245
  });
244
246
 
@@ -247,14 +249,16 @@ describe("HkColorSchemeEditor", () => {
247
249
  await nextTick();
248
250
 
249
251
  const before = ref.value!.getDraft();
250
- expect(before.dark.primary).toEqual({ r: 255, g: 107, b: 157 });
252
+ // Seeded from the stock preset table: assert against it rather than a
253
+ // literal that has to be re-typed every time the default scheme moves.
254
+ expect(before.dark.primary).toEqual(themePresets.default.dark.primary);
251
255
 
252
256
  await setPrimaryHex("00ff00");
253
257
  expect(ref.value!.getDraft().dark.primary).toEqual({ r: 0, g: 255, b: 0 });
254
258
 
255
259
  ref.value!.reset();
256
260
  await nextTick();
257
- expect(ref.value!.getDraft().dark.primary).toEqual({ r: 255, g: 107, b: 157 });
261
+ expect(ref.value!.getDraft().dark.primary).toEqual(themePresets.default.dark.primary);
258
262
  });
259
263
 
260
264
  it("reset() re-seeds on-solid slots to white when the prefill omits them", async () => {
@@ -315,3 +319,213 @@ describe("HkColorSchemeEditor", () => {
315
319
  expect(exposed!.getDraft().name).toBe("Host owned name");
316
320
  });
317
321
  });
322
+
323
+ describe("HkColorSchemeEditor seed provenance", () => {
324
+ /**
325
+ * Behavioural, not textual.
326
+ *
327
+ * An earlier cut of this guard scanned the component source for hard-coded
328
+ * palette literals. Mutation rounds killed it three times over: a re-spelled
329
+ * literal, a hex literal, a member access and a reordered key object all
330
+ * walked through it, while its exact-string anchors went red on a mere
331
+ * reformat. Text cannot pin this invariant.
332
+ *
333
+ * So pin the invariant itself — move the stock table and require the editor
334
+ * to follow. A hard-coded seed cannot, whatever spelling it uses, and no
335
+ * formatting choice can make this false-positive.
336
+ */
337
+ it("seeds the dark scheme from the live preset table", async () => {
338
+ const sentinel = { r: 7, g: 8, b: 9 };
339
+ const scheme = themePresets.default.dark;
340
+ const original = scheme.primary;
341
+ scheme.primary = sentinel;
342
+ try {
343
+ const { ref } = mountEditor();
344
+ await nextTick();
345
+ expect(ref.value!.getDraft().dark.primary).toEqual(sentinel);
346
+ } finally {
347
+ scheme.primary = original;
348
+ }
349
+ });
350
+
351
+ it("seeds the light scheme from the live preset table", async () => {
352
+ const sentinel = { r: 11, g: 12, b: 13 };
353
+ const scheme = themePresets.default.light;
354
+ const original = scheme.primary;
355
+ scheme.primary = sentinel;
356
+ try {
357
+ const { ref } = mountEditor();
358
+ await nextTick();
359
+ expect(ref.value!.getDraft().light.primary).toEqual(sentinel);
360
+ } finally {
361
+ scheme.primary = original;
362
+ }
363
+ });
364
+
365
+ it("survives a consumer that clears the stock keys", async () => {
366
+ // Chest does exactly this at boot: every stock key is deleted and only its
367
+ // brand line is registered (brandPresets.ts), so `themePresets.default` is
368
+ // simply absent downstream. An unconditional dereference mounts fine in
369
+ // every test in this repo and throws at mount inside that consumer — this
370
+ // case is the only thing that can see it.
371
+ const table = themePresets as Record<string, (typeof themePresets)[string]>;
372
+ const stock = table.default;
373
+ const brand = {
374
+ id: "brand",
375
+ name: "Brand",
376
+ dark: { ...stock.dark, primary: { r: 1, g: 2, b: 3 } },
377
+ light: { ...stock.light, primary: { r: 4, g: 5, b: 6 } },
378
+ };
379
+ for (const key of Object.keys(table)) delete table[key];
380
+ table.brand = brand;
381
+ try {
382
+ const { ref } = mountEditor();
383
+ await nextTick();
384
+ // Seeded from the consumer's line, not from a missing stock entry.
385
+ expect(ref.value!.getDraft().dark.primary).toEqual(brand.dark.primary);
386
+ expect(ref.value!.getDraft().light.primary).toEqual(brand.light.primary);
387
+ } finally {
388
+ delete table.brand;
389
+ table.default = stock;
390
+ }
391
+ });
392
+
393
+ it("still seeds when the table is empty altogether", async () => {
394
+ // Defensive arm: a consumer that clears the table without registering
395
+ // anything leaves `stockDefaultPreset` as the only reachable palette. The
396
+ // editor must mount rather than throw.
397
+ const table = themePresets as Record<string, (typeof themePresets)[string]>;
398
+ const stock = table.default;
399
+ delete table.default;
400
+ try {
401
+ const { ref } = mountEditor();
402
+ await nextTick();
403
+ expect(ref.value!.getDraft().dark.primary).toEqual(stock.dark.primary);
404
+ } finally {
405
+ table.default = stock;
406
+ }
407
+ });
408
+ });
409
+
410
+ // ── Widened slot kinds: numbers and enums next to colors ─────────────
411
+
412
+ /** Registered for the cases below only, so the counting assertions of the
413
+ * cases above never see it. */
414
+ const KIND_SLOTS_GROUP: TokenGroupDefinition = {
415
+ id: "editor-kinds",
416
+ label: "Editor kinds",
417
+ slots: [
418
+ {
419
+ key: "radius-md",
420
+ cssVar: "--radius-md",
421
+ kind: "number",
422
+ label: { en: "Medium radius", "zh-Hans": "中圆角" },
423
+ defaults: { dark: 8, light: 8 },
424
+ min: 0,
425
+ max: 32,
426
+ step: 1,
427
+ unit: "px",
428
+ },
429
+ {
430
+ key: "density",
431
+ kind: "enum",
432
+ label: { en: "Density", "zh-Hans": "密度" },
433
+ defaults: { dark: "compact", light: "cozy" },
434
+ options: [
435
+ { value: "compact", label: { en: "Compact", "zh-Hans": "紧凑" } },
436
+ { value: "cozy", label: "Cozy" },
437
+ ],
438
+ },
439
+ {
440
+ key: "wire",
441
+ label: { en: "Wire", "zh-Hans": "导线" },
442
+ defaults: { dark: { r: 220, g: 60, b: 60 }, light: { r: 180, g: 40, b: 40 } },
443
+ },
444
+ ],
445
+ };
446
+
447
+ describe("HkColorSchemeEditor slot kinds", () => {
448
+ beforeEach(() => {
449
+ registerTokenGroup(KIND_SLOTS_GROUP);
450
+ });
451
+
452
+ /** The expansion panel holding this group's slots. */
453
+ function kindPanel(container: HTMLElement): HTMLElement {
454
+ const slider = container.querySelector(".s-scheme-group-field .hk-slider");
455
+ expect(slider, "the number slot renders a slider").not.toBeNull();
456
+ return slider!.closest(".hk-expansion-panel") as HTMLElement;
457
+ }
458
+
459
+ it("renders a slider for number slots and a segmented strip for enum slots", async () => {
460
+ const { container } = mountEditor();
461
+ await nextTick();
462
+
463
+ const panel = kindPanel(container);
464
+ // The slot's own field wrapper carries the label a slider cannot show.
465
+ const fields = panel.querySelectorAll(".s-scheme-group-field");
466
+ expect(fields).toHaveLength(2); // number + enum (the color slot keeps its picker)
467
+ expect(fields[0].querySelector(".s-scheme-group-field-label")!.textContent).toBe(
468
+ "Medium radius",
469
+ );
470
+
471
+ const slider = panel.querySelector(".hk-slider") as HTMLElement;
472
+ expect(slider.getAttribute("aria-label")).toBe("Medium radius");
473
+ expect(slider.getAttribute("aria-valuemin")).toBe("0");
474
+ expect(slider.getAttribute("aria-valuemax")).toBe("32");
475
+ expect(slider.getAttribute("aria-valuenow")).toBe("8");
476
+ // formatValue feeds aria-valuetext with the slot's unit.
477
+ expect(slider.getAttribute("aria-valuetext")).toBe("8px");
478
+ expect(fields[0].querySelector(".s-scheme-group-field-value")!.textContent).toBe("8px");
479
+
480
+ // Enum slot: one segmented trigger per option, labeled per locale.
481
+ const triggers = [...panel.querySelectorAll(".hk-tabs-trigger")];
482
+ expect(triggers.map((t) => t.textContent?.trim())).toEqual(["Compact", "Cozy"]);
483
+
484
+ // The color slot is untouched: still the picker, still in the same grid.
485
+ expect(panel.querySelectorAll(".hk-color-picker")).toHaveLength(1);
486
+ expect(panel.querySelector(".s-scheme-group-grid")).not.toBeNull();
487
+ });
488
+
489
+ it("writes slider and tab edits into getDraft() as primitives", async () => {
490
+ const { ref, container } = mountEditor();
491
+ await nextTick();
492
+
493
+ const seeded = ref.value!.getDraft().groups!.dark!["editor-kinds"];
494
+ expect(seeded["radius-md"]).toBe(8);
495
+ expect(seeded.density).toBe("compact");
496
+
497
+ const panel = kindPanel(container);
498
+ const slider = panel.querySelector(".hk-slider") as HTMLElement;
499
+ slider.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
500
+ await nextTick();
501
+ const cozy = [...panel.querySelectorAll(".hk-tabs-trigger")].find(
502
+ (t) => t.textContent?.trim() === "Cozy",
503
+ ) as HTMLElement;
504
+ cozy.click();
505
+ await settle();
506
+
507
+ const edited = ref.value!.getDraft().groups!.dark!["editor-kinds"];
508
+ expect(edited["radius-md"]).toBe(9);
509
+ expect(edited.density).toBe("cozy");
510
+ });
511
+
512
+ it("clamps a stored draft value back into the slot on the way out", async () => {
513
+ // A saved theme carrying a hand-edited / since-retired value must not
514
+ // leak out of the editor: getDraft() re-clamps every slot by kind.
515
+ const { ref } = mountEditor({
516
+ initialGroups: {
517
+ dark: { "editor-kinds": { "radius-md": 999, density: "spacious" } },
518
+ light: { "editor-kinds": { "radius-md": 7, density: "cozy" } },
519
+ },
520
+ });
521
+ await nextTick();
522
+
523
+ const draft = ref.value!.getDraft();
524
+ // Out of range → the slot max; out of vocabulary → the dark anchor.
525
+ expect(draft.groups!.dark!["editor-kinds"]["radius-md"]).toBe(32);
526
+ expect(draft.groups!.dark!["editor-kinds"].density).toBe("compact");
527
+ // In-range values ride through untouched, per mode.
528
+ expect(draft.groups!.light!["editor-kinds"]["radius-md"]).toBe(7);
529
+ expect(draft.groups!.light!["editor-kinds"].density).toBe("cozy");
530
+ });
531
+ });