@celestia-island/hikari 0.55.242 → 0.55.243

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.242",
3
+ "version": "0.55.243",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -0,0 +1,91 @@
1
+ /* HkSolarLocationSetting — the solar-clock location strategy row and
2
+ * its editor modal body (see the component docblock for the strategy
3
+ * ladder). AI-generated: developed with the ZCode agent (model
4
+ * GLM-5.3); human-directed. */
5
+
6
+ /* ── The settings-page row: live summary + Edit button ────────────────── */
7
+ .hk-solar-setting {
8
+ display: flex;
9
+ align-items: center;
10
+ gap: var(--space-8, 0.5rem);
11
+ min-width: 0;
12
+ }
13
+
14
+ .hk-solar-summary {
15
+ display: inline-flex;
16
+ align-items: center;
17
+ gap: var(--space-6, 0.375rem);
18
+ min-width: 0;
19
+ flex: 1 1 auto;
20
+ color: rgb(var(--color-text-secondary, var(--color-muted)));
21
+ font-size: var(--text-sm);
22
+ }
23
+
24
+ .hk-solar-summary-icon {
25
+ display: inline-flex;
26
+ align-items: center;
27
+ justify-content: center;
28
+ flex-shrink: 0;
29
+ color: rgb(var(--color-muted));
30
+ }
31
+
32
+ /* Zone ids ("America/Argentina/Buenos_Aires") can outrun narrow side
33
+ * columns — the summary clips at the ellipsis, never pushes the Edit
34
+ * button off the row. */
35
+ .hk-solar-summary-text {
36
+ min-width: 0;
37
+ overflow: hidden;
38
+ text-overflow: ellipsis;
39
+ white-space: nowrap;
40
+ }
41
+
42
+ .hk-solar-edit {
43
+ flex-shrink: 0;
44
+ }
45
+
46
+ /* ── Editor body (inside the modal) ───────────────────────────────────── */
47
+ .hk-solar-editor {
48
+ display: flex;
49
+ flex-direction: column;
50
+ gap: var(--space-12, 0.75rem);
51
+ }
52
+
53
+ /* Per-strategy explanation under the segmented strip — muted caption
54
+ * prose, same tier as the select's field label. */
55
+ .hk-solar-hint {
56
+ margin: 0;
57
+ color: rgb(var(--color-muted));
58
+ font-size: var(--text-xs);
59
+ line-height: 1.5;
60
+ }
61
+
62
+ .hk-solar-manual {
63
+ display: flex;
64
+ flex-direction: column;
65
+ gap: var(--space-12, 0.75rem);
66
+ }
67
+
68
+ .hk-solar-hemisphere {
69
+ display: flex;
70
+ flex-direction: column;
71
+ }
72
+
73
+ /* Field label for the hemisphere group — the SAME metrics as
74
+ * .hk-select-label so the two manual fields read as one form. */
75
+ .hk-solar-field-label {
76
+ display: block;
77
+ margin-bottom: var(--space-4, 0.25rem);
78
+ font-size: var(--text-xs);
79
+ font-weight: 600;
80
+ letter-spacing: 0.02em;
81
+ color: rgb(var(--color-text) / var(--hk-field-label-alpha, 72%));
82
+ }
83
+
84
+ /* Resolved-coordinates readout (manual pane only) — a quiet trace of
85
+ * what the engine will actually feed the sun clock. */
86
+ .hk-solar-resolved {
87
+ margin: 0;
88
+ color: rgb(var(--color-muted));
89
+ font-size: var(--text-xs);
90
+ font-variant-numeric: tabular-nums;
91
+ }
@@ -0,0 +1,379 @@
1
+ // AI-generated: developed with the ZCode agent (model GLM-5.3); human-directed.
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { createApp, h, nextTick } from "vue";
4
+
5
+ import { HkSolarLocationSetting } from "./HkSolarLocationSetting";
6
+ import { setLocale } from "../i18n/context";
7
+ import {
8
+ SOLAR_LOCATION_STRATEGY_STORAGE_KEY,
9
+ SOLAR_MANUAL_LOCATION_STORAGE_KEY,
10
+ listTimeZones,
11
+ manualLocationOf,
12
+ setSolarLocationStrategy,
13
+ setSolarManualLocation,
14
+ solarLocationStrategy,
15
+ solarManualLocation,
16
+ systemTimeZoneId,
17
+ type SolarLocationStrategy,
18
+ } from "../theme/useSolarTime";
19
+
20
+ /**
21
+ * HkSolarLocationSetting — the solar clock's location-strategy row.
22
+ * The strategy engine (theme/useSolarTime.ts) has its own suite; these
23
+ * cases pin the COMPONENT contract: the live summary row, the secondary
24
+ * HkModal editor (strategy tabs / IANA zone select / explicit
25
+ * hemisphere), OK applying through setSolarManualLocation →
26
+ * setSolarLocationStrategy → refreshThemeClock with storage evidence,
27
+ * and cancel leaving the engine untouched.
28
+ */
29
+
30
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
31
+ const appliedEvents: SolarLocationStrategy[] = [];
32
+
33
+ const ORIGINAL_WIDTH = window.innerWidth;
34
+
35
+ function setViewportWidth(px: number): void {
36
+ Object.defineProperty(window, "innerWidth", {
37
+ configurable: true,
38
+ writable: true,
39
+ value: px,
40
+ });
41
+ }
42
+
43
+ function resetEngineState(): void {
44
+ // Back to the offline default; manual config cleared. The setters are
45
+ // the engine's own reset surface (they heal refs AND storage).
46
+ setSolarLocationStrategy("timezone");
47
+ setSolarManualLocation(null);
48
+ }
49
+
50
+ function mountSetting(): HTMLElement {
51
+ const container = document.createElement("div");
52
+ document.body.appendChild(container);
53
+ const app = createApp({
54
+ render: () =>
55
+ h(HkSolarLocationSetting, {
56
+ onApplied: (strategy: SolarLocationStrategy) => {
57
+ appliedEvents.push(strategy);
58
+ },
59
+ }),
60
+ });
61
+ app.mount(container);
62
+ mounts.push({ app, container });
63
+ return container;
64
+ }
65
+
66
+ async function settle(): Promise<void> {
67
+ await nextTick();
68
+ // Surface transitions (modal enter/leave, tab swaps) resolve through
69
+ // short macrotask windows in happy-dom.
70
+ await new Promise((resolve) => setTimeout(resolve, 20));
71
+ await nextTick();
72
+ }
73
+
74
+ function rowSummary(container: HTMLElement): HTMLElement {
75
+ const el = container.querySelector<HTMLElement>(".hk-solar-summary");
76
+ expect(el, "summary row renders").toBeTruthy();
77
+ return el!;
78
+ }
79
+
80
+ function editButton(container: HTMLElement): HTMLButtonElement {
81
+ const btn = container.querySelector<HTMLButtonElement>(".hk-solar-edit");
82
+ expect(btn, "edit button renders").toBeTruthy();
83
+ return btn!;
84
+ }
85
+
86
+ /** The modal chrome teleports to document.body (HkModal Teleport). */
87
+ function modalHeader(): HTMLElement | null {
88
+ return document.body.querySelector<HTMLElement>(".hk-modal-header");
89
+ }
90
+
91
+ function strategyTriggers(): HTMLButtonElement[] {
92
+ // The strategy strip is the DIRECT child group of the editor body —
93
+ // scoped so the hemisphere strip never pollutes the query.
94
+ return [
95
+ ...document.body.querySelectorAll<HTMLButtonElement>(
96
+ ".hk-solar-editor > .hk-tabs .hk-tabs-trigger",
97
+ ),
98
+ ];
99
+ }
100
+
101
+ function hemisphereTriggers(): HTMLButtonElement[] {
102
+ return [
103
+ ...document.body.querySelectorAll<HTMLButtonElement>(
104
+ ".hk-solar-hemisphere .hk-tabs-trigger",
105
+ ),
106
+ ];
107
+ }
108
+
109
+ function activeStrategyOf(triggers: HTMLButtonElement[]): string | undefined {
110
+ return triggers.find((b) => b.hasAttribute("data-active"))?.textContent ?? undefined;
111
+ }
112
+
113
+ function footerButton(label: string): HTMLButtonElement {
114
+ const btn = [...document.body.querySelectorAll<HTMLButtonElement>(".hk-modal-footer button")]
115
+ .find((b) => b.textContent?.trim() === label);
116
+ expect(btn, `footer button "${label}" renders`).toBeTruthy();
117
+ return btn!;
118
+ }
119
+
120
+ function clickStrategyTab(label: string): void {
121
+ const trigger = strategyTriggers().find((b) => b.textContent?.trim() === label);
122
+ expect(trigger, `strategy tab "${label}" renders`).toBeTruthy();
123
+ trigger!.click();
124
+ }
125
+
126
+ function clickStrategy(key: "timezone" | "ip" | "manual"): void {
127
+ clickStrategyTab({ timezone: "Time zone", ip: "IP location", manual: "Manual" }[key]);
128
+ }
129
+
130
+ /** Opens the manual pane's IANA zone select and picks a zone. */
131
+ async function pickTimeZone(zone: string): Promise<void> {
132
+ const trigger = document.body.querySelector<HTMLButtonElement>(".hk-select-trigger");
133
+ expect(trigger, "zone select trigger renders").toBeTruthy();
134
+ trigger!.click();
135
+ await settle();
136
+ const option = [...document.body.querySelectorAll<HTMLElement>(".hk-select-option")]
137
+ .find((el) => el.textContent?.trim() === zone);
138
+ expect(option, `zone option "${zone}" renders`).toBeTruthy();
139
+ option!.click();
140
+ await settle();
141
+ }
142
+
143
+ async function openEditor(container: HTMLElement): Promise<void> {
144
+ editButton(container).click();
145
+ await settle();
146
+ expect(modalHeader(), "modal opened with a header").toBeTruthy();
147
+ }
148
+
149
+ beforeEach(() => {
150
+ // Hermetic geo: apply flows through refreshThemeClock, whose IP leg
151
+ // must never reach the network in tests.
152
+ vi.stubGlobal("fetch", vi.fn(() => Promise.resolve({ ok: false })));
153
+ // Desktop geometry: the zone select opens as an anchored popout.
154
+ setViewportWidth(1280);
155
+ appliedEvents.length = 0;
156
+ resetEngineState();
157
+ });
158
+
159
+ afterEach(async () => {
160
+ for (const { app, container } of mounts.splice(0)) {
161
+ app.unmount();
162
+ container.remove();
163
+ }
164
+ setViewportWidth(ORIGINAL_WIDTH);
165
+ vi.unstubAllGlobals();
166
+ localStorage.clear();
167
+ await setLocale("en");
168
+ });
169
+
170
+ describe("HkSolarLocationSetting row", () => {
171
+ it("renders the live summary with the strategy icon and the edit button", async () => {
172
+ const container = mountSetting();
173
+ await settle();
174
+
175
+ const summary = rowSummary(container);
176
+ expect(summary.getAttribute("data-strategy")).toBe("timezone");
177
+ // Offline default: the summary names the strategy AND the live
178
+ // system zone id (systemTimeZoneId), so the row is self-explaining.
179
+ expect(summary.textContent).toContain("Time zone");
180
+ expect(summary.textContent).toContain(systemTimeZoneId());
181
+ expect(summary.querySelector("svg"), "strategy glyph renders").toBeTruthy();
182
+
183
+ const btn = editButton(container);
184
+ expect(btn.textContent?.trim()).toBe("Edit");
185
+ // No editor in the DOM before the first open.
186
+ expect(document.body.querySelector(".hk-solar-editor")).toBeNull();
187
+ });
188
+
189
+ it("reflects the manual strategy in the row once applied", async () => {
190
+ setSolarManualLocation({ timeZone: "Australia/Sydney", hemisphere: "south" });
191
+ setSolarLocationStrategy("manual");
192
+ const container = mountSetting();
193
+ await settle();
194
+
195
+ const summary = rowSummary(container);
196
+ expect(summary.getAttribute("data-strategy")).toBe("manual");
197
+ expect(summary.textContent).toContain("Australia/Sydney");
198
+ expect(summary.textContent).toContain("Southern");
199
+ });
200
+ });
201
+
202
+ describe("HkSolarLocationSetting editor", () => {
203
+ it("opens the secondary modal with the three strategy tabs and the offline-first hint", async () => {
204
+ const container = mountSetting();
205
+ await openEditor(container);
206
+
207
+ expect(modalHeader()!.textContent).toContain("Sun clock location");
208
+ const triggers = strategyTriggers();
209
+ expect(triggers).toHaveLength(3);
210
+ expect(triggers.map((b) => b.textContent?.trim())).toEqual([
211
+ "Time zone",
212
+ "IP location",
213
+ "Manual",
214
+ ]);
215
+ expect(activeStrategyOf(triggers)).toBe("Time zone");
216
+ // Every tab carries its lucide glyph (Clock / Globe2 / MapPin).
217
+ for (const trigger of triggers) {
218
+ expect(trigger.querySelector("svg")).toBeTruthy();
219
+ }
220
+
221
+ const editor = document.body.querySelector<HTMLElement>(".hk-solar-editor");
222
+ expect(editor!.textContent).toContain("No network request, no permission prompt");
223
+ // Manual controls stay hidden while the draft is not manual.
224
+ expect(document.body.querySelector(".hk-solar-manual")).toBeNull();
225
+ });
226
+
227
+ it("switching strategies swaps the hint and reveals the manual pane", async () => {
228
+ const container = mountSetting();
229
+ await openEditor(container);
230
+
231
+ clickStrategy("ip");
232
+ await settle();
233
+ expect(document.body.querySelector<HTMLElement>(".hk-solar-editor")!.getAttribute("data-strategy")).toBe("ip");
234
+ expect(document.body.querySelector<HTMLElement>(".hk-solar-editor")!.textContent)
235
+ .toContain("IP lookup");
236
+
237
+ clickStrategy("manual");
238
+ await settle();
239
+ const editor = document.body.querySelector<HTMLElement>(".hk-solar-editor")!;
240
+ expect(editor.getAttribute("data-strategy")).toBe("manual");
241
+ expect(document.body.querySelector(".hk-solar-manual")).toBeTruthy();
242
+ expect(document.body.querySelector<HTMLButtonElement>(".hk-select-trigger")).toBeTruthy();
243
+ const hemi = hemisphereTriggers();
244
+ expect(hemi.map((b) => b.textContent?.trim())).toEqual(["Northern", "Southern"]);
245
+ // Draft default: the system zone, northern hemisphere (explicit
246
+ // pick — never inferred).
247
+ expect(activeStrategyOf(hemi)).toBe("Northern");
248
+ });
249
+
250
+ it("keeps the hemisphere explicit when the zone changes; the resolved readout follows the engine", async () => {
251
+ const container = mountSetting();
252
+ await openEditor(container);
253
+ clickStrategy("manual");
254
+ await settle();
255
+
256
+ await pickTimeZone("Australia/Sydney");
257
+ // The zone moved to the southern hemisphere's side of the globe,
258
+ // but the hemisphere knob is the USER's pick — it must NOT move.
259
+ expect(activeStrategyOf(hemisphereTriggers())).toBe("Northern");
260
+
261
+ hemisphereTriggers()[1].click();
262
+ await settle();
263
+ expect(activeStrategyOf(hemisphereTriggers())).toBe("Southern");
264
+
265
+ const resolved = document.body.querySelector<HTMLElement>('[data-test="hk-solar-resolved"]');
266
+ expect(resolved, "resolved-coordinates readout renders").toBeTruthy();
267
+ const geo = manualLocationOf({ timeZone: "Australia/Sydney", hemisphere: "south" });
268
+ expect(resolved!.textContent).toContain(geo.lat.toFixed(1));
269
+ expect(resolved!.textContent).toContain(geo.lng.toFixed(1));
270
+ });
271
+
272
+ it("OK applies the manual draft: engine refs, storage, inline summary, applied emit", async () => {
273
+ const container = mountSetting();
274
+ await openEditor(container);
275
+ clickStrategy("manual");
276
+ await settle();
277
+ await pickTimeZone("Australia/Sydney");
278
+ hemisphereTriggers()[1].click();
279
+ await settle();
280
+
281
+ footerButton("Apply").click();
282
+ await settle();
283
+
284
+ // Engine state moved.
285
+ expect(solarLocationStrategy.value).toBe("manual");
286
+ expect(solarManualLocation.value).toEqual({
287
+ timeZone: "Australia/Sydney",
288
+ hemisphere: "south",
289
+ });
290
+ // Persistence evidence — the exact keys the engine owns.
291
+ expect(localStorage.getItem(SOLAR_LOCATION_STRATEGY_STORAGE_KEY)).toBe("manual");
292
+ expect(JSON.parse(localStorage.getItem(SOLAR_MANUAL_LOCATION_STORAGE_KEY) ?? "null")).toEqual({
293
+ timeZone: "Australia/Sydney",
294
+ hemisphere: "south",
295
+ });
296
+ // The row summary reflects the new strategy IMMEDIATELY.
297
+ const summary = rowSummary(container);
298
+ expect(summary.getAttribute("data-strategy")).toBe("manual");
299
+ expect(summary.textContent).toContain("Australia/Sydney");
300
+ expect(summary.textContent).toContain("Southern");
301
+ // The host was notified.
302
+ expect(appliedEvents).toEqual(["manual"]);
303
+ });
304
+
305
+ it("OK applies the timezone strategy with no manual payload written", async () => {
306
+ const container = mountSetting();
307
+ await openEditor(container);
308
+ clickStrategy("timezone");
309
+ await settle();
310
+ footerButton("Apply").click();
311
+ await settle();
312
+
313
+ expect(solarLocationStrategy.value).toBe("timezone");
314
+ expect(localStorage.getItem(SOLAR_LOCATION_STRATEGY_STORAGE_KEY)).toBe("timezone");
315
+ expect(localStorage.getItem(SOLAR_MANUAL_LOCATION_STORAGE_KEY)).toBeNull();
316
+ expect(appliedEvents).toEqual(["timezone"]);
317
+ });
318
+
319
+ it("cancel discards the draft and leaves the engine untouched", async () => {
320
+ const container = mountSetting();
321
+ await openEditor(container);
322
+ clickStrategy("ip");
323
+ await settle();
324
+ footerButton("Cancel").click();
325
+ await settle();
326
+
327
+ expect(solarLocationStrategy.value).toBe("timezone");
328
+ expect(localStorage.getItem(SOLAR_LOCATION_STRATEGY_STORAGE_KEY)).not.toContain("ip");
329
+ expect(appliedEvents).toEqual([]);
330
+ expect(rowSummary(container).getAttribute("data-strategy")).toBe("timezone");
331
+ });
332
+ });
333
+
334
+ describe("HkSolarLocationSetting i18n", () => {
335
+ it("flows zh-Hans messages through the row and the editor", async () => {
336
+ await setLocale("zh-Hans");
337
+ const container = mountSetting();
338
+ await settle();
339
+
340
+ const summary = rowSummary(container);
341
+ expect(summary.textContent).toContain("时区");
342
+ expect(editButton(container).textContent?.trim()).toBe("编辑");
343
+
344
+ await openEditor(container);
345
+ expect(modalHeader()!.textContent).toContain("太阳钟定位");
346
+ expect(strategyTriggers().map((b) => b.textContent?.trim())).toEqual([
347
+ "时区",
348
+ "IP 定位",
349
+ "手动",
350
+ ]);
351
+
352
+ clickStrategyTab("手动");
353
+ await settle();
354
+ expect(hemisphereTriggers().map((b) => b.textContent?.trim())).toEqual([
355
+ "北半球",
356
+ "南半球",
357
+ ]);
358
+ });
359
+
360
+ it("seeds the zone list from the engine's listTimeZones catalog", async () => {
361
+ const container = mountSetting();
362
+ await openEditor(container);
363
+ clickStrategy("manual");
364
+ await settle();
365
+
366
+ const trigger = document.body.querySelector<HTMLButtonElement>(".hk-select-trigger");
367
+ trigger!.click();
368
+ await settle();
369
+
370
+ const rendered = [...document.body.querySelectorAll<HTMLElement>(".hk-select-option")]
371
+ .map((el) => el.textContent?.trim());
372
+ const catalog = listTimeZones();
373
+ expect(rendered.length).toBe(catalog.length);
374
+ expect(rendered).toContain("Asia/Shanghai");
375
+ // A catalog-present zone on every ICU: "UTC" itself is NOT part of
376
+ // Intl.supportedValuesOf("timeZone") on some ICU builds.
377
+ expect(rendered).toContain("Australia/Sydney");
378
+ });
379
+ });