@celestia-island/hikari 0.55.323 → 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.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/src/components/HkAdminShell.test.tsx +11 -5
  3. package/src/components/HkAdminTablePage.test.tsx +57 -0
  4. package/src/components/HkAdminTablePage.tsx +113 -46
  5. package/src/components/HkCardList.tsx +13 -13
  6. package/src/components/HkFaceShell.test.tsx +53 -2
  7. package/src/components/HkFaceShell.tsx +37 -13
  8. package/src/components/HkFilterOperatorIcon.test.tsx +61 -0
  9. package/src/components/HkFilterOperatorIcon.tsx +8 -2
  10. package/src/components/HkFilterPanel.test.tsx +97 -0
  11. package/src/components/HkFilterPanel.tsx +226 -115
  12. package/src/components/HkInfiniteTail.test.tsx +5 -3
  13. package/src/components/HkInfiniteTail.tsx +3 -2
  14. package/src/components/HkLoadMore.test.tsx +15 -3
  15. package/src/components/HkLoadMore.tsx +28 -5
  16. package/src/components/HkScrollContainer.test.ts +46 -1
  17. package/src/components/HkScrollContainer.tsx +18 -193
  18. package/src/components/HkScrollProgressDock.scss +101 -0
  19. package/src/components/HkScrollProgressDock.test.tsx +1069 -0
  20. package/src/components/HkScrollProgressDock.tsx +532 -0
  21. package/src/components/HkTable.sections.test.tsx +1 -1
  22. package/src/components/HkTable.tsx +10 -9
  23. package/src/i18n/locales/ar/components.json +7 -2
  24. package/src/i18n/locales/de/components.json +7 -2
  25. package/src/i18n/locales/en/components.json +7 -2
  26. package/src/i18n/locales/es/components.json +7 -2
  27. package/src/i18n/locales/fr/components.json +7 -2
  28. package/src/i18n/locales/ja/components.json +7 -2
  29. package/src/i18n/locales/ko/components.json +7 -2
  30. package/src/i18n/locales/pt/components.json +7 -2
  31. package/src/i18n/locales/ru/components.json +7 -2
  32. package/src/i18n/locales/zh-Hans/components.json +7 -2
  33. package/src/i18n/locales/zh-Hant/components.json +7 -2
  34. package/src/index.ts +9 -6
  35. package/src/components/HkScrollProgress.scss +0 -58
  36. package/src/components/HkScrollProgress.test.tsx +0 -669
  37. package/src/components/HkScrollProgress.tsx +0 -77
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.323",
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
  });
@@ -138,4 +138,61 @@ describe("HkAdminTablePage", () => {
138
138
  expect(got[0]![0]).toBe(rows[0]);
139
139
  expect(got[0]![1]).toBe(btn);
140
140
  });
141
+
142
+ it("renders the count readout in the header, preferring the declared total", () => {
143
+ // User direction 2026-11-08: every standard list names its size —
144
+ // the scaffold owns the "N in total" wording so pages stop
145
+ // hand-rolling their own count spans.
146
+ const c = mount(
147
+ h(HkAdminTablePage, {
148
+ title: "T",
149
+ rows: Array.from({ length: 12 }, (_, i) => ({ name: `r${i}` })),
150
+ columns: COLS,
151
+ total: 500,
152
+ }),
153
+ );
154
+ const readout = c.querySelector('[data-testid="hk-admin-table-page-count"]');
155
+ expect(readout).not.toBeNull();
156
+ expect(readout!.textContent).toBe("500 in total");
157
+ });
158
+
159
+ it("falls back to the delivered row count when no total is declared", () => {
160
+ const c = mount(
161
+ h(HkAdminTablePage, { title: "T", rows: [{ name: "a" }, { name: "b" }], columns: COLS }),
162
+ );
163
+ expect(c.querySelector('[data-testid="hk-admin-table-page-count"]')!.textContent).toBe("2 in total");
164
+ });
165
+
166
+ it("hides the readout on an empty list and honors countReadout:false", () => {
167
+ const empty = mount(h(HkAdminTablePage, { title: "T", rows: [], columns: COLS }));
168
+ expect(empty.querySelector('[data-testid="hk-admin-table-page-count"]')).toBeNull();
169
+ const optedOut = mount(
170
+ h(HkAdminTablePage, { title: "T", rows: [{ name: "a" }], columns: COLS, countReadout: false }),
171
+ );
172
+ expect(optedOut.querySelector('[data-testid="hk-admin-table-page-count"]')).toBeNull();
173
+ });
174
+
175
+ it("renders the readout beside page actions, rightmost", () => {
176
+ const slots = { actions: () => h("button", { class: "page-action" }, "Do") };
177
+ const c = mount(
178
+ h(HkAdminTablePage, { title: "T", rows: [{ name: "a" }], columns: COLS }, slots),
179
+ );
180
+ const header = c.querySelector(".hk-page-header")!;
181
+ expect(header.querySelector("button.page-action")).not.toBeNull();
182
+ // The count span trails the page's own actions inside the header row.
183
+ const actions = header.querySelector(".hk-page-header-actions") ?? header;
184
+ expect(actions.textContent).toContain("1 in total");
185
+ });
186
+
187
+ it("keeps the headerless actions band for a bare readout", () => {
188
+ // No title: the band is the readout's only host — one slim right
189
+ // aligned row, never a second stacked header (安全扫描 report,
190
+ // 2026-11-08).
191
+ const c = mount(
192
+ h(HkAdminTablePage, { rows: [{ name: "a" }], columns: COLS }),
193
+ );
194
+ const readout = c.querySelector('[data-testid="hk-admin-table-page-count"]');
195
+ expect(readout).not.toBeNull();
196
+ expect(readout!.textContent).toBe("1 in total");
197
+ });
141
198
  });
@@ -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
 
@@ -69,8 +70,35 @@ export const HkAdminTablePage = defineComponent({
69
70
  * denominator (the server count behind a paged list — chest's
70
71
  * usePagedTableFetch total). Forwarded to HTable, which
71
72
  * self-registers with the enclosing shell viewport's registrar.
72
- * Omitted: the pill falls back to counting the delivered DOM rows. */
73
+ * Omitted: the pill falls back to counting the delivered DOM rows.
74
+ * The SAME number feeds the header's count readout below. */
73
75
  total: { type: Number, default: undefined },
76
+ /** Top-right "共 N 条" count readout in the page header (user
77
+ * direction 2026-11-08: EVERY standard list names its size — the
78
+ * scaffold owns the wording so pages stop hand-rolling their own
79
+ * "{n} items" spans). Prefers `total` (the server count behind a
80
+ * paged list) and falls back to the delivered row count; hidden at
81
+ * zero so a fresh or empty list shows no "共 0 条" flash. Pages
82
+ * that render their own count elsewhere set this false. */
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 },
74
102
  },
75
103
  emits: {
76
104
  reorder: (_fromIndex: number, _toIndex: number) => true,
@@ -80,21 +108,53 @@ export const HkAdminTablePage = defineComponent({
80
108
  return () => {
81
109
  const { t } = useI18n();
82
110
  const emptyTitle = props.emptyTitle || t("hikari::tablePage.emptyTitle", "No data");
111
+ // The header count readout: the authoritative total when declared,
112
+ // else the delivered rows; hidden at zero (an empty list's empty
113
+ // state already says so — "共 0 条" next to it reads as noise).
114
+ const readoutCount = props.total ?? props.rows.length;
115
+ const countReadout =
116
+ props.countReadout && readoutCount > 0 ? (
117
+ <span
118
+ data-testid="hk-admin-table-page-count"
119
+ style={{
120
+ fontSize: "var(--text-xs, 0.75rem)",
121
+ color: "rgb(var(--color-muted, var(--color-text-secondary)))",
122
+ fontVariantNumeric: "tabular-nums",
123
+ whiteSpace: "nowrap",
124
+ }}
125
+ >
126
+ {t("hikari::tablePage.totalCount", "{n} in total").replace("{n}", String(readoutCount))}
127
+ </span>
128
+ ) : null;
83
129
  // Extracted to an identifier: JSX children given as an inline object
84
130
  // literal are not normalized to slots the way a bound one is (the
85
131
  // chest retrySlots idiom this mirrors relies on the identifier form).
86
132
  const emptySlots = slots["empty-action"]
87
133
  ? { action: () => slots["empty-action"]?.() }
88
134
  : undefined;
135
+ // The readout rides the header's actions row RIGHTMOST — after the
136
+ // page's own actions — and is the band's sole content on a
137
+ // headerless scaffold, so a bare readout never claims a full-width
138
+ // row of its own (the 安全扫描 double-margin report, 2026-11-08).
139
+ const actionsSlot = slots.actions
140
+ ? () => (
141
+ <>
142
+ {slots.actions?.()}
143
+ {countReadout}
144
+ </>
145
+ )
146
+ : countReadout
147
+ ? () => countReadout
148
+ : undefined;
89
149
  return (
90
150
  <div>
91
151
  {props.title ? (
92
152
  <HkPageHeader title={props.title} subtitle={props.subtitle} icon={props.icon} dense={props.dense}>
93
- {{ actions: () => slots.actions?.() }}
153
+ {{ actions: actionsSlot }}
94
154
  </HkPageHeader>
95
- ) : slots.actions ? (
96
- <div style={{ display: "flex", justifyContent: "flex-end", marginBottom: "var(--space-16, 1rem)" }}>
97
- {slots.actions()}
155
+ ) : actionsSlot ? (
156
+ <div style={{ display: "flex", justifyContent: "flex-end", alignItems: "center", gap: "var(--space-8, 0.5rem)", marginBottom: "var(--space-16, 1rem)" }}>
157
+ {actionsSlot()}
98
158
  </div>
99
159
  ) : null}
100
160
  {slots.filter ? <div class="hk-admin-table-page-filter">{slots.filter()}</div> : null}
@@ -110,48 +170,55 @@ export const HkAdminTablePage = defineComponent({
110
170
  every host dissolves between them instead of popping. The
111
171
  key collapses loading-vs-empty into one "placeholder"
112
172
  generation: spinner → empty is a data arrival, not a slot
113
- identity change worth animating. */}
114
- <HkCrossfade
115
- swapKey={
116
- props.error ? "error"
117
- : props.loading && !props.rows.length ? "placeholder"
118
- : !props.rows.length ? "placeholder"
119
- : "content"
120
- }
121
- >
122
- {props.error ? (
123
- <HAlert message={props.error} />
124
- ) : props.loading && !props.rows.length ? (
125
- <HSpinner center />
126
- ) : !props.rows.length ? (
127
- <HEmptyState
128
- boxed
129
- title={emptyTitle}
130
- description={props.emptyDescription}
131
- >
132
- {emptySlots}
133
- </HEmptyState>
134
- ) : slots.body ? (
135
- <div class="hk-admin-table-page-body">{slots.body()}</div>
136
- ) : (
137
- <HCard padded={false}>
138
- <HTable
139
- columns={props.columns}
140
- rows={props.rows}
141
- rowKey={props.rowKey}
142
- total={props.total}
143
- draggable={props.draggable}
144
- manageable={props.manageable}
145
- onReorder={(from: number, to: number) => emit("reorder", from, to)}
146
- onManage={(row: Record<string, unknown>, opener: HTMLElement) =>
147
- emit("manage", row, opener)
148
- }
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}
149
197
  >
150
- {slots}
151
- </HTable>
152
- </HCard>
153
- )}
154
- </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>
155
222
  {slots["create-modal"]?.()}
156
223
  {slots["edit-modal"]?.()}
157
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>
@@ -0,0 +1,61 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, h } from "vue";
3
+
4
+ import HkFilterOperatorIcon from "./HkFilterOperatorIcon";
5
+
6
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
7
+
8
+ function mount(op: string) {
9
+ const container = document.createElement("div");
10
+ document.body.appendChild(container);
11
+ const app = createApp({ render: () => h(HkFilterOperatorIcon, { op: op as never }) });
12
+ app.mount(container);
13
+ mounts.push({ app, container });
14
+ return container;
15
+ }
16
+
17
+ afterEach(() => {
18
+ for (const { app, container } of mounts.splice(0)) {
19
+ app.unmount();
20
+ container.remove();
21
+ }
22
+ });
23
+
24
+ /** The custom glyphs are hikari-authored SVG in lucide's stroke grammar.
25
+ * Pin the exact path data: the ordered four's underlines ride y=19 — the
26
+ * SAME optical center as lucide's Equal / EqualNot (ink y5..19). The
27
+ * first cut drew them at y=20 and the ≥/≤ glyphs sat visibly low against
28
+ * the equals family inside the 28px operator toggles (user report
29
+ * 2026-11-08); this pin makes a revert to y=20 RED (mutation-verified:
30
+ * reverting both underlines failed this suite 2/2). */
31
+ describe("HkFilterOperatorIcon custom glyph geometry", () => {
32
+ it("draws lt/gt chevrons without an underline", () => {
33
+ const ds = (op: string) =>
34
+ [...mount(op).querySelectorAll("svg path")].map((p) => p.getAttribute("d"));
35
+ expect(ds("lt")).toEqual(["m19 6-11 6 11 6"]);
36
+ expect(ds("gt")).toEqual(["m5 6 11 6-11 6"]);
37
+ });
38
+
39
+ it("draws the lte/gte underlines at y=19 (optically centered on y=12)", () => {
40
+ const ds = (op: string) =>
41
+ [...mount(op).querySelectorAll("svg path")].map((p) => p.getAttribute("d"));
42
+ expect(ds("lte")).toEqual(["m19 5-11 6 11 6", "M8 19h11"]);
43
+ expect(ds("gte")).toEqual(["m5 5 11 6-11 6", "M5 19h11"]);
44
+ // Belt against the exact regression: no underline path may reach
45
+ // y=20 — the ink band must stay y5..19 like the lucide equals pair.
46
+ for (const d of [...ds("lte"), ...ds("gte")]) {
47
+ expect(d).not.toMatch(/20/);
48
+ }
49
+ });
50
+
51
+ it("composes napprox from the lucide wave pair + EqualNot slash", () => {
52
+ const ds = [...mount("napprox").querySelectorAll("svg path")].map((p) =>
53
+ p.getAttribute("d"),
54
+ );
55
+ expect(ds).toEqual([
56
+ "M5 15a6.5 6.5 0 0 1 7 0 6.5 6.5 0 0 0 7 0",
57
+ "M5 9a6.5 6.5 0 0 1 7 0 6.5 6.5 0 0 0 7 0",
58
+ "m5 19 14-14",
59
+ ]);
60
+ });
61
+ });
@@ -24,9 +24,15 @@ const NAPPROX_PATHS = [
24
24
  const CUSTOM: Partial<Record<HkFilterOperator, string[]>> = {
25
25
  napprox: NAPPROX_PATHS,
26
26
  lt: ["m19 6-11 6 11 6"],
27
- lte: ["m19 5-11 6 11 6", "M8 20h11"],
27
+ // The comparison underline rides y=19, NOT y=20: the chevron spans
28
+ // y5..17, so a y19 line centers the ink band at y12 — the SAME optical
29
+ // center lucide's Equal / EqualNot (paths y5..19) draw. The old y20
30
+ // lines dragged the ink to y5..20 and the ≷ glyphs sat visibly low
31
+ // against the equals family inside the 28px operator toggles (user
32
+ // report 2026-11-08: ≥/≤ 没有与 = 垂直对齐).
33
+ lte: ["m19 5-11 6 11 6", "M8 19h11"],
28
34
  gt: ["m5 6 11 6-11 6"],
29
- gte: ["m5 5 11 6-11 6", "M5 20h11"],
35
+ gte: ["m5 5 11 6-11 6", "M5 19h11"],
30
36
  };
31
37
 
32
38
  const LUCIDE: Partial<Record<HkFilterOperator, typeof Equal>> = {