@celestia-island/hikari 0.40.25 → 0.40.27

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.25",
3
+ "version": "0.40.27",
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,71 @@
1
+ /**
2
+ * Source contract for HkFileBrowserDialog's ≤767px relaxation block
3
+ * (2026-09-08 scan wave 2 finding F5 — LOW but user-facing: chest's
4
+ * workspace file-browser flow opens this dialog on phones; fixed in the
5
+ * 2026-09-08 mobile sheet wave, the same family grammar as HkModal's
6
+ * docked-sheet block).
7
+ *
8
+ * The desktop frame is a 56rem modal; on a ~412px phone sheet HkModal
9
+ * docks full-bleed and the dialog's OWN internals were the squeeze
10
+ * (everything fit only thanks to ellipsis/overflow):
11
+ * .hk-file-browser-filter width 9rem + min-width 9rem fixed box
12
+ * .hk-file-browser-rename-input 8rem min-width floor
13
+ * list head/row grid fixed 6rem + 10rem tracks (256px)
14
+ *
15
+ * The mobile block must neutralize each of these INSIDE the media query
16
+ * while the desktop rules stay untouched outside it — pinned so a refactor
17
+ * cannot silently reintroduce desktop-only widths (the scan's "sidebar"
18
+ * label drifted; these three are the real fixed-width participants).
19
+ */
20
+ import { describe, expect, it } from "vitest";
21
+ import { readFileSync } from "node:fs";
22
+ import { dirname, join } from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+
25
+ const here = dirname(fileURLToPath(import.meta.url));
26
+ const src = readFileSync(join(here, "HkFileBrowserDialog.scss"), "utf-8");
27
+
28
+ const MOBILE_AT = "@media (max-width: 767px)";
29
+ const mobileStart = src.indexOf(MOBILE_AT);
30
+
31
+ describe("HkFileBrowserDialog mobile relaxation contract", () => {
32
+ it("ships a ≤767px block after the desktop rules", () => {
33
+ expect(mobileStart).toBeGreaterThan(-1);
34
+ // The block is appended at the end: every desktop rule precedes it.
35
+ expect(src.indexOf(".hk-file-browser-filter", mobileStart)).toBeGreaterThan(-1);
36
+ });
37
+
38
+ it("lets the type filter size to its trigger content instead of a fixed 9rem box", () => {
39
+ const block = src.slice(mobileStart);
40
+ const rule = block.match(/\.hk-file-browser-filter\s*{[^}]*}/)?.[0] ?? "";
41
+ expect(rule).toContain("width: auto");
42
+ expect(rule).toContain("min-width: 0");
43
+ });
44
+
45
+ it("drops the rename input's 8rem floor so the wrap row shares its line", () => {
46
+ const block = src.slice(mobileStart);
47
+ const rule = block.match(/\.hk-file-browser-rename-input\s*{[^}]*}/)?.[0] ?? "";
48
+ expect(rule).toContain("min-width: 0");
49
+ });
50
+
51
+ it("makes the list grid's fixed 6rem/10rem tracks compressible on phones", () => {
52
+ const block = src.slice(mobileStart);
53
+ const rule =
54
+ block.match(/\.hk-file-browser-list-head,\s*\n\s*\.hk-file-browser-row\s*{[^}]*}/)
55
+ ?.[0] ?? "";
56
+ // Three minmax(0, …) tracks — no bare fixed rem track may remain, and
57
+ // the rows reclaim one spacing step of horizontal padding.
58
+ expect(rule).toMatch(
59
+ /grid-template-columns:\s*minmax\(0, 1fr\) minmax\(0, [\d.]+rem\) minmax\(0, [\d.]+rem\)/,
60
+ );
61
+ expect(rule).toContain("padding-inline: var(--space-8)");
62
+ });
63
+
64
+ it("keeps every desktop width outside the mobile block (relaxation, not redesign)", () => {
65
+ const desktop = src.slice(0, mobileStart);
66
+ expect(desktop).toContain("width: 9rem");
67
+ expect(desktop).toContain("min-width: 9rem");
68
+ expect(desktop).toContain("min-width: 8rem");
69
+ expect(desktop).toContain("grid-template-columns: minmax(0, 1fr) 6rem 10rem");
70
+ });
71
+ });
@@ -528,3 +528,46 @@
528
528
  justify-content: flex-end;
529
529
  gap: var(--space-8);
530
530
  }
531
+
532
+ // ------
533
+ // Mobile (≤767px) — let the desktop internals breathe in the docked sheet
534
+ // ------
535
+ // 2026-09-08 scan wave 2 finding F5 (LOW, user-facing: chest's workspace
536
+ // file-browser flow opens this dialog on phones). HkModal's docking is
537
+ // correct (full-bleed bottom sheet); the squeeze is HERE, in internals
538
+ // sized for the 56rem desktop frame. The scan's "sidebar/tree column"
539
+ // label drifted: the 9rem pair at the flagged lines is the toolbar's type
540
+ // filter, and the 8rem floor is the rename input — there is no sidebar.
541
+ // Pure relaxation only (same grammar as HkModal's ≤767px block): no
542
+ // stacking, no reordering, no JS — the existing rows/columns just stop
543
+ // reserving desktop widths.
544
+ @media (max-width: 767px) {
545
+ // Type filter: 9rem + min-width 9rem is a third of a 412px sheet for a
546
+ // select whose longest label is an extension (".csv"). Let the flex item
547
+ // size to its HkSelect trigger content instead of holding a fixed box —
548
+ // no floor needed: the trigger carries its own padding/min-height and
549
+ // the wrapping toolbar stacks it onto its own line when truly cramped.
550
+ .hk-file-browser-filter {
551
+ width: auto;
552
+ min-width: 0;
553
+ }
554
+
555
+ // Rename input: the 8rem floor decided the row's wrap point before the
556
+ // label and the two buttons got their share. min-width: 0 lets the
557
+ // flex-wrap row hand the input whatever line width is left.
558
+ .hk-file-browser-rename-input {
559
+ min-width: 0;
560
+ }
561
+
562
+ // List rows: desktop reserves fixed 6rem + 10rem (256px) for the
563
+ // size/modified tracks — about three quarters of a 412px sheet's content
564
+ // width, leaving the name column a sliver. Both cells already ellipsize,
565
+ // so cap them as compressible maxima (they still take the full cap on
566
+ // roomier sheets and shrink only under real pressure) and reclaim one
567
+ // spacing step of horizontal row padding for the name column.
568
+ .hk-file-browser-list-head,
569
+ .hk-file-browser-row {
570
+ grid-template-columns: minmax(0, 1fr) minmax(0, 4.5rem) minmax(0, 6.5rem);
571
+ padding-inline: var(--space-8);
572
+ }
573
+ }
@@ -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
  }