@celestia-island/hikari 0.55.188 → 0.55.190

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.
Files changed (46) hide show
  1. package/package.json +1 -1
  2. package/src/components/HkAffixPicker.scss +2 -2
  3. package/src/components/HkAuthSubmitButton.scss +1 -1
  4. package/src/components/HkAvatar.scss +2 -2
  5. package/src/components/HkBlockingToast.scss +6 -2
  6. package/src/components/HkConfirmDialog.scss +1 -1
  7. package/src/components/HkCornerBadge.scss +1 -1
  8. package/src/components/HkDockBar.scss +8 -2
  9. package/src/components/HkDrawer.motion.test.ts +111 -0
  10. package/src/components/HkDrawer.scss +24 -0
  11. package/src/components/HkIconButtonGroup.scss +1 -1
  12. package/src/components/HkMenu.scss +1 -1
  13. package/src/components/HkMenuActionItem.tsx +6 -0
  14. package/src/components/HkMessageBox.scss +1 -1
  15. package/src/components/HkModalBreadcrumb.overflow.contract.test.ts +1 -1
  16. package/src/components/HkModalBreadcrumb.scss +30 -9
  17. package/src/components/HkModalBreadcrumb.test.tsx +45 -22
  18. package/src/components/HkModalBreadcrumb.tokens.contract.test.ts +81 -0
  19. package/src/components/HkModalBreadcrumb.tsx +56 -16
  20. package/src/components/HkPopover.tsx +9 -1
  21. package/src/components/HkThemeToggle.scss +6 -1
  22. package/src/components/HkToast.scss +4 -2
  23. package/src/components/_menu-item.scss +11 -5
  24. package/src/demo.ts +3 -2
  25. package/src/i18n/locales/ar/components.json +1 -1
  26. package/src/i18n/locales/de/components.json +1 -1
  27. package/src/i18n/locales/en/components.json +1 -1
  28. package/src/i18n/locales/es/components.json +1 -1
  29. package/src/i18n/locales/fr/components.json +1 -1
  30. package/src/i18n/locales/ja/components.json +1 -1
  31. package/src/i18n/locales/ko/components.json +1 -1
  32. package/src/i18n/locales/pt/components.json +1 -1
  33. package/src/i18n/locales/ru/components.json +1 -1
  34. package/src/i18n/locales/zh-Hans/components.json +1 -1
  35. package/src/i18n/locales/zh-Hant/components.json +1 -1
  36. package/src/i18n/modalKeys.test.ts +2 -2
  37. package/src/index.ts +3 -0
  38. package/src/runtime/index.ts +5 -0
  39. package/src/runtime/mobileViewport.ts +5 -3
  40. package/src/runtime/tapHighlight.test.ts +134 -0
  41. package/src/runtime/tapHighlight.ts +169 -0
  42. package/src/styles/admin-tokens.scss +3 -3
  43. package/src/styles/channelFallbacks.test.ts +8 -6
  44. package/src/styles/ghostChannels.contract.test.ts +138 -0
  45. package/src/styles/reset.scss +19 -0
  46. package/src/styles/resetSheet.test.ts +20 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.188",
3
+ "version": "0.55.190",
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",
@@ -196,8 +196,8 @@
196
196
 
197
197
  &:hover,
198
198
  &:focus-visible {
199
- color: rgb(var(--color-danger, 240 70 80));
200
- background: rgb(var(--color-danger, 240 70 80) / 12%);
199
+ color: rgb(var(--color-error, 247 118 142));
200
+ background: rgb(var(--color-error, 247 118 142) / 12%);
201
201
  }
202
202
  }
203
203
 
@@ -8,5 +8,5 @@
8
8
  .s-auth-submit-error {
9
9
  margin: 0;
10
10
  font-size: var(--text-sm, 0.8125rem);
11
- color: rgb(var(--color-error, 240 70 80));
11
+ color: rgb(var(--color-error, 247 118 142));
12
12
  }
@@ -7,8 +7,8 @@
7
7
  align-items: center;
8
8
  justify-content: center;
9
9
  overflow: hidden;
10
- background-color: var(--hi-color-gray-200, #E5E7EB);
11
- color: var(--hi-color-gray-600, #4B5563);
10
+ background-color: rgb(var(--color-muted, 108 108 108) / 15%);
11
+ color: rgb(var(--color-text-secondary, 102 102 102));
12
12
  transition: all 0.2s ease;
13
13
 
14
14
  img {
@@ -80,17 +80,21 @@
80
80
  // dark-on-amber warning text for contrast).
81
81
  .hk-blocking-toast-info {
82
82
  background: var(--hi-color-info, #6abed6);
83
- color: #fff;
83
+ /* On-solid content: ride the canonical slot (HkToast family), keep the
84
+ * #fff fallback so themeless hosts render as before. */
85
+ color: var(--hi-color-text-on-solid, #fff);
84
86
  }
85
87
 
86
88
  .hk-blocking-toast-warning {
87
89
  background: var(--hi-color-warning, #eed677);
90
+ /* Deliberate DARK-on-amber: the warning band is light by design, and
91
+ * the on-solid slot is white — a white glyph here would be unreadable. */
88
92
  color: #1c1c1e;
89
93
  }
90
94
 
91
95
  .hk-blocking-toast-danger {
92
96
  background: var(--hi-color-danger, #f85149);
93
- color: #fff;
97
+ color: var(--hi-color-text-on-danger, #fff);
94
98
  }
95
99
 
96
100
  // ── Actions on a tinted surface ─────────────────────────────────────
@@ -17,7 +17,7 @@
17
17
  .hk-confirm-dialog-message {
18
18
  margin: 0;
19
19
  color: color-mix(in srgb, var(--hi-color-text-secondary) 85%, transparent);
20
- font-size: var(--hi-text-sm, 0.875rem);
20
+ font-size: var(--text-sm, 0.8125rem);
21
21
  line-height: 1.6;
22
22
  overflow-wrap: anywhere;
23
23
  }
@@ -73,7 +73,7 @@
73
73
  background: var(--hk-badge-bg);
74
74
  color: var(--hk-badge-text);
75
75
  border: 1px solid var(--hk-badge-border);
76
- font-size: var(--text-4xs, 0.625rem);
76
+ font-size: var(--text-2xs, 0.625rem);
77
77
  font-weight: 700;
78
78
  line-height: 1;
79
79
  font-variant-numeric: tabular-nums;
@@ -117,8 +117,14 @@
117
117
  border: 1px solid var(--border-subtle);
118
118
  border-radius: var(--radius-md);
119
119
  /* Reverse soft light — dark mode: white halo; light mode: soft black
120
- * shadow. Strength scales with --glow-scale (Style → Shadow). */
121
- box-shadow: var(--glow-dock);
120
+ * shadow. Strength scales with --glow-scale (Style → Shadow). The
121
+ * default derives the halo from the theme's own ink channel, which IS
122
+ * mode-reversed — before 2026-10-19 the bare var(--glow-dock) had no
123
+ * publisher and no fallback, so the dock halo silently never rendered. */
124
+ box-shadow: var(
125
+ --glow-dock,
126
+ 0 8px 32px rgb(var(--color-text, 30 30 30) / calc(8% * var(--glow-scale, 1)))
127
+ );
122
128
  }
123
129
 
124
130
  /* Vertical side docks (west / east edges) stack along the viewport's
@@ -0,0 +1,111 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import * as sass from "sass";
6
+ import { describe, expect, it } from "vitest";
7
+
8
+ /**
9
+ * Side-drawer motion contract (2026-10-19 phone recording).
10
+ *
11
+ * The left/right drawer used to share the scrim's opacity fade: at
12
+ * halfway through the close the panel was still on screen but its right
13
+ * half was transparent, so the page behind showed through and the right
14
+ * rounded corners dissolved — it read as a width shrink. Side drawers
15
+ * must slide as an opaque surface (transform only); the scrim keeps its
16
+ * own fade, and top/bottom sheets keep the fade.
17
+ */
18
+
19
+ const here = dirname(fileURLToPath(import.meta.url));
20
+ const src = readFileSync(join(here, "HkDrawer.scss"), "utf-8");
21
+
22
+ // The side-drawer overrides sit after the generic blocks and before the
23
+ // reduced-motion block (which also lists the active classes with
24
+ // `transition: none`, so the full-file last occurrence would find that).
25
+ const sideStart = src.indexOf("/* Side drawers slide as an OPAQUE surface.");
26
+ const reduceStart = src.indexOf("@media (prefers-reduced-motion: reduce)");
27
+ const side = src.slice(sideStart, reduceStart);
28
+
29
+ /** Body of the LAST rule for a selector — the side overrides are declared
30
+ * after the generic four-direction blocks. */
31
+ function lastRule(selector: string, from = src): string {
32
+ const at = from.lastIndexOf(selector);
33
+ expect(at, `${selector} exists`).toBeGreaterThanOrEqual(0);
34
+ const open = from.indexOf("{", at);
35
+ const close = from.indexOf("}", open);
36
+ return from.slice(open + 1, close);
37
+ }
38
+
39
+ describe("HkDrawer side motion (opaque slide)", () => {
40
+ it("does not fade the left/right panel out", () => {
41
+ for (const sel of [
42
+ ".hk-drawer-left-enter-active",
43
+ ".hk-drawer-left-leave-active",
44
+ ".hk-drawer-right-enter-active",
45
+ ".hk-drawer-right-leave-active",
46
+ ]) {
47
+ const rule = lastRule(sel, side);
48
+ expect(rule, sel).toContain("transform");
49
+ expect(rule, sel).not.toContain("opacity");
50
+ }
51
+ for (const sel of [
52
+ ".hk-drawer-left-enter-from",
53
+ ".hk-drawer-left-leave-to",
54
+ ".hk-drawer-right-enter-from",
55
+ ".hk-drawer-right-leave-to",
56
+ ]) {
57
+ expect(lastRule(sel, side), sel).toContain("opacity: 1");
58
+ }
59
+ });
60
+
61
+ it("keeps the opaque side overrides after the generic fade rules (compiled order)", () => {
62
+ // The source slices above are anchored on a comment marker; this
63
+ // compiled assertion pins the cascade itself: the side override must
64
+ // come AFTER the generic four-direction block in the emitted CSS.
65
+ const fullCss = sass.compile(resolve(here, "HkDrawer.scss")).css;
66
+ // The reduced-motion block repeats the active selectors with
67
+ // `transition: none`; exclude it so the last occurrence is the side
68
+ // override under test.
69
+ const reduceAt = fullCss.lastIndexOf(
70
+ "@media (prefers-reduced-motion: reduce)",
71
+ );
72
+ const css = fullCss.slice(0, reduceAt > 0 ? reduceAt : fullCss.length);
73
+ const genericAt = css.indexOf(".hk-drawer-left-enter-active");
74
+ const overrideAt = css.lastIndexOf(".hk-drawer-left-enter-active");
75
+ expect(overrideAt).toBeGreaterThan(genericAt);
76
+ const overrideBody = css.slice(overrideAt, css.indexOf("}", overrideAt));
77
+ expect(overrideBody).toContain("transform");
78
+ expect(overrideBody).not.toContain("opacity");
79
+
80
+ const genericFromAt = css.indexOf(".hk-drawer-left-enter-from");
81
+ const sideFromAt = css.lastIndexOf(".hk-drawer-left-enter-from");
82
+ expect(sideFromAt).toBeGreaterThan(genericFromAt);
83
+ const sideFromBody = css.slice(sideFromAt, css.indexOf("}", sideFromAt));
84
+ expect(sideFromBody).toContain("opacity: 1");
85
+ });
86
+
87
+ it("keeps the sheet active opacity transition", () => {
88
+ // R3 mutation: deleting the generic active opacity transition kept the
89
+ // suite green. Guard that top/bottom sheets still animate opacity.
90
+ const generic = src.slice(0, sideStart);
91
+ expect(lastRule(".hk-drawer-top-enter-active", generic)).toContain("opacity");
92
+ expect(lastRule(".hk-drawer-bottom-leave-active", generic)).toContain(
93
+ "opacity",
94
+ );
95
+ });
96
+
97
+ it("keeps the off-screen side transforms", () => {
98
+ // The side override only resets opacity to 1; the generic from/to
99
+ // blocks must keep the translateX(±100%) that carries the panel in
100
+ // and out of the screen.
101
+ const left = src.slice(src.indexOf(".hk-drawer-left-enter-from"));
102
+ expect(left.slice(0, left.indexOf("}") + 1)).toContain("translateX(-100%)");
103
+ const right = src.slice(src.indexOf(".hk-drawer-right-enter-from"));
104
+ expect(right.slice(0, right.indexOf("}") + 1)).toContain("translateX(100%)");
105
+ });
106
+
107
+ it("keeps the fade for top/bottom sheets", () => {
108
+ expect(lastRule(".hk-drawer-top-enter-from")).toContain("opacity: 0");
109
+ expect(lastRule(".hk-drawer-bottom-enter-from")).toContain("opacity: 0");
110
+ });
111
+ });
@@ -123,6 +123,30 @@
123
123
  transform: translateY(100%);
124
124
  }
125
125
 
126
+ /* Side drawers slide as an OPAQUE surface. The panel used to share the
127
+ * scrim's fade (opacity: 0 in the enter/leave states); on a phone the
128
+ * drawer was still partly on screen when its right half went transparent
129
+ * — the page behind showed through and the right rounded corners
130
+ * dissolved, which reads as a width shrink (screen recording
131
+ * 2026-10-19). The scrim keeps its own fade; the panel only translates.
132
+ * Top/bottom sheets keep the fade above. */
133
+ .hk-drawer-left-enter-active,
134
+ .hk-drawer-right-enter-active {
135
+ transition: transform var(--duration-normal, 0.3s) cubic-bezier(0.16, 1, 0.3, 1);
136
+ }
137
+
138
+ .hk-drawer-left-leave-active,
139
+ .hk-drawer-right-leave-active {
140
+ transition: transform var(--duration-normal, 0.3s) cubic-bezier(0.4, 0, 1, 1);
141
+ }
142
+
143
+ .hk-drawer-left-enter-from,
144
+ .hk-drawer-left-leave-to,
145
+ .hk-drawer-right-enter-from,
146
+ .hk-drawer-right-leave-to {
147
+ opacity: 1;
148
+ }
149
+
126
150
  .hk-drawer-header {
127
151
  display: flex;
128
152
  align-items: center;
@@ -104,7 +104,7 @@
104
104
  (var(--hk-icon-group-item-size) + var(--hk-icon-group-slider-gap))
105
105
  )
106
106
  );
107
- transition: transform var(--duration, 0.18s) ease;
107
+ transition: transform var(--duration-short, 0.15s) ease;
108
108
  pointer-events: none;
109
109
  }
110
110
 
@@ -132,7 +132,7 @@
132
132
  padding: 0 var(--space-4, 4px);
133
133
  border-radius: var(--radius-full, 9999px);
134
134
  background: rgb(var(--color-error, 247 118 142));
135
- color: #fff;
135
+ color: var(--hi-color-text-on-danger, #fff);
136
136
  font-size: var(--text-2xs, 10px);
137
137
  font-weight: 700;
138
138
  line-height: 1;
@@ -21,6 +21,11 @@ export default defineComponent({
21
21
  /** Destructive styling (logout, delete…). */
22
22
  danger: { type: Boolean, default: false },
23
23
  disabled: { type: Boolean, default: false },
24
+ /** Selected/current row — the unified menu-item grammar's [data-active]
25
+ * face (primary wash + strong border + primary text; see
26
+ * _menu-item.scss). For lists that mark "you are here", e.g. the
27
+ * modal-breadcrumb layer picker highlighting the current layer. */
28
+ active: { type: Boolean, default: false },
24
29
  },
25
30
  emits: {
26
31
  click: (_e: MouseEvent) => true,
@@ -31,6 +36,7 @@ export default defineComponent({
31
36
  type="button"
32
37
  class={["hk-menu-action-item", props.danger ? "hk-menu-action-item--danger" : ""]}
33
38
  data-danger={props.danger || undefined}
39
+ data-active={props.active || undefined}
34
40
  disabled={props.disabled}
35
41
  role="menuitem"
36
42
  onClick={(e: MouseEvent) => emit("click", e)}
@@ -20,7 +20,7 @@
20
20
 
21
21
  [data-tone="warning"] &,
22
22
  [data-tone="danger"] & {
23
- color: rgb(var(--color-danger, 240 70 80));
23
+ color: rgb(var(--color-error, 247 118 142));
24
24
  }
25
25
  }
26
26
 
@@ -95,7 +95,7 @@ describe("HkModalBreadcrumb overflow fence", () => {
95
95
  expect(offset).toBeGreaterThan(clearance);
96
96
  });
97
97
 
98
- it("routes the hidden-layers menu through the shared form-factor rule", () => {
98
+ it("routes the all-layers menu through the shared form-factor rule", () => {
99
99
  // HkPopover owns "anchored under the trigger on desktop, bottom-up
100
100
  // sheet on mobile" and registers the surface with the popup manager;
101
101
  // a bespoke panel here would fork that behaviour.
@@ -109,9 +109,22 @@
109
109
  inset: calc(-1 * var(--space-8, 0.5rem));
110
110
  }
111
111
 
112
- .hk-modal-breadcrumb-more:hover {
113
- background: var(--hk-breadcrumb-more-hover, rgba(0, 0, 0, 0.06));
114
- color: var(--hi-color-text, #1f2328);
112
+ /* Hover AND the open state (aria-expanded — on a phone the tap leaves
113
+ * :hover stuck on the trigger, so the two must share one legible face).
114
+ * Both colors ride tokens that exist in every theme: the wash is the
115
+ * menu-item grammar's own primary-subtle (a raw rgba(0,0,0,…) fallback
116
+ * vanished on dark surfaces), and the text is --hi-color-text-primary —
117
+ * the token family defines NO --hi-color-text, so the old fallback
118
+ * (#1f2328, light-theme ink) used to repaint the glyph near-black on a
119
+ * dark strip, i.e. unreadable exactly after the tap (2026-10-18 report).
120
+ * The host knob --hk-breadcrumb-more-hover still wins if declared. */
121
+ .hk-modal-breadcrumb-more:hover,
122
+ .hk-modal-breadcrumb-more[aria-expanded="true"] {
123
+ background: var(
124
+ --hk-breadcrumb-more-hover,
125
+ var(--c-primary-subtle, rgb(var(--color-primary, 122 162 247) / 8%))
126
+ );
127
+ color: var(--hi-color-text-primary, rgb(var(--color-text, 30 30 30)));
115
128
  }
116
129
 
117
130
  .hk-modal-breadcrumb-more:focus-visible {
@@ -119,7 +132,7 @@
119
132
  outline-offset: 1px;
120
133
  }
121
134
 
122
- /* The hidden-layers menu rides HkPopover, so it inherits the family's
135
+ /* The all-layers menu rides HkPopover, so it inherits the family's
123
136
  dropdown chrome and mobile-sheet form. Only the inset is re-anchored:
124
137
  the popover panel already pads 4px, and HkMenuPanel's drawer-scale 12px
125
138
  inset on top of it read as a double margin. */
@@ -128,7 +141,7 @@
128
141
  }
129
142
 
130
143
  /* A visible-but-cut label is tappable: it opens the whole name in the same
131
- popover family as the hidden-layers menu. The strip is pointer-
144
+ popover family as the all-layers menu. The strip is pointer-
132
145
  transparent, so only these buttons (and the "…" trigger) take the click. */
133
146
  .hk-modal-breadcrumb-item-reveal {
134
147
  /* No padding: the strip's ruler measures a plain item, so a padded
@@ -160,8 +173,14 @@
160
173
 
161
174
  .hk-modal-breadcrumb-item-reveal:hover,
162
175
  .hk-modal-breadcrumb-item-reveal[aria-expanded="true"] {
163
- background: var(--hk-breadcrumb-more-hover, rgba(0, 0, 0, 0.06));
164
- color: var(--hi-color-text, #1f2328);
176
+ /* Same token contract as the "…" trigger above (see there): sticky
177
+ * touch hover makes this face linger after the tap, so it must stay
178
+ * legible on dark surfaces too. */
179
+ background: var(
180
+ --hk-breadcrumb-more-hover,
181
+ var(--c-primary-subtle, rgb(var(--color-primary, 122 162 247) / 8%))
182
+ );
183
+ color: var(--hi-color-text-primary, rgb(var(--color-text, 30 30 30)));
165
184
  }
166
185
 
167
186
  .hk-modal-breadcrumb-item-reveal:focus-visible {
@@ -170,7 +189,9 @@
170
189
  }
171
190
 
172
191
  /* The revealed name itself: the strip's own type, wrapping instead of
173
- cutting, and selectable (the strip around it is not). */
192
+ cutting, and selectable (the strip around it is not). Same token
193
+ contract as the trigger faces above — --hi-color-text does not exist
194
+ in any theme, and its light-ink fallback was unreadable on dark. */
174
195
  .hk-modal-breadcrumb-reveal {
175
196
  margin: 0;
176
197
  padding: var(--space-8, 0.5rem) var(--space-12, 0.75rem);
@@ -178,7 +199,7 @@
178
199
  font-size: var(--text-xs, 0.75rem);
179
200
  font-weight: 600;
180
201
  line-height: 1.5;
181
- color: var(--hi-color-text, #1f2328);
202
+ color: var(--hi-color-text-primary, rgb(var(--color-text, 30 30 30)));
182
203
  overflow-wrap: anywhere;
183
204
  user-select: text;
184
205
  }
@@ -483,7 +483,7 @@ describe("HkModalBreadcrumb label budget", () => {
483
483
  expect(document.body.querySelector(".hk-modal-breadcrumb-reveal")).toBeNull();
484
484
  });
485
485
 
486
- it("opens the reveal instead of the hidden-layers menu", async () => {
486
+ it("opens the reveal instead of the all-layers menu", async () => {
487
487
  // The two surfaces are never open together: tapping a crumb closes the
488
488
  // menu it was opened from (its leave plays out, so the logical state is
489
489
  // what the assertion reads).
@@ -905,34 +905,52 @@ describe("HkModalBreadcrumb overflow fold", () => {
905
905
  });
906
906
  });
907
907
 
908
- describe("HkModalBreadcrumb hidden-layers menu", () => {
908
+ describe("HkModalBreadcrumb all-layers menu", () => {
909
909
  function registerStack(extraTitle: string): void {
910
910
  STACK_TITLES.forEach((title) => manager.register("modal", true, title));
911
911
  manager.register("modal", true, extraTitle);
912
912
  }
913
913
 
914
- it("lists the folded layers with their full names", async () => {
914
+ it("lists every layer reversed, full names, current row selected", async () => {
915
915
  setViewport(360);
916
916
  registerStack("细节");
917
917
  await mountStrip();
918
918
  const allTitles = [...STACK_TITLES, "细节"];
919
- // The tail that stays on the strip before the menu opens: the menu
920
- // snapshots exactly the layers this run folds away.
921
- const tailBefore = labels();
922
919
  expect(more()).not.toBeNull();
923
- expect(tailBefore.length).toBeGreaterThan(0);
924
- expect(tailBefore.length).toBeLessThan(allTitles.length);
925
920
 
926
921
  more()!.click();
927
922
  await nextTick();
928
923
  await nextTick();
929
924
  const rows = menuRows();
930
- // Full names, in stack order — the strip's clamp never reaches here.
925
+ // EVERY layer, topmost first (reverse stack order) — not just the
926
+ // folded prefix — with full names: the strip's clamp never reaches
927
+ // here.
931
928
  expect(rows.map((row) => row.textContent)).toEqual(
932
- allTitles.slice(0, allTitles.length - tailBefore.length),
929
+ allTitles.slice().reverse(),
933
930
  );
934
- expect(rows.map((row) => row.textContent)).not.toContain(LONG_TAIL);
935
931
  expect(rows.some((row) => row.textContent?.includes("…"))).toBe(false);
932
+ // The current layer is marked with the unified menu-item grammar's
933
+ // selected face (HkMenuActionItem `active` → [data-active]), and it
934
+ // is the ONLY selected row.
935
+ const selected = rows.filter((row) => row.hasAttribute("data-active"));
936
+ expect(selected).toHaveLength(1);
937
+ expect(selected[0].textContent).toBe("细节");
938
+ });
939
+
940
+ it("never lists the menu's own sheet among the layers", async () => {
941
+ setViewport(360);
942
+ registerStack("细节");
943
+ await mountStrip();
944
+ more()!.click();
945
+ await nextTick();
946
+ await nextTick();
947
+ // The sheet form registered as a blocking layer (the strip itself
948
+ // lists it as a crumb — see the docking test below), yet the menu's
949
+ // rows stay the WINDOWS only: selecting "the menu" is not a place
950
+ // to go.
951
+ const rows = menuRows();
952
+ expect(rows.map((row) => row.textContent)).not.toContain("All layers");
953
+ expect(rows).toHaveLength(STACK_TITLES.length + 1);
936
954
  });
937
955
 
938
956
  it("docks the menu as a bottom-up sheet on mobile", async () => {
@@ -950,12 +968,12 @@ describe("HkModalBreadcrumb hidden-layers menu", () => {
950
968
  // window layer — it is named, and it counts as a layer of the stack it
951
969
  // was opened from.
952
970
  const blocking = [...manager.registry.value.values()].filter((entry) => entry.blocking);
953
- expect(blocking.map((entry) => entry.title)).toContain("Hidden layers");
971
+ expect(blocking.map((entry) => entry.title)).toContain("All layers");
954
972
  // It is a window layer in every sense — including naming itself as the
955
973
  // current crumb of the very strip it was opened from.
956
974
  expect(
957
975
  strip()!.querySelector(".hk-modal-breadcrumb-item-current")!.textContent,
958
- ).toBe("Hidden layers");
976
+ ).toBe("All layers");
959
977
  });
960
978
 
961
979
  it("drops the trigger and the menu once the stack stops folding", async () => {
@@ -971,8 +989,9 @@ describe("HkModalBreadcrumb hidden-layers menu", () => {
971
989
  expect(more()!.getAttribute("aria-expanded")).toBe("true");
972
990
 
973
991
  // Wide enough for the whole stack, still the same form factor (so the
974
- // popover's own breakpoint rule is not what closes it): nothing is left
975
- // to list, and the trigger the menu is anchored to must go with it.
992
+ // popover's own breakpoint rule is not what closes it): the strip now
993
+ // shows every layer itself, and the trigger the menu is anchored to
994
+ // goes with it.
976
995
  setViewport(760);
977
996
  await until(
978
997
  () =>
@@ -993,7 +1012,7 @@ describe("HkModalBreadcrumb hidden-layers menu", () => {
993
1012
  const panel = document.body.querySelector<HTMLElement>(".hk-popover-panel")!;
994
1013
  expect(panel).toBeTruthy();
995
1014
  expect(panel.classList.contains("hk-is-sheet")).toBe(false);
996
- expect(panel.getAttribute("aria-label")).toBe("Hidden layers");
1015
+ expect(panel.getAttribute("aria-label")).toBe("All layers");
997
1016
  expect(document.body.querySelector(".hk-popover-scrim")).toBeNull();
998
1017
  // Anchored = a hidden level: nothing new joins the window stack.
999
1018
  expect([...manager.registry.value.values()].filter((entry) => entry.blocking)).toHaveLength(0);
@@ -1015,15 +1034,19 @@ describe("HkModalBreadcrumb hidden-layers menu", () => {
1015
1034
  await nextTick();
1016
1035
  const rows = menuRows();
1017
1036
  expect(rows.length).toBeGreaterThan(1);
1018
- expect(rows[0].textContent).toBe(LONG);
1037
+ // Reversed order: the topmost layer (the no-close-channel one) leads.
1038
+ expect(rows[0].textContent).toBe("没有关闭通道的层级占位文本");
1019
1039
 
1020
- // Pick the SECOND folded layer: everything above it closes, it and
1040
+ // Rows are reverse stack order: past the no-channel topmost (row 0),
1041
+ // rows[k] is STACK_TITLES[STACK_TITLES.length - k] — pick k = 3,
1042
+ // i.e. STACK_TITLES[5]; everything above it closes, it and
1021
1043
  // everything below it stay.
1022
- rows[1].click();
1044
+ rows[3].click();
1023
1045
  await nextTick();
1024
- expect(closes[0]).not.toHaveBeenCalled();
1025
- expect(closes[1]).not.toHaveBeenCalled();
1026
- for (let i = 2; i < closes.length; i++) {
1046
+ for (let i = 0; i <= 5; i++) {
1047
+ expect(closes[i]).not.toHaveBeenCalled();
1048
+ }
1049
+ for (let i = 6; i < closes.length; i++) {
1027
1050
  expect(closes[i]).toHaveBeenCalledTimes(1);
1028
1051
  }
1029
1052
  expect(manager.registry.value.has(noChannel.id)).toBe(true);
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Source contract for the strip's trigger/reveal interaction face.
3
+ *
4
+ * The behavioural suite runs in happy-dom, which resolves no theme
5
+ * custom properties — a rule that falls back to a WRONG literal paints
6
+ * fine in the suite and breaks only in a real theme. That is exactly
7
+ * what shipped: the hover/open faces colored their glyphs with a
8
+ * var() reference to --hi-color-text (fallback #1f2328) — a token NO
9
+ * theme defines (the family spells it --hi-color-text-primary), so the
10
+ * light-theme ink #1f2328 won
11
+ * — and on a phone the tap leaves :hover stuck, making the "…" trigger
12
+ * near-invisible on every dark theme right after the tap (2026-10-18
13
+ * user report). The pins live here, next to the reason each exists.
14
+ */
15
+ import { readFileSync } from "node:fs";
16
+ import { dirname, join } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ import { describe, expect, it } from "vitest";
20
+
21
+ const here = dirname(fileURLToPath(import.meta.url));
22
+ const scss = readFileSync(join(here, "HkModalBreadcrumb.scss"), "utf-8");
23
+
24
+ function rule(selector: string): string {
25
+ return scss.match(new RegExp(`${selector}\\s*{[^}]*}`, "m"))?.[0] ?? "";
26
+ }
27
+
28
+ describe("HkModalBreadcrumb trigger face tokens", () => {
29
+ it("never references the nonexistent --hi-color-text alias", () => {
30
+ // The alias family defines --hi-color-text-primary; --hi-color-text
31
+ // does not exist, so any var() use silently takes its light-theme
32
+ // fallback. (Matched as var() usage so prose in comments about the
33
+ // alias itself stays legal.)
34
+ expect(scss).not.toMatch(/var\(\s*--hi-color-text[,)]/);
35
+ });
36
+
37
+ it("paints the trigger's hover AND open face from live theme tokens", () => {
38
+ // One legible face for both states: the tap on a phone leaves :hover
39
+ // stuck on the trigger, so aria-expanded must share the rule.
40
+ const group = scss.match(
41
+ /\.hk-modal-breadcrumb-more:hover,\s*\n\.hk-modal-breadcrumb-more\[aria-expanded="true"\]\s*{[^}]*}/,
42
+ )?.[0];
43
+ expect(group).toBeTruthy();
44
+ expect(group).toContain("var(--hi-color-text-primary");
45
+ // The wash falls back to the menu-item grammar's own primary-subtle —
46
+ // a raw rgba(0,0,0,…) fallback vanished on dark surfaces.
47
+ expect(group).toContain("var(--c-primary-subtle");
48
+ expect(group).not.toContain("rgba(0, 0, 0");
49
+ // The host knob still wins when declared.
50
+ expect(group).toContain("--hk-breadcrumb-more-hover");
51
+ });
52
+
53
+ it("gives the cut-label reveal button the same token contract", () => {
54
+ const group = scss.match(
55
+ /\.hk-modal-breadcrumb-item-reveal:hover,\s*\n\.hk-modal-breadcrumb-item-reveal\[aria-expanded="true"\]\s*{[^}]*}/,
56
+ )?.[0];
57
+ expect(group).toBeTruthy();
58
+ expect(group).toContain("var(--hi-color-text-primary");
59
+ expect(group).toContain("var(--c-primary-subtle");
60
+ expect(group).not.toContain("rgba(0, 0, 0");
61
+ });
62
+
63
+ it("keeps the base faces on the tokens the strip already used", () => {
64
+ // Untouched by the fix, pinned so a refactor cannot quietly
65
+ // reintroduce a raw literal here either.
66
+ expect(rule(".hk-modal-breadcrumb-more")).toContain(
67
+ "color: var(--hi-color-text-secondary",
68
+ );
69
+ expect(rule(".hk-modal-breadcrumb-item")).toContain(
70
+ "color: var(--hi-color-text-secondary",
71
+ );
72
+ });
73
+
74
+ it("colors the revealed full name from a live token too", () => {
75
+ // The reveal body text rode the same broken alias — near-black ink
76
+ // on every dark theme.
77
+ expect(rule(".hk-modal-breadcrumb-reveal")).toContain(
78
+ "color: var(--hi-color-text-primary",
79
+ );
80
+ });
81
+ });