@celestia-island/hikari 0.40.24 → 0.40.26

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.40.24",
3
+ "version": "0.40.26",
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",
@@ -179,6 +179,16 @@
179
179
  max-width: min(19rem, calc(100vw - 2rem));
180
180
  }
181
181
 
182
+ /* Mobile sheet context (#424 pattern): inside the full-width select sheet
183
+ * the popup's designed measure becomes a centered cap instead of a
184
+ * left-glued clamp — the list reads as one designed block inside the
185
+ * viewport-wide sheet rather than hugging its left edge. */
186
+ .hk-select-sheet-panel .hk-affix-scroll {
187
+ max-width: min(19rem, 100%);
188
+ margin-inline: auto;
189
+ width: 100%;
190
+ }
191
+
182
192
  .hk-affix-list {
183
193
  display: flex;
184
194
  flex-direction: column;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Source contract for the affix picker's measure inside the mobile select
3
+ * sheet (2026-09-08 scan wave 2, finding F2 — #424 pattern).
4
+ *
5
+ * The picker renders on phones inside the full-width `.hk-select-sheet-panel`;
6
+ * the base 19rem (304px) cap left-glued with ~92px dead space right. Pinned
7
+ * here so the sheet-context centering rule cannot be dropped in a refactor:
8
+ * the designed measure stays as a cap but the list reads as one centered
9
+ * block inside the viewport-wide sheet.
10
+ */
11
+ import { describe, expect, it } from "vitest";
12
+ import { readFileSync } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+
16
+ const here = dirname(fileURLToPath(import.meta.url));
17
+ const src = readFileSync(join(here, "HkAffixPicker.scss"), "utf-8");
18
+
19
+ describe("HkAffixPicker mobile sheet measure contract", () => {
20
+ it("caps and centers the list inside the full-width select sheet", () => {
21
+ const block =
22
+ src.match(/\.hk-select-sheet-panel \.hk-affix-scroll\s*{[^}]*}/)?.[0] ??
23
+ "";
24
+ expect(block).toContain("max-width: min(19rem, 100%)");
25
+ expect(block).toContain("margin-inline: auto");
26
+ });
27
+ });
@@ -12,7 +12,15 @@
12
12
  border: 1px solid transparent;
13
13
  border-radius: var(--radius-sm);
14
14
  cursor: pointer;
15
- transition: background-color, border-color, color, box-shadow, opacity, transform, filter var(--duration-normal) var(--ease-standard);
15
+ /* Longhand on purpose: the former comma-separated shorthand
16
+ * `transition: background-color, ..., filter var(--duration-normal) ...`
17
+ * declares one transition PER property where only the LAST carries the
18
+ * duration — every property but `filter` animated at 0s and hover states
19
+ * snapped (host overrides kept patching over it). One property LIST with
20
+ * one duration/easing animates them all. */
21
+ transition-property: background-color, border-color, color, box-shadow, opacity, transform, filter;
22
+ transition-duration: var(--duration-normal);
23
+ transition-timing-function: var(--ease-standard);
16
24
  white-space: nowrap;
17
25
  user-select: none;
18
26
  outline: none;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Source contract for the HkButton transition declaration (2026-09-08 scan
3
+ * wave 2, finding F5).
4
+ *
5
+ * The former comma-separated shorthand
6
+ * `transition: background-color, ..., filter var(--duration-normal) ...`
7
+ * declared one transition PER property where only the LAST carried the
8
+ * duration/easing — every property but `filter` animated at 0s and hover
9
+ * color/border/background snapped instead of fading (hosts kept patching
10
+ * over it; chest's deleted .hk-btn-ghost override was one). Pinned here so
11
+ * the malformed shorthand cannot silently return.
12
+ */
13
+ import { describe, expect, it } from "vitest";
14
+ import { readFileSync } from "node:fs";
15
+ import { dirname, join } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+
18
+ const here = dirname(fileURLToPath(import.meta.url));
19
+ const src = readFileSync(join(here, "HkButton.scss"), "utf-8");
20
+ // Comments stripped before the negative assertion: the explanatory comment
21
+ // intentionally QUOTES the old malformed shorthand, and this contract is
22
+ // about live CSS, not prose.
23
+ const css = src
24
+ .replace(/\/\*[\s\S]*?\*\//g, "")
25
+ .replace(/^[ \t]*\/\/.*$/gm, "");
26
+
27
+ describe("HkButton transition contract", () => {
28
+ it("declares the transition in longhand with one shared duration/easing", () => {
29
+ expect(src).toContain(
30
+ "transition-property: background-color, border-color, color, box-shadow, opacity, transform, filter;"
31
+ );
32
+ expect(src).toContain("transition-duration: var(--duration-normal);");
33
+ expect(src).toContain("transition-timing-function: var(--ease-standard);");
34
+ });
35
+
36
+ it("does not regress to the malformed comma-separated shorthand", () => {
37
+ expect(css).not.toMatch(/transition:\s*background-color,/);
38
+ });
39
+ });
@@ -165,3 +165,58 @@
165
165
  border-top: 1px solid var(--hi-color-border, rgba(0, 0, 0, 0.06));
166
166
  flex-shrink: 0;
167
167
  }
168
+
169
+ // ------
170
+ // Mobile bottom drawer = the sheet family (2026-09-08 scan wave 2).
171
+ // HkAdaptiveDialog renders its mobile form as a bottom drawer, which
172
+ // made it the ONLY bottom-docked window outside the HkModal / HkPopover /
173
+ // HkSelectPanel sheet family contract: glued to `bottom: 0` (hosts lifting
174
+ // --hk-sheet-bottom-gap saw no effect), capped by the inline `size` 70vh
175
+ // with no top-inset reservation (a tall dialog covered the secondary-window
176
+ // breadcrumb strip), 8px corners against the family's 12px, a footer that
177
+ // ignored the home-bar safe area, and sm/md footer buttons without the
178
+ // family's 44px touch lift. Same geometry, same tokens, one family.
179
+ // ------
180
+ @media (max-width: 767px) {
181
+ .hk-drawer-bottom {
182
+ bottom: var(--hk-sheet-bottom-gap, 0px);
183
+ border-radius: var(--hk-modal-radius, var(--hi-radius-lg, 12px))
184
+ var(--hk-modal-radius, var(--hi-radius-lg, 12px))
185
+ 0 0;
186
+ // The inline maxHeight from the `size` prop must lose to the family cap
187
+ // (plain-vh fallback first for dvh-less engines, then dvh) — same
188
+ // inline-beating !important pattern as HkModal's mobile max-width.
189
+ max-height: calc(
190
+ 100vh - var(--hk-sheet-top-inset, 4.25rem) - var(--hk-sheet-bottom-gap, 0px)
191
+ ) !important;
192
+ max-height: calc(
193
+ 100dvh - var(--hk-sheet-top-inset, 4.25rem) - var(--hk-sheet-bottom-gap, 0px)
194
+ ) !important;
195
+ }
196
+
197
+ // Scoped to BOTTOM drawers only: side drawers (admin panels etc.) keep
198
+ // their desktop footer contract on phones — the sheet family is the
199
+ // bottom-docked form factor. Three-value padding keeps the axes right:
200
+ // the --hk-drawer-footer-padding hook owns the TOP, 1rem the sides, and
201
+ // the home-bar safe area lands on the BOTTOM where it belongs (a 2-value
202
+ // form would put the safe-area calc on the horizontal axis — and for
203
+ // hosts that zero the var it would strip the bottom protection entirely,
204
+ // the exact opposite of the intent).
205
+ .hk-drawer-bottom .hk-drawer-footer {
206
+ // Family baseline: home-bar safe area stacked on the base inset (same
207
+ // stacking as HkModal's mobile footer), on top of the existing
208
+ // --hk-drawer-footer-padding hook for menu-panel hosts.
209
+ padding: var(--hk-drawer-footer-padding, 0.625rem 1rem) 1rem
210
+ calc(0.375rem + var(--hk-sheet-footer-gap, 0.5rem) + env(safe-area-inset-bottom, 0px));
211
+
212
+ // Footer actions are the drawer's primary touch targets on phones:
213
+ // lift sm/md buttons to the 44px class, exactly like HkModal's mobile
214
+ // footer (padding/min-height only; font-size stays --text-base).
215
+ .hk-btn-sm,
216
+ .hk-btn-md {
217
+ min-height: 2.75rem;
218
+ padding: var(--space-8) var(--space-16);
219
+ font-size: var(--text-base);
220
+ }
221
+ }
222
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Source contract for the mobile bottom-drawer sheet family membership
3
+ * (2026-09-08 scan wave 2, finding F1).
4
+ *
5
+ * HkAdaptiveDialog renders its mobile form as a bottom drawer, which made
6
+ * it the ONLY bottom-docked window outside the HkModal / HkPopover /
7
+ * HkSelectPanel sheet family contract. Pinned here so a refactor cannot
8
+ * silently regress to the pre-family geometry:
9
+ * - docks on the shared --hk-sheet-bottom-gap hook (was hard `bottom: 0`)
10
+ * - capped by the family formula (top inset + bottom gap), NOT the inline
11
+ * `size` 70vh — the inline maxHeight must lose via !important (same
12
+ * inline-beating pattern as HkModal's mobile `max-width: 100%
13
+ * !important`), with the plain-vh fallback ahead of the dvh twin
14
+ * - 12px family corners (was 8px)
15
+ * - footer stacks the home-bar safe area on the BOTTOM axis (3-value
16
+ * padding: hook top / 1rem sides / safe-area bottom — a 2-value form
17
+ * would put the safe-area calc on the horizontal axis and strip the
18
+ * bottom protection for hosts that zero the hook) and is scoped to
19
+ * BOTTOM drawers only (side drawers keep their desktop footer
20
+ * contract on phones); sm/md buttons lift to the 44px class, like
21
+ * HkModal's mobile footer
22
+ */
23
+ import { beforeAll, describe, expect, it } from "vitest";
24
+ import { readFileSync } from "node:fs";
25
+ import { dirname, join } from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+
28
+ const here = dirname(fileURLToPath(import.meta.url));
29
+ const src = readFileSync(join(here, "HkDrawer.scss"), "utf-8");
30
+
31
+ describe("HkDrawer mobile bottom sheet-family contract", () => {
32
+ let media = "";
33
+ let drawer = "";
34
+ let footer = "";
35
+ beforeAll(() => {
36
+ media = src.slice(src.indexOf("@media (max-width: 767px)"));
37
+ drawer = media.match(/\.hk-drawer-bottom\s*{[^}]*}/)?.[0] ?? "";
38
+ footer = media.match(/\.hk-drawer-bottom \.hk-drawer-footer\s*{[\s\S]*?^ }/m)?.[0] ?? "";
39
+ });
40
+
41
+ it("docks the bottom drawer on the shared --hk-sheet-bottom-gap hook", () => {
42
+ expect(drawer).toContain("bottom: var(--hk-sheet-bottom-gap");
43
+ });
44
+
45
+ it("caps the sheet with the family top-inset formula in both vh and dvh", () => {
46
+ expect(drawer).toContain("100vh - var(--hk-sheet-top-inset");
47
+ expect(drawer).toContain("100dvh - var(--hk-sheet-top-inset");
48
+ // dvh-less engines (older Android WebView / Tauri) drop the whole dvh
49
+ // calc — the plain-vh fallback must stay ahead of it.
50
+ expect(drawer.indexOf("100vh - var(")).toBeLessThan(
51
+ drawer.indexOf("100dvh - var(")
52
+ );
53
+ });
54
+
55
+ it("beats the inline `size` maxHeight with !important on both caps", () => {
56
+ // HkDrawer applies `maxHeight: props.size` (default 70vh) inline for
57
+ // horizontal drawers — inline beats stylesheet, so the family cap must
58
+ // carry !important on the vh fallback AND the dvh twin.
59
+ expect(drawer.match(/\) !important;/g)).toHaveLength(2);
60
+ });
61
+
62
+ it("adopts the 12px family corner radius", () => {
63
+ expect(drawer).toContain("border-radius: var(--hk-modal-radius, var(--hi-radius-lg, 12px))");
64
+ });
65
+
66
+ it("stacks the home-bar safe area on the mobile footer's BOTTOM axis", () => {
67
+ expect(footer).toContain("env(safe-area-inset-bottom");
68
+ expect(footer).toContain("--hk-sheet-footer-gap");
69
+ // 3-value padding: hook top / 1rem sides / safe-area calc bottom. The
70
+ // safe-area calc must be the THIRD value, not the second (2-value form
71
+ // puts it on the horizontal axis and zeroes the bottom for hook=0 hosts).
72
+ expect(footer).toMatch(
73
+ /padding: var\(--hk-drawer-footer-padding,[^)]*\) 1rem\s*\n?\s*calc\(0\.375rem \+ var\(--hk-sheet-footer-gap/
74
+ );
75
+ });
76
+
77
+ it("scopes the mobile footer rules to bottom drawers only", () => {
78
+ // Side drawers (admin panels) keep the desktop footer contract on
79
+ // phones — the media block must not carry an unscoped .hk-drawer-footer.
80
+ expect(media).not.toMatch(/@media[\s\S]*?\n \.hk-drawer-footer\s*{/);
81
+ });
82
+
83
+ it("lifts sm/md footer buttons to the 44px touch-target class", () => {
84
+ expect(footer).toMatch(/\.hk-btn-sm,\s*\n\s*\.hk-btn-md/);
85
+ expect(footer).toContain("min-height: 2.75rem");
86
+ });
87
+ });
@@ -102,9 +102,16 @@
102
102
  top: 0;
103
103
  bottom: auto;
104
104
  left: 0;
105
- width: 100vw;
105
+ // Full-bleed width from the containing block, NOT 100vw and NOT the
106
+ // desktop rule's inherited `min(96vw, 80rem)`: that unconditional
107
+ // same-specificity width would persist into mobile and, over-constrained
108
+ // against the inherited `left: 0; right: 0` pair, drop `right` under
109
+ // LTR — a persistent ~4vw dead strip on the right edge (the exact gap
110
+ // class this wave hunts). 100vw itself also measures the ICB including
111
+ // classic scrollbar gutters.
106
112
  // Plain-vh fallback first for dvh-less engines (older Android
107
113
  // WebView / Tauri), then the dynamic-viewport height.
114
+ width: 100%;
108
115
  height: 100vh;
109
116
  height: 100dvh;
110
117
  max-height: none;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Source contract for the mobile lightbox horizontal sizing (2026-09-08
3
+ * scan wave 2, finding F3 + round-3 fix).
4
+ *
5
+ * The fullscreen mobile block used to declare `width: 100vw`, but 100vw
6
+ * measures the ICB including classic scrollbar gutters — combined with the
7
+ * inherited `left: 0; right: 0` docking it over-constrained the box (LTR
8
+ * dropped `right`) and clipped the right edge under a classic scrollbar.
9
+ * Deleting the width alone was NOT enough (round-3 adversarial catch):
10
+ * the unconditional desktop rule `width: min(96vw, 80rem)` (same selector,
11
+ * same specificity) then persisted into mobile and, over-constrained
12
+ * against left/right, dropped `right` — a persistent ~4vw dead strip on
13
+ * the right edge. The mobile block must declare its OWN width: 100% so
14
+ * the containing block (not the viewport unit, not the desktop measure)
15
+ * owns the full-bleed. Pinned here so neither regression can return.
16
+ */
17
+ import { describe, expect, it } from "vitest";
18
+ import { readFileSync } from "node:fs";
19
+ import { dirname, join } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ const here = dirname(fileURLToPath(import.meta.url));
23
+ const src = readFileSync(join(here, "HkImageLightbox.scss"), "utf-8");
24
+
25
+ describe("HkImageLightbox mobile width contract", () => {
26
+ let mobile = "";
27
+ it("declares its own full-bleed width in the mobile block", () => {
28
+ mobile = src.slice(src.indexOf("@media (max-width: 767px)"));
29
+ const panel = mobile.match(/\.hk-modal-content\.hk-image-lightbox\s*{[^}]*}/)?.[0] ?? "";
30
+ expect(panel).toContain("left: 0;");
31
+ expect(panel).toContain("width: 100%;");
32
+ });
33
+
34
+ it("keeps 100vw sizing out (scrollbar-gutter divergence)", () => {
35
+ expect(mobile).not.toContain("width: 100vw");
36
+ });
37
+
38
+ it("keeps the desktop 96vw measure out of the mobile block", () => {
39
+ // The unconditional desktop rule keeps min(96vw, 80rem) — only the
40
+ // mobile panel block's own width: 100% stops it from leaking in.
41
+ // Comments are stripped first: the rationale text legitimately quotes
42
+ // the desktop measure, and only live declarations are under contract.
43
+ const panel = (mobile.match(/\.hk-modal-content\.hk-image-lightbox\s*{[^}]*}/)?.[0] ?? "")
44
+ .replace(/\/\*[\s\S]*?\*\//g, "")
45
+ .replace(/^\s*\/\/.*$/gm, "");
46
+ expect(panel).not.toContain("96vw");
47
+ });
48
+ });
@@ -393,8 +393,15 @@
393
393
  // sits off the very bottom edge (chest asked for a visibly narrower
394
394
  // gap than body bottom + safe area on phones). Safe area is added ON
395
395
  // TOP of the gap so home-bar devices never lose tappable chrome.
396
- padding: 0.625rem 1rem
397
- calc(0.375rem + var(--hk-sheet-footer-gap, 0.5rem) + env(safe-area-inset-bottom, 0px));
396
+ // --hk-modal-footer-padding-mobile is the host-tunable mobile footer
397
+ // inset: a host with edge-to-edge footer content (chest's report
398
+ // reply bar) sets it to 0 instead of repainting the rule and
399
+ // duplicating the formula — desktop keeps --hk-modal-padding-footer.
400
+ padding: var(
401
+ --hk-modal-footer-padding-mobile,
402
+ 0.625rem 1rem
403
+ calc(0.375rem + var(--hk-sheet-footer-gap, 0.5rem) + env(safe-area-inset-bottom, 0px))
404
+ );
398
405
 
399
406
  // Footer actions are the sheet's primary touch targets: lift sm/md
400
407
  // buttons one size up to the 44px class regardless of the caller's
@@ -132,6 +132,16 @@
132
132
  var(--hk-sheet-bottom-gap, 0px)
133
133
  )
134
134
  );
135
+ /* Dynamic-viewport pairing (HkModal parity): the dvh twin follows the
136
+ * vh cap so a maxed sheet cannot extend under the URL bar on
137
+ * dynamic-toolbar browsers. */
138
+ max-height: min(
139
+ 72vh,
140
+ calc(
141
+ 100dvh - var(--hk-sheet-top-inset, 4.25rem) -
142
+ var(--hk-sheet-bottom-gap, 0px)
143
+ )
144
+ );
135
145
  display: flex;
136
146
  flex-direction: column;
137
147
  overflow-y: auto;
@@ -271,6 +271,16 @@
271
271
  var(--hk-sheet-bottom-gap, 0px)
272
272
  )
273
273
  );
274
+ /* Dynamic-viewport pairing (HkModal parity): the dvh twin follows the
275
+ * vh cap so a maxed sheet cannot extend under the URL bar on
276
+ * dynamic-toolbar browsers. */
277
+ max-height: min(
278
+ 72vh,
279
+ calc(
280
+ 100dvh - var(--hk-sheet-top-inset, 4.25rem) -
281
+ var(--hk-sheet-bottom-gap, 0px)
282
+ )
283
+ );
274
284
  background: rgb(var(--color-surface));
275
285
  border-radius: var(--hk-modal-radius, var(--hi-radius-lg, 12px))
276
286
  var(--hk-modal-radius, var(--hi-radius-lg, 12px))
@@ -198,3 +198,83 @@ describe("usePopupManager blocking flag (breadcrumb levels)", () => {
198
198
  }
199
199
  });
200
200
  });
201
+
202
+ describe("usePopupManager window stack (blocking sheets reband)", () => {
203
+ it("stacks a blocking sheet with windows in open order — a window opened from inside the sheet paints above it", () => {
204
+ // The chest mobile regression: the theme menu docks as a bottom
205
+ // sheet, its row's edit affordance opens a MODAL — kind bands put
206
+ // the sheet (dropdown, 2000) above the modal (1000) and the editor
207
+ // rendered BEHIND its own opener. A blocking sheet is a window: it
208
+ // must share the window band and lose to windows pushed after it.
209
+ const m = freshManager();
210
+ const sheet = m.register("dropdown", false, "Themes", true);
211
+ expect(sheet.zIndex).toBe(POPUP_Z_BANDS.modal);
212
+
213
+ const editor = m.register("modal", true, "Edit theme");
214
+ expect(editor.zIndex).toBe(sheet.zIndex + POPUP_Z_STEP);
215
+
216
+ // A sheet opened from INSIDE that modal pushes on top of it.
217
+ const innerSheet = m.register("dropdown", false, "Picker", true);
218
+ expect(innerSheet.zIndex).toBe(editor.zIndex + POPUP_Z_STEP);
219
+ });
220
+
221
+ it("keeps an ANCHORED dropdown above the whole window stack, blocking sheets included", () => {
222
+ // The in-modal select flow: anchored panels stay in the dropdown
223
+ // band so they paint above whichever window (modal OR blocking
224
+ // sheet) contains them.
225
+ const m = freshManager();
226
+ const sheet = m.register("dropdown", false, "Themes", true);
227
+ const modal = m.register("modal", true, "Edit");
228
+ const panel = m.register("dropdown", false);
229
+ expect(panel.zIndex).toBe(POPUP_Z_BANDS.dropdown);
230
+ expect(panel.zIndex).toBeGreaterThan(sheet.zIndex);
231
+ expect(panel.zIndex).toBeGreaterThan(modal.zIndex);
232
+ });
233
+
234
+ it("reclaims window-band slots across mixed windows and sheets", () => {
235
+ const m = freshManager();
236
+ const sheet = m.register("dropdown", false, "Themes", true);
237
+ const modal = m.register("modal", true, "Edit");
238
+ expect(modal.zIndex).toBe(sheet.zIndex + POPUP_Z_STEP);
239
+
240
+ m.unregister(modal.id);
241
+ const drawer = m.register("drawer", true, "Details");
242
+ // The drawer reclaims the modal's slot, not a third one.
243
+ expect(drawer.zIndex).toBe(modal.zIndex);
244
+ });
245
+
246
+ it("setBlocking promotion pushes onto the top of the window band, demotion returns to the anchored band", () => {
247
+ const m = freshManager();
248
+ const modal = m.register("modal", true, "Edit");
249
+ // Anchored first: above every window while non-blocking.
250
+ const popup = m.register("dropdown", false, "Menu");
251
+ expect(popup.zIndex).toBe(POPUP_Z_BANDS.dropdown);
252
+
253
+ // Viewport crosses the mobile breakpoint: the popover docks as a
254
+ // sheet — becoming a window is a PUSH, so it lands above the modal.
255
+ m.setBlocking(popup.id, true);
256
+ expect(m.registry.value.get(popup.id)!.zIndex).toBe(modal.zIndex + POPUP_Z_STEP);
257
+
258
+ // Back to desktop: an anchored attachment again — top of the
259
+ // anchored band, above the whole window stack.
260
+ m.setBlocking(popup.id, false);
261
+ const entry = m.registry.value.get(popup.id)!;
262
+ expect(entry.zIndex).toBe(POPUP_Z_BANDS.dropdown);
263
+ expect(entry.zIndex).toBeGreaterThan(modal.zIndex);
264
+ });
265
+
266
+ it("orders the breadcrumb window stack by push order (sheet → editor)", () => {
267
+ // HkModalBreadcrumb sorts by zIndex; with blocking sheets sharing
268
+ // the window band, the strip reads in navigation order: the sheet
269
+ // that opened the editor, then the editor as the current level.
270
+ const m = freshManager();
271
+ const sheet = m.register("dropdown", false, "Themes", true);
272
+ const editor = m.register("modal", true, "Edit theme");
273
+ const levels = [...m.registry.value.values()]
274
+ .filter((e) => e.kind === "modal" || e.kind === "drawer" || e.blocking)
275
+ .sort((a, b) => a.zIndex - b.zIndex)
276
+ .map((e) => e.title);
277
+ expect(levels).toEqual(["Themes", "Edit theme"]);
278
+ expect(editor.zIndex).toBeGreaterThan(sheet.zIndex);
279
+ });
280
+ });
@@ -5,23 +5,28 @@ export type PopupKind = "dropdown" | "modal" | "drawer" | "tooltip" | "toast";
5
5
  /**
6
6
  * Kind-priority z bands — the single source of truth for popup stacking.
7
7
  *
8
- * Every registered popup lands in its kind's band, so layering is decided
9
- * by WHAT a surface is, never by WHEN it happened to register. Bands are
10
- * ordered low → high:
8
+ * Layering is decided by WHAT a surface IS — window or attachment — never
9
+ * by WHEN it happened to register:
11
10
  *
12
- * modal (1000) centered dialogs + phone bottom sheets
13
- * drawer (1000) edge drawers share the overlay band with modals —
14
- * inside the band they stack in open order, so a
15
- * drawer opened over a modal still paints above it
16
- * dropdown (2000) anchor-attached popovers, select panels, menus —
17
- * ABOVE the overlay band on purpose: a select panel
18
- * opened from inside a modal portals to <body> and
19
- * must paint above the modal that contains it (the
20
- * common in-modal form flow). A page-level dropdown
21
- * can only coexist with a modal programmatically (the
22
- * modal overlay intercepts pointers), so the rare
23
- * stale-dropdown-over-modal case is accepted, matching
24
- * Ant Design
11
+ * modal (1000) the WINDOW band: centered dialogs + phone bottom
12
+ * drawer (1000) sheets + edge drawers. Anything that BLOCKS the page
13
+ * like a window is a window: modals and drawers by
14
+ * kind, and a dropdown-kind surface while it is docked
15
+ * as a mobile bottom sheet (`blocking`). Within the
16
+ * band surfaces stack purely in open order (push/pop
17
+ * stack semantics), so a window opened FROM inside a
18
+ * sheet always paints above it — the sheet keeps no
19
+ * kind-priority claim over windows opened later.
20
+ * dropdown (2000) ANCHORED popovers, select panels, menus — surfaces
21
+ * attached to an anchor on whatever window is on top,
22
+ * not windows themselves. ABOVE the window band on
23
+ * purpose: a select panel opened from inside a modal
24
+ * portals to <body> and must paint above the modal
25
+ * that contains it (the common in-modal form flow). A
26
+ * page-level anchored dropdown can only coexist with a
27
+ * modal programmatically (the modal overlay intercepts
28
+ * pointers), so the rare stale-dropdown-over-modal case
29
+ * is accepted, matching Ant Design
25
30
  * tooltip (3000) transient annotations must stay visible above the
26
31
  * surfaces they annotate
27
32
  * toast (4000) ALWAYS the topmost surface — a toast must never be
@@ -33,8 +38,11 @@ export type PopupKind = "dropdown" | "modal" | "drawer" | "tooltip" | "toast";
33
38
  * freed slots are reclaimed automatically because the next z derives from
34
39
  * the CURRENT live entries of that band only — no monotonic counter, no
35
40
  * cross-band coupling (a persistent toast/tooltip registration can no
36
- * longer push later modals up the ladder; that ordering bug is what this
37
- * band scheme replaces). Overlay roots are spaced one Z_STEP apart so each
41
+ * longer push later modals up the ladder). A dropdown flipping its
42
+ * blocking flag (anchored popover docking as a sheet, or the reverse)
43
+ * REBANDS in place: promotion pushes onto the top of the window band
44
+ * (becoming a window IS a push), demotion lands on the top of the
45
+ * anchored band. Overlay roots are spaced one Z_STEP apart so each
38
46
  * overlay's +1 content/panel layer always has a free slot above its own
39
47
  * root and below the next overlay.
40
48
  *
@@ -49,6 +57,17 @@ export const POPUP_Z_BANDS: Record<PopupKind, number> = {
49
57
  toast: 4000,
50
58
  };
51
59
 
60
+ /**
61
+ * The band a popup CURRENTLY stacks in. Dropdown-kind surfaces have two:
62
+ * the anchored band while attached to an anchor, the WINDOW band
63
+ * (shared with modals/drawers) while docked as a blocking bottom sheet.
64
+ * Window kinds always live in the window band.
65
+ */
66
+ function effectiveBand(kind: PopupKind, blocking: boolean): number {
67
+ if (kind === "dropdown") return blocking ? POPUP_Z_BANDS.modal : POPUP_Z_BANDS.dropdown;
68
+ return POPUP_Z_BANDS[kind];
69
+ }
70
+
52
71
  /** In-band stacking step. Even numbers leave the odd slot free for the
53
72
  * +1 content/panel layer each overlay puts above its own root. */
54
73
  export const POPUP_Z_STEP = 2;
@@ -61,11 +80,15 @@ export interface PopupEntry {
61
80
  title?: string;
62
81
  /**
63
82
  * True while the popup is a blocking window the user "navigates" —
64
- * a mobile bottom sheet that rose from a dropdown-kind surface. The
65
- * modal-stack breadcrumb lists window kinds (modal/drawer) always and
66
- * dropdown kinds only while they block like this; an anchored desktop
67
- * popover stays a hidden level. Named surfaces only: a blocking popup
68
- * without a title is a naming bug (dev warn at registration).
83
+ * a mobile bottom sheet that rose from a dropdown-kind surface. This
84
+ * flag is window-stack MEMBERSHIP: it lists the popup in the
85
+ * modal-stack breadcrumb AND moves it into the window z band (see
86
+ * effectiveBand) — a blocking sheet is a window in every sense, so
87
+ * windows opened from inside it stack above it. Window kinds
88
+ * (modal/drawer) are windows by kind and always listed; an anchored
89
+ * desktop popover stays a hidden level. Named surfaces only: a
90
+ * blocking popup without a title is a naming bug (dev warn at
91
+ * registration).
69
92
  */
70
93
  blocking: boolean;
71
94
  }
@@ -121,11 +144,14 @@ export function usePopupManager() {
121
144
  blocking = false,
122
145
  ): PopupHandle {
123
146
  const id = uid();
124
- const band = POPUP_Z_BANDS[kind];
147
+ const band = effectiveBand(kind, blocking);
125
148
  // Next slot = one step above the highest LIVE entry of the same band.
149
+ // Band membership follows the entry's CURRENT shape (see
150
+ // effectiveBand): a blocking sheet scans the window band, so it lands
151
+ // above every window opened before it.
126
152
  let maxSlot = -1;
127
153
  for (const entry of registry.value.values()) {
128
- if (POPUP_Z_BANDS[entry.kind] !== band) continue;
154
+ if (effectiveBand(entry.kind, entry.blocking) !== band) continue;
129
155
  const slot = (entry.zIndex - band) / POPUP_Z_STEP;
130
156
  if (slot > maxSlot) maxSlot = slot;
131
157
  }
@@ -151,11 +177,26 @@ export function usePopupManager() {
151
177
  * Flip the blocking flag while the popup stays open — a dropdown that
152
178
  * docks as a bottom sheet when the viewport crosses the mobile
153
179
  * breakpoint becomes a breadcrumb level mid-flight (and back).
180
+ *
181
+ * The flip also REBANDS the z: promotion pushes the sheet onto the top
182
+ * of the window band (becoming a window is a push — it must cover the
183
+ * page), demotion lands it on the top of the anchored band. The freed
184
+ * slot in the old band reclaims lazily via the usual max-live-slot
185
+ * derivation.
154
186
  */
155
187
  function setBlocking(id: string, blocking: boolean) {
156
188
  const entry = registry.value.get(id);
157
189
  if (!entry || entry.blocking === blocking) return;
158
190
  entry.blocking = blocking;
191
+ const band = effectiveBand(entry.kind, entry.blocking);
192
+ let maxSlot = -1;
193
+ for (const other of registry.value.values()) {
194
+ if (other === entry) continue;
195
+ if (effectiveBand(other.kind, other.blocking) !== band) continue;
196
+ const slot = (other.zIndex - band) / POPUP_Z_STEP;
197
+ if (slot > maxSlot) maxSlot = slot;
198
+ }
199
+ entry.zIndex = band + (maxSlot + 1) * POPUP_Z_STEP;
159
200
  registry.value = new Map(registry.value);
160
201
  if (blocking) warnUntitled(entry.kind, true, entry.title);
161
202
  }