@celestia-island/hikari 0.55.219 → 0.55.221

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.219",
3
+ "version": "0.55.221",
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",
@@ -122,6 +122,39 @@
122
122
  flex-direction: column;
123
123
  gap: var(--space-16);
124
124
  }
125
+
126
+ /* ── phone: the rail becomes a top tab strip ────────────────────────
127
+ Under the family's mobile breakpoint the left rail moves to the
128
+ window's top edge as the shared pill strip (HTabs), horizontally
129
+ scrollable with edge fades — every section fits, the window never
130
+ grows a second nav column. The DOM branch and this layout share ONE
131
+ breakpoint through the data-phone attribute (useBreakpoint), so
132
+ neither half can drift. Desktop never sees these rules. */
133
+ &[data-phone] {
134
+ flex-direction: column;
135
+ /* A small breath between the strip and the pane (2026-10-28 user
136
+ direction) — tighter than the desktop rail gutter. */
137
+ gap: var(--space-12);
138
+
139
+ > .hk-settings__topnav {
140
+ flex: none;
141
+ /* Center the strip while it fits (the pill-strip family look);
142
+ once it overflows the scroller caps at 100% and the fades take
143
+ over, so centering never clips the overflowing start. */
144
+ display: flex;
145
+ justify-content: center;
146
+
147
+ /* One notch UP from the regular strips (text-sm, 4/12 padding,
148
+ 16px icons): the phone nav is the window's primary switcher
149
+ (2026-10-28 user direction). Icons scale through the strip's
150
+ own trigger icon cell (the shell renders them at 18). */
151
+ .hk-tabs-trigger {
152
+ gap: var(--space-8);
153
+ padding: var(--space-6) var(--space-14);
154
+ font-size: var(--text-base);
155
+ }
156
+ }
157
+ }
125
158
  }
126
159
 
127
160
  /* ── Dialog host: stable frame + flush scrollbar edge ────────────────── */
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { describe, expect, it, afterEach } from "vitest";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { createApp, defineComponent, h, nextTick, ref } from "vue";
4
4
  import { Languages, Globe } from "lucide-vue-next";
@@ -322,3 +322,196 @@ describe("settings dialog SCSS host contract (source-level)", () => {
322
322
  unmount();
323
323
  });
324
324
  });
325
+
326
+ describe("phone shell (rail → top tab strip, 2026-10-28 user direction)", () => {
327
+ const originalWidth = window.innerWidth;
328
+
329
+ const setWidth = (w: number) => {
330
+ Object.defineProperty(window, "innerWidth", {
331
+ configurable: true,
332
+ writable: true,
333
+ value: w,
334
+ });
335
+ window.dispatchEvent(new Event("resize"));
336
+ };
337
+
338
+ afterEach(() => setWidth(originalWidth));
339
+
340
+ it("renders the top strip instead of the rail under the mobile breakpoint", () => {
341
+ setWidth(390);
342
+ const { container, unmount } = mount(Host);
343
+ // The rail is gone; the strip carries every section as a trigger and
344
+ // the shell marks the column layout for the SCSS.
345
+ expect(container.querySelector(".hk-settings__rail")).toBeNull();
346
+ expect(container.querySelector(".hk-settings")!.hasAttribute("data-phone")).toBe(true);
347
+ const triggers = container.querySelectorAll<HTMLButtonElement>(
348
+ ".hk-settings__topnav .hk-tabs-trigger",
349
+ );
350
+ expect(triggers).toHaveLength(3);
351
+ expect(triggers[0].textContent).toContain("Language");
352
+ // Icons ride one notch up with the strip (18 vs the rail's 16).
353
+ expect(triggers[0].querySelector(".hk-tabs-trigger-icon svg")).toBeTruthy();
354
+ expect(container.querySelector('[data-test="pane-language"]')).toBeTruthy();
355
+ unmount();
356
+ });
357
+
358
+ it("switches sections from the strip and reports selection", async () => {
359
+ setWidth(390);
360
+ const { container, unmount } = mount(Host);
361
+ const triggers = container.querySelectorAll<HTMLButtonElement>(
362
+ ".hk-settings__topnav .hk-tabs-trigger",
363
+ );
364
+ triggers[1].click();
365
+ await nextTick();
366
+ expect(container.querySelector('[data-test="pane-network"]')).toBeTruthy();
367
+ expect(triggers[1].getAttribute("aria-selected")).toBe("true");
368
+ // The strip's pill semantics: inactive triggers carry "false", not
369
+ // the attribute's absence.
370
+ expect(triggers[0].getAttribute("aria-selected")).toBe("false");
371
+ unmount();
372
+ });
373
+
374
+ it("never activates a disabled section from the strip", async () => {
375
+ setWidth(390);
376
+ const { container, unmount } = mount(Host);
377
+ const triggers = container.querySelectorAll<HTMLButtonElement>(
378
+ ".hk-settings__topnav .hk-tabs-trigger",
379
+ );
380
+ expect(triggers[2].disabled).toBe(true);
381
+ triggers[2].click();
382
+ await nextTick();
383
+ expect(container.querySelector('[data-test="pane-language"]')).toBeTruthy();
384
+ expect(triggers[2].getAttribute("aria-selected")).toBe("false");
385
+ unmount();
386
+ });
387
+
388
+ it("flips live when the viewport crosses the breakpoint", async () => {
389
+ // Start desktop: the rail is the nav.
390
+ const { container, unmount } = mount(Host);
391
+ expect(container.querySelector(".hk-settings__rail")).toBeTruthy();
392
+ expect(container.querySelector(".hk-settings__topnav")).toBeNull();
393
+ setWidth(390);
394
+ await nextTick();
395
+ expect(container.querySelector(".hk-settings__topnav")).toBeTruthy();
396
+ expect(container.querySelector(".hk-settings__rail")).toBeNull();
397
+ // …and back.
398
+ setWidth(1024);
399
+ await nextTick();
400
+ expect(container.querySelector(".hk-settings__rail")).toBeTruthy();
401
+ expect(container.querySelector(".hk-settings__topnav")).toBeNull();
402
+ unmount();
403
+ });
404
+
405
+ it("lands navLabel on the strip's tablist", () => {
406
+ setWidth(390);
407
+ const Named = defineComponent({
408
+ setup() {
409
+ return () => (
410
+ <HSettingsBody sections={SECTIONS.slice(0, 2)} navLabel="Settings sections">
411
+ {{
412
+ language: () => <div data-test="pane-language" />,
413
+ network: () => <div data-test="pane-network" />,
414
+ }}
415
+ </HSettingsBody>
416
+ );
417
+ },
418
+ });
419
+ const { container, unmount } = mount(Named);
420
+ const list = container.querySelector(".hk-settings__topnav .hk-tabs-list");
421
+ expect(list).toBeTruthy();
422
+ expect(list!.getAttribute("role")).toBe("tablist");
423
+ expect(list!.getAttribute("aria-label")).toBe("Settings sections");
424
+ unmount();
425
+ });
426
+
427
+ it("never shows a disabled-first section as selected (either nav)", async () => {
428
+ // R2 2026-10-28: the landing default is the first USABLE key — a
429
+ // disabled-first host must not open on the dead entry, and the phone
430
+ // strip must not frame it aria-selected while the desktop rail
431
+ // highlights nothing.
432
+ const LockedFirst = defineComponent({
433
+ setup() {
434
+ return () => (
435
+ <HSettingsBody
436
+ sections={[
437
+ { key: "locked", label: "Locked", disabled: true },
438
+ { key: "network", label: "Network", icon: Globe },
439
+ ]}
440
+ >
441
+ {{
442
+ locked: () => <div data-test="pane-locked" />,
443
+ network: () => <div data-test="pane-network" />,
444
+ }}
445
+ </HSettingsBody>
446
+ );
447
+ },
448
+ });
449
+ // Desktop: the rail never marks the dead entry active.
450
+ const desktop = mount(LockedFirst);
451
+ const railItems = desktop.container.querySelectorAll<HTMLButtonElement>(".hk-settings__rail-item");
452
+ expect(railItems[0].classList.contains("is-active")).toBe(false);
453
+ expect(railItems[1].classList.contains("is-active")).toBe(true);
454
+ desktop.unmount();
455
+ // Phone: the strip's dead trigger stays unselected; the usable one
456
+ // carries the selection and its pane renders.
457
+ setWidth(390);
458
+ const phone = mount(LockedFirst);
459
+ const triggers = phone.container.querySelectorAll<HTMLButtonElement>(
460
+ ".hk-settings__topnav .hk-tabs-trigger",
461
+ );
462
+ expect(triggers[0].getAttribute("aria-selected")).toBe("false");
463
+ expect(triggers[1].getAttribute("aria-selected")).toBe("true");
464
+ expect(phone.container.querySelector('[data-test="pane-network"]')).toBeTruthy();
465
+ phone.unmount();
466
+ });
467
+
468
+ it("round-trips strip picks to a bound section prop (controlled contract)", async () => {
469
+ // The pane follows internal state, so a deleted update:section emit
470
+ // would be invisible to an unbound host — pin the controlled half.
471
+ setWidth(390);
472
+ const section = ref("language");
473
+ const Controlled = defineComponent({
474
+ setup() {
475
+ return () => (
476
+ <HSettingsBody
477
+ sections={SECTIONS.slice(0, 2)}
478
+ section={section.value}
479
+ onUpdate:section={(v: string) => (section.value = v)}
480
+ >
481
+ {{
482
+ language: () => <div data-test="pane-language" />,
483
+ network: () => <div data-test="pane-network" />,
484
+ }}
485
+ </HSettingsBody>
486
+ );
487
+ },
488
+ });
489
+ const { container, unmount } = mount(Controlled);
490
+ const triggers = container.querySelectorAll<HTMLButtonElement>(
491
+ ".hk-settings__topnav .hk-tabs-trigger",
492
+ );
493
+ triggers[1].click();
494
+ await nextTick();
495
+ expect(section.value).toBe("network");
496
+ unmount();
497
+ });
498
+
499
+ it("pins the phone layout contract in the SCSS source", () => {
500
+ // happy-dom cannot exercise the cascade: pin the column flip, the
501
+ // gap, the centering and the one-notch-up sizing at the source. The
502
+ // assertions read INSIDE the &[data-phone] block — several of these
503
+ // declarations also exist elsewhere in the sheet, and a bare
504
+ // toContain would keep passing after the phone rule dies (R2).
505
+ const scss = readFileSync("src/components/HkSettingsDialog.scss", "utf8");
506
+ const phoneBlock = scss.slice(
507
+ scss.indexOf("&[data-phone] {"),
508
+ scss.indexOf("Dialog host"),
509
+ );
510
+ expect(phoneBlock).toContain("flex-direction: column;");
511
+ expect(phoneBlock).toContain("gap: var(--space-12);");
512
+ expect(phoneBlock).toContain("> .hk-settings__topnav {");
513
+ expect(phoneBlock).toContain("justify-content: center;");
514
+ expect(phoneBlock).toContain("padding: var(--space-6) var(--space-14);");
515
+ expect(phoneBlock).toContain("font-size: var(--text-base);");
516
+ });
517
+ });
@@ -10,6 +10,8 @@ import {
10
10
 
11
11
  import HkTransitionContainer from "./HkTransitionContainer";
12
12
  import HkScrollContainer from "./HkScrollContainer";
13
+ import HTabs from "./HkTabs";
14
+ import { useBreakpoint } from "../runtime/useBreakpoint";
13
15
  import HModal from "./HkModal";
14
16
  import "./HkSettingsDialog.scss";
15
17
 
@@ -48,6 +50,15 @@ export interface HkSettingsSection {
48
50
  * SettingsShell.scss). Inside the dialog the modal body's own scroller is
49
51
  * made mathematically idle by the host height rule (see the SCSS); on a
50
52
  * page the host manages its own layout.
53
+ *
54
+ * Phone anatomy (2026-10-28 user direction): under the family's mobile
55
+ * breakpoint the left rail moves to the TOP of the window as the shared
56
+ * pill strip (HTabs) — horizontally scrollable with edge fades, so every
57
+ * section fits no matter how many entries a host offers — one notch
58
+ * larger than the regular strips, with a small gap before the pane.
59
+ * Desktop keeps the rail exactly as it was; the flip is live on resize
60
+ * and shared by every consumer of this shell (page-embedded bodies
61
+ * included).
51
62
  */
52
63
  export const HkSettingsBody = defineComponent({
53
64
  name: "HkSettingsBody",
@@ -63,7 +74,14 @@ export const HkSettingsBody = defineComponent({
63
74
  "update:section": (key: string) => typeof key === "string",
64
75
  },
65
76
  setup(props, { emit, slots }) {
66
- const internal = ref(props.sections[0]?.key ?? "");
77
+ // The landing default is the FIRST USABLE section, never a disabled
78
+ // one: a disabled entry "renders but cannot be activated", so it must
79
+ // not win the default (R2 2026-10-28 — the old first-entry fallback
80
+ // let a disabled-first host open on a dead section, and the phone
81
+ // strip went further and FRAMED it as selected).
82
+ const firstUsable = () =>
83
+ props.sections.find((s) => !s.disabled)?.key ?? "";
84
+ const internal = ref(firstUsable());
67
85
  // Controlled while a section prop is bound: external changes flow in,
68
86
  // clicks flow out. Unbound: the internal ref alone decides. Immediate
69
87
  // so an opener that mounts ALREADY on a deep-linked section
@@ -86,7 +104,7 @@ export const HkSettingsBody = defineComponent({
86
104
  const active = () =>
87
105
  props.sections.some((s) => s.key === internal.value)
88
106
  ? internal.value
89
- : (props.sections[0]?.key ?? "");
107
+ : firstUsable();
90
108
 
91
109
  // The pane is an HkScrollContainer; only its consumed ref slice is
92
110
  // typed here — the scroll reset needs the live viewport element and
@@ -103,42 +121,83 @@ export const HkSettingsBody = defineComponent({
103
121
  });
104
122
 
105
123
  function pick(key: string) {
124
+ // Belt-and-braces: every caller already refuses disabled entries
125
+ // upstream (rail: natively disabled buttons; strip: HTabs' disabled
126
+ // triggers + its keyboard guard; deep links: the section watch), so
127
+ // deleting this guard is provably behavior-neutral today (R3
128
+ // mutation M6, 2026-10-28). It stays so a future caller cannot
129
+ // bypass the contract by accident.
106
130
  const entry = props.sections.find((s) => s.key === key);
107
131
  if (!entry || entry.disabled) return;
108
132
  internal.value = key;
109
133
  emit("update:section", key);
110
134
  }
111
135
 
136
+ // Phone flips the nav to the top strip; desktop keeps the rail. One
137
+ // breakpoint source (useBreakpoint) drives BOTH the DOM branch and
138
+ // the data-phone attribute the SCSS keys the column layout on, so
139
+ // the two halves cannot drift (see the SCSS block for the layout).
140
+ const { isMobile } = useBreakpoint();
141
+
112
142
  return () => (
113
- <div class="hk-settings">
143
+ <div class="hk-settings" data-phone={isMobile.value || undefined}>
114
144
  {/* Scroll ownership (see the docblock): both scrollers are
115
145
  HkScrollContainers — the overlay chrome replaces the native
116
146
  bars, and the paired-class rules in the SCSS carry each
117
147
  region's padding/gap vocabulary into its viewport. */}
118
- <HkScrollContainer as="nav" class="hk-settings__rail" aria-label={props.navLabel}>
119
- {props.sections.map((s) => {
120
- const Icon = s.icon;
121
- const on = active() === s.key && !s.disabled;
122
- return (
123
- <button
124
- key={s.key}
125
- type="button"
126
- class={["hk-settings__rail-item", on ? "is-active" : ""]}
127
- aria-current={on ? "true" : undefined}
128
- aria-disabled={s.disabled || undefined}
129
- disabled={s.disabled}
130
- onClick={() => pick(s.key)}
131
- >
132
- {typeof Icon === "function" ? (
133
- <span class="hk-settings__rail-icon" aria-hidden="true">
134
- <Icon size={16} />
135
- </span>
136
- ) : null}
137
- <span class="hk-settings__rail-label">{s.label}</span>
138
- </button>
139
- );
140
- })}
141
- </HkScrollContainer>
148
+ {isMobile.value ? (
149
+ <div class="hk-settings__topnav">
150
+ {/* The shared pill strip IS the button-group primitive: it
151
+ scrolls horizontally with edge fades, keeps the active
152
+ tab scrolled into view, and carries the tablist
153
+ semantics the rail had as a nav landmark. Panels stay
154
+ with the shell's own pane (renderPanels off). Icons ride
155
+ one notch up with the strip (18px vs the rail's 16). */}
156
+ <HTabs
157
+ modelValue={active()}
158
+ tabs={props.sections.map((s) => {
159
+ const Icon = s.icon;
160
+ return {
161
+ key: s.key,
162
+ label: s.label,
163
+ // Same functional-component guard the rail applies —
164
+ // Component covers object-form components too, which
165
+ // JSX cannot invoke directly.
166
+ icon: typeof Icon === "function" ? <Icon size={18} /> : undefined,
167
+ disabled: s.disabled,
168
+ };
169
+ })}
170
+ renderPanels={false}
171
+ ariaLabel={props.navLabel}
172
+ onUpdate:modelValue={(key: string) => pick(key)}
173
+ />
174
+ </div>
175
+ ) : (
176
+ <HkScrollContainer as="nav" class="hk-settings__rail" aria-label={props.navLabel}>
177
+ {props.sections.map((s) => {
178
+ const Icon = s.icon;
179
+ const on = active() === s.key && !s.disabled;
180
+ return (
181
+ <button
182
+ key={s.key}
183
+ type="button"
184
+ class={["hk-settings__rail-item", on ? "is-active" : ""]}
185
+ aria-current={on ? "true" : undefined}
186
+ aria-disabled={s.disabled || undefined}
187
+ disabled={s.disabled}
188
+ onClick={() => pick(s.key)}
189
+ >
190
+ {typeof Icon === "function" ? (
191
+ <span class="hk-settings__rail-icon" aria-hidden="true">
192
+ <Icon size={16} />
193
+ </span>
194
+ ) : null}
195
+ <span class="hk-settings__rail-label">{s.label}</span>
196
+ </button>
197
+ );
198
+ })}
199
+ </HkScrollContainer>
200
+ )}
142
201
  <HkScrollContainer class="hk-settings__pane" ref={paneRef}>
143
202
  {/* The section swap runs through the family's standard morph
144
203
  container (audit 2026-10-23): the pane's frame follows the
@@ -229,18 +229,13 @@ describe("HkTreemap pure layout input", () => {
229
229
  ).toEqual({ nodes: [], total: 0 });
230
230
  });
231
231
 
232
- it("chooses readable label colours across pale, mid-tone and dark backgrounds", () => {
233
- expect(contrastTextColor("#edc948")).toBe("#0b1220");
234
- expect(contrastTextColor("#4e79a7")).toBe("#fff");
235
- expect(contrastTextColor("#76b7b2")).toBe("#0b1220");
236
- expect(contrastTextColor("rgb(242, 142, 43)")).toBe("#0b1220");
237
- expect(contrastTextColor("#abc")).toBe("#0b1220");
238
- expect(contrastTextColor("hsl(0, 0%, 100%)")).toBe("#0b1220");
239
- expect(contrastTextColor("not-a-color")).toBe("#fff");
240
- // Every default palette member must clear WCAG AA with its chosen text.
241
- for (const color of HK_TREEMAP_DEFAULT_COLORS) {
242
- expect(["#fff", "#0b1220"]).toContain(contrastTextColor(color));
243
- }
232
+ it("cell labels follow the theme icon/text token, not a per-block flip", () => {
233
+ // User direction 2026-10-28: cells read like icon glyphs — white on the
234
+ // dark theme via rgb(var(--color-text)), the same vocabulary as
235
+ // HkIconButton; the token flips the ink in the light theme.
236
+ expect(contrastTextColor("#edc948")).toBe("rgb(var(--color-text, 30 30 30))");
237
+ expect(contrastTextColor("#4e79a7")).toBe("rgb(var(--color-text, 30 30 30))");
238
+ expect(contrastTextColor("not-a-color")).toBe("rgb(var(--color-text, 30 30 30))");
244
239
  });
245
240
 
246
241
  it("builds an accessible leaf-path summary from the same layout", () => {
@@ -144,21 +144,18 @@ function resolveOpaqueColor(raw: string, fallback: string): string {
144
144
  return `rgb(${Math.round(channels[0])} ${Math.round(channels[1])} ${Math.round(channels[2])})`;
145
145
  }
146
146
 
147
- /** Pick the higher-contrast label colour for an opaque CSS background. */
148
- export function contrastTextColor(background: string): string {
149
- const channels = parseCssColor(background);
150
- if (!channels || channels[3] < 0.999) return "#fff";
151
- // sRGB relative luminance, with the standard gamma expansion. The two
152
- // candidate text colours (#fff / #0b1220) cross at luminance ~= 0.193.
153
- const linear = (channel: number) => {
154
- const value = channel / 255;
155
- return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
156
- };
157
- const luminance =
158
- 0.2126 * linear(channels[0]) +
159
- 0.7152 * linear(channels[1]) +
160
- 0.0722 * linear(channels[2]);
161
- return luminance > 0.193 ? "#0b1220" : "#fff";
147
+ /**
148
+ * Cell label colour — user direction 2026-10-28: the text inside the colour
149
+ * blocks should read like the family's icon glyphs (WHITE on the dark
150
+ * theme), not a per-block black/white flip that made mid-tone cells carry
151
+ * dark text. So the foreground simply follows the theme's icon/text token
152
+ * (`--hi-icon-color` resolves to `rgb(var(--color-text))`), the same
153
+ * vocabulary HkIconButton uses; the light theme keeps dark ink via the same
154
+ * token. The background argument is kept for signature compatibility and
155
+ * intentionally unused.
156
+ */
157
+ export function contrastTextColor(_background: string): string {
158
+ return "rgb(var(--color-text, 30 30 30))";
162
159
  }
163
160
 
164
161
  export const HK_TREEMAP_DEFAULT_COLORS = [