@celestia-island/hikari 0.55.187 → 0.55.189

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 (32) hide show
  1. package/package.json +1 -1
  2. package/src/components/HkMenuActionItem.tsx +6 -0
  3. package/src/components/HkModalBreadcrumb.overflow.contract.test.ts +1 -1
  4. package/src/components/HkModalBreadcrumb.scss +30 -9
  5. package/src/components/HkModalBreadcrumb.test.tsx +45 -22
  6. package/src/components/HkModalBreadcrumb.tokens.contract.test.ts +81 -0
  7. package/src/components/HkModalBreadcrumb.tsx +56 -16
  8. package/src/components/HkPopover.tsx +9 -1
  9. package/src/components/HkRowActions.test.tsx +26 -0
  10. package/src/components/HkRowActions.tsx +5 -1
  11. package/src/components/HkThemeToggle.scss +6 -1
  12. package/src/components/_menu-item.scss +7 -1
  13. package/src/demo.ts +3 -2
  14. package/src/i18n/locales/ar/components.json +1 -1
  15. package/src/i18n/locales/de/components.json +1 -1
  16. package/src/i18n/locales/en/components.json +1 -1
  17. package/src/i18n/locales/es/components.json +1 -1
  18. package/src/i18n/locales/fr/components.json +1 -1
  19. package/src/i18n/locales/ja/components.json +1 -1
  20. package/src/i18n/locales/ko/components.json +1 -1
  21. package/src/i18n/locales/pt/components.json +1 -1
  22. package/src/i18n/locales/ru/components.json +1 -1
  23. package/src/i18n/locales/zh-Hans/components.json +1 -1
  24. package/src/i18n/locales/zh-Hant/components.json +1 -1
  25. package/src/i18n/modalKeys.test.ts +2 -2
  26. package/src/index.ts +3 -0
  27. package/src/runtime/index.ts +5 -0
  28. package/src/runtime/mobileViewport.ts +5 -3
  29. package/src/runtime/tapHighlight.test.ts +134 -0
  30. package/src/runtime/tapHighlight.ts +169 -0
  31. package/src/styles/reset.scss +19 -0
  32. 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.187",
3
+ "version": "0.55.189",
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",
@@ -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)}
@@ -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
+ });
@@ -71,7 +71,7 @@ export default defineComponent({
71
71
  * both viewport edges (2026-09-16 report). The ellipsis counts inside the
72
72
  * budget: a 10-unit cap renders ten ideographs, or nine plus the
73
73
  * ellipsis. Full names stay in the DOM for assistive tech and in the
74
- * hidden-layers menu.
74
+ * all-layers menu.
75
75
  */
76
76
  maxLabelUnits: { type: Number, default: 10 },
77
77
  /**
@@ -287,16 +287,40 @@ export default defineComponent({
287
287
  const tail = computed(() => crumbs.value.slice(hiddenCount.value));
288
288
  const folded = computed(() => hidden.value.length > 0);
289
289
 
290
- // ── Hidden-layers menu ────────────────────────────────────────────
291
- // The trigger lists the layers it folded away, in stack order, with
292
- // their FULL names — the strip's clamp never reaches this menu.
293
- // HkPopover owns the form-factor rules (anchored under the trigger on
294
- // desktop, bottom-up sheet on mobile) and registers the surface with
295
- // the popup manager, so the menu is a first-class layer of the very
296
- // stack it lists.
290
+ // ── All-layers menu ──────────────────────────────────────────────
291
+ // The trigger lists EVERY layer of the stack, topmost first, with
292
+ // their FULL names — the strip's clamp never reaches this menu. The
293
+ // layer the user is currently on is marked with the menu row's own
294
+ // selected face (HkMenuActionItem `active` → the unified menu-item
295
+ // grammar's [data-active] box). HkPopover owns the form-factor rules
296
+ // (anchored under the trigger on desktop, bottom-up sheet on mobile)
297
+ // and registers the surface with the popup manager, so the menu is a
298
+ // first-class layer of the very stack it lists — and must not list
299
+ // ITSELF: the popover's `registered` handle tells this strip which
300
+ // layer to drop from the rows.
297
301
  const menuOpen = ref(false);
298
- const menuItems = ref<{ id: string; label: string }[]>([]);
299
- const menuLabel = computed(() => t("hikari::modal.hiddenLayers", "Hidden layers"));
302
+ const menuSheetId = ref<string | null>(null);
303
+ const menuLabel = computed(() => t("hikari::modal.allLayers", "All layers"));
304
+
305
+ /** Live rows: every stack layer but the menu's own surface, topmost
306
+ * first (reverse stack order — the current layer leads the list).
307
+ * Live, not a snapshot, so an anchored desktop menu keeps agreeing
308
+ * with a stack that changed underneath it. */
309
+ const menuItems = computed<{ id: string; label: string; current: boolean }[]>(() => {
310
+ const layers = stackEntries.value.filter((entry) => entry.id !== menuSheetId.value);
311
+ return layers
312
+ .slice()
313
+ .reverse()
314
+ .map((entry, i) => ({
315
+ id: entry.id,
316
+ label:
317
+ entry.title ||
318
+ (entry.kind === "dropdown"
319
+ ? t("hikari::modal.unnamedSheet", "Menu")
320
+ : t("hikari::modal.unnamedWindow", "Window")),
321
+ current: i === 0,
322
+ }));
323
+ });
300
324
 
301
325
  /** The trigger must stay mounted while its menu is open (the popover
302
326
  * anchors to it); a stack change that unfolds everything mid-menu
@@ -304,29 +328,37 @@ export default defineComponent({
304
328
  const triggerShown = computed(() => folded.value || menuOpen.value);
305
329
 
306
330
  function openMenu(): void {
307
- if (!hidden.value.length) return;
331
+ if (!crumbs.value.length) return;
308
332
  // The two surfaces are never open together — and the symmetric close
309
333
  // matters on a phone: the strip paints above the sheet's scrim, so
310
334
  // its trigger stays tappable while a reveal is docked (review round
311
335
  // two), and a menu opening underneath it would leave the pair
312
336
  // fighting over one Escape.
313
337
  revealed.value = null;
314
- menuItems.value = hidden.value.map(({ id, label }) => ({ id, label }));
315
338
  menuOpen.value = true;
316
339
  }
317
340
 
318
- /** Jump back to a folded layer: the menu closes and everything stacked
341
+ /** Jump to a listed layer: the menu closes and everything stacked
319
342
  * ABOVE the chosen level is asked to close, top-down. The close rides
320
343
  * the popup manager's per-entry close channel, so a layer whose owner
321
344
  * registered none simply stays — the strip never guesses at foreign
322
- * state. */
345
+ * state. The CURRENT layer sits on top with nothing above it, so
346
+ * picking it just dismisses the menu. */
323
347
  function selectLayer(id: string): void {
324
348
  menuOpen.value = false;
325
349
  manager.closeAbove(id);
326
350
  }
327
351
 
328
- // Unfolding everything while the menu is open leaves nothing to list:
329
- // drop the menu rather than keep a stale snapshot on screen.
352
+ // The menu's own sheet id is only meaningful while that surface lives;
353
+ // drop it when the surface goes so a FUTURE popover registration is
354
+ // never mistaken for "the menu".
355
+ function onMenuClosed(): void {
356
+ menuSheetId.value = null;
357
+ }
358
+
359
+ // Unfolding everything while the menu is open dissolves the trigger's
360
+ // reason to exist — the strip itself now shows every layer, so the
361
+ // anchored pair steps aside instead of duplicating it.
330
362
  watch(folded, (stillFolded) => {
331
363
  if (!stillFolded) menuOpen.value = false;
332
364
  });
@@ -702,12 +734,20 @@ export default defineComponent({
702
734
  sheetOnMobile
703
735
  title={menuLabel.value}
704
736
  class="hk-modal-breadcrumb-menu"
737
+ // The menu's own registry handle: while the sheet form blocks,
738
+ // it is a layer of the very stack this menu lists — the rows
739
+ // must skip it (selecting "the menu" is not a place to go).
740
+ onRegistered={(handle: { id: string }) => {
741
+ menuSheetId.value = handle.id;
742
+ }}
743
+ onClosed={onMenuClosed}
705
744
  >
706
745
  <HkMenuPanel label={menuLabel.value}>
707
746
  {menuItems.value.map((item) => (
708
747
  <HkMenuActionItem
709
748
  key={item.id}
710
749
  label={item.label}
750
+ active={item.current}
711
751
  onClick={() => selectLayer(item.id)}
712
752
  />
713
753
  ))}
@@ -85,6 +85,12 @@ export default defineComponent({
85
85
  },
86
86
  emits: {
87
87
  "update:modelValue": (_v: boolean) => true,
88
+ /** Fired when the surface joins the popup registry — the handle
89
+ * (id + zIndex) is the surface's identity as a stack layer. Hosts
90
+ * that reason about the WINDOW STACK (the modal breadcrumb listing
91
+ * every layer but its own sheet) use this edge to tell the
92
+ * popover's layer apart from the windows it floats above. */
93
+ registered: (_handle: PopupHandle) => true,
88
94
  /** Fired once the close has fully settled (the leave transition
89
95
  * completed and the panel left the DOM) — the moment focus that
90
96
  * lived inside the closing panel falls back to <body>. Hosts that
@@ -169,13 +175,15 @@ export default defineComponent({
169
175
  // they govern accidental DISMISSAL GESTURES, not closability, and
170
176
  // the sheet always renders its own ✕ (this popover has no
171
177
  // `closable` to mirror HkModal's).
172
- handle.value = manager.register(
178
+ const registered = manager.register(
173
179
  "dropdown",
174
180
  false,
175
181
  props.title || undefined,
176
182
  sheetMode.value,
177
183
  close,
178
184
  );
185
+ handle.value = registered;
186
+ emit("registered", registered);
179
187
  if (sheetMode.value) {
180
188
  // Bottom sheet: nothing to anchor-measure; the scrim handles
181
189
  // dismissal (the outside-click shield is desktop-only).
@@ -69,6 +69,32 @@ describe("HkRowActions", () => {
69
69
  expect(btn.getAttribute("aria-expanded")).toBe("false");
70
70
  });
71
71
 
72
+ it("menu mode: a leaf row click emits select with the item and closes", async () => {
73
+ const items: HkMenuItem[] = [
74
+ { key: "edit", label: "Edit" },
75
+ { key: "delete", label: "Delete", danger: true },
76
+ ];
77
+ const selected: HkMenuItem[] = [];
78
+ const c = mount(h(HkRowActions, { items, onSelect: (item: HkMenuItem) => selected.push(item) }));
79
+ const btn = c.querySelector("button.hk-icon-button") as HTMLButtonElement;
80
+ btn.click();
81
+ await Promise.resolve();
82
+ await Promise.resolve();
83
+ // The menu rows render through HkSelectPanel's teleported popout.
84
+ const row = Array.from(
85
+ document.body.querySelectorAll<HTMLButtonElement>("button.hk-menu-row"),
86
+ ).find((b) => b.textContent?.trim() === "Edit");
87
+ expect(row).toBeTruthy();
88
+ row!.click();
89
+ await Promise.resolve();
90
+ await Promise.resolve();
91
+ expect(selected).toHaveLength(1);
92
+ expect(selected[0]!.key).toBe("edit");
93
+ expect(selected[0]!.label).toBe("Edit");
94
+ // The leaf path closed the popup inside HkMenu; aria follows.
95
+ expect(btn.getAttribute("aria-expanded")).toBe("false");
96
+ });
97
+
72
98
  it("stops the click from reaching clickable ancestors", () => {
73
99
  const outer = vi.fn();
74
100
  const c = mount(
@@ -89,7 +89,11 @@ export default defineComponent({
89
89
  emit("open", openerEl() ?? (e.currentTarget as HTMLElement));
90
90
  }
91
91
 
92
- function onSelect(item: HkMenuItem) {
92
+ function onSelect(key: string, item: HkMenuItem) {
93
+ // HkMenu's select emit is (key, item) — the key FIRST, matching the
94
+ // HkMenu contract; the consumer-facing select carries the item.
95
+ // The popup is already closed by HkMenu's leaf path (closeAll runs
96
+ // before the emit), so `open` only needs to follow on paper.
93
97
  open.value = false;
94
98
  emit("select", item);
95
99
  }
@@ -96,7 +96,12 @@
96
96
  cursor: pointer;
97
97
  user-select: none;
98
98
  -webkit-user-select: none;
99
- -webkit-tap-highlight-color: transparent;
99
+ /* Same policy as the canonical reset; keeps standalone component styles
100
+ working and lets the document-level native escape hatch through. */
101
+ -webkit-tap-highlight-color: var(--hk-tap-highlight-color, transparent);
102
+ html[data-hk-tap-highlight='native'] & {
103
+ -webkit-tap-highlight-color: revert;
104
+ }
100
105
  appearance: none;
101
106
  transition: color 0.15s ease, background 0.15s ease, transform 0.12s ease;
102
107
  }
@@ -133,7 +133,13 @@
133
133
  appearance: none;
134
134
  user-select: none;
135
135
  -webkit-user-select: none;
136
- -webkit-tap-highlight-color: transparent;
136
+ /* Ride the canonical reset policy: this component sheet is also exported
137
+ standalone, so it keeps the token fallback (transparent) and honours the
138
+ document-level native escape hatch. */
139
+ -webkit-tap-highlight-color: var(--hk-tap-highlight-color, transparent);
140
+ html[data-hk-tap-highlight='native'] & {
141
+ -webkit-tap-highlight-color: revert;
142
+ }
137
143
  transition: background-color 0.12s ease, border-color 0.12s ease, color 0.12s ease;
138
144
 
139
145
  &:hover:not(:disabled):not([data-disabled]):not([data-active]):not([data-checked]) {
package/src/demo.ts CHANGED
@@ -3,8 +3,9 @@ import { createApp } from 'vue'
3
3
  import { initTheme } from './theme/useTheme'
4
4
  import * as hikari from './index'
5
5
 
6
- // Mobile UX contract (viewport zoom never refused; tap-highlight lives in
7
- // foundation.scss). Applied at bootstrap before mount, per the hook contract.
6
+ // Mobile UX contract (viewport zoom never refused; the tap-highlight reset
7
+ // lives in the canonical host reset, styles/reset.scss). Applied at
8
+ // bootstrap before mount, per the hook contract.
8
9
  hikari.applyViewportPolicy({ allowZoomOut: true })
9
10
 
10
11
  localStorage.setItem('hikari-theme', 'default')
@@ -240,7 +240,7 @@
240
240
  "hikari::tagInput.remove": "إزالة",
241
241
  "hikari::tagInput.addCustom": "استخدام \"{q}\"",
242
242
  "hikari::tagInput.reorder": "إعادة الترتيب — {label}",
243
- "hikari::modal.hiddenLayers": "الطبقات المخفية",
243
+ "hikari::modal.allLayers": "جميع الطبقات",
244
244
  "hikari::theme.modeTabHint": "هذه التبويبات تختار لوحة الألوان التي تحرّرها؛ لتبديل الوضع الفاتح/الداكن للتطبيق كله استخدم شريط الأوضاع أعلى نافذة السمة",
245
245
  "hikari::theme.defaultThemeName": "افتراضي",
246
246
  "hikari::emptyState.loading": "جارٍ التحميل",
@@ -240,7 +240,7 @@
240
240
  "hikari::tagInput.remove": "Entfernen",
241
241
  "hikari::tagInput.addCustom": "„{q}“ verwenden",
242
242
  "hikari::tagInput.reorder": "Neu anordnen — {label}",
243
- "hikari::modal.hiddenLayers": "Verdeckte Ebenen",
243
+ "hikari::modal.allLayers": "Alle Ebenen",
244
244
  "hikari::theme.modeTabHint": "Diese Reiter wählen die zu bearbeitende Palette; die appweite Hell/Dunkel-Umschaltung liegt im Modusstreifen des Themen-Popups",
245
245
  "hikari::theme.defaultThemeName": "Standard",
246
246
  "hikari::emptyState.loading": "Wird geladen",
@@ -240,7 +240,7 @@
240
240
  "hikari::tagInput.remove": "Remove",
241
241
  "hikari::tagInput.addCustom": "Use “{q}”",
242
242
  "hikari::tagInput.reorder": "Reorder {label}",
243
- "hikari::modal.hiddenLayers": "Hidden layers",
243
+ "hikari::modal.allLayers": "All layers",
244
244
  "hikari::theme.modeTabHint": "These tabs pick which palette you are EDITING; to switch the app-wide light/dark, use the mode strip at the top of the theme popup",
245
245
  "hikari::theme.defaultThemeName": "Default",
246
246
  "hikari::emptyState.loading": "Loading",
@@ -240,7 +240,7 @@
240
240
  "hikari::tagInput.remove": "Quitar",
241
241
  "hikari::tagInput.addCustom": "Usar «{q}»",
242
242
  "hikari::tagInput.reorder": "Reordenar — {label}",
243
- "hikari::modal.hiddenLayers": "Capas ocultas",
243
+ "hikari::modal.allLayers": "Todas las capas",
244
244
  "hikari::theme.modeTabHint": "Estas pestañas eligen la paleta que estás EDITANDO; para cambiar el modo claro/oscuro de toda la app, usa la franja de modos de la parte superior del panel de temas",
245
245
  "hikari::theme.defaultThemeName": "Predeterminado",
246
246
  "hikari::emptyState.loading": "Cargando",
@@ -240,7 +240,7 @@
240
240
  "hikari::tagInput.remove": "Retirer",
241
241
  "hikari::tagInput.addCustom": "Utiliser « {q} »",
242
242
  "hikari::tagInput.reorder": "Réorganiser — {label}",
243
- "hikari::modal.hiddenLayers": "Couches masquées",
243
+ "hikari::modal.allLayers": "Toutes les couches",
244
244
  "hikari::theme.modeTabHint": "Ces onglets choisissent la palette éditée ; pour basculer clair/sombre dans toute l’app, utilisez la barre de mode du menu de thème",
245
245
  "hikari::theme.defaultThemeName": "Par défaut",
246
246
  "hikari::emptyState.loading": "Chargement",
@@ -240,7 +240,7 @@
240
240
  "hikari::tagInput.remove": "削除",
241
241
  "hikari::tagInput.addCustom": "「{q}」を使用",
242
242
  "hikari::tagInput.reorder": "並べ替え — {label}",
243
- "hikari::modal.hiddenLayers": "非表示の階層",
243
+ "hikari::modal.allLayers": "すべての階層",
244
244
  "hikari::theme.modeTabHint": "このタブは編集対象のパレットを選ぶだけです。アプリ全体の明/暗はテーマポップアップ上部のモードストリップで切り替えてください",
245
245
  "hikari::theme.defaultThemeName": "デフォルト",
246
246
  "hikari::emptyState.loading": "読み込み中",
@@ -240,7 +240,7 @@
240
240
  "hikari::tagInput.remove": "제거",
241
241
  "hikari::tagInput.addCustom": "\"{q}\" 사용",
242
242
  "hikari::tagInput.reorder": "순서 변경 — {label}",
243
- "hikari::modal.hiddenLayers": "숨겨진 레이어",
243
+ "hikari::modal.allLayers": "모든 레이어",
244
244
  "hikari::theme.modeTabHint": "이 탭은 편집할 팔레트를 고를 뿐입니다. 앱 전체 밝기/어둠은 테마 팝업 상단의 모드 스트립에서 바꾸세요",
245
245
  "hikari::theme.defaultThemeName": "기본값",
246
246
  "hikari::emptyState.loading": "불러오는 중",
@@ -240,7 +240,7 @@
240
240
  "hikari::tagInput.remove": "Remover",
241
241
  "hikari::tagInput.addCustom": "Usar \"{q}\"",
242
242
  "hikari::tagInput.reorder": "Reordenar — {label}",
243
- "hikari::modal.hiddenLayers": "Camadas ocultas",
243
+ "hikari::modal.allLayers": "Todas as camadas",
244
244
  "hikari::theme.modeTabHint": "Estas abas escolhem a paleta que você está EDITANDO; para alternar o claro/escuro do app inteiro, use a faixa de modos no topo do painel de temas",
245
245
  "hikari::theme.defaultThemeName": "Padrão",
246
246
  "hikari::emptyState.loading": "Carregando",
@@ -240,7 +240,7 @@
240
240
  "hikari::tagInput.remove": "Удалить",
241
241
  "hikari::tagInput.addCustom": "Использовать «{q}»",
242
242
  "hikari::tagInput.reorder": "Изменить порядок — {label}",
243
- "hikari::modal.hiddenLayers": "Скрытые слои",
243
+ "hikari::modal.allLayers": "Все слои",
244
244
  "hikari::theme.modeTabHint": "Вкладки выбирают редактируемую палитру; общесистемное переключение светлой/тёмной — в полосе режимов вверху меню темы",
245
245
  "hikari::theme.defaultThemeName": "По умолчанию",
246
246
  "hikari::emptyState.loading": "Загрузка",
@@ -244,7 +244,7 @@
244
244
  "hikari::tagInput.remove": "移除",
245
245
  "hikari::tagInput.addCustom": "使用「{q}」",
246
246
  "hikari::tagInput.reorder": "拖动排序 — {label}",
247
- "hikari::modal.hiddenLayers": "隐藏层级",
247
+ "hikari::modal.allLayers": "所有层级",
248
248
  "hikari::theme.modeTabHint": "切换这里编辑的是对应模式的调色板;要切换应用整体深/浅,请用主题弹窗顶部的模式条",
249
249
  "hikari::theme.defaultThemeName": "默认",
250
250
  "hikari::emptyState.loading": "加载中",
@@ -244,7 +244,7 @@
244
244
  "hikari::tagInput.remove": "移除",
245
245
  "hikari::tagInput.addCustom": "使用「{q}」",
246
246
  "hikari::tagInput.reorder": "拖曳排序 — {label}",
247
- "hikari::modal.hiddenLayers": "隱藏層級",
247
+ "hikari::modal.allLayers": "所有層級",
248
248
  "hikari::theme.modeTabHint": "切換這裡編輯的是對應模式的調色盤;要切換應用整體深/淺,請用主題彈窗頂部的模式條",
249
249
  "hikari::theme.defaultThemeName": "預設",
250
250
  "hikari::emptyState.loading": "載入中",
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
2
2
 
3
3
  /**
4
4
  * The `hikari::modal.*` family is the window chrome's own vocabulary
5
- * (stack label, unnamed-window/sheet fallbacks, the hidden-layers menu),
5
+ * (stack label, unnamed-window/sheet fallbacks, the all-layers menu),
6
6
  * referenced from source by literal key with an English fallback.
7
7
  *
8
8
  * flatKeyParity.test.ts proves the locales AGREE with en — but it takes
@@ -23,7 +23,7 @@ const REQUIRED_KEYS = [
23
23
  "hikari::modal.stackLabel",
24
24
  "hikari::modal.unnamedSheet",
25
25
  "hikari::modal.unnamedWindow",
26
- "hikari::modal.hiddenLayers",
26
+ "hikari::modal.allLayers",
27
27
  ];
28
28
 
29
29
  describe("hikari::modal locale keys", () => {
package/src/index.ts CHANGED
@@ -391,6 +391,9 @@ export {
391
391
  type HkTooltipBridgeOptions,
392
392
  installHkImageFallback,
393
393
  type HkImageFallbackOptions,
394
+ installHkTapHighlight,
395
+ type HkTapHighlightOptions,
396
+ type HkTapHighlightColor,
394
397
  reportHkRuntime,
395
398
  getHkRuntimeEntry,
396
399
  listHkRuntime,
@@ -12,6 +12,11 @@ export {
12
12
  installHkImageFallback,
13
13
  type HkImageFallbackOptions,
14
14
  } from "./imageFallback";
15
+ export {
16
+ installHkTapHighlight,
17
+ type HkTapHighlightOptions,
18
+ type HkTapHighlightColor,
19
+ } from "./tapHighlight";
15
20
  export {
16
21
  tooltipPositionStyle,
17
22
  applyTooltipPosition,
@@ -84,9 +84,11 @@ function clampOutScale(scale: number): string {
84
84
  * Normalize the document's viewport meta so mobile browsers never refuse
85
85
  * user zoom. Idempotent; safe to call during app bootstrap before mount.
86
86
  *
87
- * The tap-highlight half of the mobile UX contract lives in the stylesheet
88
- * foundation (`foundation.scss` clears `-webkit-tap-highlight-color`
89
- * globally) and needs no JS.
87
+ * The tap-highlight half of the mobile UX contract lives in the canonical
88
+ * host reset (`styles/reset.scss`, composed first by `styles/index.scss`):
89
+ * it clears `-webkit-tap-highlight-color` at the root, and
90
+ * `installHkTapHighlight()` is the runtime policy layer for hosts that
91
+ * need a themed tint or the iOS :active enabler.
90
92
  */
91
93
  export function applyViewportPolicy(
92
94
  options: ApplyViewportPolicyOptions = {},
@@ -0,0 +1,134 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import { getHkRuntimeEntry, readHkRuntime } from "./registry";
4
+ import { installHkTapHighlight } from "./tapHighlight";
5
+
6
+ /**
7
+ * installHkTapHighlight contract tests:
8
+ * - default policy clears the UA overlay at the root
9
+ * - a CSS colour keeps the mechanism and tints it
10
+ * - "native" restores the browser default through the data attribute
11
+ * - invalid colours fall back to transparent instead of the UA default
12
+ * - the iOS :active enabler is a passive document touchstart, removable
13
+ * - install is idempotent per document; uninstall restores prior inline
14
+ * state and disposes the registry entry
15
+ */
16
+
17
+ const TOKEN = "--hk-tap-highlight-color";
18
+ const NATIVE_ATTR = "data-hk-tap-highlight";
19
+
20
+ const uninstalls: Array<() => void> = [];
21
+
22
+ function install(options?: Parameters<typeof installHkTapHighlight>[0]) {
23
+ const uninstall = installHkTapHighlight(options);
24
+ uninstalls.push(uninstall);
25
+ return uninstall;
26
+ }
27
+
28
+ function root(): HTMLElement {
29
+ return document.documentElement;
30
+ }
31
+
32
+ afterEach(() => {
33
+ for (const uninstall of uninstalls.splice(0)) uninstall();
34
+ vi.unstubAllGlobals();
35
+ vi.restoreAllMocks();
36
+ root().removeAttribute("style");
37
+ root().removeAttribute(NATIVE_ATTR);
38
+ });
39
+
40
+ describe("installHkTapHighlight", () => {
41
+ it("clears the UA overlay at the root by default", () => {
42
+ install();
43
+ expect(root().style.getPropertyValue(TOKEN)).toBe("transparent");
44
+ expect(root().hasAttribute(NATIVE_ATTR)).toBe(false);
45
+ });
46
+
47
+ it("keeps a themed tint when a colour is given", () => {
48
+ install({ color: "rgb(47 111 237 / 12%)" });
49
+ expect(root().style.getPropertyValue(TOKEN)).toBe(
50
+ "rgb(47 111 237 / 12%)",
51
+ );
52
+ });
53
+
54
+ it("restores the UA default through the native escape hatch", () => {
55
+ install({ color: "native" });
56
+ expect(root().getAttribute(NATIVE_ATTR)).toBe("native");
57
+ expect(root().style.getPropertyValue(TOKEN)).toBe("");
58
+ });
59
+
60
+ it("falls back to transparent for an invalid colour", () => {
61
+ vi.stubGlobal("CSS", { supports: () => false });
62
+ install({ color: "not-a-colour" });
63
+ expect(root().style.getPropertyValue(TOKEN)).toBe("transparent");
64
+ });
65
+
66
+ it("adds a passive touchstart enabler and removes it on uninstall", () => {
67
+ const add = vi.spyOn(document, "addEventListener");
68
+ const remove = vi.spyOn(document, "removeEventListener");
69
+ const uninstall = install();
70
+ expect(add).toHaveBeenCalledWith("touchstart", expect.any(Function), {
71
+ passive: true,
72
+ });
73
+ uninstall();
74
+ expect(remove).toHaveBeenCalledWith("touchstart", expect.any(Function));
75
+ });
76
+
77
+ it("can opt out of the active-state enabler", () => {
78
+ const add = vi.spyOn(document, "addEventListener");
79
+ install({ enableActiveState: false });
80
+ expect(add).not.toHaveBeenCalledWith(
81
+ "touchstart",
82
+ expect.any(Function),
83
+ expect.anything(),
84
+ );
85
+ });
86
+
87
+ it("restores a pre-existing inline token and attribute on uninstall", () => {
88
+ root().style.setProperty(TOKEN, "red");
89
+ root().setAttribute(NATIVE_ATTR, "native");
90
+ const uninstall = install({ color: "blue" });
91
+ expect(root().style.getPropertyValue(TOKEN)).toBe("blue");
92
+ expect(root().hasAttribute(NATIVE_ATTR)).toBe(false);
93
+ uninstall();
94
+ expect(root().style.getPropertyValue(TOKEN)).toBe("red");
95
+ expect(root().getAttribute(NATIVE_ATTR)).toBe("native");
96
+ });
97
+
98
+ it("is idempotent per document (second install keeps the first policy)", () => {
99
+ install({ color: "red" });
100
+ install({ color: "blue" });
101
+ expect(root().style.getPropertyValue(TOKEN)).toBe("red");
102
+ });
103
+
104
+ it("treats an uninstaller from a replaced install as a no-op", () => {
105
+ const first = install({ color: "red" });
106
+ first();
107
+ install({ color: "blue" });
108
+ first();
109
+ expect(root().style.getPropertyValue(TOKEN)).toBe("blue");
110
+ });
111
+
112
+ it("reports the effective colour and disposes the registry entry", () => {
113
+ vi.stubGlobal("CSS", { supports: () => false });
114
+ const uninstall = install({
115
+ color: "not-a-colour",
116
+ enableActiveState: false,
117
+ });
118
+ expect(readHkRuntime("tapHighlight")).toMatchObject({
119
+ color: "none",
120
+ requestedColor: "not-a-colour",
121
+ });
122
+ expect(getHkRuntimeEntry("tapHighlight")?.status).toBe("active");
123
+ uninstall();
124
+ expect(getHkRuntimeEntry("tapHighlight")?.status).toBe("disposed");
125
+ expect(readHkRuntime("tapHighlight")).toBeUndefined();
126
+ });
127
+
128
+ it("no-ops without a DOM", () => {
129
+ vi.stubGlobal("document", undefined);
130
+ const uninstall = installHkTapHighlight();
131
+ expect(typeof uninstall).toBe("function");
132
+ expect(() => uninstall()).not.toThrow();
133
+ });
134
+ });
@@ -0,0 +1,169 @@
1
+ // installHkTapHighlight — document-level policy for the UA tap highlight.
2
+ //
3
+ // Blink/WebKit paint a platform-owned translucent overlay over the tapped
4
+ // node (Android Chrome measures rgba(51, 181, 229, 0.4) in mobile
5
+ // emulation). The canonical host reset (styles/reset.scss) already clears
6
+ // it at the root and exposes two declarative knobs:
7
+ //
8
+ // --hk-tap-highlight-color: transparent → no overlay (the default)
9
+ // --hk-tap-highlight-color: <CSS color> → keep the feedback, tint it
10
+ // html[data-hk-tap-highlight="native"] → restore the UA default
11
+ //
12
+ // This hook is the runtime policy layer on top of that contract. It also
13
+ // enables `:active` on iOS Safari: WebKit only applies the pseudo-class
14
+ // when a touch listener exists (WebKit bug 138816), so clearing the UA
15
+ // overlay must not leave taps without a pressed state.
16
+ //
17
+ // Lifecycle mirrors installHkImageFallback: idempotent per document,
18
+ // returns the uninstaller, restores the previous inline token/attribute,
19
+ // and reports itself to the runtime registry.
20
+
21
+ import { reportHkRuntime } from "./registry";
22
+ import type { HkRuntimeHandle } from "./registry";
23
+
24
+ export type HkTapHighlightColor = "none" | "native" | (string & {});
25
+
26
+ export interface HkTapHighlightOptions {
27
+ /**
28
+ * "none" clears the UA overlay (the app convention); "native" restores
29
+ * the browser default; any other valid CSS color keeps the feedback but
30
+ * tints it — e.g. `rgb(var(--color-primary) / 12%)`.
31
+ */
32
+ color?: HkTapHighlightColor;
33
+ /**
34
+ * Enable `:active` on iOS Safari with a passive document touchstart
35
+ * listener (default true). Removing the UA highlight makes the pressed
36
+ * state the only feedback, so this is on by default; hosts with their
37
+ * own touch handling may opt out.
38
+ */
39
+ enableActiveState?: boolean;
40
+ }
41
+
42
+ const TOKEN = "--hk-tap-highlight-color";
43
+ const NATIVE_ATTR = "data-hk-tap-highlight";
44
+ const TRANSPARENT = "transparent";
45
+
46
+ interface TapHighlightState {
47
+ doc: Document;
48
+ root: HTMLElement;
49
+ previousToken: string;
50
+ previousNative: string | null;
51
+ enableActiveState: boolean;
52
+ onTouchStart: EventListener;
53
+ installedColor: HkTapHighlightColor;
54
+ requestedColor: HkTapHighlightColor;
55
+ runtime?: HkRuntimeHandle;
56
+ }
57
+
58
+ const INSTALLS = new WeakMap<Document, TapHighlightState>();
59
+
60
+ function noop(): void {}
61
+
62
+ function normalizeColor(color: HkTapHighlightColor | undefined): {
63
+ token: string | null;
64
+ native: boolean;
65
+ effective: HkTapHighlightColor;
66
+ } {
67
+ if (color === undefined || color === "none") {
68
+ return { token: TRANSPARENT, native: false, effective: "none" };
69
+ }
70
+ if (color === "native") {
71
+ return { token: null, native: true, effective: "native" };
72
+ }
73
+ // Validate against the property so a bad host value cannot become an
74
+ // "invalid at computed-value time" flash of the UA default colour.
75
+ const supported =
76
+ typeof CSS === "undefined" ||
77
+ typeof CSS.supports !== "function" ||
78
+ CSS.supports("-webkit-tap-highlight-color", color);
79
+ return supported
80
+ ? { token: color, native: false, effective: color }
81
+ : { token: TRANSPARENT, native: false, effective: "none" };
82
+ }
83
+
84
+ function uninstall(state: TapHighlightState): void {
85
+ // A teardown from a previous install must not clear the policy that
86
+ // replaced it (uninstall → reinstall → call the old uninstaller).
87
+ if (INSTALLS.get(state.doc) !== state) return;
88
+ INSTALLS.delete(state.doc);
89
+ if (state.enableActiveState) {
90
+ state.doc.removeEventListener("touchstart", state.onTouchStart);
91
+ }
92
+ if (state.previousToken) {
93
+ state.root.style.setProperty(TOKEN, state.previousToken);
94
+ } else {
95
+ state.root.style.removeProperty(TOKEN);
96
+ }
97
+ if (state.previousNative === null) {
98
+ state.root.removeAttribute(NATIVE_ATTR);
99
+ } else {
100
+ state.root.setAttribute(NATIVE_ATTR, state.previousNative);
101
+ }
102
+ state.runtime?.dispose();
103
+ }
104
+
105
+ /**
106
+ * Install the document-level tap-highlight policy. Returns the
107
+ * uninstaller; idempotent per document (a second call returns the
108
+ * existing teardown). No-op outside a DOM.
109
+ */
110
+ export function installHkTapHighlight(
111
+ options: HkTapHighlightOptions = {},
112
+ ): () => void {
113
+ if (typeof document === "undefined") return () => undefined;
114
+ const doc = document;
115
+ const existing = INSTALLS.get(doc);
116
+ if (existing) return () => uninstall(existing);
117
+
118
+ const root = doc.documentElement;
119
+ const { token, native, effective } = normalizeColor(options.color);
120
+ const enableActiveState = options.enableActiveState ?? true;
121
+ const requestedColor = options.color ?? "none";
122
+ const state: TapHighlightState = {
123
+ doc,
124
+ root,
125
+ previousToken: root.style.getPropertyValue(TOKEN),
126
+ previousNative: root.getAttribute(NATIVE_ATTR),
127
+ enableActiveState,
128
+ onTouchStart: noop,
129
+ installedColor: effective,
130
+ requestedColor,
131
+ };
132
+
133
+ if (token === null) {
134
+ root.style.removeProperty(TOKEN);
135
+ } else {
136
+ root.style.setProperty(TOKEN, token);
137
+ }
138
+ if (native) {
139
+ root.setAttribute(NATIVE_ATTR, "native");
140
+ } else {
141
+ root.removeAttribute(NATIVE_ATTR);
142
+ }
143
+
144
+ if (enableActiveState) {
145
+ doc.addEventListener("touchstart", state.onTouchStart, { passive: true });
146
+ }
147
+
148
+ state.runtime = reportHkRuntime("tapHighlight", {
149
+ kind: "hook",
150
+ description:
151
+ "Document-level mobile tap-highlight policy: clears or tints the UA overlay and enables :active on iOS.",
152
+ meta: {
153
+ color: state.installedColor,
154
+ requestedColor: state.requestedColor,
155
+ activeState: enableActiveState,
156
+ native,
157
+ },
158
+ read: () => ({
159
+ installed: true,
160
+ color: state.installedColor,
161
+ requestedColor: state.requestedColor,
162
+ activeState: enableActiveState,
163
+ native,
164
+ }),
165
+ });
166
+
167
+ INSTALLS.set(doc, state);
168
+ return () => uninstall(state);
169
+ }
@@ -25,6 +25,25 @@
25
25
 
26
26
  html {
27
27
  line-height: 1.5;
28
+
29
+ /* Mobile tap highlight (UA-owned): Blink/WebKit paint a translucent
30
+ * overlay over the tapped node — Android Chrome measures
31
+ * rgba(51, 181, 229, 0.4) in mobile emulation. The property is
32
+ * inherited, so this root declaration covers every element (including
33
+ * shadow trees). Hosts opt into a themed tint with
34
+ * --hk-tap-highlight-color, or restore the UA default with
35
+ * data-hk-tap-highlight="native".
36
+ *
37
+ * This is the canonical home of the #325 tap-highlight contract. It
38
+ * first lived in the Gen-1 foundation sheet; #480 retired that sheet
39
+ * and #523/#861 replaced it with this reset, which then missed the
40
+ * rule — every host composing the canonical entry silently regained
41
+ * the blue flash. Keep it here, not in a host stylesheet. */
42
+ -webkit-tap-highlight-color: var(--hk-tap-highlight-color, transparent);
43
+ }
44
+
45
+ html[data-hk-tap-highlight='native'] {
46
+ -webkit-tap-highlight-color: revert;
28
47
  }
29
48
 
30
49
  body {
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
2
2
  import { dirname, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
 
5
+ import * as sass from "sass";
5
6
  import { describe, expect, it } from "vitest";
6
7
 
7
8
  /**
@@ -31,6 +32,25 @@ describe("host reset sheet", () => {
31
32
  expect(first).toBe("./reset.scss");
32
33
  });
33
34
 
35
+ it("clears the UA tap highlight at the root (mobile parity)", () => {
36
+ // Regression guard for the #325 contract: #480 retired the Gen-1
37
+ // foundation sheet, and #523/#861 composed this reset without the
38
+ // tap rule, so chest/hydro silently re-gained the Android blue
39
+ // overlay. Removing either half of this contract must fail here.
40
+ //
41
+ // Assert on the COMPILED sheet (compressed drops both comment
42
+ // styles): raw-text matching would pass on a commented-out rule.
43
+ const { css } = sass.compile(resolve(stylesDir, "reset.scss"), {
44
+ style: "compressed",
45
+ });
46
+ expect(css).toMatch(
47
+ /html\s*\{[^}]*?-webkit-tap-highlight-color:\s*var\(--hk-tap-highlight-color,\s*transparent\)/,
48
+ );
49
+ expect(css).toMatch(
50
+ /html\[data-hk-tap-highlight=(?:"|')?native(?:"|')?\]\s*\{[^}]*?-webkit-tap-highlight-color:\s*revert/,
51
+ );
52
+ });
53
+
34
54
  it("normalizes the native control typography chain", () => {
35
55
  const reset = read("reset.scss");
36
56
  for (const selector of ["button,", "input,", "textarea"]) {