@celestia-island/hikari 0.55.164 → 0.55.166

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.164",
3
+ "version": "0.55.166",
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",
@@ -0,0 +1,69 @@
1
+ import { defineComponent } from "vue";
2
+ import { Equal, EqualApproximately, EqualNot } from "lucide-vue-next";
3
+
4
+ import type { HkFilterOperator } from "./HkFilterPanel";
5
+
6
+ /**
7
+ * The operator glyph as an ICON, never a text character (user direction
8
+ * 2026-10-15: font stacks render U+2249 ≉ unevenly — the operator
9
+ * buttons are UI chrome, so they draw like every other icon in the
10
+ * family). Lucide ships =, ≠ and ≈; the ordered four and ≉ are
11
+ * hikari-authored in lucide's exact stroke grammar (24×24, stroke 2,
12
+ * currentColor) — ≉ is lucide's own ≈ path pair with EqualNot's slash
13
+ * composed in, so the set reads as one family.
14
+ */
15
+
16
+ // Lucide's EqualApproximately path pair (ISC, verbatim) + its EqualNot
17
+ // slash — composed rather than reinvented.
18
+ const NAPPROX_PATHS = [
19
+ "M5 15a6.5 6.5 0 0 1 7 0 6.5 6.5 0 0 0 7 0",
20
+ "M5 9a6.5 6.5 0 0 1 7 0 6.5 6.5 0 0 0 7 0",
21
+ "m5 19 14-14",
22
+ ];
23
+
24
+ const CUSTOM: Partial<Record<HkFilterOperator, string[]>> = {
25
+ napprox: NAPPROX_PATHS,
26
+ lt: ["m19 6-11 6 11 6"],
27
+ lte: ["m19 5-11 6 11 6", "M8 20h11"],
28
+ gt: ["m5 6 11 6-11 6"],
29
+ gte: ["m5 5 11 6-11 6", "M5 20h11"],
30
+ };
31
+
32
+ const LUCIDE: Partial<Record<HkFilterOperator, typeof Equal>> = {
33
+ eq: Equal,
34
+ ne: EqualNot,
35
+ approx: EqualApproximately,
36
+ };
37
+
38
+ export default defineComponent({
39
+ name: "HkFilterOperatorIcon",
40
+ props: {
41
+ op: { type: String as () => HkFilterOperator, required: true },
42
+ size: { type: Number, default: 14 },
43
+ },
44
+ setup(props) {
45
+ return () => {
46
+ const Lucide = LUCIDE[props.op];
47
+ if (Lucide) return <Lucide size={props.size} aria-hidden="true" />;
48
+ const paths = CUSTOM[props.op]!;
49
+ return (
50
+ <svg
51
+ xmlns="http://www.w3.org/2000/svg"
52
+ width={props.size}
53
+ height={props.size}
54
+ viewBox="0 0 24 24"
55
+ fill="none"
56
+ stroke="currentColor"
57
+ stroke-width="2"
58
+ stroke-linecap="round"
59
+ stroke-linejoin="round"
60
+ aria-hidden="true"
61
+ >
62
+ {paths.map((d) => (
63
+ <path key={d} d={d} />
64
+ ))}
65
+ </svg>
66
+ );
67
+ };
68
+ },
69
+ });
@@ -42,6 +42,14 @@
42
42
  /* Minimum outer margins for every column (user direction
43
43
  * 2026-10-12): the rows never touch the panel edge. */
44
44
  padding: var(--space-8, 0.5rem) var(--space-12, 0.75rem);
45
+ /* Empty panel collapses to the add verb's own width (user direction
46
+ * 2026-10-15): no rows means nothing to align columns against, so the
47
+ * popover hugs the 添加条件 button instead of holding the full-row
48
+ * width. Desktop only — the phone branch below re-widens to 100%. */
49
+ &[data-empty] {
50
+ width: max-content;
51
+ min-width: 0;
52
+ }
45
53
  }
46
54
 
47
55
  /* A row is not a box — it donates its three cells to the panel grid so
@@ -73,6 +73,12 @@ describe("HkFilterPanel stylesheet contract", () => {
73
73
  expect(rule).toContain("justify-self: start");
74
74
  });
75
75
 
76
+ it("the empty panel collapses to the add verb's own width", () => {
77
+ // 2026-10-15: no rows = nothing to align columns against — the
78
+ // desktop popover hugs the 添加条件 button (data-empty marker).
79
+ expect(css).toMatch(/\.hk-filter-panel\[data-empty\]\s*\{[^}]*width:\s*max-content/);
80
+ });
81
+
76
82
  it("the phone operator column keeps its widened cap (≥ the old 4.25rem)", () => {
77
83
  expect(css).toContain("@media (max-width: 767px)");
78
84
  const media = css.slice(css.indexOf("@media (max-width: 767px)"));
@@ -45,6 +45,9 @@ describe("HkFilterPanel (array state, 2026-10-15)", () => {
45
45
  expect(c.querySelectorAll(".hk-filter-panel-row")).toHaveLength(0);
46
46
  expect(c.querySelector(".hk-filter-panel-add")).toBeTruthy();
47
47
  expect(c.querySelectorAll(".hk-filter-panel-field")).toHaveLength(0);
48
+ // The empty panel carries the collapse marker (width hugs the add
49
+ // verb on desktop — pinned in the styles contract test).
50
+ expect(c.querySelector(".hk-filter-panel")!.getAttribute("data-empty")).toBe("true");
48
51
 
49
52
  const state: HkFilterState = [
50
53
  { key: "tool", op: "approx", value: "web" },
@@ -188,13 +191,15 @@ describe("HkFilterPanel (array state, 2026-10-15)", () => {
188
191
  );
189
192
  const rows = [...c.querySelectorAll(".hk-filter-panel-row")];
190
193
  const opsOf = (row: Element) =>
191
- [...row.querySelectorAll(".hk-filter-panel-op")].map((b) => b.textContent);
194
+ [...row.querySelectorAll(".hk-filter-panel-op")].map((b) => b.getAttribute("title"));
192
195
  // Text speaks ONLY the fuzzy pair (2026-10-15: 等于其实是模糊匹配 —
193
196
  // label it ≈, and the exclusion ≉).
194
- expect(opsOf(rows[0]!)).toEqual(["≈", "≉"]);
195
- expect(opsOf(rows[1]!)).toEqual(["<", "≤", "=", "≥", ">"]);
196
- expect(opsOf(rows[2]!)).toEqual(["=", "≠"]);
197
- expect(opsOf(rows[3]!)).toEqual(["≥", "≤"]);
197
+ expect(opsOf(rows[0]!)).toEqual(["fuzzy match", "fuzzy non-match"]);
198
+ expect(opsOf(rows[1]!)).toEqual(["less than", "at most", "equals", "at least", "greater than"]);
199
+ // Select speaks ONLY the eq/ne pair — rendered as the fuzzy pair
200
+ // glyphs (2026-10-15 follow-up: 等于/不等于 换成 ≈/≉).
201
+ expect(opsOf(rows[2]!)).toEqual(["fuzzy match", "fuzzy non-match"]);
202
+ expect(opsOf(rows[3]!)).toEqual(["at least", "at most"]);
198
203
  });
199
204
 
200
205
  it("emits a new state when an operator is picked (live, no confirm)", async () => {
@@ -207,7 +212,7 @@ describe("HkFilterPanel (array state, 2026-10-15)", () => {
207
212
  }),
208
213
  );
209
214
  const notMatch = [...c.querySelectorAll(".hk-filter-panel-row")[0]!.querySelectorAll(".hk-filter-panel-op")]
210
- .find((b) => b.textContent === "≉")!;
215
+ .find((b) => b.getAttribute("title") === "fuzzy non-match")!;
211
216
  notMatch.dispatchEvent(new MouseEvent("click", { bubbles: true }));
212
217
  await Promise.resolve();
213
218
  expect(seen).toEqual([{ key: "tool", op: "napprox", value: "web" }]);
@@ -5,6 +5,7 @@ import HkDatePicker from "./HkDatePicker";
5
5
  import HkInput from "./HkInput";
6
6
  import HkPopover from "./HkPopover";
7
7
  import HkPopupSelect, { type HkPopupSelectOption } from "./HkPopupSelect";
8
+ import HkFilterOperatorIcon from "./HkFilterOperatorIcon";
8
9
  import HkScrollContainer from "./HkScrollContainer";
9
10
  import { useI18n } from "../i18n/context";
10
11
  import "./HkFilterPanel.scss";
@@ -53,16 +54,15 @@ export interface HkFilterCondition {
53
54
  * field chooser. */
54
55
  export type HkFilterState = HkFilterCondition[];
55
56
 
56
- const OPERATOR_SYMBOL: Record<HkFilterOperator, string> = {
57
- eq: "=",
58
- ne: "≠",
59
- approx: "≈",
60
- napprox: "≉",
61
- lt: "<",
62
- lte: "≤",
63
- gte: "≥",
64
- gt: ">",
65
- };
57
+ /** Which ICON the operator renders as: fields that speak ONLY the
58
+ * eq/ne pair (select) render the fuzzy pair ≈/≉ (2026-10-15 — 等于
59
+ * 其实是模糊匹配 applies to them too); number's exact = keeps the
60
+ * plain equals icon inside the ordered five. */
61
+ function glyphOpFor(kind: HkFilterFieldDef["kind"], op: HkFilterOperator): HkFilterOperator {
62
+ if (kind === "select" && op === "eq") return "approx";
63
+ if (kind === "select" && op === "ne") return "napprox";
64
+ return op;
65
+ }
66
66
 
67
67
  const OPERATOR_LABEL: Record<HkFilterOperator, { key: string; fallback: string }> = {
68
68
  eq: { key: "hikari::filterPanel.op.eq", fallback: "equals" },
@@ -309,7 +309,7 @@ export default defineComponent({
309
309
  return () => {
310
310
  const label = props.label ?? t("hikari::filterBar.trigger", "Filters");
311
311
  return (
312
- <div class="hk-filter-panel" data-testid="hk-filter-panel" data-active={activeCount.value > 0 || undefined} aria-label={label}>
312
+ <div class="hk-filter-panel" data-testid="hk-filter-panel" data-active={activeCount.value > 0 || undefined} data-empty={rows.value.length === 0 || undefined} aria-label={label}>
313
313
  {rows.value.map((cond, index) => {
314
314
  const field = fieldByKey(cond.key);
315
315
  // An undeclared key (the page renamed a field under live
@@ -322,20 +322,23 @@ export default defineComponent({
322
322
  <div key={index} class="hk-filter-panel-row" data-kind={kind}>
323
323
  <span class="hk-filter-panel-field">{labelText}</span>
324
324
  <div class="hk-filter-panel-ops" role="group" aria-label={`${labelText} — ${label}`}>
325
- {ops.map((op) => (
325
+ {ops.map((op) => {
326
+ const glyph = glyphOpFor(kind, op);
327
+ return (
326
328
  <button
327
329
  key={op}
328
330
  type="button"
329
331
  class="hk-filter-panel-op"
330
332
  aria-pressed={cond.op === op}
331
- aria-label={t(OPERATOR_LABEL[op].key, OPERATOR_LABEL[op].fallback)}
332
- title={t(OPERATOR_LABEL[op].key, OPERATOR_LABEL[op].fallback)}
333
+ aria-label={t(OPERATOR_LABEL[glyph].key, OPERATOR_LABEL[glyph].fallback)}
334
+ title={t(OPERATOR_LABEL[glyph].key, OPERATOR_LABEL[glyph].fallback)}
333
335
  disabled={props.disabled || !field}
334
336
  onClick={() => emitCondition(index, { op })}
335
337
  >
336
- {OPERATOR_SYMBOL[op]}
338
+ <HkFilterOperatorIcon op={glyph} />
337
339
  </button>
338
- ))}
340
+ );
341
+ })}
339
342
  </div>
340
343
  <div class="hk-filter-panel-value">
341
344
  <div class="hk-filter-panel-control">
@@ -319,7 +319,7 @@ describe("HkFilterTrigger", () => {
319
319
  // stand down for it, not swallow it. (Pick the ≈ toggle; the first
320
320
  // op button is the already-pressed =.)
321
321
  const op = [...document.body.querySelectorAll<HTMLButtonElement>(".hk-filter-panel .hk-filter-panel-op")].find(
322
- (b) => b.textContent === "≈",
322
+ (b) => b.getAttribute("title") === "fuzzy match",
323
323
  )!;
324
324
  op.dispatchEvent(new MouseEvent("click", { bubbles: true }));
325
325
  await Promise.resolve();
@@ -1,5 +1,5 @@
1
1
  import { computed, defineComponent, ref, type PropType } from "vue";
2
- import { SlidersHorizontal } from "lucide-vue-next";
2
+ import { Filter } from "lucide-vue-next";
3
3
 
4
4
  import HkButton from "./HkButton";
5
5
  import HkCornerBadge from "./HkCornerBadge";
@@ -74,7 +74,7 @@ export default defineComponent({
74
74
  open.value = !open.value;
75
75
  }}
76
76
  >
77
- {{ icon: () => <SlidersHorizontal size={16} /> }}
77
+ {{ icon: () => <Filter size={16} /> }}
78
78
  </HkIconButton>
79
79
  </HkCornerBadge>
80
80
  <HkPopover
@@ -1,9 +1,12 @@
1
1
  // HkErrorReportingOverlay — fixed full-viewport host for the error landing.
2
- // Sits above every popup band (modal 1000 / dropdown 2000 / tooltip 3000 /
3
- // toast 4000): an uncaught-error takeover must never be covered.
2
+ // Mounts on document.body (NOT the overlay root — the takeover must survive
3
+ // even a broken floating tree) and sits on --hk-z-error, ABOVE the fenced
4
+ // overlay root (--hk-z-overlay-root): an uncaught-error takeover must never
5
+ // be covered — not by app chrome, not by any floating layer. Only the
6
+ // window titlebar (--hk-z-titlebar) stays above it in the Tauri shell.
4
7
 
5
8
  .hk-error-reporting-overlay {
6
9
  position: fixed;
7
10
  inset: 0;
8
- z-index: var(--hk-z-modal);
11
+ z-index: var(--hk-z-error);
9
12
  }
@@ -9,9 +9,21 @@
9
9
  */
10
10
  #hk-overlay-root {
11
11
  /* Fences the root's z-index space from the global stacking order —
12
- * the entire reason this container exists. */
12
+ * the entire reason this container exists. The fence disciplines the
13
+ * root's INTERNALS only; the elevation below is what lifts the root
14
+ * itself above app chrome. */
13
15
  isolation: isolate;
14
16
 
17
+ /* Document-space elevation — without it, any fixed/sticky chrome with
18
+ * a positive z-index (the admin status bar sits at
19
+ * --hk-z-toolbar: 50) painted over EVERY floating layer: the
20
+ * connection popover rendered behind the panel footer (2026-10-15
21
+ * defect report). The band lives in scale.scss between the old
22
+ * floating bands and --hk-z-error, preserving the pre-isolation
23
+ * order: content chrome < floating layers < error landing < titlebar.
24
+ * The fallback must match overlayRoot.ts's OVERLAY_ROOT_DOCUMENT_Z. */
25
+ z-index: var(--hk-z-overlay-root, 90000);
26
+
15
27
  /* The root itself doesn't intercept pointer events; each teleported
16
28
  * child restores its own pointer-events. */
17
29
  pointer-events: none;
@@ -0,0 +1,185 @@
1
+ import { readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { join, dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { compile } from "sass";
6
+ import { describe, expect, it } from "vitest";
7
+
8
+ import { OVERLAY_ROOT_DOCUMENT_Z, ensureOverlayRoot } from "./overlayRoot";
9
+
10
+ /**
11
+ * Compiled-stylesheet + cross-file contract for the overlay root's
12
+ * document-space elevation. `isolation` fences the root's INTERNAL
13
+ * ordinals but does not lift the root above app chrome — without the
14
+ * elevation, any fixed/sticky bar with a positive z-index (the admin
15
+ * status bar's --hk-z-toolbar: 50) painted over every floating layer
16
+ * and the connection popover ended up behind the panel footer
17
+ * (2026-10-15 defect report). These assertions pin the elevation in
18
+ * all three places it must agree: scale.scss (the band), the compiled
19
+ * sheet, and the inline self-sufficient cssText.
20
+ */
21
+ const overlayDir = resolve(dirname(fileURLToPath(import.meta.url)));
22
+ const vueSrc = resolve(overlayDir, "..");
23
+
24
+ function sourcesIn(dir: string): string[] {
25
+ const out: string[] = [];
26
+ for (const entry of readdirSync(dir)) {
27
+ if (entry === "node_modules" || entry === "__tests__") continue;
28
+ const path = join(dir, entry);
29
+ if (statSync(path).isDirectory()) out.push(...sourcesIn(path));
30
+ else if (/\.(scss|tsx)$/.test(entry)) out.push(path);
31
+ }
32
+ return out;
33
+ }
34
+
35
+ describe("overlay root document-space elevation contract", () => {
36
+ const css = compile(resolve(overlayDir, "overlayRoot.scss"), {
37
+ style: "expanded",
38
+ }).css;
39
+ const scale = readFileSync(resolve(vueSrc, "scale.scss"), "utf8");
40
+ const source = readFileSync(resolve(overlayDir, "overlayRoot.ts"), "utf8");
41
+
42
+ it("declares the elevation in the sheet, with the fence kept", () => {
43
+ expect(css).toContain("z-index: var(--hk-z-overlay-root, 90000);");
44
+ expect(css).toContain("isolation: isolate;");
45
+ expect(css).toContain("position: relative;");
46
+ });
47
+
48
+ it("keeps scale.scss, the sheet fallback and the TS fallback on one value", () => {
49
+ const band = scale.match(/--hk-z-overlay-root:\s*(\d+)/);
50
+ // A missing match is a broken contract, not an empty pass.
51
+ expect(band).not.toBeNull();
52
+ expect(Number(band![1])).toBe(OVERLAY_ROOT_DOCUMENT_Z);
53
+ // The inline cssText interpolates the same constant.
54
+ expect(source).toContain(
55
+ "z-index:var(--hk-z-overlay-root,${OVERLAY_ROOT_DOCUMENT_Z})",
56
+ );
57
+ });
58
+
59
+ it("parks the band between the floating bands and error/titlebar", () => {
60
+ const value = (name: string) => {
61
+ const m = scale.match(new RegExp(`--hk-z-${name}:\\s*(\\d+)`));
62
+ expect(m, `--hk-z-${name} must stay defined in scale.scss`).not.toBeNull();
63
+ return Number(m![1]);
64
+ };
65
+ const band = value("overlay-root");
66
+ // Above every content-chrome band (the defect: footer at 50 won).
67
+ expect(band).toBeGreaterThan(value("tooltip"));
68
+ expect(band).toBeGreaterThan(value("modal"));
69
+ expect(band).toBeGreaterThan(value("toolbar"));
70
+ // Below the two surfaces that intentionally stay on top, exactly as
71
+ // in the pre-isolation global scale.
72
+ expect(band).toBeLessThan(value("error"));
73
+ expect(band).toBeLessThan(value("titlebar"));
74
+ });
75
+
76
+ it("inlines a self-sufficient pre-stylesheet elevation", () => {
77
+ const root = ensureOverlayRoot();
78
+ expect(root.id).toBe("hk-overlay-root");
79
+ expect(root.style.getPropertyValue("z-index")).toContain(
80
+ "var(--hk-z-overlay-root",
81
+ );
82
+ expect(root.style.getPropertyValue("position")).toBe("relative");
83
+ });
84
+
85
+ it("lets no z-index consumer land between the root band and the error band", () => {
86
+ // The exact gap class the 2026-10-15 fix closed: a document-space
87
+ // surface that must own the top (hikari's error landing) sat at
88
+ // --hk-z-modal = 5000, and the elevated root silently jumped over
89
+ // it. Every --hk-z-* consumer must resolve STRICTLY below the root
90
+ // band (content chrome) or be one of the two intentional
91
+ // top-owners, which must stay ABOVE it — nothing ties the band
92
+ // (a tie would be DOM-order roulette) and nothing lands between
93
+ // the two bands. Consumers are matched anywhere INSIDE a
94
+ // z-index-bearing line, so calc()/nested-fallback wrappers
95
+ // (`var(--x, var(--hk-z-scrollbar))`) are still inventoried; their
96
+ // ARITHMETIC is not evaluated (a calc() offset off a top-owner
97
+ // token is textual guessing — same out-of-scope class as raw
98
+ // numbers, audited by hand at ≤ 51 in this package). Single-line
99
+ // declarations only.
100
+ const bandValues = new Map<string, number>();
101
+ for (const [, name, num] of scale.matchAll(/--hk-z-([a-z-]+):\s*(\d+)/g)) {
102
+ bandValues.set(name, Number(num));
103
+ }
104
+ // The ladder itself must be present — a broken parse must not
105
+ // masquerade as an empty pass.
106
+ expect(bandValues.size).toBeGreaterThanOrEqual(10);
107
+ expect(bandValues.get("overlay-root")).toBe(OVERLAY_ROOT_DOCUMENT_Z);
108
+
109
+ const sources = sourcesIn(vueSrc);
110
+ // Positive floor for the walk: hundreds of sheets/components live
111
+ // under src/ — fewer means the glob broke, not that the code did.
112
+ expect(sources.length).toBeGreaterThanOrEqual(200);
113
+
114
+ const found = new Map<string, Set<string>>(); // token name -> files
115
+ for (const file of sources) {
116
+ for (const line of readFileSync(file, "utf8").split("\n")) {
117
+ if (!/\bz-index\s*[:=]|\bzIndex\s*[:=]/.test(line)) continue;
118
+ for (const [, name] of line.matchAll(/var\(\s*--hk-z-([a-z-]+)/g)) {
119
+ if (!found.has(name)) found.set(name, new Set());
120
+ found.get(name)!.add(file);
121
+ }
122
+ }
123
+ }
124
+ // Positive controls: the scan must see the known top-owners and the
125
+ // root itself, or the line patterns stopped matching.
126
+ for (const required of ["overlay-root", "error", "titlebar", "toolbar"]) {
127
+ expect(
128
+ found.has(required),
129
+ `scan lost the --hk-z-${required} consumer — patterns rotted`,
130
+ ).toBe(true);
131
+ }
132
+
133
+ // File binding, not just name presence: the error takeover's
134
+ // "never covered" promise lives in ONE sheet — assert IT consumes
135
+ // the top-owner band, so the promise survives even if a second
136
+ // --hk-z-error consumer appears elsewhere.
137
+ const errorSheet = join(vueSrc, "errorReporting", "HkErrorReportingOverlay.scss");
138
+ expect(readFileSync(errorSheet, "utf8")).toMatch(
139
+ /z-index:\s*var\(\s*--hk-z-error[\s,)]/,
140
+ );
141
+ const titlebarSheet = join(vueSrc, "components", "HkTitleBar.scss");
142
+ expect(readFileSync(titlebarSheet, "utf8")).toMatch(
143
+ /z-index:\s*var\(\s*--hk-z-titlebar[\s,)]/,
144
+ );
145
+
146
+ const topOwners = new Set(["error", "titlebar"]);
147
+ const rootSheet = resolve(overlayDir, "overlayRoot.scss");
148
+ for (const [name, files] of found) {
149
+ const value = bandValues.get(name);
150
+ expect(
151
+ value,
152
+ `--hk-z-${name} consumed (e.g. ${files.values().next().value}) but not defined in scale.scss`,
153
+ ).not.toBeUndefined();
154
+ if (topOwners.has(name)) {
155
+ // A top-owner demoted to/below the root band would be painted
156
+ // over by the floating tree (the error landing sat at
157
+ // --hk-z-modal = 5000 when this guard was born).
158
+ expect(
159
+ value!,
160
+ `--hk-z-${name} (${value}) must stay above the overlay root band ` +
161
+ `(${OVERLAY_ROOT_DOCUMENT_Z}) — consumers: ${[...files].join(", ")}`,
162
+ ).toBeGreaterThan(OVERLAY_ROOT_DOCUMENT_Z);
163
+ } else if (name === "overlay-root") {
164
+ // The band itself: ONLY the root's own sheet may sit at it —
165
+ // any other consumer at exactly 90000 races the root by DOM
166
+ // order.
167
+ for (const file of files) {
168
+ expect(
169
+ file,
170
+ `--hk-z-overlay-root consumed at ${file} — only ${rootSheet} may tie the band`,
171
+ ).toBe(rootSheet);
172
+ }
173
+ } else {
174
+ expect(
175
+ value!,
176
+ `--hk-z-${name} (${value}) at ${[...files].join(", ")} must resolve ` +
177
+ `strictly below the overlay root band (${OVERLAY_ROOT_DOCUMENT_Z}) ` +
178
+ `or above the error band — ties and in-between values get painted ` +
179
+ `over by (or paint over) the floating tree`,
180
+ ).toBeLessThan(OVERLAY_ROOT_DOCUMENT_Z);
181
+ }
182
+ }
183
+ });
184
+ });
185
+
@@ -5,10 +5,21 @@
5
5
  *
6
6
  * Inside this root, z-index values only compete with each other (the
7
7
  * `isolation` property creates a new stacking context), so overlay types
8
- * use simple ordinals (1 = lowest … 10 = highest) instead of the old
9
- * global thousand-scale bands. A tooltip at z-index 8 inside the root
10
- * always paints above a modal at z-index 3 inside the root, regardless
11
- * of what the global stacking space looks like outside.
8
+ * use simple ordinal bands instead of the old global thousand-scale. A
9
+ * tooltip at ordinal 300 inside the root always paints above a modal at
10
+ * ordinal 10 inside the root, regardless of what the global stacking
11
+ * space looks like outside.
12
+ *
13
+ * The fence only disciplines the root's INTERNALS, though — by itself
14
+ * `isolation` does not lift the root above app chrome. A fixed/sticky
15
+ * bar with a positive document-space z-index (the admin status bar sits
16
+ * at `--hk-z-toolbar: 50`) painted over EVERY floating layer, dropping
17
+ * the connection popover behind the panel footer (2026-10-15 defect
18
+ * report). The root therefore carries ONE document-space z-index of its
19
+ * own — `--hk-z-overlay-root` (fallback 90000) — parked between the old
20
+ * floating bands and `--hk-z-error`, so the pre-isolation global order
21
+ * is preserved: content chrome < floating layers < error landing <
22
+ * window titlebar.
12
23
  *
13
24
  * The root is created lazily on first use (`ensureOverlayRoot()`), so
14
25
  * consumers don't need to include a bootstrap component — any Teleport
@@ -19,14 +30,14 @@
19
30
  *
20
31
  * | z-index | Layer | Examples |
21
32
  * |---------|----------------|---------------------------------------|
22
- * | 1 | modal backdrop | HkModal's fixed backdrop |
23
- * | 2 | modal frame | HkModal body, dialogs |
24
- * | 3 | blocking toast | HkBlockingToast |
25
- * | 4 | toast | HkToast, persistent toasts |
26
- * | 5 | loading veil | HkLoadingVeil |
27
- * | 6 | anchored popup | HkSelectPanel popout, menus, popover |
28
- * | 7 | breadcrumb | HkModalBreadcrumb strip |
29
- * | 8 | tooltip | HkTooltip (always on top) |
33
+ * | 5 | modal backdrop | HkModal's fixed backdrop |
34
+ * | 10 | modal frame | HkModal body, dialogs |
35
+ * | 50 | blocking toast | HkBlockingToast |
36
+ * | 100 | toast | HkToast, persistent toasts |
37
+ * | 150 | loading veil | HkLoadingVeil |
38
+ * | 200 | anchored popup | HkSelectPanel popout, menus, popover |
39
+ * | 250 | breadcrumb | HkModalBreadcrumb strip |
40
+ * | 300 | tooltip | HkTooltip (always on top) |
30
41
  *
31
42
  * Components that need to layer within the SAME band (e.g. multiple
32
43
  * stacked modals) use their own local stacking mechanisms (the modal
@@ -48,6 +59,15 @@ export const OVERLAY_Z = {
48
59
  tooltip: 300,
49
60
  } as const;
50
61
 
62
+ /**
63
+ * The root's document-space elevation fallback — must match the
64
+ * `--hk-z-overlay-root` value in `scale.scss` (kept honest by
65
+ * overlayRoot.styles.test.ts). Inlined because the root is created
66
+ * before any stylesheet loads; the var() indirection lets the sheet
67
+ * retune the band without touching this file.
68
+ */
69
+ export const OVERLAY_ROOT_DOCUMENT_Z = 90000;
70
+
51
71
  /**
52
72
  * Idempotently create and append the overlay root to `<body>`.
53
73
  * Called on module load — the first `import` of this module guarantees
@@ -61,10 +81,16 @@ export function ensureOverlayRoot(): HTMLElement {
61
81
  root.id = "hk-overlay-root";
62
82
  root.setAttribute("aria-hidden", "true");
63
83
  // Inline styles — this element is created BEFORE any stylesheet
64
- // loads, so it must be self-sufficient. The `isolation` is the
65
- // whole point: it fences the root's internal z-index space from
66
- // the document's global stacking order.
67
- root.style.cssText = "isolation:isolate;pointer-events:none;";
84
+ // loads, so it must be self-sufficient. `isolation` fences the
85
+ // root's internal z-index space from the document's global stacking
86
+ // order; `position` + `z-index` then lift the fenced root above
87
+ // every content-chrome band (see the module doc) — `position` is
88
+ // repeated here so the elevation also applies pre-stylesheet. The
89
+ // var() name is the sheet's retuning path; the fallback constant
90
+ // must equal scale.scss's --hk-z-overlay-root.
91
+ root.style.cssText =
92
+ "isolation:isolate;pointer-events:none;position:relative;" +
93
+ `z-index:var(--hk-z-overlay-root,${OVERLAY_ROOT_DOCUMENT_Z});`;
68
94
  document.body.appendChild(root);
69
95
  }
70
96
  return root;
package/src/scale.scss CHANGED
@@ -69,7 +69,11 @@
69
69
  * base content (0) to the window titlebar (100001); a higher band
70
70
  * always paints above a lower one. Tooltips sit above toasts AND
71
71
  * modals so their text stays readable and copyable while any overlay
72
- * is showing (user direction 2026-10-15). */
72
+ * is showing (user direction 2026-10-15). --hk-z-overlay-root is the
73
+ * ONE band consumed by the fenced floating-layer root itself rather
74
+ * than by a component: it lifts #hk-overlay-root above every
75
+ * content-chrome band while keeping the pre-isolation order (floating
76
+ * layers < error landing < window titlebar). */
73
77
  --hk-z-base: 0; /* base content stacking context */
74
78
  --hk-z-above: 1; /* local bump: checkbox marks, switch thumbs */
75
79
  --hk-z-scrollbar: 10; /* scrollbar tracks and corner rulers */
@@ -85,6 +89,9 @@
85
89
  --hk-z-modal: 5000; /* modal backdrop + frame */
86
90
  --hk-z-tooltip: 6000; /* tooltip — above toasts and modals so its text
87
91
  is always readable and copyable */
92
+ --hk-z-overlay-root: 90000; /* #hk-overlay-root — every floating layer's
93
+ document-space elevation (overlay/overlayRoot.ts);
94
+ above all content chrome, below error + titlebar */
88
95
  --hk-z-error: 95000; /* error landing overlay (covers everything) */
89
96
  --hk-z-titlebar: 100001; /* window titlebar (Tauri shell) */
90
97
 
@@ -71,7 +71,11 @@
71
71
  * base content (0) to the window titlebar (100001); a higher band
72
72
  * always paints above a lower one. Tooltips sit above toasts AND
73
73
  * modals so their text stays readable and copyable while any overlay
74
- * is showing (user direction 2026-10-15). */
74
+ * is showing (user direction 2026-10-15). --hk-z-overlay-root is the
75
+ * ONE band consumed by the fenced floating-layer root itself rather
76
+ * than by a component: it lifts #hk-overlay-root above every
77
+ * content-chrome band while keeping the pre-isolation order (floating
78
+ * layers < error landing < window titlebar). */
75
79
  --hk-z-base: 0; /* base content stacking context */
76
80
  --hk-z-above: 1; /* local bump: checkbox marks, switch thumbs */
77
81
  --hk-z-scrollbar: 10; /* scrollbar tracks and corner rulers */
@@ -87,6 +91,9 @@
87
91
  --hk-z-modal: 5000; /* modal backdrop + frame */
88
92
  --hk-z-tooltip: 6000; /* tooltip — above toasts and modals so its text
89
93
  is always readable and copyable */
94
+ --hk-z-overlay-root: 90000; /* #hk-overlay-root — every floating layer's
95
+ document-space elevation (overlay/overlayRoot.ts);
96
+ above all content chrome, below error + titlebar */
90
97
  --hk-z-error: 95000; /* error landing overlay (covers everything) */
91
98
  --hk-z-titlebar: 100001; /* window titlebar (Tauri shell) */
92
99