@celestia-island/hikari 0.55.111 → 0.55.113

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.111",
3
+ "version": "0.55.113",
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,205 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { mobileSideGutterFree, scanPaddingSides, splitPaddingSides } from "./HkAdminShell";
4
+
5
+ /**
6
+ * Direct unit tests for the padding-shorthand helpers behind
7
+ * `HkAdminShell`'s `contentBleedOnMobile`.
8
+ *
9
+ * WHY THIS FILE EXISTS (and why the component-level suite cannot replace
10
+ * it): the DOM assertions in HkAdminShell.test.tsx run through
11
+ * happy-dom's CSS parser, which refuses to round-trip exactly the values
12
+ * these helpers exist to protect — `var(...)` with a fallback, `clamp()`,
13
+ * a quoted argument — and normalizes the ones it does keep, so a
14
+ * regression there is invisible at the component level (round-2 and
15
+ * round-3 verification both proved a surviving mutant that the DOM suite
16
+ * scored green). The helpers are exported for this file only: they are
17
+ * not re-exported from the package barrel.
18
+ *
19
+ * The contract, in one line each:
20
+ * - `splitPaddingSides` splits on TOP-LEVEL whitespace only (functions
21
+ * and quoted arguments are one track), tolerates tabs/newlines and
22
+ * surrounding whitespace, and never lets a stray `)` invert the depth;
23
+ * - `mobileSideGutterFree` keeps the vertical tracks, zeroes the
24
+ * horizontal ones, and returns the value VERBATIM whenever the rewrite
25
+ * could produce a declaration a browser would drop whole.
26
+ */
27
+ describe("splitPaddingSides", () => {
28
+ it("splits a plain shorthand into its tracks", () => {
29
+ expect(splitPaddingSides("2rem")).toEqual(["2rem"]);
30
+ expect(splitPaddingSides("1rem 2rem")).toEqual(["1rem", "2rem"]);
31
+ expect(splitPaddingSides("1rem 2rem 3rem")).toEqual(["1rem", "2rem", "3rem"]);
32
+ expect(splitPaddingSides("1rem 2rem 3rem 4rem")).toEqual(["1rem", "2rem", "3rem", "4rem"]);
33
+ });
34
+
35
+ it("treats every CSS separator like a space and drops no empty track", () => {
36
+ expect(splitPaddingSides("1rem\t2rem")).toEqual(["1rem", "2rem"]);
37
+ expect(splitPaddingSides("1rem\n2rem")).toEqual(["1rem", "2rem"]);
38
+ expect(splitPaddingSides("1rem \n\t 2rem")).toEqual(["1rem", "2rem"]);
39
+ expect(splitPaddingSides(" 1rem 2rem ")).toEqual(["1rem", "2rem"]);
40
+ expect(splitPaddingSides("")).toEqual([]);
41
+ expect(splitPaddingSides(" ")).toEqual([]);
42
+ });
43
+
44
+ it("keeps a function value as ONE track, however many spaces it holds", () => {
45
+ expect(splitPaddingSides("calc(1rem + 2px)")).toEqual(["calc(1rem + 2px)"]);
46
+ expect(splitPaddingSides("calc(1rem + 2px) 1rem")).toEqual(["calc(1rem + 2px)", "1rem"]);
47
+ expect(splitPaddingSides("1rem calc(2px + 3px)")).toEqual(["1rem", "calc(2px + 3px)"]);
48
+ // A space directly after "(" is inside the function too.
49
+ expect(splitPaddingSides("calc( 1rem + 2px ) 1rem")).toEqual(["calc( 1rem + 2px )", "1rem"]);
50
+ });
51
+
52
+ it("balances nested parentheses", () => {
53
+ expect(splitPaddingSides("clamp(1rem, min(2vw, 3px), 4rem) 2rem"))
54
+ .toEqual(["clamp(1rem, min(2vw, 3px), 4rem)", "2rem"]);
55
+ expect(splitPaddingSides("calc(1rem + calc(2px * 2))"))
56
+ .toEqual(["calc(1rem + calc(2px * 2))"]);
57
+ });
58
+
59
+ it("keeps a quoted argument intact, its parens and spaces included", () => {
60
+ expect(splitPaddingSides("var(--pad, \") 1rem\") 2rem"))
61
+ .toEqual(["var(--pad, \") 1rem\")", "2rem"]);
62
+ expect(splitPaddingSides("var(--pad, ' + ')")).toEqual(["var(--pad, ' + ')"]);
63
+ // A paren inside a SINGLE-quoted argument, and the other quote char
64
+ // inside it: only the matching quote closes the string.
65
+ expect(splitPaddingSides("var(--pad, ') x') 2rem"))
66
+ .toEqual(["var(--pad, ') x')", "2rem"]);
67
+ expect(splitPaddingSides("var(--pad, 'a\") b') 2rem"))
68
+ .toEqual(["var(--pad, 'a\") b')", "2rem"]);
69
+ });
70
+
71
+ it("opens a quote at top level too (and reports it unterminated)", () => {
72
+ expect(splitPaddingSides("\"a b")).toEqual(["\u0022a b"]);
73
+ expect(scanPaddingSides("\"a b").balanced).toBe(false);
74
+ });
75
+
76
+ it("never lets a stray close paren invert the depth", () => {
77
+ // Malformed CSS in, but the tracks must not silently merge: without
78
+ // the clamp the `)` would drive the depth to -1 and swallow the next
79
+ // separator.
80
+ expect(splitPaddingSides("1rem ) 2rem")).toEqual(["1rem", ")", "2rem"]);
81
+ });
82
+
83
+ it("reports an unterminated function or quote as unbalanced", () => {
84
+ expect(scanPaddingSides("calc(1rem + 2px")).toEqual({
85
+ sides: ["calc(1rem + 2px"],
86
+ balanced: false,
87
+ });
88
+ expect(scanPaddingSides("1rem 2rem").balanced).toBe(true);
89
+ expect(scanPaddingSides("calc(1rem + 2px) 1rem").balanced).toBe(true);
90
+ expect(scanPaddingSides("var(--pad, ') x')").balanced).toBe(true);
91
+ });
92
+ });
93
+
94
+ describe("mobileSideGutterFree", () => {
95
+ it("keeps the vertical tracks and zeroes the horizontal ones", () => {
96
+ expect(mobileSideGutterFree("2rem")).toBe("2rem 0 2rem");
97
+ expect(mobileSideGutterFree("1rem 2rem")).toBe("1rem 0 1rem");
98
+ expect(mobileSideGutterFree("1rem 2rem 3rem")).toBe("1rem 0 3rem");
99
+ expect(mobileSideGutterFree("1rem 2rem 3rem 4rem")).toBe("1rem 0 3rem");
100
+ expect(mobileSideGutterFree("0")).toBe("0 0 0");
101
+ expect(mobileSideGutterFree(" 1rem\t2rem ")).toBe("1rem 0 1rem");
102
+ });
103
+
104
+ it("keeps a function value whole", () => {
105
+ expect(mobileSideGutterFree("calc(1rem + 2px)")).toBe("calc(1rem + 2px) 0 calc(1rem + 2px)");
106
+ expect(mobileSideGutterFree("calc(1rem + 2px) 1rem")).toBe("calc(1rem + 2px) 0 calc(1rem + 2px)");
107
+ expect(mobileSideGutterFree("5rem clamp(1rem, min(2vw, 3px), 4rem)")).toBe("5rem 0 5rem");
108
+ });
109
+
110
+ it("returns the value verbatim when the rewrite cannot be expressed", () => {
111
+ // Nothing to strip.
112
+ expect(mobileSideGutterFree("")).toBe("");
113
+ expect(mobileSideGutterFree(" ")).toBe(" ");
114
+ // A CSS-wide keyword cannot be combined per side: `inherit 0 inherit`
115
+ // is not a padding at all, so bleeding would cost the page its
116
+ // vertical clearance.
117
+ for (const keyword of ["inherit", "initial", "unset", "revert", "revert-layer"]) {
118
+ expect(mobileSideGutterFree(keyword), keyword).toBe(keyword);
119
+ expect(mobileSideGutterFree(keyword.toUpperCase()), keyword).toBe(keyword.toUpperCase());
120
+ }
121
+ // A comment's text is not a track; rebuilding would emit one
122
+ // ("1rem 0 x") that a browser drops whole. An UNCLOSED comment bails
123
+ // the same way, and so does a quoted `/*` — the guard reads the raw
124
+ // text and does not try to be clever about strings.
125
+ expect(mobileSideGutterFree("1rem /* x */ 2rem")).toBe("1rem /* x */ 2rem");
126
+ expect(mobileSideGutterFree("1rem /* x 2rem")).toBe("1rem /* x 2rem");
127
+ expect(mobileSideGutterFree("var(--pad, \"/*\") 2rem")).toBe("var(--pad, \"/*\") 2rem");
128
+ });
129
+
130
+ it("refuses any value carrying a backslash escape", () => {
131
+ // The scanner does not decode CSS escapes, and an escape can hide a
132
+ // quote, a paren or a whole keyword — `"\69 nherit"` IS `inherit` to
133
+ // an engine (measured in Chromium), so the literal text is not the
134
+ // value and no rewrite of it can be trusted. Round 4/5 verification
135
+ // measured every one of these losing all four sides to a dropped
136
+ // declaration.
137
+ const escaped = [
138
+ "\\69 nherit",
139
+ "var(--pad, \"a\\\"b\") 2rem",
140
+ "var(--pad, 'a\\'b') 2rem",
141
+ "var(--pad, a\\(b) 2rem",
142
+ "\"a\\\" b\" 1rem",
143
+ ];
144
+ for (const value of escaped) {
145
+ expect(mobileSideGutterFree(value), value).toBe(value);
146
+ }
147
+ });
148
+
149
+ it("refuses a value whose functions or quotes never close", () => {
150
+ // CSS auto-closes an open function at EOF, so the AUTHORED value can
151
+ // be a real padding (Chromium: 18px on all four sides for
152
+ // "calc(1rem + 2px") while the rewrite of it is not — the rewrite
153
+ // would cost the page every side, vertical included.
154
+ const unterminated = [
155
+ "calc(1rem + 2px",
156
+ "calc(1rem",
157
+ "min(1rem, 2rem",
158
+ "1rem calc(2px",
159
+ "\"a b",
160
+ "var(--pad, 'x",
161
+ ];
162
+ for (const value of unterminated) {
163
+ expect(mobileSideGutterFree(value), value).toBe(value);
164
+ }
165
+ });
166
+
167
+ it("refuses a value whose priority annotation cannot ride the rewrite", () => {
168
+ // `!important` annotates the DECLARATION, not a track. Rewriting it
169
+ // would either drop the priority (`2rem !important` -> `2rem 0 2rem`)
170
+ // or rebuild a declaration every engine drops (`inherit !important`
171
+ // -> `inherit 0 inherit`, measured in Chromium as rejected whole,
172
+ // with the page falling back to the cascade). Round 5 found the
173
+ // family; the value now passes through untouched.
174
+ const flagged = [
175
+ "2rem !important",
176
+ "1rem 2rem 3rem !important",
177
+ "inherit !important",
178
+ "unset !important",
179
+ "initial!important",
180
+ "revert-layer !important",
181
+ "2rem ! important",
182
+ "2rem !IMPORTANT",
183
+ "2rem !important ",
184
+ ];
185
+ for (const value of flagged) {
186
+ expect(mobileSideGutterFree(value), value).toBe(value);
187
+ }
188
+ // …and only a TRAILING annotation is one: mid-value text is just
189
+ // another track (the authored value is invalid CSS either way, so
190
+ // this guard makes no promise about it).
191
+ expect(mobileSideGutterFree("1rem !important 2rem")).toBe("1rem 0 2rem");
192
+ });
193
+
194
+ it("still rewrites a keyword-shaped track that is not alone", () => {
195
+ // A CSS-wide keyword is only valid as a declaration's SOLE value, so
196
+ // "inherit 2rem" is invalid CSS whatever we do with it, and its
197
+ // rewrite is dropped exactly like the authored value. The lone
198
+ // spelling above IS valid, which is why it bails instead. ("1rem
199
+ // inherit" is the looser case: the authored value is already
200
+ // invalid, while the rewrite happens to be valid — a repair, not a
201
+ // loss.)
202
+ expect(mobileSideGutterFree("inherit 2rem")).toBe("inherit 0 inherit");
203
+ expect(mobileSideGutterFree("1rem inherit")).toBe("1rem 0 1rem");
204
+ });
205
+ });
@@ -42,7 +42,9 @@ import { HkAdminShell } from "./HkAdminShell";
42
42
  * 3. The header slot receives `onOpenDrawer` so a header trigger (the
43
43
  * avatar in drawer action mode) can open the nav drawer.
44
44
  * 4. Content padding rides an inner wrapper inside the scroll viewport
45
- * (card box-shadows are never clipped at the viewport edges).
45
+ * (card box-shadows are never clipped at the viewport edges), and the
46
+ * mobile side-gutter drop happens ONLY for a page that declares
47
+ * `contentBleedOnMobile` — an ordinary page keeps its phone gutters.
46
48
  *
47
49
  * (Repo test convention: raw createApp + container queries, no
48
50
  * @vue/test-utils dependency.)
@@ -246,15 +248,86 @@ describe("HkAdminShell", () => {
246
248
  expect(inner?.style.padding).toBe("2rem");
247
249
  });
248
250
 
249
- it("drops the horizontal half of the content padding below the mobile breakpoint", async () => {
250
- // Phone width: side gutters must vanish; vertical clearance survives.
251
+ it("keeps the content padding on phones unless the page declares a bleed", async () => {
252
+ // 2026-10-04 regression: the shell used to drop the side gutters on
253
+ // EVERY page below the breakpoint, so an ordinary roster / overview
254
+ // page rendered flush against the phone edges. The drop is opt-in.
251
255
  // Asserted via longhands: happy-dom's shorthand parser collapses
252
256
  // 3-value paddings, so style.padding cannot distinguish them here.
253
257
  setWidth(390);
254
- const mobileShell = mount(shellNode(
258
+ const plain = mount(shellNode(
255
259
  { navTitle: "Navigation", contentPadding: "2rem" },
256
260
  { header: () => null, sidebar: NAV, content: CONTENT },
257
261
  ));
262
+ const plainInner = paddingInner(plain);
263
+ expect(plainInner?.style.paddingTop).toBe("2rem");
264
+ expect(plainInner?.style.paddingBottom).toBe("2rem");
265
+ expect(plainInner?.style.paddingLeft).toBe("2rem");
266
+ expect(plainInner?.style.paddingRight).toBe("2rem");
267
+
268
+ // A multi-value value stays verbatim too — no per-side rewriting of
269
+ // a page that never asked for the bleed.
270
+ const plainMulti = mount(shellNode(
271
+ { navTitle: "Navigation", contentPadding: "1rem 2rem" },
272
+ { header: () => null, sidebar: NAV, content: CONTENT },
273
+ ));
274
+ const plainMultiInner = paddingInner(plainMulti);
275
+ expect(plainMultiInner?.style.paddingTop).toBe("1rem");
276
+ expect(plainMultiInner?.style.paddingLeft).toBe("2rem");
277
+ expect(plainMultiInner?.style.paddingRight).toBe("2rem");
278
+
279
+ // Resizing within the sub-breakpoint range never changes it either —
280
+ // asserted across the WHOLE range, not at hand-picked widths. The
281
+ // regression is "an ordinary page loses its gutters on phones", and
282
+ // rounds 2 and 3 both escaped guards that sampled a few widths by
283
+ // moving the leak into a band those widths miss (a mutant confined to
284
+ // [400, 499] would strip the gutters of a Pixel 412 / iPhone 430
285
+ // while the suite stayed green).
286
+ for (const width of [320, 360, 390, 412, 430, 500, 640, 700, 768, 900, 1023]) {
287
+ setWidth(width);
288
+ await nextTick();
289
+ expect(plainInner?.style.paddingLeft, `at ${width}px`).toBe("2rem");
290
+ expect(plainInner?.style.paddingRight, `at ${width}px`).toBe("2rem");
291
+ // …VERTICAL included: the regression is not only "the sides moved".
292
+ expect(plainInner?.style.paddingTop, `at ${width}px`).toBe("2rem");
293
+ expect(plainInner?.style.paddingBottom, `at ${width}px`).toBe("2rem");
294
+ expect(plainMultiInner?.style.paddingLeft, `at ${width}px (multi)`).toBe("2rem");
295
+ }
296
+
297
+ // A custom breakpoint on an UNDECLARED page must not move its gutters
298
+ // either (round 4 mutated the guard to depend on the 1024 default and
299
+ // every custom-breakpoint mount in this file declared a bleed).
300
+ const plainNarrow = mount(shellNode(
301
+ { navTitle: "Navigation", contentPadding: "2rem", mobileBreakpoint: 768 },
302
+ { header: () => null, sidebar: NAV, content: CONTENT },
303
+ ));
304
+ setWidth(500);
305
+ await nextTick();
306
+ expect(paddingInner(plainNarrow)?.style.paddingLeft).toBe("2rem");
307
+ expect(paddingInner(plainNarrow)?.style.paddingRight).toBe("2rem");
308
+
309
+ // An explicit `false` reads exactly like an absent prop.
310
+ const declaredOff = mount(shellNode(
311
+ { navTitle: "Navigation", contentPadding: "1rem 2rem", contentBleedOnMobile: false },
312
+ { header: () => null, sidebar: NAV, content: CONTENT },
313
+ ));
314
+ expect(paddingInner(declaredOff)?.style.paddingLeft).toBe("2rem");
315
+
316
+ // Above the breakpoint it is verbatim for the declared page too.
317
+ setWidth(1280);
318
+ await nextTick();
319
+ expect(mobileInnerPadding(plain)).toBe("2rem");
320
+ });
321
+
322
+ it("drops the horizontal half of the content padding below the mobile breakpoint when the page declares a bleed", async () => {
323
+ // A page that declares `contentBleedOnMobile` (a 2D board or the 3D
324
+ // scene) loses the side gutters on phones; vertical clearance
325
+ // survives. Asserted via longhands — see the note above.
326
+ setWidth(390);
327
+ const mobileShell = mount(shellNode(
328
+ { navTitle: "Navigation", contentPadding: "2rem", contentBleedOnMobile: true },
329
+ { header: () => null, sidebar: NAV, content: CONTENT },
330
+ ));
258
331
  const inner = paddingInner(mobileShell);
259
332
  expect(inner?.style.paddingTop).toBe("2rem");
260
333
  expect(inner?.style.paddingBottom).toBe("2rem");
@@ -264,7 +337,7 @@ describe("HkAdminShell", () => {
264
337
  // Multi-value shorthand keeps per-side meaning: vertical survives,
265
338
  // horizontal is zeroed (never the inverted "1rem 2rem 0").
266
339
  const multi = mount(shellNode(
267
- { navTitle: "Navigation", contentPadding: "1rem 2rem" },
340
+ { navTitle: "Navigation", contentPadding: "1rem 2rem", contentBleedOnMobile: true },
268
341
  { header: () => null, sidebar: NAV, content: CONTENT },
269
342
  ));
270
343
  const multiInner = paddingInner(multi);
@@ -273,9 +346,196 @@ describe("HkAdminShell", () => {
273
346
  expect(multiInner?.style.paddingLeft).toBe("0px");
274
347
  expect(multiInner?.style.paddingRight).toBe("0px");
275
348
 
276
- // Crossing back to desktop restores the verbatim padding.
349
+ // Three-value shorthand: the vertical tracks are the FIRST and the
350
+ // THIRD, so the parser has to keep top and bottom in order. The
351
+ // 1- and 2-value cases above cannot see a swapped pair — with ≤2
352
+ // values the bottom track defaults to the top one, which makes the
353
+ // correct and the transposed string identical (round-1 verification
354
+ // mutation (e) survived on exactly that hole).
355
+ const triple = mount(shellNode(
356
+ { navTitle: "Navigation", contentPadding: "1rem 2rem 3rem", contentBleedOnMobile: true },
357
+ { header: () => null, sidebar: NAV, content: CONTENT },
358
+ ));
359
+ const tripleInner = paddingInner(triple);
360
+ expect(tripleInner?.style.paddingTop).toBe("1rem");
361
+ expect(tripleInner?.style.paddingBottom).toBe("3rem");
362
+ expect(tripleInner?.style.paddingLeft).toBe("0px");
363
+ expect(tripleInner?.style.paddingRight).toBe("0px");
364
+
365
+ // Four-value shorthand: right and left are DIFFERENT tracks, and the
366
+ // rebuilt declaration has no horizontal track at all — both sides
367
+ // must read zero. A parser that lands a horizontal value in the wrong
368
+ // slot is invisible to the shorter cases (round-2 verification
369
+ // mutated "1rem 2rem 3rem 4rem" into "1rem 0 3rem 2rem" and the suite
370
+ // stayed green).
371
+ const quad = mount(shellNode(
372
+ { navTitle: "Navigation", contentPadding: "1rem 2rem 3rem 4rem", contentBleedOnMobile: true },
373
+ { header: () => null, sidebar: NAV, content: CONTENT },
374
+ ));
375
+ const quadInner = paddingInner(quad);
376
+ expect(quadInner?.style.paddingTop).toBe("1rem");
377
+ expect(quadInner?.style.paddingBottom).toBe("3rem");
378
+ expect(quadInner?.style.paddingLeft).toBe("0px");
379
+ expect(quadInner?.style.paddingRight).toBe("0px");
380
+
381
+ // A function value is ONE track: whitespace inside `calc(...)` belongs
382
+ // to the value, not to the shorthand. Splitting on plain whitespace
383
+ // shreds it into three tracks and rebuilds an invalid declaration,
384
+ // which a real browser drops WHOLE — taking the vertical clearance
385
+ // this prop promises to keep with it (rounds 2 and 3 both hit this on
386
+ // the shell as it shipped). happy-dom stores calc() values, so the
387
+ // longhands are readable here; it cannot round-trip var()/min(), which
388
+ // is why calc() is the probe.
389
+ const fn = mount(shellNode(
390
+ { navTitle: "Navigation", contentPadding: "calc(1rem + 2px) 1rem", contentBleedOnMobile: true },
391
+ { header: () => null, sidebar: NAV, content: CONTENT },
392
+ ));
393
+ const fnInner = paddingInner(fn);
394
+ expect(fnInner?.style.paddingTop).toBe("calc(1rem + 2px)");
395
+ expect(fnInner?.style.paddingBottom).toBe("calc(1rem + 2px)");
396
+ expect(fnInner?.style.paddingLeft).toBe("0px");
397
+ expect(fnInner?.style.paddingRight).toBe("0px");
398
+
399
+ // NESTED functions are one track as well (the depth counter, not a
400
+ // single-level check): a bare `clamp()` cannot be the probe because
401
+ // happy-dom rejects it outright, so the function rides in the second
402
+ // track of a plain length.
403
+ const nested = mount(shellNode(
404
+ { navTitle: "Navigation", contentPadding: "5rem clamp(1rem, min(2vw, 3px), 4rem)", contentBleedOnMobile: true },
405
+ { header: () => null, sidebar: NAV, content: CONTENT },
406
+ ));
407
+ const nestedInner = paddingInner(nested);
408
+ expect(nestedInner?.style.paddingTop).toBe("5rem");
409
+ expect(nestedInner?.style.paddingBottom).toBe("5rem");
410
+ expect(nestedInner?.style.paddingLeft).toBe("0px");
411
+ expect(nestedInner?.style.paddingRight).toBe("0px");
412
+
413
+ // …and so is a space directly after the opening parenthesis.
414
+ const spacedFn = mount(shellNode(
415
+ { navTitle: "Navigation", contentPadding: "calc( 1rem + 2px ) 1rem", contentBleedOnMobile: true },
416
+ { header: () => null, sidebar: NAV, content: CONTENT },
417
+ ));
418
+ const spacedFnInner = paddingInner(spacedFn);
419
+ expect(spacedFnInner?.style.paddingTop).toBe("calc( 1rem + 2px )");
420
+ expect(spacedFnInner?.style.paddingBottom).toBe("calc( 1rem + 2px )");
421
+ expect(spacedFnInner?.style.paddingLeft).toBe("0px");
422
+
423
+ // A lone CSS-wide keyword cannot be rewritten per side (`inherit 0
424
+ // inherit` is not a padding — every engine rejects it), so the value
425
+ // passes through VERBATIM: the page keeps its padding instead of
426
+ // losing all four sides to a dropped declaration. (The comment case
427
+ // behaves the same way; it is pinned in HkAdminShell.padding.test.ts
428
+ // because happy-dom refuses to store a comment-bearing value at all.)
429
+ for (const keyword of ["inherit", "unset"]) {
430
+ const wide = mount(shellNode(
431
+ { navTitle: "Navigation", contentPadding: keyword, contentBleedOnMobile: true },
432
+ { header: () => null, sidebar: NAV, content: CONTENT },
433
+ ));
434
+ expect(paddingInner(wide)?.style.padding, keyword).toBe(keyword);
435
+ }
436
+
437
+ // Tabs and newlines are valid CSS separators, and the whitespace
438
+ // around a shorthand is not a track of its own ("\t1rem 2rem" must
439
+ // read exactly like "1rem 2rem", never like a three-track value).
440
+ for (const padded of ["1rem\t2rem", "1rem\n2rem", " 1rem 2rem ", "\t1rem 2rem"]) {
441
+ const ws = mount(shellNode(
442
+ { navTitle: "Navigation", contentPadding: padded, contentBleedOnMobile: true },
443
+ { header: () => null, sidebar: NAV, content: CONTENT },
444
+ ));
445
+ const wsInner = paddingInner(ws);
446
+ expect(wsInner?.style.paddingTop, JSON.stringify(padded)).toBe("1rem");
447
+ expect(wsInner?.style.paddingBottom, JSON.stringify(padded)).toBe("1rem");
448
+ expect(wsInner?.style.paddingLeft, JSON.stringify(padded)).toBe("0px");
449
+ expect(wsInner?.style.paddingRight, JSON.stringify(padded)).toBe("0px");
450
+ }
451
+
452
+ // Nothing to strip: an empty value passes through untouched, it does
453
+ // NOT become a zero padding of its own.
454
+ const blank = mount(shellNode(
455
+ { navTitle: "Navigation", contentPadding: "", contentBleedOnMobile: true },
456
+ { header: () => null, sidebar: NAV, content: CONTENT },
457
+ ));
458
+ expect(paddingInner(blank)?.style.padding).toBe("");
459
+
460
+ // The declared page bleeds across the WHOLE sub-breakpoint range, not
461
+ // just at the hand-picked widths above: round 4 mutated the bleed to
462
+ // stop inside [400, 499) and nothing in this file sampled a declared
463
+ // page in that band (412 / 430 px phones included).
464
+ for (const width of [320, 360, 390, 412, 430, 500, 640, 700, 768, 900, 1023]) {
465
+ setWidth(width);
466
+ await nextTick();
467
+ expect(paddingInner(mobileShell)?.style.paddingLeft, `declared at ${width}px`).toBe("0px");
468
+ expect(paddingInner(mobileShell)?.style.paddingRight, `declared at ${width}px`).toBe("0px");
469
+ expect(paddingInner(mobileShell)?.style.paddingTop, `declared at ${width}px`).toBe("2rem");
470
+ }
471
+
472
+ // The shell's own default padding (1.5rem) is what a consumer that
473
+ // passes nothing actually gets — pinned here because this file
474
+ // otherwise only ever exercises explicit values.
475
+ const defaulted = mount(shellNode(
476
+ { navTitle: "Navigation", contentBleedOnMobile: true },
477
+ { header: () => null, sidebar: NAV, content: CONTENT },
478
+ ));
479
+ setWidth(390);
480
+ await nextTick();
481
+ expect(paddingInner(defaulted)?.style.paddingTop).toBe("1.5rem");
482
+ expect(paddingInner(defaulted)?.style.paddingBottom).toBe("1.5rem");
483
+ expect(paddingInner(defaulted)?.style.paddingLeft).toBe("0px");
484
+ expect(paddingInner(defaulted)?.style.paddingRight).toBe("0px");
485
+
486
+ // The gate follows THIS shell's breakpoint, and the boundary is the
487
+ // breakpoint itself: 1023 is still mobile (bleed), 1024 is desktop
488
+ // (verbatim) — the same `>=` the sidebar takes over on.
489
+ setWidth(1023);
490
+ await nextTick();
491
+ expect(paddingInner(mobileShell)?.style.paddingLeft).toBe("0px");
492
+ setWidth(1024);
493
+ await nextTick();
494
+ expect(paddingInner(mobileShell)?.style.paddingLeft).toBe("2rem");
495
+
496
+ // …and a custom breakpoint moves it: the padding gate must read the
497
+ // same `isDesktop` the layout does, not the 1024 default (round 3
498
+ // mutated it to a hardcoded 1024 and the suite stayed green).
499
+ const narrow = mount(shellNode(
500
+ { navTitle: "Navigation", contentPadding: "2rem", contentBleedOnMobile: true, mobileBreakpoint: 768 },
501
+ { header: () => null, sidebar: NAV, content: CONTENT },
502
+ ));
503
+ setWidth(767);
504
+ await nextTick();
505
+ expect(paddingInner(narrow)?.style.paddingLeft).toBe("0px");
506
+ setWidth(768);
507
+ await nextTick();
508
+ expect(paddingInner(narrow)?.style.paddingLeft).toBe("2rem");
509
+ setWidth(800);
510
+ await nextTick();
511
+ expect(paddingInner(narrow)?.style.paddingLeft).toBe("2rem");
512
+
513
+ // …in BOTH directions: a breakpoint raised above the 1024 default must
514
+ // move the padding gate too. Round 3 mutated the gate to saturate at
515
+ // 1024 and only a downward custom breakpoint was covered, so the
516
+ // mirror image survived.
517
+ const tablet = mount(shellNode(
518
+ { navTitle: "Navigation", contentPadding: "2rem", contentBleedOnMobile: true, mobileBreakpoint: 1440 },
519
+ { header: () => null, sidebar: NAV, content: CONTENT },
520
+ ));
521
+ setWidth(1200);
522
+ await nextTick();
523
+ expect(paddingInner(tablet)?.style.paddingLeft).toBe("0px");
524
+ setWidth(1440);
525
+ await nextTick();
526
+ expect(paddingInner(tablet)?.style.paddingLeft).toBe("2rem");
527
+
528
+ // The declaration never reaches desktop: crossing back above the
529
+ // breakpoint restores the verbatim padding.
277
530
  setWidth(1280);
278
531
  await nextTick();
279
532
  expect(mobileInnerPadding(mobileShell)).toBe("2rem");
533
+ expect(mobileInnerPadding(multi)).toBe("1rem 2rem");
534
+ // …per side, too: the restored shorthand must not be a collapsed
535
+ // approximation of the three tracks it came from.
536
+ expect(tripleInner?.style.paddingTop).toBe("1rem");
537
+ expect(tripleInner?.style.paddingBottom).toBe("3rem");
538
+ expect(tripleInner?.style.paddingLeft).toBe("2rem");
539
+ expect(tripleInner?.style.paddingRight).toBe("2rem");
280
540
  });
281
541
  });
@@ -3,6 +3,133 @@ import { HDrawer, HScrollContainer, useBreakpoint } from "@celestia-island/hikar
3
3
  import { provideActionBar } from "../composables/useActionBar";
4
4
  import { useI18n } from "../i18n/context";
5
5
 
6
+ /** CSS-wide keywords. A shorthand rebuilt from one of them is NOT a
7
+ * padding (`inherit 0 inherit` is rejected by every engine, measured in
8
+ * Chromium), so the declaration would be dropped WHOLE and the page
9
+ * would lose the vertical clearance too — worse than not bleeding. */
10
+ const CSS_WIDE_KEYWORDS = new Set(["inherit", "initial", "unset", "revert", "revert-layer"]);
11
+
12
+ /**
13
+ * A scanned padding shorthand: the top-level `sides` plus whether the
14
+ * value ENDED with every function closed and every quote terminated.
15
+ * `balanced: false` means the scan cannot know where the value ends —
16
+ * CSS closes an open function at EOF, so the authored declaration can
17
+ * still be valid while a rewrite of it cannot be.
18
+ */
19
+ export interface PaddingSidesScan {
20
+ sides: string[];
21
+ balanced: boolean;
22
+ }
23
+
24
+ /**
25
+ * Scan a CSS padding shorthand into its top-level tracks: split on
26
+ * TOP-LEVEL whitespace, quote- and function-aware, so whitespace inside
27
+ * a function belongs to that value — `calc(1rem + 2px)`,
28
+ * `var(--pad, 1rem)` and a quoted argument are ONE track. A plain
29
+ * `split(/\s+/)` shreds them (`["calc(1rem", "+", "2px)"]`), and the
30
+ * rebuilt shorthand is invalid CSS — a browser drops the whole
31
+ * declaration, taking with it the vertical clearance
32
+ * `contentBleedOnMobile` promises to keep (rounds 2 and 3 of this change
33
+ * hit exactly that; rounds 4 and 5 the escape and EOF variants). The
34
+ * depth counter balances parentheses, so nested functions
35
+ * (`clamp(1rem, min(2vw, 3px), 4rem)`) survive; a stray `)` never drives
36
+ * the depth negative. Tabs and newlines are valid CSS separators and
37
+ * split like spaces, and the whitespace around a shorthand yields no
38
+ * empty tracks.
39
+ *
40
+ * Exported for its own unit test: happy-dom cannot round-trip
41
+ * `var()`/`clamp()` values or the hostile inputs below, so the
42
+ * component-level suite cannot observe them.
43
+ */
44
+ export function scanPaddingSides(value: string): PaddingSidesScan {
45
+ const sides: string[] = [];
46
+ let current = "";
47
+ let depth = 0;
48
+ let quote = "";
49
+ for (const ch of value) {
50
+ if (quote) {
51
+ current += ch;
52
+ if (ch === quote) quote = "";
53
+ continue;
54
+ }
55
+ if (ch === "\"" || ch === "'") {
56
+ quote = ch;
57
+ current += ch;
58
+ continue;
59
+ }
60
+ if (ch === "(") depth += 1;
61
+ else if (ch === ")") depth = Math.max(0, depth - 1);
62
+ if (depth === 0 && /\s/.test(ch)) {
63
+ if (current) sides.push(current);
64
+ current = "";
65
+ continue;
66
+ }
67
+ current += ch;
68
+ }
69
+ if (current) sides.push(current);
70
+ return { sides, balanced: depth === 0 && quote === "" };
71
+ }
72
+
73
+ /** The tracks of {@link scanPaddingSides} — for callers that only need
74
+ * the split, and for the unit test that pins the splitting rules. */
75
+ export function splitPaddingSides(value: string): string[] {
76
+ return scanPaddingSides(value).sides;
77
+ }
78
+
79
+ /**
80
+ * The side-gutter-free form of a padding shorthand: the vertical tracks
81
+ * survive, the horizontal ones become zero (`"1rem 2rem"` -> `"1rem 0
82
+ * 1rem"`). The value is returned VERBATIM — the page keeps its padding
83
+ * and simply does not bleed — whenever the shorthand cannot be rewritten
84
+ * safely. That fallback IS the contract: emitting a declaration a browser
85
+ * drops costs the page ALL FOUR sides, including the vertical clearance
86
+ * this prop exists to keep.
87
+ *
88
+ * Verbatim cases:
89
+ *
90
+ * - an empty / whitespace-only value has nothing to strip;
91
+ * - a LONE CSS-wide keyword cannot be combined per side (`inherit 0
92
+ * inherit` is not a padding — every engine rejects it);
93
+ * - the value contains a CSS comment, whose text would land in the
94
+ * rebuilt declaration as a garbage track (`1rem 0 x`, measured in
95
+ * Chromium: dropped, all four sides lost);
96
+ * - the value contains a backslash: this scanner does not decode CSS
97
+ * escapes, and an escape can hide a quote, a parenthesis or a whole
98
+ * keyword (`\69 nherit` IS `inherit` to an engine) — the literal text
99
+ * is not the value, so no rewrite of it is trustworthy (round 5);
100
+ * - the value ends with a `!important` annotation: the annotation
101
+ * belongs to the DECLARATION, so a per-side rewrite would either
102
+ * silently lose the priority (`2rem !important`) or rebuild the
103
+ * `inherit 0 inherit` every engine rejects (`inherit !important`,
104
+ * measured in Chromium, round 5);
105
+ * - the scan ends inside an open function or an unterminated quote: CSS
106
+ * auto-closes a function at EOF, so `"calc(1rem + 2px"` IS a real
107
+ * padding (measured: all four sides) while its rewrite is not; an
108
+ * unterminated quote is invalid at both ends, and bailing keeps the
109
+ * two paths identical (round 5).
110
+ *
111
+ * Known residual (documented, not fixable at string level): a `var()`
112
+ * whose custom property resolves to MORE than one track cannot keep its
113
+ * vertical half — `padding: var(--multi) 0 var(--multi)` is valid at
114
+ * parse time and invalid at computed-value time, and all four sides
115
+ * collapse. No consumer passes a multi-track custom property today.
116
+ */
117
+ export function mobileSideGutterFree(value: string): string {
118
+ if (value.includes("/*") || value.includes("\\")) return value;
119
+ // `!important` is an annotation on the DECLARATION, not a track — it
120
+ // cannot ride a per-side rewrite: `2rem !important` would silently lose
121
+ // its priority, and `inherit !important` would rebuild into the
122
+ // `inherit 0 inherit` every engine drops (measured in Chromium, round
123
+ // 5). Bail out and let the authored padding stand.
124
+ if (/!\s*important\s*$/i.test(value)) return value;
125
+ const { sides, balanced } = scanPaddingSides(value);
126
+ if (!balanced) return value;
127
+ if (sides.length === 0) return value;
128
+ if (sides.length === 1 && CSS_WIDE_KEYWORDS.has(sides[0].toLowerCase())) return value;
129
+ const [top, , bottom] = sides;
130
+ return `${top} 0 ${bottom ?? top}`;
131
+ }
132
+
6
133
  export const HkAdminShell = defineComponent({
7
134
  name: "HkAdminShell",
8
135
  props: {
@@ -21,12 +148,29 @@ export const HkAdminShell = defineComponent({
21
148
  drawerPanelClass: { type: String, default: undefined },
22
149
  /** Content-area padding (inside the scroll viewport). Padding is
23
150
  * applied to an inner wrapper rather than the scroll container so
24
- * card box-shadows are never clipped at the viewport edges. Below
25
- * the mobile breakpoint the horizontal half is dropped
26
- * automatically (2026-10-03 user direction: on phones the content
27
- * area must span the full width for 2D panels and the 3D scene —
28
- * only the vertical clearance survives). */
151
+ * card box-shadows are never clipped at the viewport edges. Applied
152
+ * verbatim at every width unless the page opts into
153
+ * `contentBleedOnMobile`. */
29
154
  contentPadding: { type: String, default: "1.5rem" },
155
+ /** Whether the content on screen is a CANVAS — a 2D board or the 3D
156
+ * scene — that must span the full phone width. OPT-IN, declared by
157
+ * the page that renders the canvas (2026-10-04 user direction: the
158
+ * side-gutter drop is a PER-PAGE declaration, never a shell-wide
159
+ * rule — an ordinary page keeps its side gutters on phones).
160
+ *
161
+ * When true, below `mobileBreakpoint` the horizontal half of
162
+ * `contentPadding` is dropped while the vertical clearance
163
+ * survives; desktop is never affected and the verbatim padding is
164
+ * restored when the viewport crosses back above the breakpoint.
165
+ * A `contentPadding` that cannot be rewritten per side (empty, a
166
+ * lone CSS-wide keyword, or a value this scanner cannot trust: one
167
+ * carrying a CSS comment or a backslash escape, one ending in a
168
+ * `!important` annotation, or one whose functions/quotes never
169
+ * close) falls back to the verbatim value — see
170
+ * `mobileSideGutterFree`; the page then keeps its gutters instead
171
+ * of risking a declaration a browser would drop whole.
172
+ * Default false: the padding reads the same at every width. */
173
+ contentBleedOnMobile: { type: Boolean, default: false },
30
174
  },
31
175
  setup(props, { slots }) {
32
176
  const { t } = useI18n();
@@ -34,20 +178,10 @@ export const HkAdminShell = defineComponent({
34
178
  const isDesktop = computed(() => viewportWidth.value >= props.mobileBreakpoint);
35
179
  const sidebarOpen = ref(false);
36
180
 
37
- // Content padding with the mobile side-gutter rule applied: desktop
38
- // gets the value verbatim; below the breakpoint the horizontal
39
- // tracks become zero while the vertical tracks survive. The CSS
40
- // padding shorthand is parsed per side so multi-value values keep
41
- // their meaning ("1rem 2rem" -> "1rem 0", not the invalid or
42
- // inverted "1rem 2rem 0"). An empty value passes through untouched.
43
- const mobileSideGutterFree = (value: string): string => {
44
- const sides = value.trim().split(/\s+/).filter(Boolean);
45
- if (sides.length === 0) return value;
46
- const [top, , bottom] = sides;
47
- return `${top} 0 ${bottom ?? top}`;
48
- };
49
181
  const contentStyle = computed(() => ({
50
- padding: isDesktop.value ? props.contentPadding : mobileSideGutterFree(props.contentPadding),
182
+ padding: isDesktop.value || !props.contentBleedOnMobile
183
+ ? props.contentPadding
184
+ : mobileSideGutterFree(props.contentPadding),
51
185
  }));
52
186
 
53
187
  const actionBar = provideActionBar();
@@ -143,3 +143,24 @@ describe("HkFloatingLayer", () => {
143
143
  expect(m.root()!.querySelector(".probe")).not.toBeNull();
144
144
  });
145
145
  });
146
+
147
+ describe("HkFloatingLayer yieldToWindows", () => {
148
+ it("hides while any window is open and returns when the last window closes", async () => {
149
+ const manager = usePopupManager();
150
+ const win = manager.register("modal", true, "Blocking");
151
+ const m = mountLayer({ yieldToWindows: true });
152
+ expect(m.root()).toBeNull();
153
+ manager.unregister(win.id);
154
+ await nextTick();
155
+ expect(m.root()).not.toBeNull();
156
+ });
157
+
158
+ it("default layers keep floating over windows (opt-in only)", async () => {
159
+ const manager = usePopupManager();
160
+ const win = manager.register("modal", true, "Blocking");
161
+ const m = mountLayer({});
162
+ expect(m.root()).not.toBeNull();
163
+ manager.unregister(win.id);
164
+ await nextTick();
165
+ });
166
+ });
@@ -7,7 +7,7 @@ import {
7
7
  type PropType,
8
8
  } from "vue";
9
9
 
10
- import { usePopupManager } from "../runtime/usePopupManager";
10
+ import { hasLiveWindow, usePopupManager } from "../runtime/usePopupManager";
11
11
  import "./HkFloatingLayer.scss";
12
12
 
13
13
  type FloatCorner = "bottom-right" | "bottom-left" | "top-right" | "top-left";
@@ -63,6 +63,11 @@ export default defineComponent({
63
63
  offsetY: { type: String, default: undefined },
64
64
  /** Accessible group name for the chrome (e.g. "Date dial"). */
65
65
  ariaLabel: { type: String, default: "" },
66
+ /** Stack priority (user direction 2026-10-04): when true the layer
67
+ * yields the screen while ANY window is open - page-level dial
68
+ * chrome disappears under the top window instead of floating over
69
+ * it, and returns when the last window closes. */
70
+ yieldToWindows: { type: Boolean, default: false },
66
71
  },
67
72
  setup(props, { slots }) {
68
73
  const { register, unregister } = usePopupManager();
@@ -71,7 +76,10 @@ export default defineComponent({
71
76
  // Annotation-band chrome, not a breadcrumb level: non-blocking,
72
77
  // untitled, no scroll lock, no outside-dismiss channel. Registered
73
78
  // exactly while shown (immediate, so the first paint already
74
- // carries the band z); a hidden layer holds no slot.
79
+ // carries the band z); a hidden layer holds no slot. (A
80
+ // yielded layer keeps its slot held while hidden — constant chrome
81
+ // flipping a registered slot on every window open/close would churn
82
+ // the band for no visual effect.)
75
83
  watch(
76
84
  () => props.open,
77
85
  (show) => {
@@ -89,7 +97,7 @@ export default defineComponent({
89
97
  });
90
98
 
91
99
  return () => {
92
- if (!props.open) return null;
100
+ if (!props.open || (props.yieldToWindows && hasLiveWindow.value)) return null;
93
101
  return (
94
102
  <Teleport to="body">
95
103
  <div
@@ -415,3 +415,64 @@ describe("HkModal autoFollow", () => {
415
415
  expect(s.jump()).toBeNull();
416
416
  });
417
417
  });
418
+
419
+ // Stack-priority dial slot (user direction 2026-10-04): the window owns
420
+ // its dial while it is the topmost window, yields while another window
421
+ // stacks above, and the page dial returns on close. The surface machine
422
+ // runs on macrotask timers, so every stack transition below is staged
423
+ // through vi.waitFor, never bare ticks.
424
+ describe("HkModal dial slot (stack priority)", () => {
425
+ async function mountDialModal(withDial: boolean) {
426
+ const container = document.createElement("div");
427
+ document.body.appendChild(container);
428
+ containers.push(container);
429
+ const open = ref(true);
430
+ const Wrapper = defineComponent({
431
+ setup() {
432
+ return () =>
433
+ h(HkModal, {
434
+ modelValue: open.value,
435
+ title: "Window",
436
+ "onUpdate:modelValue": (v: boolean) => { open.value = v; },
437
+ }, withDial
438
+ ? {
439
+ default: () => h("div", "body content"),
440
+ dial: () => h("button", { class: "dial-probe" }, "dial"),
441
+ }
442
+ : { default: () => h("div", "body content") });
443
+ },
444
+ });
445
+ const app = createApp(Wrapper);
446
+ mounts.push(app);
447
+ app.mount(container);
448
+ return { open };
449
+ }
450
+ const dialLayer = () => document.body.querySelector(".hk-floating-layer");
451
+
452
+ it("mounts its dial slot on the top layer while the window is topmost", async () => {
453
+ await mountDialModal(true);
454
+ await vi.waitFor(() => {
455
+ const layer = dialLayer();
456
+ expect(layer).not.toBeNull();
457
+ expect(layer!.querySelector(".dial-probe")).not.toBeNull();
458
+ });
459
+ });
460
+
461
+ it("yields while another window stacks above and returns when it closes", async () => {
462
+ const m = await mountDialModal(true);
463
+ await vi.waitFor(() => expect(dialLayer()).not.toBeNull());
464
+ const { register, unregister } = usePopupManager();
465
+ const above = register("modal", true, "Above");
466
+ await vi.waitFor(() => expect(dialLayer()).toBeNull());
467
+ unregister(above.id);
468
+ await vi.waitFor(() => expect(dialLayer()).not.toBeNull());
469
+ m.open.value = false;
470
+ await vi.waitFor(() => expect(dialLayer()).toBeNull());
471
+ });
472
+
473
+ it("renders no dial layer when the window declares none", async () => {
474
+ await mountDialModal(false);
475
+ await vi.waitFor(() => expect(document.body.querySelector(".hk-modal-root")).not.toBeNull());
476
+ expect(dialLayer()).toBeNull();
477
+ });
478
+ });
@@ -2,9 +2,10 @@ import { computed, defineComponent, nextTick, onBeforeUnmount, onMounted, ref, s
2
2
 
3
3
  import { useI18n } from "../i18n/context";
4
4
  import "./HkModal.scss";
5
+ import HkFloatingLayer from "./HkFloatingLayer";
5
6
  import { focusFirst, trapFocus } from "../utils/dom";
6
7
  import { useOverlay } from "../runtime/useOverlay";
7
- import { usePopupManager } from "../runtime/usePopupManager";
8
+ import { topWindowZ, usePopupManager } from "../runtime/usePopupManager";
8
9
  import { createBackGuard } from "../runtime/backStack";
9
10
  import { scheduleFrame, type AnimationHandle } from "../runtime/animationBus";
10
11
  import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
@@ -936,12 +937,24 @@ export default defineComponent({
936
937
 
937
938
  const contentHold = useSurfaceContentHold(machine.phase);
938
939
 
940
+ // Stack-priority dial chrome (user direction 2026-10-04): the
941
+ // window's own dial replaces the page's while this window is the
942
+ // topmost one, yields while another window stacks above it (dial
943
+ // or not), and the page dial returns when the last window closes.
944
+ const dialVisible = computed(() =>
945
+ Boolean(
946
+ machine.mounted.value &&
947
+ handle.value &&
948
+ (topWindowZ.value === null || topWindowZ.value <= handle.value.zIndex),
949
+ ),
950
+ );
951
+
939
952
  return () => {
940
953
  if (!machine.mounted.value) return null;
941
954
 
942
955
  const headerShown = props.title || props.closable || slots.header || slots.headerLead;
943
956
 
944
- return (
957
+ const windowSurface = (
945
958
  <Teleport to="body">
946
959
  <div
947
960
  class="hk-modal-root"
@@ -1066,6 +1079,19 @@ export default defineComponent({
1066
1079
  </div>
1067
1080
  </Teleport>
1068
1081
  );
1082
+
1083
+ // The dial rides the top layer beside its window (stack
1084
+ // priority): mounted with the window, yielded above, retired
1085
+ // when the window closes so the page dial can return.
1086
+ if (!slots.dial) return windowSurface;
1087
+ return (
1088
+ <>
1089
+ {windowSurface}
1090
+ <HkFloatingLayer open={dialVisible.value} corner="bottom-right">
1091
+ {slots.dial()}
1092
+ </HkFloatingLayer>
1093
+ </>
1094
+ );
1069
1095
  };
1070
1096
 
1071
1097
  function renderWindowedBody() {
package/src/index.ts CHANGED
@@ -348,6 +348,9 @@ export {
348
348
  setReducedMotion,
349
349
  notifyScrollStart,
350
350
  useOverlay,
351
+ hasLiveWindow,
352
+ isWindowEntry,
353
+ topWindowZ,
351
354
  usePopupManager,
352
355
  POPUP_Z_BANDS,
353
356
  POPUP_Z_STEP,
@@ -56,7 +56,16 @@ export {
56
56
  } from "./backStack";
57
57
  export { useMediaQuery, releaseMediaQuery } from "./useMediaQuery";
58
58
  export { useOverlay, closeAll, isOverlayOpen, type OverlayHandle, type UseOverlayOptions } from "./useOverlay";
59
- export { usePopupManager, POPUP_Z_BANDS, POPUP_Z_STEP, type PopupHandle, type PopupKind } from "./usePopupManager";
59
+ export {
60
+ hasLiveWindow,
61
+ isWindowEntry,
62
+ topWindowZ,
63
+ usePopupManager,
64
+ POPUP_Z_BANDS,
65
+ POPUP_Z_STEP,
66
+ type PopupHandle,
67
+ type PopupKind,
68
+ } from "./usePopupManager";
60
69
  export { useToast, TOAST_DURATION, type ToastItem, type ToastMessage, type ToastType } from "./useToast";
61
70
  export { useConfirm } from "./useConfirm";
62
71
  export { useBlockingToast, showBlockingToast, resolveBlockingToast, clearBlockingToasts, type BlockingToastItem, type BlockingToastOptions, type BlockingToastVariant } from "./useBlockingToast";
@@ -1,7 +1,13 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { watch } from "vue";
3
3
 
4
- import { POPUP_Z_BANDS, POPUP_Z_STEP, usePopupManager } from "./usePopupManager";
4
+ import {
5
+ hasLiveWindow,
6
+ POPUP_Z_BANDS,
7
+ POPUP_Z_STEP,
8
+ topWindowZ,
9
+ usePopupManager,
10
+ } from "./usePopupManager";
5
11
 
6
12
  const managers: ReturnType<typeof usePopupManager>[] = [];
7
13
 
@@ -338,3 +344,43 @@ describe("usePopupManager closeAbove", () => {
338
344
  }
339
345
  });
340
346
  });
347
+
348
+ describe("usePopupManager window-stack signals", () => {
349
+ it("derived signals track the live window band (page chrome's yield rule)", () => {
350
+ const m = usePopupManager();
351
+ const win = m.register("modal", true, "Window");
352
+ expect(hasLiveWindow.value).toBe(true);
353
+ expect(topWindowZ.value).toBe(win.zIndex);
354
+ // Tooltip-band entries are not windows: they must not flip the signals.
355
+ const tip = m.register("tooltip", false);
356
+ expect(hasLiveWindow.value).toBe(true);
357
+ expect(topWindowZ.value).toBe(win.zIndex);
358
+ m.unregister(tip.id);
359
+ m.unregister(win.id);
360
+ expect(hasLiveWindow.value).toBe(false);
361
+ expect(topWindowZ.value).toBeNull();
362
+ });
363
+
364
+ it("only window-band entries count: anchored dropdowns no, docked sheets yes", () => {
365
+ const m = usePopupManager();
366
+ const dd = m.register("dropdown", false);
367
+ expect(hasLiveWindow.value).toBe(false);
368
+ m.unregister(dd.id);
369
+ const sheet = m.register("dropdown", true, "Sheet", true);
370
+ expect(hasLiveWindow.value).toBe(true);
371
+ expect(topWindowZ.value).toBe(sheet.zIndex);
372
+ m.unregister(sheet.id);
373
+ expect(hasLiveWindow.value).toBe(false);
374
+ });
375
+
376
+ it("topWindowZ follows the topmost of stacked windows", () => {
377
+ const m = usePopupManager();
378
+ const low = m.register("modal", true, "Low");
379
+ const high = m.register("modal", true, "High");
380
+ expect(topWindowZ.value).toBe(high.zIndex);
381
+ expect(topWindowZ.value).toBeGreaterThan(low.zIndex);
382
+ m.unregister(high.id);
383
+ expect(topWindowZ.value).toBe(low.zIndex);
384
+ m.unregister(low.id);
385
+ });
386
+ });
@@ -1,4 +1,4 @@
1
- import { readonly, ref } from "vue";
1
+ import { computed, readonly, ref } from "vue";
2
2
 
3
3
  import { reportHkRuntime, type HkRuntimeHandle } from "./registry";
4
4
 
@@ -70,6 +70,14 @@ function effectiveBand(kind: PopupKind, blocking: boolean): number {
70
70
  return POPUP_Z_BANDS[kind];
71
71
  }
72
72
 
73
+ /** True when a live entry is a WINDOW (blocks the page like one):
74
+ * modal/drawer by kind, or a dropdown-kind surface docked as a
75
+ * blocking mobile sheet. Page chrome (dials, floating pads) uses
76
+ * this to yield the screen to whatever window is on top. */
77
+ export function isWindowEntry(kind: PopupKind, blocking: boolean): boolean {
78
+ return effectiveBand(kind, blocking) === POPUP_Z_BANDS.modal;
79
+ }
80
+
73
81
  /** In-band stacking step. Even numbers leave the odd slot free for the
74
82
  * +1 content/panel layer each overlay puts above its own root. */
75
83
  export const POPUP_Z_STEP = 2;
@@ -132,6 +140,27 @@ function uid(): string {
132
140
  }
133
141
 
134
142
  const registry = ref<Map<string, PopupEntry>>(new Map());
143
+
144
+ // Derived window-stack signals (reactive over the live registry):
145
+ // whether ANY window is open, and the topmost window's z slot. Stack
146
+ // priority for dial chrome rides these — a window's own dial replaces
147
+ // the page's while it is topmost; close the window and the page's
148
+ // dial returns.
149
+ export const hasLiveWindow = computed<boolean>(() => {
150
+ for (const entry of registry.value.values()) {
151
+ if (isWindowEntry(entry.kind, entry.blocking)) return true;
152
+ }
153
+ return false;
154
+ });
155
+
156
+ export const topWindowZ = computed<number | null>(() => {
157
+ let top: number | null = null;
158
+ for (const entry of registry.value.values()) {
159
+ if (!isWindowEntry(entry.kind, entry.blocking)) continue;
160
+ if (top === null || entry.zIndex > top) top = entry.zIndex;
161
+ }
162
+ return top;
163
+ });
135
164
  let scrollLockCount = 0;
136
165
 
137
166
  // Runtime-registry reporting (the "context of contexts"). Lazy: the