@celestia-island/hikari 0.55.0 → 0.55.2

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.0",
3
+ "version": "0.55.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -19,6 +19,7 @@ import { createBackGuard } from "../runtime/backStack";
19
19
  import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
20
20
  import { useSurfaceTransition } from "../composables/useSurfaceTransition";
21
21
  import { useSurfaceMachine } from "../composables/useSurfaceMachine";
22
+ import { useSurfaceContentHold } from "../composables/useSurfaceContentHold";
22
23
  import HIconButton from "./HkIconButton";
23
24
  import HIcon from "./HkIcon";
24
25
  import "./window-close.scss";
@@ -269,6 +270,8 @@ export default defineComponent({
269
270
  cleanup();
270
271
  });
271
272
 
273
+ const contentHold = useSurfaceContentHold(machine.phase);
274
+
272
275
  return () => {
273
276
  if (!machine.mounted.value) return null;
274
277
  const panelPrefix = `hk-drawer-${props.side}`;
@@ -295,7 +298,11 @@ export default defineComponent({
295
298
  else if (e.key === "Tab" && panelRef.value) trapFocus(panelRef.value, e);
296
299
  }}
297
300
  >
298
- {props.title || slots.header ? (
301
+ {/* Same close-fold content hold as HkModal: the sheet's
302
+ slide-away plays over the last live content even when
303
+ the consumer tears its state down on close. */}
304
+ {contentHold.hold(() => [
305
+ props.title || slots.header ? (
299
306
  <div class="hk-drawer-header">
300
307
  {slots.header ? (
301
308
  slots.header()
@@ -314,13 +321,14 @@ export default defineComponent({
314
321
  </HIconButton>
315
322
  ) : null}
316
323
  </div>
317
- ) : null}
324
+ ) : null,
318
325
  <div ref={bodyWrapRef} class="hk-drawer-body-wrap">
319
326
  <div ref={bodyRef} class="hk-drawer-body">{slots.default?.()}</div>
320
- </div>
321
- {slots.footer ? (
327
+ </div>,
328
+ slots.footer ? (
322
329
  <div class="hk-drawer-footer">{slots.footer()}</div>
323
- ) : null}
330
+ ) : null,
331
+ ])}
324
332
  </div>
325
333
  </Teleport>
326
334
  );
@@ -0,0 +1,156 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick, ref } from "vue";
3
+
4
+ import HkModal from "./HkModal";
5
+
6
+ /**
7
+ * Source contract for the close-fold content hold (chest field report
8
+ * 2026-09-16): consumers routinely tear down the state that feeds the
9
+ * window's body in the same tick as the close (TodoLogModal's
10
+ * clear-on-close watcher emptied a 697px conversation window to a 200px
11
+ * stub BEFORE the fold started). While the machine is in a closing phase
12
+ * HkModal serves the last live-rendered children, so the fold always
13
+ * plays over the content the user was looking at.
14
+ *
15
+ * happy-dom has no transition engine, so the machine's duration probe
16
+ * reads a stubbed 0.3s (same harness trick as the surface-machine tests)
17
+ * and rAF is frozen — the flip timers drive the phase edges
18
+ * deterministically.
19
+ */
20
+ const mounts: ReturnType<typeof createApp>[] = [];
21
+ const containers: HTMLElement[] = [];
22
+
23
+ afterEach(async () => {
24
+ for (const app of mounts.splice(0)) app.unmount();
25
+ for (const el of containers.splice(0)) el.remove();
26
+ vi.unstubAllGlobals();
27
+ vi.useRealTimers();
28
+ });
29
+
30
+ function freezeRaf(): void {
31
+ vi.stubGlobal("requestAnimationFrame", (_cb: FrameRequestCallback) => 0 as unknown as number);
32
+ vi.stubGlobal("cancelAnimationFrame", () => {});
33
+ }
34
+
35
+ function stubDurations(): void {
36
+ const realGCS = window.getComputedStyle.bind(window);
37
+ vi.stubGlobal("getComputedStyle", (el: Element, ...rest: unknown[]) => {
38
+ const style = realGCS(el as Element, ...(rest as []));
39
+ return { ...style, transitionDuration: "0.3s" } as CSSStyleDeclaration;
40
+ });
41
+ }
42
+
43
+ interface Rig {
44
+ open: { value: boolean };
45
+ setLines: (lines: string[]) => void;
46
+ closeWithTeardown: () => void;
47
+ bodyText: () => string;
48
+ frameEl: () => HTMLElement | null;
49
+ }
50
+
51
+ /** Mount HkModal behind a wrapper that models the chest clear-on-close
52
+ * pattern: the body state clears in the SAME handler that flips the
53
+ * v-model false. */
54
+ async function mountRig(initialLines: string[]): Promise<Rig> {
55
+ const container = document.createElement("div");
56
+ document.body.appendChild(container);
57
+ containers.push(container);
58
+
59
+ const open = ref(false);
60
+ const lines = ref<string[]>(initialLines);
61
+
62
+ const Wrapper = defineComponent({
63
+ setup() {
64
+ return () =>
65
+ h(HkModal, {
66
+ modelValue: open.value,
67
+ closable: true,
68
+ title: "hold-test",
69
+ "onUpdate:modelValue": (v: boolean) => {
70
+ open.value = v;
71
+ // The reported chest pattern: destroy the content with the
72
+ // close request, before any leave animation runs.
73
+ if (!v) lines.value = [];
74
+ },
75
+ }, {
76
+ default: () => h("div", lines.value.map((l, i) => h("p", { key: i }, l))),
77
+ });
78
+ },
79
+ });
80
+ const app = createApp(Wrapper);
81
+ mounts.push(app);
82
+ app.mount(container);
83
+ await nextTick();
84
+
85
+ return {
86
+ open,
87
+ setLines: (next: string[]) => { lines.value = next; },
88
+ /** Faithful chest pattern: the store-watcher teardown runs in the SAME
89
+ * tick as the v-model flip, regardless of who flipped it — clearing
90
+ * only inside onUpdate:modelValue would miss programmatic closes and
91
+ * the test would pass vacuously (R1 finding: the hold was never
92
+ * exercised). */
93
+ closeWithTeardown: () => {
94
+ open.value = false;
95
+ lines.value = [];
96
+ },
97
+ bodyText: () => document.querySelector(".hk-modal-body-inner")?.textContent ?? "",
98
+ frameEl: () => document.querySelector<HTMLElement>(".hk-modal-content"),
99
+ };
100
+ }
101
+
102
+ describe("HkModal close-fold content hold", () => {
103
+ it("folds over the last live content even when the consumer clears state on close", async () => {
104
+ vi.useFakeTimers();
105
+ freezeRaf();
106
+ stubDurations();
107
+ const rig = await mountRig(["alpha", "beta"]);
108
+
109
+ // Open the surface to rest (flip timer 120ms + duration 300 + slack).
110
+ rig.open.value = true;
111
+ await vi.advanceTimersByTimeAsync(600);
112
+ expect(rig.bodyText()).toBe("alphabeta");
113
+
114
+ // Close with the SAME-TICK teardown — this is the exact moment the
115
+ // hold must earn its keep (without it the body blanks immediately).
116
+ rig.closeWithTeardown();
117
+ await vi.advanceTimersByTimeAsync(150); // mid-leave (past the flip)
118
+ expect(rig.frameEl()).not.toBeNull();
119
+ // THE contract: the fold plays over the ORIGINAL content — the
120
+ // same-tick teardown must not blank the window mid-fold.
121
+ expect(rig.bodyText()).toBe("alphabeta");
122
+
123
+ // The leave settles and the DOM goes away with it.
124
+ await vi.advanceTimersByTimeAsync(600);
125
+ expect(rig.frameEl()).toBeNull();
126
+ expect(rig.bodyText()).toBe("");
127
+ });
128
+
129
+ it("a reopen interrupt drops the hold and serves live content again", async () => {
130
+ vi.useFakeTimers();
131
+ freezeRaf();
132
+ stubDurations();
133
+ const rig = await mountRig(["alpha", "beta"]);
134
+
135
+ rig.open.value = true;
136
+ await vi.advanceTimersByTimeAsync(600);
137
+ const frameBeforeClose = rig.frameEl();
138
+
139
+ // Close with the same-tick teardown, then interrupt mid-leave.
140
+ rig.closeWithTeardown();
141
+ await vi.advanceTimersByTimeAsync(150);
142
+ expect(rig.bodyText()).toBe("alphabeta");
143
+
144
+ // Reopen with FRESH content — the hold releases with the phase.
145
+ rig.open.value = true;
146
+ rig.setLines(["gamma"]);
147
+ await vi.advanceTimersByTimeAsync(30);
148
+ expect(rig.bodyText()).toBe("gamma");
149
+
150
+ // …and the same element survives the reversal (no remount flash).
151
+ expect(rig.frameEl()).toBe(frameBeforeClose);
152
+ await vi.advanceTimersByTimeAsync(800);
153
+ expect(rig.bodyText()).toBe("gamma");
154
+ expect(rig.frameEl()).not.toBeNull();
155
+ });
156
+ });
@@ -0,0 +1,169 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick, ref } from "vue";
3
+
4
+ import HkModal from "./HkModal";
5
+
6
+ /**
7
+ * Source contract for the leave-window height pin (chest field report
8
+ * 2026-09-16): the close fold owns the frame's geometry for the whole
9
+ * leave, so HkModal must KEEP the size morph's pin through closingFrom →
10
+ * closingTo (morph.hold()) and only release it on the finalize edge. The
11
+ * composable-level semantics are pinned by useSizeMorph.test.ts; this file
12
+ * pins the HkModal INTEGRATION — the wiring bug class (calling stop()
13
+ * instead of hold()) leaves the composable tests green while every real
14
+ * modal folds over a resizing frame.
15
+ *
16
+ * happy-dom has no layout engine, so the frame's offsetHeight is stubbed
17
+ * at the HTMLElement prototype level BEFORE mount — the morph's
18
+ * openingFrom-tick arm then measures 500px and pins it, and every
19
+ * mid-leave assertion reads the inline height string directly.
20
+ */
21
+ const mounts: ReturnType<typeof createApp>[] = [];
22
+ const containers: HTMLElement[] = [];
23
+
24
+ afterEach(async () => {
25
+ for (const app of mounts.splice(0)) app.unmount();
26
+ for (const el of containers.splice(0)) el.remove();
27
+ vi.unstubAllGlobals();
28
+ vi.useRealTimers();
29
+ });
30
+
31
+ function freezeRaf(): void {
32
+ vi.stubGlobal("requestAnimationFrame", (_cb: FrameRequestCallback) => 0 as unknown as number);
33
+ vi.stubGlobal("cancelAnimationFrame", () => {});
34
+ }
35
+
36
+ function stubDurations(): void {
37
+ const realGCS = window.getComputedStyle.bind(window);
38
+ vi.stubGlobal("getComputedStyle", (el: Element, ...rest: unknown[]) => {
39
+ const style = realGCS(el as Element, ...(rest as []));
40
+ return { ...style, transitionDuration: "0.3s" } as CSSStyleDeclaration;
41
+ });
42
+ }
43
+
44
+ /** Prototype-level layout stub: every element measures `layoutH` tall.
45
+ * Mutable so a test can simulate content growth mid-enter. */
46
+ let layoutH = 500;
47
+ function stubLayout(initial: number): void {
48
+ layoutH = initial;
49
+ Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
50
+ configurable: true,
51
+ get: () => layoutH,
52
+ });
53
+ }
54
+
55
+ function restoreLayout(): void {
56
+ delete (HTMLElement.prototype as { offsetHeight?: number }).offsetHeight;
57
+ }
58
+
59
+ interface Rig {
60
+ open: { value: boolean };
61
+ pinHeight: () => string;
62
+ frameEl: () => HTMLElement | null;
63
+ clickClose: () => void;
64
+ }
65
+
66
+ async function mountRig(): Promise<Rig> {
67
+ const container = document.createElement("div");
68
+ document.body.appendChild(container);
69
+ containers.push(container);
70
+
71
+ const open = ref(false);
72
+
73
+ const Wrapper = defineComponent({
74
+ setup() {
75
+ return () =>
76
+ h(HkModal, {
77
+ modelValue: open.value,
78
+ closable: true,
79
+ title: "pin-test",
80
+ "onUpdate:modelValue": (v: boolean) => { open.value = v; },
81
+ }, { default: () => h("div", "content") });
82
+ },
83
+ });
84
+ const app = createApp(Wrapper);
85
+ mounts.push(app);
86
+ app.mount(container);
87
+ await nextTick();
88
+
89
+ return {
90
+ open,
91
+ pinHeight: () => document.querySelector<HTMLElement>(".hk-modal-content")?.style.height ?? "",
92
+ frameEl: () => document.querySelector<HTMLElement>(".hk-modal-content"),
93
+ clickClose: () => {
94
+ const btn = document.querySelector<HTMLElement>(".hk-modal-close");
95
+ btn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
96
+ },
97
+ };
98
+ }
99
+
100
+ describe("HkModal leave-window height pin", () => {
101
+ it("keeps the morph pin through the whole leave and releases at finalize", async () => {
102
+ vi.useFakeTimers();
103
+ freezeRaf();
104
+ stubDurations();
105
+ stubLayout(500);
106
+ try {
107
+ const rig = await mountRig();
108
+
109
+ // Open to rest: the enter-edge arm measured 500px and pinned it.
110
+ rig.open.value = true;
111
+ await vi.advanceTimersByTimeAsync(600);
112
+ expect(rig.frameEl()).not.toBeNull();
113
+ expect(rig.pinHeight()).toBe("500px");
114
+
115
+ // Close through the modal's own close button (the real path).
116
+ rig.clickClose();
117
+ await vi.advanceTimersByTimeAsync(150); // mid-leave, past the flip
118
+ expect(rig.frameEl()).not.toBeNull();
119
+ // THE contract: the pin survives the whole closing window — a
120
+ // stop()-instead-of-hold() wiring drops it here and the fold plays
121
+ // over a frame handed back to `height: auto`.
122
+ expect(rig.pinHeight()).toBe("500px");
123
+
124
+ // Still pinned late in the leave.
125
+ await vi.advanceTimersByTimeAsync(250);
126
+ if (rig.frameEl()) {
127
+ expect(rig.pinHeight()).toBe("500px");
128
+ }
129
+
130
+ // Finalize: the surface unmounts (the release write is invisible).
131
+ await vi.advanceTimersByTimeAsync(600);
132
+ expect(rig.frameEl()).toBeNull();
133
+ } finally {
134
+ restoreLayout();
135
+ }
136
+ });
137
+
138
+ it("flushes growth deferred through the enter with an open-edge remeasure", async () => {
139
+ vi.useFakeTimers();
140
+ freezeRaf();
141
+ stubDurations();
142
+ stubLayout(400);
143
+ try {
144
+ const rig = await mountRig();
145
+
146
+ // Open: the enter-edge arm pins the enter-time height (400px).
147
+ rig.open.value = true;
148
+ await vi.advanceTimersByTimeAsync(150); // mid-enter, past the flip
149
+ expect(rig.pinHeight()).toBe("400px");
150
+
151
+ // Content grows mid-enter — with the enter's defer gate the pin
152
+ // must NOT chase it (the choreography's height-relative geometry
153
+ // stays put; in a real browser the RO fires and is deferred too).
154
+ layoutH = 700;
155
+ await vi.advanceTimersByTimeAsync(50);
156
+ expect(rig.pinHeight()).toBe("400px");
157
+
158
+ // The open edge flushes the deferred growth one tick after the
159
+ // transition classes clear — the pin lands at the grown height.
160
+ await vi.advanceTimersByTimeAsync(450);
161
+ expect(rig.pinHeight()).toBe("700px");
162
+
163
+ // Sanity: the surface really rested open before the flush assertion.
164
+ expect(rig.frameEl()).not.toBeNull();
165
+ } finally {
166
+ restoreLayout();
167
+ }
168
+ });
169
+ });
@@ -21,6 +21,7 @@ import { scheduleFrame, type AnimationHandle } from "../runtime/animationBus";
21
21
  import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
22
22
  import { useSurfaceTransition } from "../composables/useSurfaceTransition";
23
23
  import { useSurfaceMachine } from "../composables/useSurfaceMachine";
24
+ import { useSurfaceContentHold } from "../composables/useSurfaceContentHold";
24
25
  import { useSizeMorph } from "../composables/useSizeMorph";
25
26
  import HButton from "./HkButton";
26
27
  import HFab from "./HkFab";
@@ -201,8 +202,19 @@ export default defineComponent({
201
202
  * useSizeMorph). */
202
203
  const innerRef = ref<HTMLElement>();
203
204
  // Content-driven size morphing: the frame follows content growth with
204
- // the height transition instead of snapping (see useSizeMorph).
205
- const morph = useSizeMorph(contentRef, innerRef);
205
+ // the height transition instead of snapping (see useSizeMorph). The
206
+ // gate freezes resize-driven re-measurement through the enter unfold:
207
+ // the choreography's geometry is height-relative (translateY 5% +
208
+ // bottom-10% clip of the frame height), so content streaming in
209
+ // mid-enter would otherwise recompute the pixel geometry under the
210
+ // running animation (2026-09-16 chest field report — the frame
211
+ // snapped 459→697px mid-unfold). The open phase edge flushes the
212
+ // deferred growth with an animated remeasure.
213
+ const morph = useSizeMorph(contentRef, innerRef, {
214
+ deferRemeasure: () =>
215
+ machine.phase.value === "openingFrom" ||
216
+ machine.phase.value === "openingTo",
217
+ });
206
218
  let previouslyFocused: HTMLElement | null = null;
207
219
  let unmounted = false;
208
220
 
@@ -347,13 +359,29 @@ export default defineComponent({
347
359
  backGuard.push();
348
360
  }
349
361
  surfTrack.run();
362
+ // Arm the size morph WITH the enter (one tick later, once the
363
+ // mounting patch landed): the pin locks the frame's height for
364
+ // the whole unfold so late-streaming content cannot move the
365
+ // choreography mid-flight (see the morph's gate above). The
366
+ // zero-duration fast paths reach `open` before the tick — the
367
+ // open branch's own start() covers them.
368
+ void nextTick(() => {
369
+ const p = machine.phase.value;
370
+ if (p === "openingFrom" || p === "openingTo") morph.start();
371
+ });
350
372
  } else if (to === "open") {
351
373
  surfTrack.cancel();
352
374
  onAfterEnter();
353
- // Size morphs arm once the open choreography finished —
354
- // pinning during enter would fight the reveal's transform
355
- // transition.
375
+ // Idempotent re-arm for the fast paths (see openingFrom); the
376
+ // morph is usually armed already by the enter-edge tick.
356
377
  morph.start();
378
+ // Flush the growth deferred through the unfold. One tick later
379
+ // the transition classes are off the frame, so the base height
380
+ // transition animates the pin change instead of the enter's
381
+ // transform/clip-only transition list swallowing it as a snap.
382
+ void nextTick(() => {
383
+ if (machine.phase.value === "open") morph.remeasure();
384
+ });
357
385
  } else if (to === "closingFrom") {
358
386
  // Close-request bookkeeping (was the watcher's close arm): the
359
387
  // registries forget the surface at request time; the finalize
@@ -361,8 +389,18 @@ export default defineComponent({
361
389
  surfTrack.run();
362
390
  overlay.close();
363
391
  backGuard.release();
364
- // Release the pinned height so the leave owns the frame.
365
- morph.stop();
392
+ // Keep the height PIN through the leave: the fold owns the
393
+ // frame's geometry and a mid-leave content change must not
394
+ // resize it. The pin releases on the finalize edge below.
395
+ morph.hold();
396
+ // The leave must not chase a live tail — the window folds over
397
+ // the final frame of content, not over a moving stream. A
398
+ // self-updating child inside the held content could still
399
+ // mutate the DOM mid-fold (the observer would re-pin the
400
+ // scroll), so auto-follow tears down with the close request;
401
+ // a reopen interrupt re-arms it at the open edge
402
+ // (onAfterEnter → setupAutoFollow).
403
+ teardownAutoFollow();
366
404
  } else if (
367
405
  to === "closed" &&
368
406
  (from === "closingFrom" || from === "closingTo") &&
@@ -373,6 +411,10 @@ export default defineComponent({
373
411
  event !== "UNMOUNT"
374
412
  ) {
375
413
  surfTrack.cancel();
414
+ // Bookkeeping reset for the NEXT open cycle: zeroes the morph's
415
+ // pin so the next enter starts clean (release() writes on the
416
+ // invisible, about-to-unmount frame — no visual effect).
417
+ morph.stop();
376
418
  onAfterLeaveFinalize();
377
419
  }
378
420
  // to === "closed" via UNMOUNT: teardown is owned by
@@ -743,6 +785,8 @@ export default defineComponent({
743
785
  return null;
744
786
  }
745
787
 
788
+ const contentHold = useSurfaceContentHold(machine.phase);
789
+
746
790
  return () => {
747
791
  if (!machine.mounted.value) return null;
748
792
 
@@ -773,7 +817,12 @@ export default defineComponent({
773
817
  onClick={onContentClick}
774
818
  tabindex={-1}
775
819
  >
776
- {headerShown && (
820
+ {/* Content hold: while the machine is in a closing
821
+ phase this serves the last live-rendered children,
822
+ so a consumer tearing down its state on close
823
+ cannot blank the window mid-fold. */}
824
+ {contentHold.hold(() => [
825
+ headerShown ? (
777
826
  <>
778
827
  <div
779
828
  class={[
@@ -807,7 +856,7 @@ export default defineComponent({
807
856
  <div class="hk-modal-subheader">{slots.header()}</div>
808
857
  )}
809
858
  </>
810
- )}
859
+ ) : null,
811
860
  <div ref={bodyRef} class="hk-modal-body">
812
861
  <div
813
862
  ref={scrollContainerRef}
@@ -851,8 +900,9 @@ export default defineComponent({
851
900
  )}
852
901
  </>
853
902
  )}
854
- </div>
855
- {renderFooter()}
903
+ </div>,
904
+ renderFooter(),
905
+ ])}
856
906
  </div>
857
907
  </div>
858
908
  </Teleport>
@@ -109,7 +109,52 @@ describe("HkModalBreadcrumb overflow fence", () => {
109
109
  it("keeps the fold off the accessibility tree and the names on it", () => {
110
110
  // The clone duplicates every label — it must stay invisible to AT.
111
111
  expect(tsx).toMatch(/hk-modal-breadcrumb-measure"[^>]*aria-hidden="true"/);
112
- // A cut label still announces its whole name.
113
- expect(tsx).toMatch(/hk-modal-breadcrumb-sr-only/);
112
+ // A cut label IS a button, and a cut string is not a name: the whole
113
+ // layer name travels as its accessible name.
114
+ expect(tsx).toMatch(/aria-label=\{crumb\.label\}/);
115
+ // The tappable label must reach through the pointer-transparent strip.
116
+ expect(rule(".hk-modal-breadcrumb-item-reveal")).toContain("pointer-events: auto");
117
+ });
118
+
119
+ it("keeps the tappable label the same box the ruler measures", () => {
120
+ // The clone renders a plain span per label, so padding on the button
121
+ // would make the fold under-count its own crumb; the ~17px text line is
122
+ // also under half a fingertip, and this is the phone-primary
123
+ // affordance. Grow the hit area, not the chrome.
124
+ const block = rule(".hk-modal-breadcrumb-item-reveal");
125
+ // `padding: 0` exactly — the old `padding: 0 var(--space-4, …)` also
126
+ // satisfied a bare "padding: 0" substring, so the guard was vacuous.
127
+ expect(block).toMatch(/padding:\s*0;/);
128
+ expect(block).toContain("pointer-events: auto");
129
+ expect(block).toContain("text-align: start");
130
+ const hitArea = scss.match(/\.hk-modal-breadcrumb-item-reveal::after\s*{[^}]*}/)?.[0] ?? "";
131
+ expect(hitArea).toContain("position: absolute");
132
+ expect(hitArea).toContain("inset: calc(-1 * var(--space-8");
133
+ });
134
+
135
+ it("leaves the label palette to the item classes", () => {
136
+ // The button carries the item classes too, and an equally specific rule
137
+ // later in the file wins: redeclaring `color` here repainted every cut
138
+ // crumb (and dropped the current layer's accent) in exactly the
139
+ // long-title case this feature exists for (2026-09-16 final review).
140
+ const block = rule(".hk-modal-breadcrumb-item-reveal");
141
+ expect(block).not.toContain("color:");
142
+ expect(rule(".hk-modal-breadcrumb-item")).toContain(
143
+ "color: var(--hi-color-text-secondary",
144
+ );
145
+ expect(rule(".hk-modal-breadcrumb-item-current")).toContain(
146
+ "color: var(--hi-color-primary",
147
+ );
148
+ });
149
+
150
+ it("opens the revealed name in the same popover family as the menu", () => {
151
+ // Two surfaces, one form-factor rule: both are HkPopovers that dock as
152
+ // a sheet on mobile, anchored to the crumb they belong to.
153
+ expect(tsx.match(/<HkPopover/g)).toHaveLength(2);
154
+ expect(tsx.match(/sheetOnMobile/g)).toHaveLength(2);
155
+ // A revealed name wraps instead of cutting.
156
+ const block = rule(".hk-modal-breadcrumb-reveal");
157
+ expect(block).toContain("overflow-wrap: anywhere");
158
+ expect(block).not.toContain("text-overflow: ellipsis");
114
159
  });
115
160
  });
@@ -121,19 +121,60 @@
121
121
  --hk-menu-panel-inset: var(--space-4, 0.25rem);
122
122
  }
123
123
 
124
- /* Full layer name for assistive tech when the visible label is cut — the
125
- strip is an aria-live region, and "自动化测试流水线冒烟请巡…" announces as
126
- noise unless the whole name travels alongside it. */
127
- .hk-modal-breadcrumb-sr-only {
128
- position: absolute;
129
- width: 1px;
130
- height: 1px;
131
- margin: -1px;
124
+ /* A visible-but-cut label is tappable: it opens the whole name in the same
125
+ popover family as the hidden-layers menu. The strip is pointer-
126
+ transparent, so only these buttons (and the "…" trigger) take the click. */
127
+ .hk-modal-breadcrumb-item-reveal {
128
+ /* No padding: the strip's ruler measures a plain item, so a padded
129
+ button would make the fold under-count its own crumb. The hit area is
130
+ grown by a pseudo-element instead, exactly like the "…" trigger. */
131
+ margin: 0;
132
132
  padding: 0;
133
- overflow: hidden;
134
- clip: rect(0, 0, 0, 0);
135
- white-space: nowrap;
136
133
  border: 0;
134
+ border-radius: var(--radius-sm, 0.25rem);
135
+ background: transparent;
136
+ /* font-family only: the item classes above own size, weight AND colour —
137
+ redeclaring `color` here would repaint every cut crumb (and drop the
138
+ current layer's accent) because an equally specific rule later in the
139
+ file wins. */
140
+ font-family: inherit;
141
+ text-align: start;
142
+ cursor: pointer;
143
+ pointer-events: auto;
144
+ position: relative;
145
+ }
146
+
147
+ /* A label is one text line tall (~17px) — under half a fingertip, and this
148
+ is the phone-primary affordance. Grow the hit area, not the chrome. */
149
+ .hk-modal-breadcrumb-item-reveal::after {
150
+ content: "";
151
+ position: absolute;
152
+ inset: calc(-1 * var(--space-8, 0.5rem)) 0;
153
+ }
154
+
155
+ .hk-modal-breadcrumb-item-reveal:hover,
156
+ .hk-modal-breadcrumb-item-reveal[aria-expanded="true"] {
157
+ background: var(--hk-breadcrumb-more-hover, rgba(0, 0, 0, 0.06));
158
+ color: var(--hi-color-text, #1f2328);
159
+ }
160
+
161
+ .hk-modal-breadcrumb-item-reveal:focus-visible {
162
+ outline: 2px solid var(--hi-color-primary, #7aa2f7);
163
+ outline-offset: 1px;
164
+ }
165
+
166
+ /* The revealed name itself: the strip's own type, wrapping instead of
167
+ cutting, and selectable (the strip around it is not). */
168
+ .hk-modal-breadcrumb-reveal {
169
+ margin: 0;
170
+ padding: var(--space-8, 0.5rem) var(--space-12, 0.75rem);
171
+ max-width: min(24rem, calc(100vw - 2 * var(--viewport-gutter, 1rem)));
172
+ font-size: var(--text-xs, 0.75rem);
173
+ font-weight: 600;
174
+ line-height: 1.5;
175
+ color: var(--hi-color-text, #1f2328);
176
+ overflow-wrap: anywhere;
177
+ user-select: text;
137
178
  }
138
179
 
139
180
  /* The strip's own ruler: every layer laid out at its natural width so the