@celestia-island/hikari 0.55.239 → 0.55.241

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 (93) hide show
  1. package/package.json +1 -1
  2. package/src/components/HkAboutModal.scss +2 -1
  3. package/src/components/HkAlert.scss +3 -2
  4. package/src/components/HkAvatar.scss +3 -1
  5. package/src/components/HkBadge.scss +2 -2
  6. package/src/components/HkBlockingToast.scss +6 -6
  7. package/src/components/HkBoard.scss +3 -1
  8. package/src/components/HkBoard3D.scss +3 -1
  9. package/src/components/HkBreadcrumb.scss +2 -1
  10. package/src/components/HkButton.scss +3 -2
  11. package/src/components/HkCard.scss +2 -1
  12. package/src/components/HkCheckbox.scss +3 -1
  13. package/src/components/HkColorPicker.scss +5 -3
  14. package/src/components/HkConfirmDialog.scss +3 -2
  15. package/src/components/HkCopyBadge.test.tsx +1 -1
  16. package/src/components/HkDatePicker.scss +3 -2
  17. package/src/components/HkDateTimePicker.scss +2 -1
  18. package/src/components/HkDiffViewer.scss +6 -6
  19. package/src/components/HkDockBar.scss +4 -2
  20. package/src/components/HkDockBar.test.tsx +1 -1
  21. package/src/components/HkDraggableGrid.scss +4 -3
  22. package/src/components/HkDraggableList.scss +4 -3
  23. package/src/components/HkDrawer.scss +5 -4
  24. package/src/components/HkEmptyState.scss +3 -1
  25. package/src/components/HkEmptyState.surface.contract.test.ts +5 -2
  26. package/src/components/HkErrorLanding.scss +3 -1
  27. package/src/components/HkFab.scss +5 -2
  28. package/src/components/HkFaceShell.rail.test.tsx +285 -4
  29. package/src/components/HkFaceShell.tsx +14 -0
  30. package/src/components/HkImageLightbox.scss +3 -1
  31. package/src/components/HkImageViewer.scss +3 -1
  32. package/src/components/HkJsonTree.scss +3 -3
  33. package/src/components/HkLoadingVeil.scss +4 -2
  34. package/src/components/HkLogWindow.scss +2 -2
  35. package/src/components/HkLogo.scss +3 -1
  36. package/src/components/HkMarkdownRenderer.scss +3 -3
  37. package/src/components/HkMediaControlBar.scss +4 -2
  38. package/src/components/HkMediaSlider.scss +3 -1
  39. package/src/components/HkMenu.rail.test.tsx +29 -0
  40. package/src/components/HkMenu.tsx +62 -17
  41. package/src/components/HkMenuPanel.scss +4 -0
  42. package/src/components/HkMenuSection.test.tsx +191 -0
  43. package/src/components/HkMenuSection.tsx +95 -0
  44. package/src/components/HkMessageBox.scss +3 -2
  45. package/src/components/HkMinimap.scss +5 -3
  46. package/src/components/HkModal.scss +10 -11
  47. package/src/components/HkModal.sheetmorph.test.ts +8 -6
  48. package/src/components/HkModalBreadcrumb.blur.test.ts +3 -3
  49. package/src/components/HkModalBreadcrumb.scss +6 -4
  50. package/src/components/HkNavItem.scss +2 -1
  51. package/src/components/HkNodeCanvas.scss +3 -1
  52. package/src/components/HkOtpInput.scss +2 -1
  53. package/src/components/HkPasswordSurface.scss +4 -3
  54. package/src/components/HkPillToggleGroup.scss +13 -18
  55. package/src/components/HkPopover.scss +5 -6
  56. package/src/components/HkProgressRing.scss +3 -1
  57. package/src/components/HkRadio.scss +3 -1
  58. package/src/components/HkScrollContainer.fade.contract.test.ts +7 -2
  59. package/src/components/HkScrollContainer.scss +23 -46
  60. package/src/components/HkScrollPin.scss +3 -1
  61. package/src/components/HkSelect.scss +4 -5
  62. package/src/components/HkSelectionGrid.scss +3 -1
  63. package/src/components/HkSelectionWaterfall.scss +3 -1
  64. package/src/components/HkSidebar.rail.test.tsx +105 -3
  65. package/src/components/HkSidebar.scss +4 -1
  66. package/src/components/HkSidebar.tsx +15 -0
  67. package/src/components/HkSidebarRailSwap.tsx +9 -3
  68. package/src/components/HkSlider.scss +3 -1
  69. package/src/components/HkSpinner.scss +5 -2
  70. package/src/components/HkSwitch.scss +3 -1
  71. package/src/components/HkTable.interactions.test.tsx +4 -2
  72. package/src/components/HkTable.scss +8 -8
  73. package/src/components/HkTag.scss +3 -1
  74. package/src/components/HkTagInput.scss +5 -6
  75. package/src/components/HkTagInput.test.tsx +5 -4
  76. package/src/components/HkTemplateField.scss +22 -7
  77. package/src/components/HkTemplateField.styles.test.ts +50 -2
  78. package/src/components/HkTextarea.scss +3 -1
  79. package/src/components/HkThemeToggle.scss +2 -2
  80. package/src/components/HkTitleBar.scss +4 -3
  81. package/src/components/HkToast.scss +5 -4
  82. package/src/components/HkTreemapStage.scss +3 -1
  83. package/src/components/HkZoomToolbar.scss +3 -2
  84. package/src/components/_menu-item.contract.test.ts +450 -0
  85. package/src/components/_menu-item.scss +98 -4
  86. package/src/components/window-close.scss +4 -2
  87. package/src/composables/useMenuGroupState.ts +90 -0
  88. package/src/index.ts +1 -0
  89. package/src/styles/admin-tokens.scss +21 -3
  90. package/src/styles/polyfill.contract.test.ts +294 -0
  91. package/src/styles/polyfill.scss +110 -0
  92. package/src/styles/selection.scss +4 -4
  93. package/src/theme/_field.scss +3 -1
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
- import { createApp, defineComponent, h, ref } from "vue";
2
+ import { createApp, defineComponent, h, ref, type VNode } from "vue";
3
3
 
4
4
  // Same stub shape as HkFaceShell.test.tsx: HDrawer teleports and
5
5
  // animates; the desktop rail contract is testable without the overlay
@@ -33,6 +33,7 @@ vi.mock("@celestia-island/hikari", async (importOriginal) => {
33
33
 
34
34
  import { HkFaceShell } from "./HkFaceShell";
35
35
  import { HkAdminShell } from "./HkAdminShell";
36
+ import HkMenu, { type HkMenuItem } from "./HkMenu";
36
37
 
37
38
  /**
38
39
  * HkFaceShell desktop rail + peek integration (2026-10-30). What this
@@ -75,11 +76,19 @@ interface RailMount {
75
76
  main(): HTMLElement;
76
77
  toggle(): HTMLButtonElement;
77
78
  slotParams: SlotParams[];
79
+ headerParams: Array<Record<string, unknown>>;
80
+ /** Open the mobile nav drawer through the header's onOpenDrawer (the
81
+ * sanctioned host channel — the hamburger lives in the header slot). */
82
+ openDrawer(): void;
78
83
  emitted: string[];
79
84
  }
80
85
 
81
- function mountShell(props: Record<string, unknown> = {}): RailMount {
86
+ function mountShell(
87
+ props: Record<string, unknown> = {},
88
+ sidebarSlot?: (params: SlotParams) => VNode,
89
+ ): RailMount {
82
90
  const slotParams: SlotParams[] = [];
91
+ const headerParams: Array<Record<string, unknown>> = [];
83
92
  const emitted: string[] = [];
84
93
  const container = document.createElement("div");
85
94
  document.body.appendChild(container);
@@ -100,10 +109,13 @@ function mountShell(props: Record<string, unknown> = {}): RailMount {
100
109
  railStyle.value = v as "expanded" | "rail";
101
110
  },
102
111
  }, {
103
- header: () => null,
112
+ header: (params: Record<string, unknown>) => {
113
+ headerParams.push(params);
114
+ return null;
115
+ },
104
116
  sidebar: (params: SlotParams) => {
105
117
  slotParams.push(params);
106
- return h("div", { class: "sidebar-mock" }, "NAV");
118
+ return sidebarSlot?.(params) ?? h("div", { class: "sidebar-mock" }, "NAV");
107
119
  },
108
120
  content: () => h("div", { class: "content-mock" }, "CONTENT"),
109
121
  });
@@ -121,6 +133,8 @@ function mountShell(props: Record<string, unknown> = {}): RailMount {
121
133
  toggle: () =>
122
134
  container.querySelector(".hk-sidebar-rail-toggle button") as HTMLButtonElement,
123
135
  slotParams,
136
+ headerParams,
137
+ openDrawer: () => (headerParams[headerParams.length - 1]!.onOpenDrawer as () => void)(),
124
138
  emitted,
125
139
  };
126
140
  }
@@ -130,6 +144,11 @@ afterEach(() => {
130
144
  app.unmount();
131
145
  container.remove();
132
146
  }
147
+ // HkMenu's rail rows ride HkTooltip (teleported to the overlay root).
148
+ document.querySelectorAll("#hk-overlay-root, .hk-tooltip-popup").forEach((n) => n.remove());
149
+ // The drawer-path tests below resize the window to the mobile side;
150
+ // every other case here assumes the desktop width.
151
+ window.innerWidth = 1024;
133
152
  vi.useRealTimers();
134
153
  });
135
154
 
@@ -611,3 +630,265 @@ describe("HkFaceShell hover suppression: a pointer on the toggle never peeks", (
611
630
  expect(c.aside().hasAttribute("data-pinning"), "the peek pin is a rail -> expanded flip").toBe(true);
612
631
  });
613
632
  });
633
+
634
+ /**
635
+ * The menu group fold vs the swap's GENERATIONS (#43, known-live-issues).
636
+ *
637
+ * HkSidebarRailSwap mounts each density as a FRESH generation — the
638
+ * tab-panel contract — so an HkMenu inside the sidebar slot is a NEW
639
+ * component instance in every generation. HkMenu's own docs still promise
640
+ * "a group collapsed in the rail is still collapsed after a peek/expand
641
+ * (and vice versa)"; 0.55.239 broke that promise by keeping the fold in
642
+ * instance-local refs, measured on chest (same chest source: 2 rows /
643
+ * aria-expanded=false on 0.55.236, 5 rows / true on 0.55.239).
644
+ *
645
+ * The fix is a stable fold store provided ABOVE the swap: every
646
+ * generation injects the same instance, so the user's word survives an
647
+ * arbitrary number of remounts. These are the tests that go red when the
648
+ * store stops being provided above the swap (or HkMenu stops reading it).
649
+ */
650
+ const GROUP_ITEMS: HkMenuItem[] = [
651
+ { key: "home", label: "Home" },
652
+ {
653
+ key: "admin",
654
+ label: "Administration",
655
+ children: [
656
+ { key: "users", label: "Users" },
657
+ { key: "audit", label: "Audit log" },
658
+ ],
659
+ },
660
+ ];
661
+
662
+ /** The ACTIVE generation, selected by the key it renders under: the
663
+ * leaving copy is frozen at its last render (it still shows the OLD
664
+ * fold), so asserting against it would pass for the wrong reason. */
665
+ function generation(container: HTMLElement, key: "rail" | "full"): HTMLElement {
666
+ const gen = container.querySelector<HTMLElement>(
667
+ `.hk-crossfade-item[data-crossfade-key="${key}"]`,
668
+ );
669
+ expect(gen, `the ${key} generation renders`).toBeTruthy();
670
+ return gen!;
671
+ }
672
+
673
+ /** The group header inside a generation — the rail and full forms share
674
+ * the same class + aria-expanded contract. */
675
+ function groupToggle(root: HTMLElement): HTMLButtonElement {
676
+ const row = root.querySelector<HTMLButtonElement>("button.hk-menu-sidebar-group-toggle");
677
+ expect(row, "the menu's group header renders").toBeTruthy();
678
+ return row!;
679
+ }
680
+
681
+ /** Let a dissolve settle (past HkCrossfade's 450ms watchdog), then hand
682
+ * back the generation that should now be the only live one. */
683
+ async function settle(container: HTMLElement, key: "rail" | "full"): Promise<HTMLElement> {
684
+ await vi.advanceTimersByTimeAsync(600);
685
+ return generation(container, key);
686
+ }
687
+
688
+ describe("rail group fold survives the crossfade generations (#43)", () => {
689
+ beforeEach(() => {
690
+ vi.useFakeTimers();
691
+ });
692
+
693
+ it("keeps a group collapsed in the rail through a peek AND a full expand", async () => {
694
+ const c = mountShell({ sidebarCollapsible: true }, () =>
695
+ h(HkMenu, {
696
+ variant: "sidebar",
697
+ items: GROUP_ITEMS,
698
+ open: false,
699
+ defaultExpandGroups: true,
700
+ }),
701
+ );
702
+ c.toggle().click();
703
+ await vi.advanceTimersByTimeAsync(0);
704
+
705
+ const railGen = await settle(c.container, "rail");
706
+ expect(
707
+ groupToggle(railGen).getAttribute("aria-expanded"),
708
+ "table-of-contents default: every group starts open",
709
+ ).toBe("true");
710
+ groupToggle(railGen).click();
711
+ await vi.advanceTimersByTimeAsync(0);
712
+ expect(groupToggle(railGen).getAttribute("aria-expanded")).toBe("false");
713
+ expect(railGen.textContent, "the closed group lists no children").not.toContain("Audit log");
714
+
715
+ // Peek: the slot is handed FULL rows again — a brand-new HkMenu.
716
+ c.aside().dispatchEvent(POINTER_ENTER());
717
+ await vi.advanceTimersByTimeAsync(400);
718
+ const peekGen = await settle(c.container, "full");
719
+ expect(c.aside().hasAttribute("data-peeking"), "the peek is open").toBe(true);
720
+ expect(
721
+ groupToggle(peekGen).getAttribute("aria-expanded"),
722
+ "the fold crosses into the peek's fresh generation",
723
+ ).toBe("false");
724
+ expect(peekGen.textContent, "and the peek lists no children either").not.toContain("Audit log");
725
+
726
+ // Leave -> grace -> the rail generation mounts a THIRD time, closed.
727
+ c.aside().dispatchEvent(POINTER_LEAVE());
728
+ await vi.advanceTimersByTimeAsync(150);
729
+ const backGen = await settle(c.container, "rail");
730
+ expect(c.aside().hasAttribute("data-peeking")).toBe(false);
731
+ expect(
732
+ groupToggle(backGen).getAttribute("aria-expanded"),
733
+ "the fold survives the return leg too",
734
+ ).toBe("false");
735
+
736
+ // A full EXPAND (not a peek) reads the same fold.
737
+ c.toggle().click();
738
+ await vi.advanceTimersByTimeAsync(0);
739
+ const expandedGen = await settle(c.container, "full");
740
+ expect(
741
+ groupToggle(expandedGen).getAttribute("aria-expanded"),
742
+ "and the expanded sidebar inherits it",
743
+ ).toBe("false");
744
+ });
745
+
746
+ it("does not let the active row's auto-expansion resurrect a rail-collapsed group", async () => {
747
+ // The chest shape: the group holding the active row auto-opens on every
748
+ // FRESH mount (the auto seed), which is exactly what brought the
749
+ // collapsed group back. The user's word must outrank the re-seed.
750
+ const c = mountShell({ sidebarCollapsible: true }, () =>
751
+ h(HkMenu, {
752
+ variant: "sidebar",
753
+ items: GROUP_ITEMS,
754
+ open: false,
755
+ activeKey: "users",
756
+ }),
757
+ );
758
+ c.toggle().click();
759
+ await vi.advanceTimersByTimeAsync(0);
760
+
761
+ const railGen = await settle(c.container, "rail");
762
+ expect(
763
+ groupToggle(railGen).getAttribute("aria-expanded"),
764
+ "the active row auto-opens its group",
765
+ ).toBe("true");
766
+ groupToggle(railGen).click();
767
+ await vi.advanceTimersByTimeAsync(0);
768
+ expect(groupToggle(railGen).getAttribute("aria-expanded")).toBe("false");
769
+
770
+ c.aside().dispatchEvent(POINTER_ENTER());
771
+ await vi.advanceTimersByTimeAsync(400);
772
+ const peekGen = await settle(c.container, "full");
773
+ expect(
774
+ groupToggle(peekGen).getAttribute("aria-expanded"),
775
+ "the fresh generation re-seeds its auto set but keeps the user's word",
776
+ ).toBe("false");
777
+ });
778
+
779
+ it("shows NO expanded flash mid-dissolve, and never patches the leaving copy", async () => {
780
+ // MEASURED contract, not an inference. happy-dom completes a CSS
781
+ // transition with no duration instantly, so the leaving copy would be
782
+ // gone before it could be inspected; freezing rAF holds the leave open
783
+ // exactly the way a real browser holds it for --duration-normal (the
784
+ // same technique the swap test above uses).
785
+ const c = mountShell({ sidebarCollapsible: true }, () =>
786
+ h(HkMenu, {
787
+ variant: "sidebar",
788
+ items: GROUP_ITEMS,
789
+ open: false,
790
+ defaultExpandGroups: true,
791
+ }),
792
+ );
793
+ c.toggle().click();
794
+ await vi.advanceTimersByTimeAsync(0);
795
+ const railGen = await settle(c.container, "rail");
796
+ groupToggle(railGen).click();
797
+ await vi.advanceTimersByTimeAsync(0);
798
+ expect(groupToggle(railGen).getAttribute("aria-expanded")).toBe("false");
799
+
800
+ vi.stubGlobal("requestAnimationFrame", () => 0);
801
+ vi.stubGlobal("cancelAnimationFrame", () => {});
802
+ c.aside().dispatchEvent(POINTER_ENTER());
803
+ await vi.advanceTimersByTimeAsync(400);
804
+
805
+ const copiesNow = () => Array.from(c.container.querySelectorAll<HTMLElement>(".hk-crossfade-item"));
806
+ expect(
807
+ copiesNow().map((el) => el.dataset.crossfadeKey),
808
+ "both generations share the swap cell during the dissolve",
809
+ ).toEqual(["rail", "full"]);
810
+ expect(
811
+ copiesNow().map((el) => groupToggle(el).getAttribute("aria-expanded")),
812
+ "neither copy shows the group re-opened",
813
+ ).toEqual(["false", "false"]);
814
+
815
+ // Now write the shared store DURING the dissolve (a toggle in the live
816
+ // copy). The live copy re-renders; the leaving one keeps the DOM of its
817
+ // last render — the frozen copy is out of the patch path, which is what
818
+ // makes the dissolve safe. Reading the frozen copy after the write is
819
+ // the measurement of that claim.
820
+ const live = copiesNow()[1]!;
821
+ groupToggle(live).click();
822
+ await vi.advanceTimersByTimeAsync(0);
823
+ const afterWrite = copiesNow().map((el) => ({
824
+ key: el.dataset.crossfadeKey,
825
+ expanded: groupToggle(el).getAttribute("aria-expanded"),
826
+ }));
827
+ expect(afterWrite).toEqual([
828
+ { key: "rail", expanded: "false" },
829
+ { key: "full", expanded: "true" },
830
+ ]);
831
+ vi.unstubAllGlobals();
832
+
833
+ // The open group is the shared word: the next rail generation after the
834
+ // peek closes shows it open (nothing was silently reverted mid-swap).
835
+ c.aside().dispatchEvent(POINTER_LEAVE());
836
+ await vi.advanceTimersByTimeAsync(150);
837
+ const backGen = await settle(c.container, "rail");
838
+ expect(groupToggle(backGen).getAttribute("aria-expanded")).toBe("true");
839
+ });
840
+
841
+ it("shares the fold with the mobile drawer's menu (the provide sits above BOTH slots)", async () => {
842
+ // The rail is not the only slot consumer: under the breakpoint the same
843
+ // sidebar slot renders inside the drawer. The store is provided from
844
+ // HkFaceShell's own setup, so the drawer's fresh HkMenu instance must
845
+ // read the desktop rail's fold too — otherwise the rail would be fixed
846
+ // while mobile silently loses it. (provide/inject follows the component
847
+ // tree, so the real HDrawer's teleport does not change the provider —
848
+ // this stub renders inline and still pins the channel.)
849
+ const c = mountShell({ sidebarCollapsible: true }, () =>
850
+ h(HkMenu, {
851
+ variant: "sidebar",
852
+ items: GROUP_ITEMS,
853
+ open: false,
854
+ defaultExpandGroups: true,
855
+ }),
856
+ );
857
+ c.toggle().click();
858
+ await vi.advanceTimersByTimeAsync(0);
859
+ const railGen = await settle(c.container, "rail");
860
+ groupToggle(railGen).click();
861
+ await vi.advanceTimersByTimeAsync(0);
862
+ expect(groupToggle(railGen).getAttribute("aria-expanded"), "collapsed in the rail").toBe("false");
863
+
864
+ // Phone width: the desktop sidebar unmounts, the drawer becomes the nav.
865
+ window.innerWidth = 800;
866
+ window.dispatchEvent(new Event("resize"));
867
+ await vi.advanceTimersByTimeAsync(0);
868
+ expect(c.aside(), "no desktop rail under the breakpoint").toBeFalsy();
869
+
870
+ c.openDrawer();
871
+ await vi.advanceTimersByTimeAsync(0);
872
+ const drawerBody = c.container.querySelector(".drawer-stub-body") as HTMLElement;
873
+ expect(drawerBody, "the drawer renders the sidebar slot").toBeTruthy();
874
+ expect(
875
+ groupToggle(drawerBody).getAttribute("aria-expanded"),
876
+ "the drawer's menu reads the SAME fold",
877
+ ).toBe("false");
878
+
879
+ // And the other direction: opening it in the drawer is what the desktop
880
+ // sidebar shows when the window grows back.
881
+ groupToggle(drawerBody).click();
882
+ await vi.advanceTimersByTimeAsync(0);
883
+ expect(groupToggle(drawerBody).getAttribute("aria-expanded")).toBe("true");
884
+
885
+ window.innerWidth = 1024;
886
+ window.dispatchEvent(new Event("resize"));
887
+ await vi.advanceTimersByTimeAsync(0);
888
+ const desktopGen = await settle(c.container, "rail");
889
+ expect(
890
+ groupToggle(desktopGen).getAttribute("aria-expanded"),
891
+ "the rail inherits the drawer's word",
892
+ ).toBe("true");
893
+ });
894
+ });
@@ -2,6 +2,7 @@ import { computed, defineComponent, ref, watch, type PropType } from "vue";
2
2
  import { HkCrossfade, HDrawer, HScrollContainer, useBreakpoint } from "@celestia-island/hikari";
3
3
  import { useI18n } from "../i18n/context";
4
4
  import { useSidebarRail } from "../composables/useSidebarRail";
5
+ import { provideMenuGroupState } from "../composables/useMenuGroupState";
5
6
  import HkSidebarRailSwap from "./HkSidebarRailSwap";
6
7
  import HkSidebarRailToggle, { isPointerOverRailToggle } from "./HkSidebarRailToggle";
7
8
 
@@ -280,6 +281,19 @@ export const HkFaceShell = defineComponent({
280
281
  const isDesktop = computed(() => viewportWidth.value >= props.mobileBreakpoint);
281
282
  const sidebarOpen = ref(false);
282
283
 
284
+ // ── sidebar menu GROUP fold, stable across the rail crossfade ─────
285
+ // The rail swap mounts each density as a FRESH generation, so an
286
+ // HkMenu in the sidebar slot is a new instance per generation and its
287
+ // instance-local group fold would be lost on every flip (a group
288
+ // collapsed in the rail came back expanded after a peek/expand —
289
+ // known-live-issues #43). Providing the fold store HERE — one level
290
+ // above the swap — makes every generation inject the SAME instance,
291
+ // so the user's word survives the remounts without a new prop and
292
+ // without any host-side change. Unconditional: the store is inert
293
+ // until an HkMenu reads it, and the mobile drawer's slot must see the
294
+ // same one as the desktop sidebar.
295
+ provideMenuGroupState();
296
+
283
297
  // ── desktop icon rail + hover peek (sidebarCollapsible) ──────────
284
298
  // SEMI-CONTROLLED rail state: a local session-only ref is the live
285
299
  // state, so the toggle works with NO channel bound at all (the
@@ -17,6 +17,8 @@
17
17
  // every wheel/pointer gesture dead because their surface had no area).
18
18
  // ------
19
19
 
20
+ @use "../styles/polyfill" as *;
21
+
20
22
  .hk-modal-content.hk-image-lightbox {
21
23
  width: min(96vw, 80rem);
22
24
  height: min(90vh, 56rem);
@@ -101,7 +103,7 @@
101
103
  top: var(--space-12, 0.75rem);
102
104
  right: var(--space-12, 0.75rem);
103
105
  z-index: 2;
104
- backdrop-filter: blur(6px);
106
+ @include backdrop-filter(blur(6px));
105
107
 
106
108
  &:hover,
107
109
  &:focus-visible {
@@ -1,3 +1,5 @@
1
+ @use "../styles/polyfill" as *;
2
+
1
3
  .hk-image-viewer {
2
4
  position: relative;
3
5
  width: 100%;
@@ -7,7 +9,7 @@
7
9
  background: rgb(0 0 0 / 45%);
8
10
  border-radius: var(--radius-sm);
9
11
  touch-action: none;
10
- user-select: none;
12
+ @include user-select(none);
11
13
  }
12
14
 
13
15
  // The transform glide sits behind the .is-animatable gate the component
@@ -5,6 +5,7 @@
5
5
 
6
6
  // Use theme variables with namespace to avoid conflicts
7
7
  @use "./hikari-vars" as vars;
8
+ @use "../styles/polyfill" as *;
8
9
 
9
10
  .s-tool-json-tree {
10
11
  --jt-toggle: 14px;
@@ -16,7 +17,7 @@
16
17
  background: rgb(var(--color-surface) / 0.5);
17
18
  border-radius: var(--radius-sm, 4px);
18
19
  border: 1px solid var(--border-faint, rgb(var(--color-border) / 10%));
19
- user-select: none;
20
+ @include user-select(none);
20
21
  max-height: 320px;
21
22
  overflow: auto;
22
23
 
@@ -48,8 +49,7 @@
48
49
  /* Tree rows are content — values users copy out of a payload — while
49
50
  * the pane chrome above stays denied (L2 selection policy, 2026-10-29;
50
51
  * the copy-tree affordance remains the bulk path). */
51
- -webkit-user-select: text;
52
- user-select: text;
52
+ @include user-select(text);
53
53
  /* Baseline alignment keeps the small type hints (boolean/number/string)
54
54
  on the value's text baseline instead of floating above it. */
55
55
  display: flex;
@@ -2,6 +2,9 @@
2
2
  // The z-index sits above the toast band (--hk-z-toast) so the veil is
3
3
  // the topmost painted surface while active; pointer-events: all on the
4
4
  // root swallows every interaction beneath (the blocking contract).
5
+
6
+ @use "../styles/polyfill" as *;
7
+
5
8
  .hk-loading-veil {
6
9
  position: fixed;
7
10
  inset: 0;
@@ -12,8 +15,7 @@
12
15
  // Semi-transparent backdrop with a slight blur: the page beneath
13
16
  // stays readable (orientation) but clearly inactive.
14
17
  background: rgb(var(--color-background, 255 255 255) / 0.55);
15
- backdrop-filter: blur(6px);
16
- -webkit-backdrop-filter: blur(6px);
18
+ @include backdrop-filter(blur(6px));
17
19
  pointer-events: all;
18
20
  animation: hk-loading-veil-in 150ms ease-out;
19
21
 
@@ -1,5 +1,6 @@
1
1
  // Use theme variables with namespace to avoid conflicts
2
2
  @use "./hikari-vars" as vars;
3
+ @use "../styles/polyfill" as *;
3
4
 
4
5
  .s-log-viewer {
5
6
  display: flex;
@@ -121,8 +122,7 @@
121
122
  * select per-fragment, not just via the toolbar copy-all) — re-enable
122
123
  * under the L2 selection policy (2026-10-29); the toolbar chrome
123
124
  * around them stays denied. */
124
- -webkit-user-select: text;
125
- user-select: text;
125
+ @include user-select(text);
126
126
  padding: var(--space-2, 0.125rem) var(--space-4, 0.25rem);
127
127
  font-family: var(--font-mono, vars.$hikari-font-family-mono);
128
128
  font-size: var(--text-xs, 0.75rem);
@@ -1,3 +1,5 @@
1
+ @use "../styles/polyfill" as *;
2
+
1
3
  .hk-logo {
2
4
  display: inline-flex;
3
5
  align-items: center;
@@ -6,7 +8,7 @@
6
8
  border-radius: var(--radius-md, 8px);
7
9
  overflow: hidden;
8
10
  flex-shrink: 0;
9
- user-select: none;
11
+ @include user-select(none);
10
12
  }
11
13
 
12
14
  a.hk-logo {
@@ -1,12 +1,12 @@
1
1
  // Use theme variables with namespace to avoid conflicts
2
2
  @use "./hikari-vars" as vars;
3
+ @use "../styles/polyfill" as *;
3
4
 
4
5
  .hk-markdown {
5
6
  /* Rendered prose is content by definition — the whole subtree selects
6
7
  * again under the L2 selection policy (2026-10-29); its code blocks
7
8
  * are covered twice over (whitelist tier 3). */
8
- -webkit-user-select: text;
9
- user-select: text;
9
+ @include user-select(text);
10
10
  font-size: var(--text-base);
11
11
  line-height: 1.7;
12
12
  color: var(--hi-color-text-primary, #1e1e1e);
@@ -204,6 +204,6 @@
204
204
  align-items: center;
205
205
  justify-content: center;
206
206
  background: color-mix(in srgb, var(--hi-color-surface, #ffffff) 70%, transparent);
207
- backdrop-filter: blur(4px);
207
+ @include backdrop-filter(blur(4px));
208
208
  z-index: 10;
209
209
  }
@@ -1,10 +1,12 @@
1
+ @use "../styles/polyfill" as *;
2
+
1
3
  .hk-media-controls {
2
4
  display: flex;
3
5
  align-items: center;
4
6
  gap: var(--space-8);
5
7
  padding: var(--space-6) var(--space-10);
6
8
  background: rgb(var(--color-surface) / 0.85);
7
- backdrop-filter: blur(var(--blur-sm));
9
+ @include backdrop-filter(blur(var(--blur-sm)));
8
10
  border-top: 1px solid var(--border-faint);
9
11
  color: rgb(var(--color-text));
10
12
  }
@@ -34,7 +36,7 @@
34
36
  font-variant-numeric: tabular-nums;
35
37
  color: rgb(var(--color-muted));
36
38
  white-space: nowrap;
37
- user-select: none;
39
+ @include user-select(none);
38
40
  }
39
41
 
40
42
  .hk-media-volume {
@@ -1,3 +1,5 @@
1
+ @use "../styles/polyfill" as *;
2
+
1
3
  .hk-media-slider {
2
4
  position: relative;
3
5
  height: 6px;
@@ -5,7 +7,7 @@
5
7
  background: rgb(var(--color-muted) / 22%);
6
8
  cursor: pointer;
7
9
  touch-action: none;
8
- user-select: none;
10
+ @include user-select(none);
9
11
  }
10
12
 
11
13
  .hk-media-slider[data-size="sm"] {
@@ -250,6 +250,35 @@ describe("HkMenu sidebar icon rail", () => {
250
250
  expect(groupRows(c)[0]!.getAttribute("aria-expanded")).toBe("false");
251
251
  });
252
252
 
253
+ it("keeps the fold INSTANCE-LOCAL when no shell provides one (standalone menus)", async () => {
254
+ // The other half of the #43 contract. HkFaceShell provides the fold
255
+ // store above its rail crossfade so a swap's fresh generations share
256
+ // it; a standalone menu has no shell above it and must keep the
257
+ // instance-local sets it has always had. A module-level singleton
258
+ // store would silently make two menus on one page share fold keys —
259
+ // that regression is what this pin catches.
260
+ const a = mountMenu(ITEMS, { collapsed: true });
261
+ const b = mountMenu(ITEMS, { collapsed: true });
262
+ expect(groupRows(a)[0]!.getAttribute("aria-expanded")).toBe("false");
263
+ expect(groupRows(b)[0]!.getAttribute("aria-expanded")).toBe("false");
264
+
265
+ groupRows(a)[0]!.click();
266
+ await nextTick();
267
+ expect(groupRows(a)[0]!.getAttribute("aria-expanded"), "menu A opens its group").toBe("true");
268
+ expect(
269
+ groupRows(b)[0]!.getAttribute("aria-expanded"),
270
+ "menu B keeps its own fold — nothing is shared without a provider",
271
+ ).toBe("false");
272
+
273
+ // And the standalone flip-in-place contract still holds: the rail and
274
+ // the expanded sidebar are the SAME instance here, so the fold carries
275
+ // exactly as it did before the shared store existed.
276
+ groupRows(b)[0]!.click();
277
+ await nextTick();
278
+ expect(groupRows(b)[0]!.getAttribute("aria-expanded")).toBe("true");
279
+ expect(groupRows(a)[0]!.getAttribute("aria-expanded"), "A is still open, B now open").toBe("true");
280
+ });
281
+
253
282
  it("recurses into nested groups — 2nd/3rd-level destinations stay reachable", async () => {
254
283
  const nested: HkMenuItem[] = [
255
284
  {