@celestia-island/hikari 0.55.220 → 0.55.222

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.220",
3
+ "version": "0.55.222",
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",
@@ -193,6 +193,21 @@ describe("HkAdminHeader", () => {
193
193
  expect(c.textContent).not.toContain("alice");
194
194
  });
195
195
 
196
+ it("reserves the workspace-switcher slot in drawer mode only", () => {
197
+ // 2026-10-29 user direction: the frontend header's left wing is
198
+ // avatar + ws-trigger; the console drawer face keeps the SLOT as a
199
+ // deliberate blank so the centre strip can never slide into the
200
+ // space — menu mode carries the page title in that position instead.
201
+ const drawer = mount(headerNode({ avatarAction: "drawer", userEmail: "alice@example.com" }));
202
+ const slot = drawer.querySelector(".s-admin-header-ws-slot");
203
+ expect(slot, "drawer face must render the reserved ws slot").toBeTruthy();
204
+ expect(slot?.getAttribute("aria-hidden")).toBe("true");
205
+
206
+ const menu = mount(headerNode({ title: "Providers", logoutLabel: "Log out" }));
207
+ expect(menu.querySelector(".s-admin-header-ws-slot")).toBeNull();
208
+ expect(menu.querySelector(".s-admin-header-title")).toBeTruthy();
209
+ });
210
+
196
211
  it("renders the signing-in placeholder with the force-sign-out escape when the identity is empty", async () => {
197
212
  const onForceSignOut = vi.fn();
198
213
  const c = mount(headerNode({
@@ -249,6 +249,20 @@ export const HkAdminHeader = defineComponent({
249
249
  )}
250
250
  </button>
251
251
  )}
252
+ {/* The reserved workspace-switcher slot (drawer face only).
253
+ The chat header's left wing is avatar + ws-trigger button,
254
+ and that wing's min-content floor is what keeps the
255
+ header-centre strip off the space on a phone. The console
256
+ has no workspace concept, but the drawer face hides the
257
+ title too — without a stand-in the wing's floor is just
258
+ the 28px avatar and a wide centre strip slides left into
259
+ the slot and reads as hugging the avatar (2026-10-29 user
260
+ direction: 头像右侧留白,前后台布局统一). An aria-hidden
261
+ blank of the trigger's own footprint keeps both faces'
262
+ left wings at the same floor. */}
263
+ {props.avatarAction === "drawer" && (
264
+ <span class="s-admin-header-ws-slot" aria-hidden="true" />
265
+ )}
252
266
  {/* WHERE am I — the open view's title, not the nickname. The
253
267
  1.5 line-height (in the class) keeps descenders (g, y, p)
254
268
  inside the truncate clip box: the default text-sm box is
@@ -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
@@ -95,6 +95,20 @@ describe("admin header chrome geometry (the two user-requested fixes)", () => {
95
95
  expect(rule).toContainEqual(["gap", "var(--space-16)"]);
96
96
  });
97
97
 
98
+ it("reserves the drawer face's workspace-switcher slot at the trigger's own footprint", () => {
99
+ // 2026-10-29 user direction: the chat header's left wing is avatar +
100
+ // ws-trigger, and that wing's min-content floor is what stops the
101
+ // balanced grid's centre strip from sliding into the space on a
102
+ // phone. The console drawer face (no title, no workspace concept)
103
+ // must keep the slot as a blank of the trigger's own 28px box —
104
+ // dropping the rule would drop the floor and the strip would hug
105
+ // the avatar again.
106
+ const rule = ruleDeclarations(adminTokens, ".s-admin-header-ws-slot");
107
+ expect(rule, ".s-admin-header-ws-slot must be declared").not.toBeNull();
108
+ expect(rule).toContainEqual(["width", "var(--space-28)"]);
109
+ expect(rule).toContainEqual(["flex-shrink", "0"]);
110
+ });
111
+
98
112
  it("hangs the shared balanced-header grid contract (the ONE header layout)", () => {
99
113
  // User report 2026-10-15: the console's header-centre tab strip sat
100
114
  // LEFT of centre on phones (drawer mode hides the title, so the left
@@ -597,6 +597,23 @@
597
597
  object-fit: cover;
598
598
  }
599
599
 
600
+ /* The reserved workspace-switcher slot (HkAdminHeader, drawer face).
601
+ * The chat header's left wing is avatar + ws-trigger (8px flex gap +
602
+ * 28px button box), and that wing's min-content floor is what keeps the
603
+ * balanced grid's centre strip off the space on a phone: the strip can
604
+ * only shrink into its own column, never into a wing. The console's
605
+ * drawer face hides both the title and the workspace concept, so
606
+ * without a stand-in the wing's floor is just the 28px avatar — a wide
607
+ * strip (the firewall page's three sections on a 412px phone) slides
608
+ * left until it hugs the avatar (2026-10-29 user report). The blank
609
+ * span carries the trigger's 28px box; the wing's 16px gap already
610
+ * covers the button's 8px inline padding (the 2026-09-28 title-distance
611
+ * pin), so the floor lands within one padding-step of the frontend's. */
612
+ .s-admin-header-ws-slot {
613
+ width: var(--space-28);
614
+ flex-shrink: 0;
615
+ }
616
+
600
617
  /* The old hover camera veil ("change avatar" affordance) is gone: the
601
618
  * avatar trigger opens the account MENU — the avatar edit is the Camera
602
619
  * item inside it — so the veil promised an action the click never