@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 +1 -1
- package/src/components/HkFilterOperatorIcon.tsx +69 -0
- package/src/components/HkFilterPanel.scss +8 -0
- package/src/components/HkFilterPanel.styles.test.ts +6 -0
- package/src/components/HkFilterPanel.test.tsx +11 -6
- package/src/components/HkFilterPanel.tsx +19 -16
- package/src/components/HkFilterTrigger.test.tsx +1 -1
- package/src/components/HkFilterTrigger.tsx +2 -2
- package/src/errorReporting/HkErrorReportingOverlay.scss +6 -3
- package/src/overlay/overlayRoot.scss +13 -1
- package/src/overlay/overlayRoot.styles.test.ts +185 -0
- package/src/overlay/overlayRoot.ts +42 -16
- package/src/scale.scss +8 -1
- package/src/styles/theme/scale.scss +8 -1
package/package.json
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
197
|
-
|
|
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.
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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[
|
|
332
|
-
title={t(OPERATOR_LABEL[
|
|
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
|
-
{
|
|
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.
|
|
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 {
|
|
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: () => <
|
|
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
|
-
//
|
|
3
|
-
//
|
|
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-
|
|
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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
* |
|
|
23
|
-
* |
|
|
24
|
-
* |
|
|
25
|
-
* |
|
|
26
|
-
* |
|
|
27
|
-
* |
|
|
28
|
-
* |
|
|
29
|
-
* |
|
|
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.
|
|
65
|
-
//
|
|
66
|
-
// the
|
|
67
|
-
|
|
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
|
|