@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.
- package/package.json +1 -1
- package/src/components/HkColorSchemeDialog.scss +25 -0
- package/src/components/HkColorSchemeDialog.tsx +2 -2
- package/src/components/HkColorSchemeEditor.initTheme.test.ts +207 -0
- package/src/components/HkColorSchemeEditor.test.ts +218 -4
- package/src/components/HkColorSchemeEditor.tsx +103 -54
- package/src/components/HkModal.scss +12 -0
- package/src/components/HkStatusTray.scss +36 -0
- package/src/components/HkStatusTray.size.test.ts +218 -0
- package/src/components/HkStatusTray.test.tsx +178 -0
- package/src/components/HkStatusTray.tsx +187 -0
- package/src/components/HkThemeDecor.test.tsx +203 -0
- package/src/components/HkThemeDecor.tsx +120 -0
- package/src/components/HkThemeToggle.test.ts +10 -1
- package/src/components/HkWallpaperBackdrop.scss +68 -0
- package/src/components/HkWallpaperBackdrop.ssr.test.ts +45 -0
- package/src/components/HkWallpaperBackdrop.styles.contract.test.ts +190 -0
- package/src/components/HkWallpaperBackdrop.test.tsx +430 -0
- package/src/components/HkWallpaperBackdrop.tsx +447 -0
- package/src/components/themeDecorLayout.contract.test.ts +128 -0
- package/src/composables/useReducedMotion.ts +7 -0
- package/src/composables/useSizeMorph.test.ts +65 -7
- package/src/composables/useSizeMorph.ts +61 -19
- package/src/demo.ts +1 -1
- package/src/index.ts +57 -2
- package/src/render.ts +1 -1
- package/src/styles/channelFallbacks.test.ts +9 -1
- package/src/theme/backdropDecor.test.ts +124 -0
- package/src/theme/index.ts +46 -3
- package/src/theme/presets.ts +61 -154
- package/src/theme/safeStorage.ts +56 -0
- package/src/theme/slotKinds.test.ts +478 -0
- package/src/theme/standardDecor.test.ts +122 -0
- package/src/theme/standardDecor.ts +64 -0
- package/src/theme/standardGroups.test.ts +131 -0
- package/src/theme/standardGroups.ts +91 -0
- package/src/theme/themeDecor.test.ts +175 -0
- package/src/theme/themeDecor.ts +179 -0
- package/src/theme/themeDecorSurface.test.ts +82 -0
- package/src/theme/themeLoaderContract.test.ts +102 -0
- package/src/theme/tokenGroups.test.ts +34 -25
- package/src/theme/tokenGroups.ts +410 -48
- package/src/theme/useBackgroundLuminance.test.ts +232 -0
- package/src/theme/useBackgroundLuminance.ts +162 -14
- package/src/theme/useTheme.test.ts +42 -23
- package/src/theme/useTheme.ts +27 -7
- package/src/theme/useWallpaper.test.ts +382 -0
- package/src/theme/useWallpaper.ts +440 -0
- package/src/theme/wallpaper.ts +362 -0
- package/src/theme/wallpaperClock.test.ts +84 -0
- package/src/theme/wallpaperCustom.test.ts +281 -0
- package/src/theme/wallpaperDisplay.ts +23 -0
- package/src/theme/wallpaperNamespace.test.ts +170 -0
- package/src/theme/wallpaperSettings.test.ts +119 -0
- package/src/theme/wallpaperSingleton.contract.test.ts +139 -0
- package/src/theme/wallpaperSsr.test.ts +123 -0
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
+
});
|