@celestia-island/hikari 0.55.211 → 0.55.213

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 (33) hide show
  1. package/package.json +5 -1
  2. package/src/components/HkMinimap.collapse.test.tsx +149 -0
  3. package/src/components/HkMinimap.scss +34 -0
  4. package/src/components/HkMinimap.tsx +70 -18
  5. package/src/components/HkTitleBar.test.tsx +68 -0
  6. package/src/components/HkTitleBar.tsx +11 -0
  7. package/src/components/HkTreemapStage.scss +114 -0
  8. package/src/components/HkTreemapStage.test.tsx +283 -0
  9. package/src/components/HkTreemapStage.tsx +382 -0
  10. package/src/components/HkWindowControls.test.tsx +234 -1
  11. package/src/components/HkWindowControls.tsx +68 -26
  12. package/src/i18n/locales/ar/components.json +2 -0
  13. package/src/i18n/locales/de/components.json +2 -0
  14. package/src/i18n/locales/en/components.json +2 -0
  15. package/src/i18n/locales/es/components.json +2 -0
  16. package/src/i18n/locales/fr/components.json +2 -0
  17. package/src/i18n/locales/ja/components.json +2 -0
  18. package/src/i18n/locales/ko/components.json +2 -0
  19. package/src/i18n/locales/pt/components.json +2 -0
  20. package/src/i18n/locales/ru/components.json +2 -0
  21. package/src/i18n/locales/zh-Hans/components.json +2 -0
  22. package/src/i18n/locales/zh-Hant/components.json +2 -0
  23. package/src/index.ts +27 -0
  24. package/src/plugins/boot-chrome.d.mts +41 -0
  25. package/src/plugins/boot-chrome.install.test.ts +272 -0
  26. package/src/plugins/boot-chrome.mjs +263 -0
  27. package/src/plugins/boot-chrome.test.ts +427 -0
  28. package/src/plugins/index.ts +10 -0
  29. package/src/res/boot-chrome.js +916 -0
  30. package/src/res/bootChrome.test.ts +648 -0
  31. package/src/runtime/index.ts +9 -0
  32. package/src/runtime/windowChrome.test.ts +238 -0
  33. package/src/runtime/windowChrome.ts +264 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.211",
3
+ "version": "0.55.213",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -37,6 +37,10 @@
37
37
  "./plugins": {
38
38
  "import": "./src/plugins/index.ts",
39
39
  "types": "./src/plugins/index.ts"
40
+ },
41
+ "./plugins/boot-chrome": {
42
+ "import": "./src/plugins/boot-chrome.mjs",
43
+ "types": "./src/plugins/boot-chrome.d.mts"
40
44
  }
41
45
  },
42
46
  "files": [
@@ -0,0 +1,149 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, h, nextTick } from "vue";
3
+
4
+ import HkMinimap from "./HkMinimap";
5
+
6
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
7
+
8
+ function mount(props: Record<string, unknown>) {
9
+ const container = document.createElement("div");
10
+ document.body.appendChild(container);
11
+ const app = createApp({ render: () => h(HkMinimap, props) });
12
+ app.mount(container);
13
+ mounts.push({ app, container });
14
+ return container;
15
+ }
16
+
17
+ afterEach(() => {
18
+ for (const { app, container } of mounts.splice(0)) {
19
+ app.unmount();
20
+ container.remove();
21
+ }
22
+ });
23
+
24
+ const BOXES = [
25
+ { id: "a", bounds: { x: 0, y: 0, w: 10, h: 10 }, color: "#f00" },
26
+ { id: "b", bounds: { x: 10, y: 0, w: 10, h: 10 }, color: "#0f0" },
27
+ ];
28
+
29
+ describe("HkMinimap collapse (user direction 2026-10-27)", () => {
30
+ it("a non-collapsible minimap renders the card and NO minimize button", () => {
31
+ const c = mount({ boxes: BOXES });
32
+ expect(c.querySelector(".hk-minimap--collapsed")).toBeNull();
33
+ expect(c.querySelector(".hk-mm-collapse-btn")).toBeNull();
34
+ expect(c.querySelector("svg.hk-minimap-svg")).toBeTruthy();
35
+ });
36
+
37
+ it("collapsible renders the minimize button in the zoom bar", () => {
38
+ const c = mount({ boxes: BOXES, collapsible: true });
39
+ expect(c.querySelector(".hk-mm-collapse-btn")).toBeTruthy();
40
+ });
41
+
42
+ it("collapsing swaps the card for the semi-transparent map-icon button", async () => {
43
+ const c = mount({ boxes: BOXES, collapsible: true });
44
+ (c.querySelector(".hk-mm-collapse-btn") as HTMLElement).click();
45
+ await nextTick();
46
+ expect(c.querySelector("svg.hk-minimap-svg")).toBeNull();
47
+ const glyph = c.querySelector<HTMLButtonElement>(".hk-minimap--collapsed");
48
+ expect(glyph).toBeTruthy();
49
+ // The collapsed glyph announces itself and is translucent at rest.
50
+ expect(glyph!.getAttribute("aria-label")).toBe("Show minimap");
51
+ expect(glyph!.className).toContain("hk-minimap--collapsed");
52
+ });
53
+
54
+ it("clicking the collapsed glyph expands the card again (uncontrolled)", async () => {
55
+ const c = mount({ boxes: BOXES, collapsible: true });
56
+ (c.querySelector(".hk-mm-collapse-btn") as HTMLElement).click();
57
+ await nextTick();
58
+ (c.querySelector(".hk-minimap--collapsed") as HTMLElement).click();
59
+ await nextTick();
60
+ expect(c.querySelector("svg.hk-minimap-svg")).toBeTruthy();
61
+ expect(c.querySelector(".hk-minimap--collapsed")).toBeNull();
62
+ });
63
+
64
+ it("the controlled mode honours the collapsed prop and emits updates", async () => {
65
+ const seen: boolean[] = [];
66
+ const container = document.createElement("div");
67
+ document.body.appendChild(container);
68
+ const app = createApp({
69
+ data: () => ({ collapsed: false }),
70
+ render() {
71
+ return h(HkMinimap, {
72
+ boxes: BOXES,
73
+ collapsible: true,
74
+ collapsed: (this as { collapsed: boolean }).collapsed,
75
+ "onUpdate:collapsed": (v: boolean) => {
76
+ seen.push(v);
77
+ (this as { collapsed: boolean }).collapsed = v;
78
+ },
79
+ });
80
+ },
81
+ });
82
+ app.mount(container);
83
+ mounts.push({ app, container });
84
+
85
+ (container.querySelector(".hk-mm-collapse-btn") as HTMLElement).click();
86
+ await nextTick();
87
+ expect(seen).toEqual([true]);
88
+ // The controlled prop drove the swap.
89
+ expect(container.querySelector(".hk-minimap--collapsed")).toBeTruthy();
90
+
91
+ (container.querySelector(".hk-minimap--collapsed") as HTMLElement).click();
92
+ await nextTick();
93
+ expect(seen).toEqual([true, false]);
94
+ expect(container.querySelector("svg.hk-minimap-svg")).toBeTruthy();
95
+ });
96
+
97
+ it("drag-to-pan SURVIVES a collapse→expand cycle (the R2/R3 S1)", async () => {
98
+ // The old imperative listeners bound once at mount to the mount-time
99
+ // element; the collapsed swap replaced the node and the re-expanded
100
+ // card never got them back. The handlers now ride the vnodes, so the
101
+ // gesture works on whatever element renders.
102
+ const c = mount({ boxes: BOXES, collapsible: true });
103
+ const card = c.querySelector(".hk-minimap") as HTMLElement;
104
+ const before = c.querySelector("svg.hk-minimap-svg")!;
105
+ card.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, clientX: 10, clientY: 10 }));
106
+ card.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, clientX: 20, clientY: 14 }));
107
+ card.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
108
+ expect(c.querySelector(".hk-minimap")).toBeTruthy(); // gesture ran
109
+ void before;
110
+ // Round-trip: collapse then expand.
111
+ (c.querySelector(".hk-mm-collapse-btn") as HTMLElement).click();
112
+ await nextTick();
113
+ (c.querySelector(".hk-minimap--collapsed") as HTMLElement).click();
114
+ await nextTick();
115
+ // The re-expanded card is a NEW element — the drag handlers must be
116
+ // on IT. A pointer sequence must reach the pan machinery: assert via
117
+ // the dragging data flag, which only onDown sets.
118
+ const card2 = c.querySelector(".hk-minimap") as HTMLElement;
119
+ expect(card2).not.toBe(card); // the node really was replaced
120
+ card2.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, clientX: 5, clientY: 5 }));
121
+ await nextTick(); // the data flag renders on the next patch
122
+ expect(card2.getAttribute("data-dragging")).toBe("");
123
+ card2.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
124
+ await nextTick();
125
+ expect(card2.getAttribute("data-dragging")).toBeNull();
126
+ });
127
+
128
+ it("the collapsed glyph is a button, never a drag surface (delta F2)", async () => {
129
+ const c = mount({ boxes: BOXES, collapsible: true, collapsed: true });
130
+ const glyph = c.querySelector(".hk-minimap--collapsed") as HTMLElement;
131
+ glyph.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, clientX: 5, clientY: 5 }));
132
+ glyph.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, clientX: 25, clientY: 20 }));
133
+ await nextTick();
134
+ // The drag machinery never engaged: the swap element carries no
135
+ // data-dragging and a later expand starts clean.
136
+ expect(glyph.getAttribute("data-dragging")).toBeNull();
137
+ glyph.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
138
+ glyph.click();
139
+ await nextTick();
140
+ const card = c.querySelector(".hk-minimap") as HTMLElement;
141
+ expect(card.getAttribute("data-dragging")).toBeNull();
142
+ });
143
+
144
+ it("a controlled `collapsed: true` starts collapsed even without a click", () => {
145
+ const c = mount({ boxes: BOXES, collapsible: true, collapsed: true });
146
+ expect(c.querySelector(".hk-minimap--collapsed")).toBeTruthy();
147
+ expect(c.querySelector("svg.hk-minimap-svg")).toBeNull();
148
+ });
149
+ });
@@ -149,3 +149,37 @@
149
149
  font-variant-numeric: tabular-nums;
150
150
  color: rgb(var(--color-muted));
151
151
  }
152
+
153
+ /* ── The collapsed affordance (user direction 2026-10-27) ─────────────
154
+ * The card folds down to a small map-icon button pinned where the card
155
+ * sat. It is deliberately SEMI-TRANSPARENT at rest — collapsed means
156
+ * "out of the way" and it must not compete with the content underneath —
157
+ * and only reaches full opacity on hover. */
158
+ .hk-minimap--collapsed {
159
+ display: inline-flex;
160
+ align-items: center;
161
+ justify-content: center;
162
+ width: 30px;
163
+ height: 30px;
164
+ padding: 0;
165
+ cursor: pointer;
166
+ color: rgb(var(--color-text) / 70%);
167
+ background: rgb(var(--color-surface) / 0.55);
168
+ border: 1px solid var(--border-faint);
169
+ border-radius: var(--radius-sm);
170
+ opacity: 0.55;
171
+ transition: opacity var(--duration-short, 0.15s), background var(--duration-short, 0.15s);
172
+
173
+ &:hover,
174
+ &:focus-visible {
175
+ opacity: 1;
176
+ background: rgb(var(--color-surface) / 0.9);
177
+ color: rgb(var(--color-text));
178
+ }
179
+ }
180
+
181
+ /* The minimize button rides the zoom bar's far end — visually set
182
+ * apart from the zoom pair it does not belong to. */
183
+ .hk-mm-collapse-btn {
184
+ margin-inline-start: auto;
185
+ }
@@ -1,5 +1,5 @@
1
- import { Maximize2, ZoomIn, ZoomOut } from "lucide-vue-next";
2
- import { computed, defineComponent, onBeforeUnmount, onMounted, ref, watch, type PropType } from "vue";
1
+ import { Map as MapIcon, Maximize2, Minimize2, ZoomIn, ZoomOut } from "lucide-vue-next";
2
+ import { computed, defineComponent, onBeforeUnmount, ref, watch, type PropType } from "vue";
3
3
 
4
4
  import HSlider from "./HkSlider";
5
5
 
@@ -88,6 +88,14 @@ export default defineComponent({
88
88
  * visible; the map is expected to reappear once it has something to
89
89
  * say (the viewer switches this off when zoomed past fit). */
90
90
  toolbarOnly: { type: Boolean, default: false },
91
+ /** Enable the collapse affordance (user direction 2026-10-27): the
92
+ * card grows a minimize button in its zoom bar, and the collapsed
93
+ * state renders as the small semi-transparent map-icon button
94
+ * (fully opaque on hover). */
95
+ collapsible: { type: Boolean, default: false },
96
+ /** The collapsed state (v-model:collapsed). `undefined` = uncontrolled
97
+ * (the component starts expanded and owns the flag). */
98
+ collapsed: { type: Boolean, default: undefined },
91
99
  /** Optional prop-callback surface (alternative to the emits). */
92
100
  onZoomTo: {
93
101
  type: Function as PropType<(percent: number, source?: "slider" | "step") => void>,
@@ -97,6 +105,8 @@ export default defineComponent({
97
105
  onPanDelta: { type: Function as PropType<(dx: number, dy: number) => void>, default: undefined },
98
106
  },
99
107
  emits: {
108
+ /** Collapse toggle (only emitted when `collapsible`). */
109
+ "update:collapsed": (_collapsed: boolean) => true,
100
110
  /** Zoom request carrying the clamped target percent plus the gesture
101
111
  * source — `"step"` (± buttons, relative one-rung request) vs
102
112
  * `"slider"` (dropdown slider, ABSOLUTE target rung). */
@@ -109,9 +119,23 @@ export default defineComponent({
109
119
  const svgW = 160;
110
120
  const svgH = 110;
111
121
  const rootRef = ref<HTMLElement | null>(null);
122
+ const internalCollapsed = ref(false);
123
+ const sliderOpen = ref(false);
124
+ const isCollapsed = computed(() =>
125
+ props.collapsed !== undefined ? props.collapsed : internalCollapsed.value);
126
+ function toggleCollapsed(next: boolean): void {
127
+ internalCollapsed.value = next;
128
+ if (next) {
129
+ sliderOpen.value = false;
130
+ // A mid-drag collapse swaps the element: no pointerup can reach
131
+ // the new root, so end the drag HERE or unpressed hovers would
132
+ // keep panning (delta S3 F4).
133
+ dragging.value = false;
134
+ }
135
+ emit("update:collapsed", next);
136
+ }
112
137
  const dragging = ref(false);
113
138
  const dragStart = ref({ x: 0, y: 0 });
114
- const sliderOpen = ref(false);
115
139
 
116
140
  const cb = computed(() => props.contentBounds);
117
141
  const overpanW = computed(() => Math.max(props.contentBounds.w * 0.5, props.viewportWidth * 0.3));
@@ -166,6 +190,11 @@ export default defineComponent({
166
190
  }
167
191
 
168
192
  function onDown(e: PointerEvent) {
193
+ // The collapsed glyph is a plain button, never a drag surface: its
194
+ // only svg is the 16px lucide icon, which onMove's unit conversion
195
+ // would measure as a ~10x-amplified map (delta S2 F2 — a sloppy
196
+ // press-move on the default-state control jolted the camera).
197
+ if (isCollapsed.value) return;
169
198
  const target = e.target as HTMLElement;
170
199
  // Zoom chrome (bar + slider pop) must never start a map drag — the
171
200
  // root's setPointerCapture would swallow the slider's gestures.
@@ -229,22 +258,7 @@ export default defineComponent({
229
258
  }
230
259
  });
231
260
 
232
- onMounted(() => {
233
- const el = rootRef.value;
234
- if (!el) return;
235
- el.addEventListener("pointerdown", onDown);
236
- el.addEventListener("pointermove", onMove);
237
- el.addEventListener("pointerup", onUp);
238
- el.addEventListener("pointercancel", onUp);
239
- });
240
261
  onBeforeUnmount(() => {
241
- const el = rootRef.value;
242
- if (el) {
243
- el.removeEventListener("pointerdown", onDown);
244
- el.removeEventListener("pointermove", onMove);
245
- el.removeEventListener("pointerup", onUp);
246
- el.removeEventListener("pointercancel", onUp);
247
- }
248
262
  document.removeEventListener("pointerdown", onDocPointerDown, true);
249
263
  document.removeEventListener("keydown", onDocKeydown, true);
250
264
  });
@@ -279,12 +293,39 @@ export default defineComponent({
279
293
 
280
294
  const hubP = props.hubPos ? toMap(props.hubPos.x, props.hubPos.y) : null;
281
295
 
296
+ if (isCollapsed.value) {
297
+ // The collapsed affordance: a small map-icon button pinned where
298
+ // the card sits — semi-transparent until hovered (it must not
299
+ // compete with the content underneath), fully opaque on hover.
300
+ return (
301
+ <button
302
+ ref={rootRef}
303
+ type="button"
304
+ class="hk-minimap hk-minimap--collapsed"
305
+ aria-label={t("hikari::zoomToolbar.expandMap", "Show minimap")}
306
+ title={t("hikari::zoomToolbar.expandMap", "Show minimap")}
307
+ onClick={() => toggleCollapsed(false)}
308
+ onPointerdown={onDown}
309
+ onPointermove={onMove}
310
+ onPointerup={onUp}
311
+ onPointercancel={onUp}
312
+ >
313
+ <MapIcon size={16} />
314
+ </button>
315
+ );
316
+ }
317
+
282
318
  return (
283
319
  <div
284
320
  ref={rootRef}
285
321
  class="hk-minimap"
286
322
  data-dragging={dragging.value ? "" : undefined}
287
323
  data-toolbar-only={props.toolbarOnly ? "" : undefined}
324
+ data-collapsible={props.collapsible ? "" : undefined}
325
+ onPointerdown={onDown}
326
+ onPointermove={onMove}
327
+ onPointerup={onUp}
328
+ onPointercancel={onUp}
288
329
  >
289
330
  {!props.toolbarOnly && (
290
331
  <svg viewBox={`0 0 ${svgW} ${svgH}`} width={svgW} height={svgH} class="hk-minimap-svg">
@@ -369,6 +410,17 @@ export default defineComponent({
369
410
  <Maximize2 size={11} />
370
411
  </button>
371
412
  )}
413
+ {props.collapsible && (
414
+ <button
415
+ class="hk-mm-zoom-btn hk-mm-collapse-btn"
416
+ type="button"
417
+ onClick={() => toggleCollapsed(true)}
418
+ aria-label={t("hikari::zoomToolbar.collapseMap", "Hide minimap")}
419
+ title={t("hikari::zoomToolbar.collapseMap", "Hide minimap")}
420
+ >
421
+ <Minimize2 size={11} />
422
+ </button>
423
+ )}
372
424
  </div>
373
425
  {sliderOpen.value && (
374
426
  <div class="hk-mm-zoom-pop">
@@ -109,6 +109,74 @@ describe("HkTitleBar", () => {
109
109
  });
110
110
  });
111
111
 
112
+ describe("HkTitleBar window-chrome claim", () => {
113
+ // The bar IS the app's title bar, so mounting it must stand hikari's boot bar
114
+ // (boot-chrome.js) down — otherwise two caption bars overlap for the rest of
115
+ // the session. Tests use a raw window listener: that is the whole contract.
116
+ function collect(): { claims: Array<{ by?: string }>; stop: () => void } {
117
+ const claims: Array<{ by?: string }> = [];
118
+ const listener = (event: Event) => {
119
+ claims.push((event as CustomEvent<{ by?: string }>).detail ?? {});
120
+ };
121
+ window.addEventListener("hk:chrome-claim", listener);
122
+ return {
123
+ claims,
124
+ stop: () => window.removeEventListener("hk:chrome-claim", listener),
125
+ };
126
+ }
127
+
128
+ function cleanup(): void {
129
+ document.documentElement.removeAttribute("data-hk-chrome");
130
+ }
131
+
132
+ it("claims the window on mount and marks the app as the chrome owner", () => {
133
+ const { claims, stop } = collect();
134
+ try {
135
+ mount(h(HkTitleBar, { title: "App" }));
136
+
137
+ expect(claims).toHaveLength(1);
138
+ expect(claims[0]?.by).toBe("HkTitleBar");
139
+ expect(document.documentElement.getAttribute("data-hk-chrome")).toBe("app");
140
+ } finally {
141
+ stop();
142
+ cleanup();
143
+ }
144
+ });
145
+
146
+ it("never claims twice for one mount (a re-render is not a new takeover)", async () => {
147
+ const { claims, stop } = collect();
148
+ try {
149
+ const maximized = ref(false);
150
+ mount(h(() => h(HkTitleBar, { title: "App", maximized: maximized.value })));
151
+ expect(claims).toHaveLength(1);
152
+
153
+ maximized.value = true;
154
+ await nextTick();
155
+ maximized.value = false;
156
+ await nextTick();
157
+
158
+ expect(claims).toHaveLength(1);
159
+ } finally {
160
+ stop();
161
+ cleanup();
162
+ }
163
+ });
164
+
165
+ it("leaves the existing props/emits contract untouched", () => {
166
+ const emitted: string[] = [];
167
+ const host = mount(
168
+ h(HkTitleBar, {
169
+ title: "App",
170
+ onMinimize: () => emitted.push("minimize"),
171
+ onClose: () => emitted.push("close"),
172
+ }),
173
+ );
174
+ host.querySelector<HTMLButtonElement>('[title="Minimize"]')!.click();
175
+ host.querySelector<HTMLButtonElement>('[title="Close"]')!.click();
176
+ expect(emitted).toEqual(["minimize", "close"]);
177
+ });
178
+ });
179
+
112
180
  describe("HkTitleBar stylesheet contract", () => {
113
181
  const css = compile(resolve(componentDir, "HkTitleBar.scss"), {
114
182
  style: "expanded",
@@ -1,5 +1,6 @@
1
1
  import { defineComponent, onBeforeUnmount, onMounted, ref } from "vue";
2
2
  import { useI18n } from "../i18n/context";
3
+ import { claimWindowChrome } from "../runtime/windowChrome";
3
4
 
4
5
  import "./HkTitleBar.scss";
5
6
 
@@ -60,6 +61,16 @@ export default defineComponent({
60
61
  },
61
62
  setup(props, { emit, slots }) {
62
63
  const { t } = useI18n();
64
+ // The bar IS the app's window chrome, so mounting it means the boot chrome
65
+ // (boot-chrome.js, drawn before the bundle exists) must hand the window
66
+ // over: dispatch `hk:chrome-claim` once per mount, never revoked. See
67
+ // runtime/windowChrome for the contract; hydration/SSR never reaches here.
68
+ let claimed = false;
69
+ onMounted(() => {
70
+ if (claimed) return;
71
+ claimed = true;
72
+ claimWindowChrome("HkTitleBar");
73
+ });
63
74
  return () => (
64
75
  <div
65
76
  class="hk-titlebar"
@@ -0,0 +1,114 @@
1
+ /* HkTreemapStage — the interactive treemap surface: a header with the
2
+ * back button + drill path, the pan/zoom cell canvas (the cells reuse
3
+ * HkTreemap's chrome classes verbatim — theme mortar, ellipsis labels),
4
+ * and the collapsible HkMinimap pinned bottom-right. */
5
+
6
+ .hk-treemap-stage {
7
+ position: relative;
8
+ display: flex;
9
+ flex-direction: column;
10
+ width: 100%;
11
+ height: 100%;
12
+ min-width: 0;
13
+ min-height: 0;
14
+ }
15
+
16
+ .hk-treemap-stage__head {
17
+ display: flex;
18
+ flex: 0 0 auto;
19
+ align-items: center;
20
+ gap: var(--space-6, 6px);
21
+ padding: var(--space-4, 4px) var(--space-8, 8px);
22
+ min-height: 32px;
23
+ border-bottom: 1px solid var(--border-faint);
24
+ }
25
+
26
+ /* Keeps the path centred when the back button is absent (root level). */
27
+ .hk-treemap-stage__head-pad {
28
+ width: 24px;
29
+ flex: 0 0 auto;
30
+ }
31
+
32
+ .hk-treemap-stage__path {
33
+ display: flex;
34
+ align-items: center;
35
+ min-width: 0;
36
+ gap: var(--space-4, 4px);
37
+ font-size: var(--text-xs, 0.75rem);
38
+ overflow: hidden;
39
+ }
40
+
41
+ .hk-treemap-stage__crumb {
42
+ appearance: none;
43
+ border: 0;
44
+ padding: 2px 4px;
45
+ background: transparent;
46
+ color: rgb(var(--color-muted));
47
+ cursor: pointer;
48
+ border-radius: var(--radius-xs, 3px);
49
+ white-space: nowrap;
50
+ overflow: hidden;
51
+ text-overflow: ellipsis;
52
+ max-width: 12rem;
53
+
54
+ &:hover {
55
+ color: rgb(var(--color-text));
56
+ background: rgb(var(--color-primary) / 6%);
57
+ }
58
+
59
+ &[data-last] {
60
+ color: rgb(var(--color-text));
61
+ font-weight: 600;
62
+ cursor: default;
63
+
64
+ &:hover {
65
+ background: transparent;
66
+ }
67
+ }
68
+ }
69
+
70
+ .hk-treemap-stage__crumb-seg {
71
+ display: inline-flex;
72
+ align-items: center;
73
+ min-width: 0;
74
+ }
75
+
76
+ .hk-treemap-stage__crumb-sep {
77
+ color: rgb(var(--color-text) / 35%);
78
+ flex: 0 0 auto;
79
+ }
80
+
81
+ .hk-treemap-stage__body {
82
+ position: relative;
83
+ flex: 1 1 auto;
84
+ min-height: 0;
85
+ }
86
+
87
+ .hk-treemap-stage__canvas {
88
+ position: absolute;
89
+ inset: 0;
90
+ overflow: hidden;
91
+ background: rgb(var(--color-muted, 108 108 108) / 12%);
92
+ border-radius: var(--radius-sm, 4px);
93
+ }
94
+
95
+ .hk-treemap-stage__surface {
96
+ position: absolute;
97
+ inset: 0;
98
+ }
99
+
100
+ /* Cells reuse .hk-treemap__cell chrome from HkTreemap.scss; the stage
101
+ * adds the drill affordance on group cells. */
102
+ .hk-treemap-stage__cell--drillable {
103
+ cursor: zoom-in;
104
+
105
+ &:hover {
106
+ outline: 2px solid rgb(var(--color-primary) / 70%);
107
+ outline-offset: -2px;
108
+ }
109
+
110
+ &:focus-visible {
111
+ outline: 2px solid rgb(var(--color-primary));
112
+ outline-offset: -2px;
113
+ }
114
+ }