@celestia-island/hikari 0.55.324 → 0.55.326

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.324",
3
+ "version": "0.55.326",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -81,7 +81,14 @@ const setWidth = (w: number) => {
81
81
  function paddingInner(container: HTMLElement): HTMLElement | null | undefined {
82
82
  const viewport = container.querySelector(".hk-scroll-container-viewport")
83
83
  ?? container.querySelector(".hk-scroll-container");
84
- return viewport?.firstElementChild as HTMLElement | null | undefined;
84
+ // The shell's fallback scroll-progress dock (2026-11-09) wraps the
85
+ // content with a display:contents scope — descend through it, the
86
+ // padding wrapper is still the viewport's only layout box.
87
+ let inner = viewport?.firstElementChild as HTMLElement | null | undefined;
88
+ if (inner?.classList.contains("hk-scroll-progress-dock-scope")) {
89
+ inner = inner.firstElementChild as HTMLElement;
90
+ }
91
+ return inner;
85
92
  }
86
93
 
87
94
  function mobileInnerPadding(container: HTMLElement): string {
@@ -240,10 +247,9 @@ describe("HkAdminShell", () => {
240
247
  { header: () => null, sidebar: NAV, content: CONTENT },
241
248
  ));
242
249
  // The padded inner wrapper exists INSIDE the scroll viewport (not as
243
- // padding on the container itself).
244
- const viewport = c.querySelector(".hk-scroll-container-viewport")
245
- ?? c.querySelector(".hk-scroll-container");
246
- const inner = viewport?.firstElementChild as HTMLElement | null | undefined;
250
+ // padding on the container itself) — behind the fallback dock's
251
+ // display:contents scope (see paddingInner).
252
+ const inner = paddingInner(c);
247
253
  expect(inner).toBeTruthy();
248
254
  expect(inner?.style.padding).toBe("2rem");
249
255
  });
@@ -1,6 +1,7 @@
1
1
  import { defineComponent, type Component, type PropType } from "vue";
2
2
  import { HAlert, HCard, HEmptyState, HSpinner, HTable, useI18n } from "@celestia-island/hikari";
3
3
  import HkCrossfade from "./HkCrossfade";
4
+ import HkScrollProgressDock from "./HkScrollProgressDock";
4
5
  import { HkPageHeader } from "./HkPageHeader";
5
6
  import type { HkRowManageGate } from "./HkTable";
6
7
 
@@ -80,6 +81,24 @@ export const HkAdminTablePage = defineComponent({
80
81
  * zero so a fresh or empty list shows no "共 0 条" flash. Pages
81
82
  * that render their own count elsewhere set this false. */
82
83
  countReadout: { type: Boolean, default: true },
84
+ /** The page's own scroll-progress DOCK (user direction 2026-11-09:
85
+ * the list large-component uses the dock itself). DEFAULT ON — the
86
+ * page mounts an HkScrollProgressDock around the body crossfade so
87
+ * the table (or a body-slot roster) self-registers against it and
88
+ * the pill floats over the scrolling viewport; the crossfade's
89
+ * generation swaps happen INSIDE the dock, so the registration
90
+ * survives every exchange. A page under a shell fallback dock has
91
+ * its OWN dock stand down automatically (the nested-dock rule
92
+ * serves the outermost enabled dock — the fallback keeps the
93
+ * pill); set `false` to demote the page — lists then fall through
94
+ * to the nearest enabled dock above, if any. Layering: the shell
95
+ * master switch (HkFaceShell `scrollProgress`) > this prop > the
96
+ * dock's own `disabled`. Like every dock gate, the layering is
97
+ * RESOLVED
98
+ * ONCE at the dock's setup — hosts pass these statically, and a
99
+ * runtime flip needs a remount (the HkScrollContainer prop
100
+ * precedent). */
101
+ progressDock: { type: Boolean, default: true },
83
102
  },
84
103
  emits: {
85
104
  reorder: (_fromIndex: number, _toIndex: number) => true,
@@ -151,48 +170,55 @@ export const HkAdminTablePage = defineComponent({
151
170
  every host dissolves between them instead of popping. The
152
171
  key collapses loading-vs-empty into one "placeholder"
153
172
  generation: spinner → empty is a data arrival, not a slot
154
- identity change worth animating. */}
155
- <HkCrossfade
156
- swapKey={
157
- props.error ? "error"
158
- : props.loading && !props.rows.length ? "placeholder"
159
- : !props.rows.length ? "placeholder"
160
- : "content"
161
- }
162
- >
163
- {props.error ? (
164
- <HAlert message={props.error} />
165
- ) : props.loading && !props.rows.length ? (
166
- <HSpinner center />
167
- ) : !props.rows.length ? (
168
- <HEmptyState
169
- boxed
170
- title={emptyTitle}
171
- description={props.emptyDescription}
172
- >
173
- {emptySlots}
174
- </HEmptyState>
175
- ) : slots.body ? (
176
- <div class="hk-admin-table-page-body">{slots.body()}</div>
177
- ) : (
178
- <HCard padded={false}>
179
- <HTable
180
- columns={props.columns}
181
- rows={props.rows}
182
- rowKey={props.rowKey}
183
- total={props.total}
184
- draggable={props.draggable}
185
- manageable={props.manageable}
186
- onReorder={(from: number, to: number) => emit("reorder", from, to)}
187
- onManage={(row: Record<string, unknown>, opener: HTMLElement) =>
188
- emit("manage", row, opener)
189
- }
173
+ identity change worth animating.
174
+ The whole exchange rides INSIDE this page's scroll-progress
175
+ dock (default on; `progressDock:false` demotes to a bare
176
+ passthrough): the dock must wrap the CROSSFADE, not sit in
177
+ a generation — a generation-scoped dock would unmount with
178
+ every swap and drop the registration. */}
179
+ <HkScrollProgressDock disabled={!props.progressDock}>
180
+ <HkCrossfade
181
+ swapKey={
182
+ props.error ? "error"
183
+ : props.loading && !props.rows.length ? "placeholder"
184
+ : !props.rows.length ? "placeholder"
185
+ : "content"
186
+ }
187
+ >
188
+ {props.error ? (
189
+ <HAlert message={props.error} />
190
+ ) : props.loading && !props.rows.length ? (
191
+ <HSpinner center />
192
+ ) : !props.rows.length ? (
193
+ <HEmptyState
194
+ boxed
195
+ title={emptyTitle}
196
+ description={props.emptyDescription}
190
197
  >
191
- {slots}
192
- </HTable>
193
- </HCard>
194
- )}
195
- </HkCrossfade>
198
+ {emptySlots}
199
+ </HEmptyState>
200
+ ) : slots.body ? (
201
+ <div class="hk-admin-table-page-body">{slots.body()}</div>
202
+ ) : (
203
+ <HCard padded={false}>
204
+ <HTable
205
+ columns={props.columns}
206
+ rows={props.rows}
207
+ rowKey={props.rowKey}
208
+ total={props.total}
209
+ draggable={props.draggable}
210
+ manageable={props.manageable}
211
+ onReorder={(from: number, to: number) => emit("reorder", from, to)}
212
+ onManage={(row: Record<string, unknown>, opener: HTMLElement) =>
213
+ emit("manage", row, opener)
214
+ }
215
+ >
216
+ {slots}
217
+ </HTable>
218
+ </HCard>
219
+ )}
220
+ </HkCrossfade>
221
+ </HkScrollProgressDock>
196
222
  {slots["create-modal"]?.()}
197
223
  {slots["edit-modal"]?.()}
198
224
  </div>
@@ -9,7 +9,7 @@ import HkTooltip from "./HkTooltip";
9
9
  import { hasRenderedContent } from "../runtime/hasRenderedContent";
10
10
  import { useMediaQuery } from "../runtime/useMediaQuery";
11
11
  import type { StatTone } from "./HkStatCard";
12
- import { HK_SCROLL_PROGRESS_REGISTRAR, type HkScrollProgressHandle } from "./HkScrollProgress";
12
+ import { HK_SCROLL_PROGRESS_REGISTRAR, type HkScrollProgressHandle } from "./HkScrollProgressDock";
13
13
  import "./HkCardList.scss";
14
14
 
15
15
  /**
@@ -47,22 +47,22 @@ export const HkCardList = defineComponent({
47
47
  props: {
48
48
  /** Authoritative row total the scroll-progress pill uses as its
49
49
  * denominator (the server count behind a windowed/infinite list).
50
- * Forwarded to the nearest enclosing HkScrollContainer with
51
- * `scrollProgress` on, exactly like HkAdminTablePage's `total`.
52
- * Omitted: the pill falls back to counting the delivered DOM rows
53
- * (right for fully-delivered lists, drifting low for windowed
54
- * ones — pass the server count there). */
50
+ * Forwarded to the nearest enclosing enabled HkScrollProgressDock,
51
+ * exactly like HkAdminTablePage's `total`. Omitted: the pill falls
52
+ * back to counting the delivered DOM rows (right for
53
+ * fully-delivered lists, drifting low for windowed ones — pass the
54
+ * server count there). */
55
55
  total: { type: Number, default: undefined },
56
56
  },
57
57
  setup(props, { slots }) {
58
58
  // Scroll-progress registration (the HkTable precedent): the NEAREST
59
- // enclosing HkScrollContainer with `scrollProgress` on provides the
60
- // registrar — provide/inject scoping resolves the admin shell's
61
- // viewport for console pages and keeps a list registered against an
62
- // inner container out of the shell. The handle hands the pill the
63
- // list element as the row root, the `total` prop live, and the
64
- // rendered HkListRow elements as the rows (a card list's rows ARE
65
- // its data rows — no section-band caveat).
59
+ // enclosing ENABLED HkScrollProgressDock provides the registrar —
60
+ // provide/inject scoping resolves the shell viewport for console
61
+ // pages and keeps a list registered against an inner dock out of
62
+ // the shell. The handle hands the pill the list element as the row
63
+ // root, the `total` prop live, and the rendered HkListRow elements
64
+ // as the rows (a card list's rows ARE its data rows — no
65
+ // section-band caveat).
66
66
  const listRef = ref<HTMLElement | null>(null);
67
67
  const progressRegistrar = inject(HK_SCROLL_PROGRESS_REGISTRAR, null);
68
68
  const progressHandle: HkScrollProgressHandle = {
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { createApp, h } from "vue";
5
+ import { createApp, defineComponent, h, inject } from "vue";
6
6
 
7
7
  // HDrawer teleports and animates; replace it with an inline passthrough
8
8
  // (same stub shape as HkAdminShell.test.tsx) so the shell contract is
@@ -36,6 +36,7 @@ vi.mock("@celestia-island/hikari", async (importOriginal) => {
36
36
 
37
37
  import { HkFaceShell } from "./HkFaceShell";
38
38
  import { HkAdminShell } from "./HkAdminShell";
39
+ import { HK_SCROLL_PROGRESS_ALLOWED } from "./HkScrollProgressDock";
39
40
 
40
41
  /**
41
42
  * HkFaceShell — the generalized face shell extracted from HkAdminShell
@@ -96,6 +97,11 @@ describe("HkFaceShell stage mode", () => {
96
97
  // The document host is GONE — that is the stage contract.
97
98
  expect(c.querySelector(".hk-scroll-container")).toBeNull();
98
99
  expect(c.querySelector(".hk-scroll-container-viewport")).toBeNull();
100
+ // …and so is the fallback scroll-progress dock (document-mode only —
101
+ // a stage face owns its own geometry, so the shell mounts no dock
102
+ // scope and provides no pill chrome at all).
103
+ expect(c.querySelector(".hk-scroll-progress-dock-scope")).toBeNull();
104
+ expect(c.querySelector(".hk-scroll-progress-dock")).toBeNull();
99
105
  // No padding wrapper anywhere: the content slot's first element IS
100
106
  // the main region's child (contentPadding is a document-mode prop).
101
107
  expect(main!.firstElementChild?.textContent).toBe("CONTENT");
@@ -158,12 +164,57 @@ describe("HkFaceShell document mode (the default)", () => {
158
164
  expect(c.querySelector(".hk-scroll-container")).toBeTruthy();
159
165
  const viewport = c.querySelector(".hk-scroll-container-viewport")
160
166
  ?? c.querySelector(".hk-scroll-container");
161
- const inner = viewport!.firstElementChild as HTMLElement;
167
+ // The shell's fallback scroll-progress dock wraps the content
168
+ // (2026-11-09): its scope is display: contents, so the padding
169
+ // wrapper below it is STILL the viewport's only layout box.
170
+ const scope = viewport!.firstElementChild as HTMLElement;
171
+ expect(scope.classList.contains("hk-scroll-progress-dock-scope")).toBe(true);
172
+ const inner = scope.firstElementChild as HTMLElement;
162
173
  expect(inner.style.padding).toBe("2rem");
163
174
  expect(inner.textContent).toContain("CONTENT");
164
175
  });
165
176
  });
166
177
 
178
+ describe("HkFaceShell scroll-progress dock master switch", () => {
179
+ const makeProbe = (box: { seen: unknown }) =>
180
+ defineComponent({
181
+ setup() {
182
+ box.seen = inject(HK_SCROLL_PROGRESS_ALLOWED, null);
183
+ return () => h("main", null, "CONTENT");
184
+ },
185
+ });
186
+
187
+ it("provides ALLOWED=true by default and mounts the fallback dock", () => {
188
+ const box: { seen: unknown } = { seen: "unset" };
189
+ const c = mount(h(HkFaceShell, {
190
+ navTitle: "Navigation",
191
+ }, {
192
+ header: () => null,
193
+ content: () => h(makeProbe(box)),
194
+ }));
195
+ // The `scrollProgress` prop (default true) is the face-level lever
196
+ // over every dock in the subtree.
197
+ expect(box.seen).toBe(true);
198
+ expect(c.querySelector(".hk-scroll-progress-dock-scope")).not.toBeNull();
199
+ });
200
+
201
+ it("scrollProgress:false closes the switch and stands the fallback dock down", () => {
202
+ const box: { seen: unknown } = { seen: "unset" };
203
+ const c = mount(h(HkFaceShell, {
204
+ navTitle: "Navigation",
205
+ scrollProgress: false,
206
+ }, {
207
+ header: () => null,
208
+ content: () => h(makeProbe(box)),
209
+ }));
210
+ expect(box.seen).toBe(false);
211
+ // Fully lazy: no scope, no pill anywhere in the shell.
212
+ expect(c.querySelector(".hk-scroll-progress-dock-scope")).toBeNull();
213
+ expect(c.querySelector(".hk-scroll-progress-dock")).toBeNull();
214
+ expect(c.querySelector(".hk-scroll-container-viewport")!.textContent).toContain("CONTENT");
215
+ });
216
+ });
217
+
167
218
  describe("the extraction boundary: the action-bar channel", () => {
168
219
  it("HkFaceShell's header slot carries no actions param (no bar provided)", () => {
169
220
  const seen: Array<Record<string, unknown>> = [];
@@ -1,10 +1,11 @@
1
- import { computed, defineComponent, ref, watch, type PropType } from "vue";
1
+ import { computed, defineComponent, provide, ref, watch, type PropType } from "vue";
2
2
  import { HkCrossfade, HDrawer, HScrollContainer, useBreakpoint } from "@celestia-island/hikari";
3
3
  import { useI18n } from "../i18n/context";
4
4
  import { useSidebarRail } from "../composables/useSidebarRail";
5
5
  import { provideMenuGroupState } from "../composables/useMenuGroupState";
6
6
  import HkSidebarRailSwap from "./HkSidebarRailSwap";
7
7
  import HkSidebarRailToggle, { isPointerOverRailToggle } from "./HkSidebarRailToggle";
8
+ import HkScrollProgressDock, { HK_SCROLL_PROGRESS_ALLOWED } from "./HkScrollProgressDock";
8
9
 
9
10
  /** CSS-wide keywords. A shorthand rebuilt from one of them is NOT a
10
11
  * padding (`inherit 0 inherit` is rejected by every engine, measured in
@@ -236,13 +237,19 @@ export const HkFaceShell = defineComponent({
236
237
  * `stage` renders the content bare — the face owns scrolling,
237
238
  * canvases and floating layers. */
238
239
  contentMode: { type: String as PropType<"document" | "stage">, default: "document" },
239
- /** Scroll-progress pill on the document viewport (2026-11): while a
240
- * registered list scrolls, the shell floats an "xx / xx" counter
241
- * near the viewport's bottom-center (HkScrollContainer
242
- * `scrollProgress`; lists register themselves — HkAdminTablePage
243
- * does it for every admin list — so hosts inherit the affordance
244
- * with zero per-page work). Default ON: the shell is the
245
- * system-wide default-on point. Pass `false` to run the face bare. */
240
+ /** Scroll-progress DOCK master switch (2026-11-09): the shell-level
241
+ * on/off lever for every HkScrollProgressDock in the face —
242
+ * provided to the subtree as `HK_SCROLL_PROGRESS_ALLOWED`, so a
243
+ * `false` here turns a page's own dock lazy too (the shell >
244
+ * page-level `progressDock` > the dock's own `disabled`). While on
245
+ * (the default), the shell ALSO mounts its own fallback dock
246
+ * around the document content: unmigrated pages and bare lists
247
+ * still float the pill with zero per-page work; a standard page's
248
+ * own dock nests INSIDE the fallback and is the side that stands
249
+ * down (the nested-dock rule serves the outermost enabled dock —
250
+ * one pill, never two). Lists register themselves (HkTable /
251
+ * HkCardList / HkAdminTablePage do it built-in). Pass `false` to
252
+ * run the face bare. */
246
253
  scrollProgress: { type: Boolean, default: true },
247
254
  /** Content-area padding (inside the scroll viewport). Document mode
248
255
  * only — a stage face owns its own geometry. Padding is applied to
@@ -302,6 +309,12 @@ export const HkFaceShell = defineComponent({
302
309
  // same one as the desktop sidebar.
303
310
  provideMenuGroupState();
304
311
 
312
+ // ── scroll-progress dock master switch ───────────────────────────
313
+ // The `scrollProgress` prop is now the FACE-LEVEL lever over every
314
+ // dock in the subtree (see the prop doc): provided once, statically
315
+ // — hosts pass it statically, the HkScrollContainer prop precedent.
316
+ provide(HK_SCROLL_PROGRESS_ALLOWED, props.scrollProgress);
317
+
305
318
  // ── desktop icon rail + hover peek (sidebarCollapsible) ──────────
306
319
  // SEMI-CONTROLLED rail state: a local session-only ref is the live
307
320
  // state, so the toggle works with NO channel bound at all (the
@@ -545,11 +558,22 @@ export const HkFaceShell = defineComponent({
545
558
  {props.contentMode === "stage" ? (
546
559
  renderContentStage()
547
560
  ) : (
548
- <HScrollContainer class="s-admin-shell-scroll" scrollProgress={props.scrollProgress}>
549
- {/* Padding lives INSIDE the scroll viewport (an inner
550
- wrapper) so card box-shadows are not clipped at the
551
- viewport edges. */}
552
- <div style={contentStyle.value}>{renderContentStage()}</div>
561
+ <HScrollContainer class="s-admin-shell-scroll">
562
+ {/* The shell's FALLBACK dock (2026-11-09): wraps the
563
+ padded content so unmigrated pages and bare lists
564
+ still float the pill. A page that mounts its own dock
565
+ (HkAdminTablePage's, by default) nests INSIDE this
566
+ one and is the side that stands down — the nested
567
+ -dock rule serves the OUTERMOST enabled dock, so the
568
+ fallback keeps sensing and one pill paints, never
569
+ two. The scope is display: contents — the padding
570
+ wrapper below stays the viewport's only layout box. */}
571
+ <HkScrollProgressDock>
572
+ {/* Padding lives INSIDE the scroll viewport (an inner
573
+ wrapper) so card box-shadows are not clipped at the
574
+ viewport edges. */}
575
+ <div style={contentStyle.value}>{renderContentStage()}</div>
576
+ </HkScrollProgressDock>
553
577
  </HScrollContainer>
554
578
  )}
555
579
  </main>
@@ -1,7 +1,8 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest";
2
- import { createApp, defineComponent, h, nextTick, reactive, ref } from "vue";
2
+ import { createApp, defineComponent, h, inject, nextTick, reactive, ref } from "vue";
3
3
 
4
4
  import HkScrollContainer from "./HkScrollContainer";
5
+ import { HK_SCROLL_PROGRESS_HOST, HK_SCROLL_PROGRESS_REGISTRAR, HK_SCROLL_PROGRESS_VIEWPORT } from "./HkScrollProgressDock";
5
6
  import type { OverlayScrollbarMarker } from "../composables/useOverlayScrollbar";
6
7
 
7
8
  interface Mounted {
@@ -648,3 +649,47 @@ describe("HkScrollContainer change-mark rail", () => {
648
649
  expect(track?.querySelectorAll(".hk-scrollbar-marker").length ?? 0).toBe(0);
649
650
  });
650
651
  });
652
+
653
+ describe("HkScrollContainer dock publication (2026-11-09)", () => {
654
+ it("publishes viewport + host refs and NO scroll-progress registrar", () => {
655
+ // The pill feature moved into the standalone HkScrollProgressDock:
656
+ // the container's only remaining role is publishing the two refs
657
+ // any dock in its slot injects (the one-way key dependency). Pin
658
+ // BOTH directions with probe injects: the dock keys resolve, the
659
+ // retired registrar key does not — a container that regrows the old
660
+ // provide (or drops the new ones) turns this red.
661
+ const seen = {
662
+ viewport: "unset" as unknown,
663
+ host: "unset" as unknown,
664
+ registrar: "unset" as unknown,
665
+ };
666
+ const Probe = defineComponent({
667
+ setup() {
668
+ seen.viewport = inject(HK_SCROLL_PROGRESS_VIEWPORT, null);
669
+ seen.host = inject(HK_SCROLL_PROGRESS_HOST, null);
670
+ seen.registrar = inject(HK_SCROLL_PROGRESS_REGISTRAR, null);
671
+ return () => h("div", "probe");
672
+ },
673
+ });
674
+ const host = document.createElement("div");
675
+ document.body.appendChild(host);
676
+ containers.push(host);
677
+ const app = createApp({ render: () => h(HkScrollContainer, {}, { default: () => h(Probe) }) });
678
+ mounts.push(app);
679
+ app.mount(host);
680
+ const root = host.querySelector<HTMLElement>(".hk-scroll-container");
681
+ const viewport = host.querySelector<HTMLElement>(".hk-scroll-container-viewport");
682
+ expect(root).not.toBeNull();
683
+ expect(viewport).not.toBeNull();
684
+ expect(seen.registrar).toBeNull();
685
+ // The refs are the shared `Ref<HTMLElement | null>` contract, and
686
+ // by probe time the template refs have landed: they point at THIS
687
+ // container's viewport / host elements.
688
+ const viewportRef = seen.viewport as { value: unknown } | null;
689
+ const hostRef = seen.host as { value: unknown } | null;
690
+ expect(viewportRef).not.toBeNull();
691
+ expect(hostRef).not.toBeNull();
692
+ expect(viewportRef!.value).toBe(viewport);
693
+ expect(hostRef!.value).toBe(root);
694
+ });
695
+ });