@celestia-island/hikari 0.55.110 → 0.55.112

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.110",
3
+ "version": "0.55.112",
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();
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vitest } from "vitest";
2
2
  import { createApp, defineComponent, h, nextTick, ref } from "vue";
3
3
 
4
4
  import HkFab from "./HkFab";
5
+ import { POPUP_Z_BANDS } from "../runtime/usePopupManager";
5
6
 
6
7
  const mounts: ReturnType<typeof createApp>[] = [];
7
8
  const containers: HTMLElement[] = [];
@@ -41,8 +42,14 @@ function mountFab(props: Record<string, unknown> = {}): Harness {
41
42
  mounts.push(app);
42
43
  app.mount(container);
43
44
 
44
- const root = container.querySelector<HTMLElement>(".hk-fab");
45
- const button = container.querySelector<HTMLButtonElement>(".hk-fab-button");
45
+ // A layer="top" fab teleports into the floating layer on <body>;
46
+ // fall back to the document scope so both modes resolve.
47
+ const root =
48
+ container.querySelector<HTMLElement>(".hk-fab") ??
49
+ document.body.querySelector<HTMLElement>(".hk-fab");
50
+ const button =
51
+ container.querySelector<HTMLButtonElement>(".hk-fab-button") ??
52
+ document.body.querySelector<HTMLButtonElement>(".hk-fab-button");
46
53
  if (!root || !button) throw new Error("HkFab did not render its shell");
47
54
  harness.root = root;
48
55
  harness.button = button;
@@ -215,3 +222,28 @@ describe("HkFab speed dial", () => {
215
222
  expect(toggles).toEqual([true, false, true, false]);
216
223
  });
217
224
  });
225
+
226
+ describe("HkFab layer", () => {
227
+ it("host (default) keeps the fab anchored in the host tree", () => {
228
+ const m = mountFab({ ariaLabel: "Host", positioning: "fixed" });
229
+ expect(document.body.querySelector(".hk-floating-layer")).toBeNull();
230
+ expect(m.root.getAttribute("data-positioning")).toBe("fixed");
231
+ });
232
+
233
+ it("top wraps the fab in an HkFloatingLayer above the window band", () => {
234
+ const m = mountFab({ ariaLabel: "Float", layer: "top", corner: "bottom-right" });
235
+ const layer = document.body.querySelector<HTMLElement>(".hk-floating-layer");
236
+ expect(layer).not.toBeNull();
237
+ expect(layer!.contains(m.root)).toBe(true);
238
+ // Geometry is owned by the layer alone once floated.
239
+ expect(m.root.getAttribute("data-positioning")).toBeNull();
240
+ expect(m.root.getAttribute("data-corner")).toBeNull();
241
+ // Offset vars hand off to the layer as well — the fab keeps none.
242
+ expect(m.root.style.getPropertyValue("--hk-fab-offset-x")).toBe("");
243
+ expect(m.root.style.getPropertyValue("--hk-fab-offset-y")).toBe("");
244
+ expect(layer!.getAttribute("data-corner")).toBe("bottom-right");
245
+ const z = Number(layer!.style.getPropertyValue("--hk-float-z"));
246
+ expect(z).toBeGreaterThanOrEqual(POPUP_Z_BANDS.tooltip);
247
+ expect(z).toBeLessThan(POPUP_Z_BANDS.toast);
248
+ });
249
+ });
@@ -6,6 +6,7 @@ import {
6
6
  } from "vue";
7
7
 
8
8
  import { iconByName } from "../composables/iconRegistry";
9
+ import HkFloatingLayer from "./HkFloatingLayer";
9
10
  import "./HkFab.scss";
10
11
 
11
12
  /** One entry of the HkFab speed dial. */
@@ -21,6 +22,7 @@ type FabCorner = "bottom-right" | "bottom-left" | "top-right" | "top-left";
21
22
  type FabSize = "sm" | "md" | "lg";
22
23
  type FabVariant = "primary" | "glass";
23
24
  type FabExpandDirection = "up" | "down" | "left" | "right";
25
+ type FabLayer = "host" | "top";
24
26
 
25
27
  /**
26
28
  * Material-design-style floating action button with an optional
@@ -46,6 +48,13 @@ export default defineComponent({
46
48
  disabled: { type: Boolean, default: false },
47
49
  actions: { type: Array as PropType<HFabAction[]>, default: undefined },
48
50
  expandDirection: { type: String as PropType<FabExpandDirection>, default: "up" },
51
+ /** "top" floats the fab above every window (HkFloatingLayer — the
52
+ * popup manager's tooltip band, teleported to <body>): dials stay
53
+ * reachable no matter which modal/sheet opens over them. The layer
54
+ * owns the geometry when floated — `positioning` is ignored, the
55
+ * corner/offset props hand off to it. "host" keeps the classic
56
+ * in-tree anchoring (the historical default, byte-identical). */
57
+ layer: { type: String as PropType<FabLayer>, default: "host" },
49
58
  },
50
59
  emits: {
51
60
  click: (_e: MouseEvent) => true,
@@ -116,6 +125,8 @@ export default defineComponent({
116
125
  }
117
126
 
118
127
  return () => {
128
+ const floated = props.layer === "top";
129
+
119
130
  // Icon precedence: slot > registry > inline chevron-down fallback.
120
131
  // `as any` matches HkIcon — the registry returns a loose Component.
121
132
  const RegistryIcon = props.icon ? (iconByName(props.icon) as any) : null;
@@ -144,19 +155,19 @@ export default defineComponent({
144
155
  </span>
145
156
  );
146
157
 
147
- return (
158
+ const fab = (
148
159
  <div
149
160
  ref={rootRef}
150
161
  class="hk-fab"
151
- data-positioning={props.positioning}
152
- data-corner={props.corner}
162
+ data-positioning={floated ? undefined : props.positioning}
163
+ data-corner={floated ? undefined : props.corner}
153
164
  data-size={props.size}
154
165
  data-variant={props.variant}
155
166
  data-expand={props.expandDirection}
156
167
  data-expanded={isOpen.value ? "true" : undefined}
157
168
  style={{
158
- "--hk-fab-offset-x": props.offsetX,
159
- "--hk-fab-offset-y": props.offsetY,
169
+ "--hk-fab-offset-x": floated ? undefined : props.offsetX,
170
+ "--hk-fab-offset-y": floated ? undefined : props.offsetY,
160
171
  }}
161
172
  >
162
173
  {(props.actions?.length ?? 0) > 0 && (
@@ -196,6 +207,23 @@ export default defineComponent({
196
207
  </button>
197
208
  </div>
198
209
  );
210
+
211
+ // Floated: the HkFloatingLayer owns viewport anchoring (corner +
212
+ // safe-area + tooltip-band z); the fab itself renders bare inside
213
+ // it (data-positioning/corner stripped above so the geometry is
214
+ // owned by exactly one layer).
215
+ if (floated) {
216
+ return (
217
+ <HkFloatingLayer
218
+ corner={props.corner}
219
+ offsetX={props.offsetX}
220
+ offsetY={props.offsetY}
221
+ >
222
+ {fab}
223
+ </HkFloatingLayer>
224
+ );
225
+ }
226
+ return fab;
199
227
  };
200
228
  },
201
229
  });
@@ -0,0 +1,58 @@
1
+ // HkFloatingLayer.scss — top-layer floating chrome.
2
+ //
3
+ // Anchoring mirrors HkFab's safe-area-aware corner insets, extended per
4
+ // instance by --hk-float-offset-*. The z rides the popup manager's
5
+ // tooltip band via --hk-float-z (inline from the component once the
6
+ // registration resolves); the fallback must stay in sync with
7
+ // POPUP_Z_BANDS.tooltip in runtime/usePopupManager.ts.
8
+ .hk-floating-layer {
9
+ position: fixed;
10
+ z-index: var(--hk-float-z, 3000);
11
+ display: flex;
12
+ flex-direction: column;
13
+ gap: var(--space-8, 0.5rem);
14
+ pointer-events: none;
15
+
16
+ --hk-float-inset-x: max(var(--space-14, 0.875rem), env(safe-area-inset-left, 0px));
17
+ --hk-float-inset-y: max(var(--space-14, 0.875rem), env(safe-area-inset-bottom, 0px));
18
+
19
+ &[data-corner$="-right"] {
20
+ --hk-float-inset-x: max(var(--space-14, 0.875rem), env(safe-area-inset-right, 0px));
21
+ right: calc(var(--hk-float-inset-x) + var(--hk-float-offset-x, 0px));
22
+ }
23
+
24
+ &[data-corner$="-left"] {
25
+ --hk-float-inset-x: max(var(--space-14, 0.875rem), env(safe-area-inset-left, 0px));
26
+ left: calc(var(--hk-float-inset-x) + var(--hk-float-offset-x, 0px));
27
+ }
28
+
29
+ &[data-corner^="top"] {
30
+ --hk-float-inset-y: max(var(--space-14, 0.875rem), env(safe-area-inset-top, 0px));
31
+ top: calc(var(--hk-float-inset-y) + var(--hk-float-offset-y, 0px));
32
+ }
33
+
34
+ &[data-corner^="bottom"] {
35
+ --hk-float-inset-y: max(var(--space-14, 0.875rem), env(safe-area-inset-bottom, 0px));
36
+ bottom: calc(var(--hk-float-inset-y) + var(--hk-float-offset-y, 0px));
37
+ }
38
+
39
+ // The layer never eats gestures; the widgets inside do. NOTE: for a
40
+ // floated HkFab this rule must LOSE to HkFab's own
41
+ // `.hk-fab { pointer-events: none }` (equal specificity — later sheet
42
+ // wins — and HkFab.tsx imports this component before its own sheet),
43
+ // so the fab root stays transparent while its button re-enables.
44
+ // Do not strengthen this selector or reorder those imports: the
45
+ // layer's gap boxes would start eating clicks.
46
+ > * {
47
+ pointer-events: auto;
48
+ }
49
+
50
+ // A floated HkFab (layer="top") hands viewport anchoring to this
51
+ // layer entirely: drop its own positioning so its corner lands in
52
+ // the layer's flow instead of double-resolving the insets.
53
+ > .hk-fab {
54
+ position: static;
55
+ inset: auto;
56
+ z-index: auto;
57
+ }
58
+ }
@@ -0,0 +1,58 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { 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
+ /**
9
+ * Compiled-stylesheet contract for HkFloatingLayer. The layer IS its
10
+ * CSS (fixed positioning, gesture transparency, the > .hk-fab
11
+ * positioning hand-off), none of which the runtime suite can see —
12
+ * vitest stubs CSS — so the sheet is compiled and asserted here.
13
+ */
14
+ const componentDir = resolve(dirname(fileURLToPath(import.meta.url)));
15
+
16
+ describe("HkFloatingLayer stylesheet contract", () => {
17
+ const tsx = readFileSync(resolve(componentDir, "HkFloatingLayer.tsx"), "utf8");
18
+ const css = compile(resolve(componentDir, "HkFloatingLayer.scss"), {
19
+ style: "expanded",
20
+ }).css;
21
+
22
+ it("compiles the fixed, gesture-transparent top-layer shell", () => {
23
+ expect(css).toContain(".hk-floating-layer {");
24
+ expect(css).toContain("position: fixed;");
25
+ expect(css).toContain("z-index: var(--hk-float-z, 3000);");
26
+ // The layer never eats gestures; the widgets inside do.
27
+ expect(css).toContain("pointer-events: none;");
28
+ expect(css).toContain(".hk-floating-layer > * {");
29
+ expect(css).toContain("pointer-events: auto;");
30
+ });
31
+
32
+ it("anchors all four corners with safe-area-aware insets", () => {
33
+ expect(css).toContain('[data-corner$=-right]');
34
+ expect(css).toContain('[data-corner$=-left]');
35
+ expect(css).toContain('[data-corner^=top]');
36
+ expect(css).toContain('[data-corner^=bottom]');
37
+ for (const side of ["right: calc", "left: calc", "top: calc", "bottom: calc"]) {
38
+ expect(css).toContain(side);
39
+ }
40
+ expect(css).toContain("env(safe-area-inset-left, 0px)");
41
+ expect(css).toContain("env(safe-area-inset-right, 0px)");
42
+ expect(css).toContain("env(safe-area-inset-top, 0px)");
43
+ expect(css).toContain("env(safe-area-inset-bottom, 0px)");
44
+ });
45
+
46
+ it("takes over a floated HkFab's positioning entirely", () => {
47
+ expect(css).toContain(".hk-floating-layer > .hk-fab {");
48
+ expect(css).toContain("position: static;");
49
+ expect(css).toContain("inset: auto;");
50
+ expect(css).toContain("z-index: auto;");
51
+ });
52
+
53
+ it("has no dead selectors: every rule the sheet emits is rendered", () => {
54
+ // The TSX must reference every state the sheet styles.
55
+ expect(tsx).toContain('class="hk-floating-layer"');
56
+ expect(tsx).toContain("data-corner={props.corner}");
57
+ });
58
+ });
@@ -0,0 +1,145 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { createApp, defineComponent, h, nextTick, ref } from "vue";
3
+
4
+ import HkFloatingLayer from "./HkFloatingLayer";
5
+ import { POPUP_Z_BANDS, usePopupManager } from "../runtime/usePopupManager";
6
+
7
+ const mounts: ReturnType<typeof createApp>[] = [];
8
+ const containers: HTMLElement[] = [];
9
+
10
+ /** Mount an HkFloatingLayer through a wrapper app (mirrors the
11
+ * HkFab.test.tsx harness). The layer teleports its node to <body>,
12
+ * so queries go through document.body, not the host container. */
13
+ function mountLayer(props: Record<string, unknown> = {}, slot?: () => unknown) {
14
+ const container = document.createElement("div");
15
+ document.body.appendChild(container);
16
+ containers.push(container);
17
+
18
+ const Wrapper = defineComponent({
19
+ setup() {
20
+ return () => h(HkFloatingLayer, props, slot ? { default: slot } : undefined);
21
+ },
22
+ });
23
+ const app = createApp(Wrapper);
24
+ mounts.push(app);
25
+ app.mount(container);
26
+
27
+ return {
28
+ app,
29
+ root: () => document.body.querySelector<HTMLElement>(".hk-floating-layer"),
30
+ };
31
+ }
32
+
33
+ afterEach(() => {
34
+ for (const app of mounts.splice(0)) app.unmount();
35
+ for (const el of containers.splice(0)) el.remove();
36
+ document.body.innerHTML = "";
37
+ });
38
+
39
+ describe("HkFloatingLayer", () => {
40
+ it("teleports the corner layer to body with a tooltip-band z slot", () => {
41
+ const m = mountLayer({ ariaLabel: "Date dial" });
42
+ const root = m.root();
43
+ expect(root).not.toBeNull();
44
+ // Teleported: outside the wrapper's host subtree.
45
+ expect(containers[0].contains(root!)).toBe(false);
46
+ expect(root!.getAttribute("data-corner")).toBe("bottom-right");
47
+ expect(root!.getAttribute("role")).toBe("group");
48
+ expect(root!.getAttribute("aria-label")).toBe("Date dial");
49
+ // Inline z from the popup manager registration — inside the
50
+ // tooltip band, strictly below the toast band.
51
+ const z = Number(root!.style.getPropertyValue("--hk-float-z"));
52
+ expect(z).toBeGreaterThanOrEqual(POPUP_Z_BANDS.tooltip);
53
+ expect(z).toBeLessThan(POPUP_Z_BANDS.toast);
54
+ });
55
+
56
+ it("open=false renders nothing at all", () => {
57
+ const m = mountLayer({ open: false });
58
+ expect(m.root()).toBeNull();
59
+ });
60
+
61
+ it("registers exactly one tooltip entry and releases it on unmount", () => {
62
+ const manager = usePopupManager();
63
+ const tooltipCount = () => {
64
+ let n = 0;
65
+ for (const entry of manager.registry.value.values()) {
66
+ if (entry.kind === "tooltip") n++;
67
+ }
68
+ return n;
69
+ };
70
+ const before = tooltipCount();
71
+ // Non-blocking, untitled chrome must stay silent (a future
72
+ // warnUntitled change folding tooltip into the naming rule would
73
+ // dev-warn on every mount — that regression goes red here).
74
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
75
+ const m = mountLayer();
76
+ expect(tooltipCount()).toBe(before + 1);
77
+ m.app.unmount();
78
+ mounts.splice(mounts.indexOf(m.app), 1);
79
+ expect(tooltipCount()).toBe(before);
80
+ expect(warn).not.toHaveBeenCalled();
81
+ warn.mockRestore();
82
+ });
83
+
84
+ it("hide releases the slot; re-show re-registers with a fresh band z", async () => {
85
+ const manager = usePopupManager();
86
+ const tooltipCount = () => {
87
+ let n = 0;
88
+ for (const entry of manager.registry.value.values()) {
89
+ if (entry.kind === "tooltip") n++;
90
+ }
91
+ return n;
92
+ };
93
+ const container = document.createElement("div");
94
+ document.body.appendChild(container);
95
+ containers.push(container);
96
+
97
+ const visible = ref(true);
98
+ const Wrapper = defineComponent({
99
+ setup() {
100
+ return () => h(HkFloatingLayer, { open: visible.value });
101
+ },
102
+ });
103
+ const app = createApp(Wrapper);
104
+ mounts.push(app);
105
+ app.mount(container);
106
+
107
+ const layer = () => document.body.querySelector<HTMLElement>(".hk-floating-layer");
108
+ const before = tooltipCount();
109
+ expect(before).toBeGreaterThanOrEqual(1);
110
+ const z1 = Number(layer()!.style.getPropertyValue("--hk-float-z"));
111
+ expect(z1).toBeGreaterThanOrEqual(POPUP_Z_BANDS.tooltip);
112
+
113
+ // Hide: the node leaves AND the band slot is released — the
114
+ // count test above cannot catch this branch on its own (unmount
115
+ // cleanup would mask it).
116
+ visible.value = false;
117
+ await nextTick();
118
+ expect(tooltipCount()).toBe(before - 1);
119
+ expect(layer()).toBeNull();
120
+
121
+ // Re-show: registered again, first paint already carries the z.
122
+ visible.value = true;
123
+ await nextTick();
124
+ expect(tooltipCount()).toBe(before);
125
+ const z2 = Number(layer()!.style.getPropertyValue("--hk-float-z"));
126
+ expect(z2).toBeGreaterThanOrEqual(POPUP_Z_BANDS.tooltip);
127
+
128
+ app.unmount();
129
+ mounts.splice(mounts.indexOf(app), 1);
130
+ expect(tooltipCount()).toBe(before - 1);
131
+ });
132
+
133
+ it("honours the corner and offset props", () => {
134
+ const m = mountLayer({ corner: "top-left", offsetX: "1rem", offsetY: "2rem" });
135
+ const root = m.root()!;
136
+ expect(root.getAttribute("data-corner")).toBe("top-left");
137
+ expect(root.style.getPropertyValue("--hk-float-offset-x")).toBe("1rem");
138
+ expect(root.style.getPropertyValue("--hk-float-offset-y")).toBe("2rem");
139
+ });
140
+
141
+ it("keeps slot content mounted inside the layer", () => {
142
+ const m = mountLayer({}, () => h("button", { class: "probe" }, "go"));
143
+ expect(m.root()!.querySelector(".probe")).not.toBeNull();
144
+ });
145
+ });
@@ -0,0 +1,112 @@
1
+ import {
2
+ defineComponent,
3
+ onBeforeUnmount,
4
+ ref,
5
+ Teleport,
6
+ watch,
7
+ type PropType,
8
+ } from "vue";
9
+
10
+ import { usePopupManager } from "../runtime/usePopupManager";
11
+ import "./HkFloatingLayer.scss";
12
+
13
+ type FloatCorner = "bottom-right" | "bottom-left" | "top-right" | "top-left";
14
+
15
+ /**
16
+ * HkFloatingLayer — viewport-fixed chrome that floats at the TOP layer.
17
+ *
18
+ * The dial/FAB problem this solves: always-reachable chrome anchored to
19
+ * a page (or a window) dies the moment a real window opens over it —
20
+ * either it is buried under the modal overlay (page pads), or it gets
21
+ * re-rendered as an in-window copy that no longer shares the page's
22
+ * geometry (the per-modal dial copies this layer replaces). Floating at
23
+ * the popup manager's tooltip band makes the chrome independent of the
24
+ * window stack, exactly like a tooltip: above every modal/drawer
25
+ * overlay, below toasts, present on its own merits.
26
+ *
27
+ * Mechanics:
28
+ * - Teleported to <body>, so no transformed ancestor (morph tabs,
29
+ * modal content frames) can re-anchor or clip a `position: fixed`
30
+ * child, and the layer resolves against the viewport full stop.
31
+ * - Registered with the popup manager (kind "tooltip", non-blocking,
32
+ * no scroll lock, no breadcrumb title): the z slot derives from the
33
+ * live registry instead of a magic constant, and the layer shows up
34
+ * in `readHkRuntime("popupManager")` like every other surface.
35
+ * - The layer itself is gesture-transparent (pointer-events: none);
36
+ * the widgets inside catch their own pointers.
37
+ * - Corner insets mirror HkFab's safe-area-aware math, extended per
38
+ * instance by the offset props.
39
+ *
40
+ * - Within the tooltip band, stacking is open-order (the manager's
41
+ * rule): a tooltip that registered later paints above this chrome
42
+ * near the corner, and a layer mounted after a live tooltip paints
43
+ * above it — transient vs persistent chrome trades locally. Do not
44
+ * "fix" this with a magic z. Two layers at one corner overlap;
45
+ * pass offsets to stack them deliberately.
46
+ * - While a modal is open, the modal's focus trap keeps this chrome
47
+ * pointer-reachable but NOT keyboard-reachable — the standing
48
+ * tradeoff for floating chrome; a focus relay is out of scope.
49
+ *
50
+ * Consumers: the shell's quick-action pad, a modal's detached date dial
51
+ * (via `HkFab layer="top"` or direct use) — anything that must survive
52
+ * the next window opening on top of it.
53
+ */
54
+ export default defineComponent({
55
+ name: "HkFloatingLayer",
56
+ props: {
57
+ /** False unmounts the teleported layer and releases its band slot
58
+ * (true = always shown). House `open` convention, like every other
59
+ * hikari surface. */
60
+ open: { type: Boolean, default: true },
61
+ corner: { type: String as PropType<FloatCorner>, default: "bottom-right" },
62
+ offsetX: { type: String, default: undefined },
63
+ offsetY: { type: String, default: undefined },
64
+ /** Accessible group name for the chrome (e.g. "Date dial"). */
65
+ ariaLabel: { type: String, default: "" },
66
+ },
67
+ setup(props, { slots }) {
68
+ const { register, unregister } = usePopupManager();
69
+ const handle = ref<{ id: string; zIndex: number } | null>(null);
70
+
71
+ // Annotation-band chrome, not a breadcrumb level: non-blocking,
72
+ // untitled, no scroll lock, no outside-dismiss channel. Registered
73
+ // exactly while shown (immediate, so the first paint already
74
+ // carries the band z); a hidden layer holds no slot.
75
+ watch(
76
+ () => props.open,
77
+ (show) => {
78
+ if (show && !handle.value) handle.value = register("tooltip", false);
79
+ else if (!show && handle.value) {
80
+ unregister(handle.value.id);
81
+ handle.value = null;
82
+ }
83
+ },
84
+ { immediate: true },
85
+ );
86
+ onBeforeUnmount(() => {
87
+ if (handle.value) unregister(handle.value.id);
88
+ handle.value = null;
89
+ });
90
+
91
+ return () => {
92
+ if (!props.open) return null;
93
+ return (
94
+ <Teleport to="body">
95
+ <div
96
+ class="hk-floating-layer"
97
+ data-corner={props.corner}
98
+ role={props.ariaLabel ? "group" : undefined}
99
+ aria-label={props.ariaLabel || undefined}
100
+ style={{
101
+ "--hk-float-z": handle.value ? String(handle.value.zIndex) : undefined,
102
+ "--hk-float-offset-x": props.offsetX,
103
+ "--hk-float-offset-y": props.offsetY,
104
+ }}
105
+ >
106
+ {slots.default?.()}
107
+ </div>
108
+ </Teleport>
109
+ );
110
+ };
111
+ },
112
+ });
package/src/index.ts CHANGED
@@ -689,6 +689,7 @@ export { default as HkDrawer } from "./components/HkDrawer";
689
689
  export { default as HkEmptyState } from "./components/HkEmptyState";
690
690
  export { default as HkExpansionPanel } from "./components/HkExpansionPanel";
691
691
  export { default as HkFab } from "./components/HkFab";
692
+ export { default as HkFloatingLayer } from "./components/HkFloatingLayer";
692
693
  export { default as HkIcon } from "./components/HkIcon";
693
694
  export { default as HkIconButton } from "./components/HkIconButton";
694
695
  export { default as HkIconButtonGroup } from "./components/HkIconButtonGroup";