@celestia-island/hikari 0.55.292 → 0.55.293

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.292",
3
+ "version": "0.55.293",
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",
@@ -59,11 +59,32 @@
59
59
 
60
60
  /* The icon-only action's tooltip host: the button itself is disabled in
61
61
  * manual modes and therefore loses pointer events (native `disabled`), so
62
- * the title has to live on this wrapper. */
62
+ * the title has to live on this wrapper. `relative` anchors the visually
63
+ * hidden reason span below. */
63
64
  .hk-solar-edit-wrap {
64
65
  display: inline-flex;
65
66
  flex: none;
66
67
  align-items: center;
68
+ position: relative;
69
+ }
70
+
71
+ /* The disabled-state reason for screen readers: a natively disabled button
72
+ * is unfocusable, so keyboard/AT users can never reach a tooltip. The span
73
+ * is referenced from the button's aria-describedby (rendered only while
74
+ * disabled) and collapses to nothing visually — the clip pair hides the
75
+ * box entirely without `display:none`, which would drop it from the
76
+ * accessibility tree too. */
77
+ .hk-solar-edit-reason {
78
+ position: absolute;
79
+ width: 1px;
80
+ height: 1px;
81
+ margin: -1px;
82
+ padding: 0;
83
+ border-width: 0;
84
+ overflow: hidden;
85
+ white-space: nowrap;
86
+ clip: rect(0 0 0 0);
87
+ clip-path: inset(50%);
67
88
  }
68
89
 
69
90
  .hk-solar-edit {
@@ -0,0 +1,77 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ /**
8
+ * HkSolarLocationSetting row-geometry contract (S3 follow-up, 2026-11-05):
9
+ * the first polish wave let `line-height: 1` clip descenders through every
10
+ * gate because ONLY Vue↔Rust sheet equality was guarded — both forms were
11
+ * wrong together. This spec pins the VALUES the row's single-center
12
+ * geometry depends on, so a future "simplification" of the leading (or the
13
+ * row minimum, or the glyph box) fails here before it ships.
14
+ *
15
+ * The vendored Rust mirror is byte-guarded by
16
+ * packages/components/tests/vue_mirror_drift_tests.rs
17
+ * (mirror_bodies_match_their_upstream_sheets); this file only has to pin
18
+ * the canonical Vue sheet.
19
+ */
20
+ const here = dirname(fileURLToPath(import.meta.url));
21
+ const scss = readFileSync(join(here, "HkSolarLocationSetting.scss"), "utf8");
22
+
23
+ /** Comments stripped: a comment naming a property must not satisfy a guard
24
+ * about it (the house rule from HkAmbientLayer's pointer contract). */
25
+ const declarations = scss.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
26
+
27
+ /** One ruleset's body, or null when the selector never appears. */
28
+ function bodyOf(selector: string): string | null {
29
+ const re = new RegExp(
30
+ selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\s*\\{([^{}]*)\\}",
31
+ );
32
+ const m = declarations.match(re);
33
+ return m ? m[1] : null;
34
+ }
35
+
36
+ describe("HkSolarLocationSetting row geometry", () => {
37
+ it("pins the row's cross size to the 28px action square", () => {
38
+ const row = bodyOf(".hk-solar-row");
39
+ expect(row, "the row ruleset exists").not.toBeNull();
40
+ // The single geometric center: the row's cross size is the icon-button
41
+ // square, not the summary's line box.
42
+ expect(row).toMatch(/min-height:\s*var\(--space-28,\s*1\.75rem\)/);
43
+ expect(row).toMatch(/align-items:\s*center/);
44
+ });
45
+
46
+ it("pins the summary leading to the resolved font's own metrics", () => {
47
+ const summary = bodyOf(".hk-solar-summary");
48
+ expect(summary, "the summary ruleset exists").not.toBeNull();
49
+ // `normal` — NOT a hand-picked multiplier: fixed 1em/1.2em line boxes
50
+ // clipped descenders in Latin, Arabic and CJK fallback faces (the text
51
+ // clips at the ellipsis via overflow:hidden).
52
+ expect(summary).toMatch(/line-height:\s*normal/);
53
+ });
54
+
55
+ it("keeps the glyph box leading-free", () => {
56
+ const icon = bodyOf(".hk-solar-summary-icon");
57
+ expect(icon, "the glyph ruleset exists").not.toBeNull();
58
+ expect(icon).toMatch(/line-height:\s*0/);
59
+ });
60
+
61
+ it("collapses the screen-reader reason without display:none", () => {
62
+ const reason = bodyOf(".hk-solar-edit-reason");
63
+ expect(reason, "the reason ruleset exists").not.toBeNull();
64
+ // display:none would drop the span from the accessibility tree, which
65
+ // defeats the aria-describedby contract; the clip pair hides it
66
+ // visually while keeping it readable.
67
+ expect(reason).not.toMatch(/display:\s*none/);
68
+ expect(reason).toMatch(/position:\s*absolute/);
69
+ expect(reason).toMatch(/clip-path:\s*inset\(50%\)/);
70
+ });
71
+
72
+ it("anchors the reason span inside the wrapper", () => {
73
+ const wrap = bodyOf(".hk-solar-edit-wrap");
74
+ expect(wrap, "the wrapper ruleset exists").not.toBeNull();
75
+ expect(wrap).toMatch(/position:\s*relative/);
76
+ });
77
+ });
@@ -256,14 +256,21 @@ describe("HkSolarLocationSetting card", () => {
256
256
  it("grays the edit action outside auto mode and re-enables it in auto", async () => {
257
257
  const { setMode } = useTheme();
258
258
  // Mount in AUTO first, then drive the live mode transitions a host's
259
- // own switch performs — this is the user path and it keeps the test
260
- // independent of which mode the singleton happened to carry at mount.
259
+ // own switch performs — the user path. The "enabled at mount" assertion
260
+ // below DEPENDS on the beforeEach resetEngineState() forcing the theme
261
+ // singleton back to "system"; do not remove that reset, or the first
262
+ // assertion here must go.
261
263
  const container = mountSetting();
262
264
  await settle();
263
265
  expect(editButton(container).disabled, "auto mode enables the editor").toBe(false);
264
266
  expect(editButton(container).closest(".hk-solar-edit-wrap")?.getAttribute("title")).toBe(
265
267
  "Edit",
266
268
  );
269
+ expect(
270
+ editButton(container).getAttribute("aria-describedby"),
271
+ "no reason reference while enabled",
272
+ ).toBeNull();
273
+ expect(container.querySelector(".hk-solar-edit-reason"), "no reason span while enabled").toBeNull();
267
274
 
268
275
  setMode("light");
269
276
  await settle();
@@ -273,19 +280,33 @@ describe("HkSolarLocationSetting card", () => {
273
280
  expect(btn.closest(".hk-solar-edit-wrap")?.getAttribute("title")).toBe(
274
281
  "Switch to Auto mode to change the daylight location",
275
282
  );
283
+ // The disabled reason must reach keyboard/AT users too: the tooltip is
284
+ // mouse-only (a natively disabled control is unfocusable), so the same
285
+ // text rides a visually hidden span referenced via aria-describedby.
286
+ const describedBy = btn.getAttribute("aria-describedby");
287
+ expect(describedBy, "the disabled button points at its reason text").toBeTruthy();
288
+ const reason = container.querySelector(`#${describedBy}`);
289
+ expect(reason, "the referenced reason span exists").toBeTruthy();
290
+ expect(reason?.classList.contains("hk-solar-edit-reason")).toBe(true);
291
+ expect(reason?.textContent?.trim()).toBe(
292
+ "Switch to Auto mode to change the daylight location",
293
+ );
276
294
 
277
295
  // A disabled native button cannot open the editor.
278
296
  btn.click();
279
297
  await settle();
280
298
  expect(modalHeader(), "disabled edit never opens the modal").toBeNull();
281
299
 
282
- // Back to auto: the SAME mounted card re-enables on the live mode ref.
300
+ // Back to auto: the SAME mounted card re-enables on the live mode ref,
301
+ // and the a11y reason leaves the DOM with the disabled state.
283
302
  setMode("system");
284
303
  await settle();
285
304
  expect(editButton(container).disabled).toBe(false);
286
305
  expect(editButton(container).closest(".hk-solar-edit-wrap")?.getAttribute("title")).toBe(
287
306
  "Edit",
288
307
  );
308
+ expect(editButton(container).getAttribute("aria-describedby")).toBeNull();
309
+ expect(container.querySelector(".hk-solar-edit-reason")).toBeNull();
289
310
 
290
311
  // Dark is just another manual side of the same gate.
291
312
  setMode("dark");
@@ -293,6 +314,26 @@ describe("HkSolarLocationSetting card", () => {
293
314
  expect(editButton(container).disabled).toBe(true);
294
315
  });
295
316
 
317
+ it("gives each mounted card its own disabled-reason id", async () => {
318
+ const { setMode } = useTheme();
319
+ setMode("dark");
320
+ const first = mountSetting();
321
+ const second = mountSetting();
322
+ await settle();
323
+
324
+ const ids = [first, second].map((c) =>
325
+ c.querySelector<HTMLElement>(".hk-solar-edit-reason")?.getAttribute("id"),
326
+ );
327
+ expect(ids.every(Boolean), "both disabled cards render their reason text").toBe(true);
328
+ expect(ids[0]).not.toBe(ids[1]);
329
+ // Each button points at its OWN card's span, not the other one's.
330
+ for (const c of [first, second]) {
331
+ const btn = editButton(c);
332
+ const own = c.querySelector(`#${btn.getAttribute("aria-describedby")}`);
333
+ expect(own, "the describedby reference resolves inside the same card").toBeTruthy();
334
+ }
335
+ });
336
+
296
337
  it("is a standard hikari card: localized default title, overridable, hideable", async () => {
297
338
  const container = mountSetting();
298
339
  await settle();
@@ -38,6 +38,12 @@ import "./HkSolarLocationSetting.scss";
38
38
  // actually opens the manual pane — the settings card stays weightless.
39
39
  const HkTimeZoneGlobe = defineAsyncComponent(() => import("./HkTimeZoneGlobe"));
40
40
 
41
+ /** Per-instance id for the disabled-state reason text (aria-describedby).
42
+ * A host page may mount more than one card (settings + a popover), so a
43
+ * static id would collide; a module counter keeps ids unique across mounts
44
+ * while staying deterministic for tests. */
45
+ let nextSolarReasonId = 0;
46
+
41
47
  /** One copyable value of the editor's detected-value readout. `mono`
42
48
  * faces technical ids (zone ids, IPs, ASNs) in the badge mono face;
43
49
  * `wrap` renders the rectangular, soft-wrapping chip for long prose
@@ -181,6 +187,7 @@ export const HkSolarLocationSetting = defineComponent({
181
187
  // AUTO-mode gate: the sun clock only drives the theme in "system".
182
188
  const { currentMode } = useTheme();
183
189
  const editorDisabled = computed(() => currentMode.value !== "system");
190
+ const reasonId = `hk-solar-edit-reason-${(nextSolarReasonId += 1)}`;
184
191
  // The engine hydrates lazily (storage-silent imports): a host that
185
192
  // mounts this card before initTheme() still sees the stored strategy.
186
193
  hydrateSolarLocationState();
@@ -449,7 +456,10 @@ export const HkSolarLocationSetting = defineComponent({
449
456
  {/* Icon-only Edit (2026-11-05): the row's action is the standard
450
457
  ghost icon-button square, not a text pill. Disabled outside
451
458
  auto mode; the wrapper owns the tooltip because a disabled
452
- button drops pointer events. */}
459
+ button drops pointer events, and — since a natively disabled
460
+ control is also UNFOCUSABLE, so keyboard/AT users never see a
461
+ tooltip — the same explanation rides a visually hidden span
462
+ referenced through aria-describedby. */}
453
463
  <span
454
464
  class="hk-solar-edit-wrap"
455
465
  title={
@@ -463,11 +473,17 @@ export const HkSolarLocationSetting = defineComponent({
463
473
  variant="ghost"
464
474
  class="hk-solar-edit"
465
475
  aria-label={t("hikari::theme.solar.edit")}
476
+ aria-describedby={editorDisabled.value ? reasonId : undefined}
466
477
  disabled={editorDisabled.value}
467
478
  onClick={openEditor}
468
479
  >
469
480
  {{ icon: () => <Pencil size={16} /> }}
470
481
  </HkIconButton>
482
+ {editorDisabled.value && (
483
+ <span id={reasonId} class="hk-solar-edit-reason">
484
+ {t("hikari::theme.solar.editAutoOnly")}
485
+ </span>
486
+ )}
471
487
  </span>
472
488
  </div>
473
489