@celestia-island/hikari 0.55.324 → 0.55.327

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 (43) hide show
  1. package/package.json +1 -1
  2. package/src/animation/registerAnimations.ts +6 -0
  3. package/src/components/HkAdminShell.test.tsx +11 -5
  4. package/src/components/HkAdminTablePage.tsx +67 -41
  5. package/src/components/HkCalendarHeatmap.scss +43 -6
  6. package/src/components/HkCalendarHeatmap.test.tsx +136 -0
  7. package/src/components/HkCalendarHeatmap.tsx +73 -6
  8. package/src/components/HkCardList.tsx +13 -13
  9. package/src/components/HkEdgeGlow.barrel.test.ts +27 -0
  10. package/src/components/HkEdgeGlow.scss +132 -0
  11. package/src/components/HkEdgeGlow.test.tsx +244 -0
  12. package/src/components/HkEdgeGlow.tsx +130 -0
  13. package/src/components/HkFaceShell.test.tsx +53 -2
  14. package/src/components/HkFaceShell.tsx +37 -13
  15. package/src/components/HkFilterPanel.scss +13 -0
  16. package/src/components/HkKanban.scss +8 -0
  17. package/src/components/HkScrollContainer.fade.contract.test.ts +9 -7
  18. package/src/components/HkScrollContainer.scss +39 -62
  19. package/src/components/HkScrollContainer.selectionHint.test.ts +106 -87
  20. package/src/components/HkScrollContainer.test.ts +337 -1
  21. package/src/components/HkScrollContainer.tsx +346 -215
  22. package/src/components/HkScrollProgressDock.scss +101 -0
  23. package/src/components/HkScrollProgressDock.test.tsx +1175 -0
  24. package/src/components/HkScrollProgressDock.tsx +532 -0
  25. package/src/components/HkTable.sections.test.tsx +1 -1
  26. package/src/components/HkTable.tsx +10 -9
  27. package/src/components/HkTimeZoneGlobe.scss +9 -0
  28. package/src/components/nativeScrollbar.contract.test.ts +603 -0
  29. package/src/i18n/locales/ar/components.json +2 -1
  30. package/src/i18n/locales/de/components.json +2 -1
  31. package/src/i18n/locales/en/components.json +2 -1
  32. package/src/i18n/locales/es/components.json +2 -1
  33. package/src/i18n/locales/fr/components.json +2 -1
  34. package/src/i18n/locales/ja/components.json +2 -1
  35. package/src/i18n/locales/ko/components.json +2 -1
  36. package/src/i18n/locales/pt/components.json +2 -1
  37. package/src/i18n/locales/ru/components.json +2 -1
  38. package/src/i18n/locales/zh-Hans/components.json +2 -1
  39. package/src/i18n/locales/zh-Hant/components.json +2 -1
  40. package/src/index.ts +17 -6
  41. package/src/components/HkScrollProgress.scss +0 -61
  42. package/src/components/HkScrollProgress.test.tsx +0 -687
  43. package/src/components/HkScrollProgress.tsx +0 -103
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.327",
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",
@@ -145,3 +145,9 @@ registerCssAnimation("hk-range-ribbon-pulse", { infinite: true });
145
145
  // prop's dot breathes while its state is in-flight — a settled dot sits
146
146
  // static. Reduced motion drops the loop (house pattern, see HkSkeleton).
147
147
  registerCssAnimation("hk-badge-dot-pulse", { infinite: true });
148
+ // components/HkEdgeGlow.scss — the shared edge-glow breathe (loops
149
+ // forever): the one painter behind HkScrollContainer's overflow glow and
150
+ // any host-mounted band (the static selection hint keeps animate off).
151
+ // Reduced motion keeps the band and drops the loop (house pattern, see
152
+ // HkSkeleton); the global CSS-animation switch pauses it as usual.
153
+ registerCssAnimation("hk-edge-glow-breathe", { infinite: true });
@@ -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>
@@ -24,20 +24,57 @@
24
24
  color: var(--hi-color-muted);
25
25
  }
26
26
 
27
- // w-max + auto margins center the whole calendar (rail included) on
28
- // wide cards while letting narrow screens scroll from the left edge —
29
- // auto margins resolve to zero when the content overflows, so mobile
30
- // keeps its natural scroll origin.
27
+ // The strip's scroll host is HkScrollContainer — the root element carries
28
+ // .hk-scroll-container PLUS this class. The native horizontal bar is gone
29
+ // (2026-11-08: the battle heatmap's platform bar was the last one in the
30
+ // chart family and read as an embedded widget); hikari's auto-hiding
31
+ // overlay scrollbar is the only bar, and the container's align="center"
32
+ // centers the calendar while it fits and lets it scroll when it does not
33
+ // (the old auto-margin recipe now lives there — do NOT re-add overflow
34
+ // here: a native bar would come back and the overlay chrome would stack
35
+ // under it).
36
+ //
37
+ // padding-bottom is the OVERLAY TRACK's strip, not breathing room: the
38
+ // shared chrome is positioned against this root (bottom: 2px, height:
39
+ // 6px), so 8px of padding keeps the bar BELOW the Sunday rail instead of
40
+ // painting over it while the strip scrolls.
31
41
  &-scroll {
32
42
  margin-top: 12px;
43
+ padding-bottom: 8px;
44
+ }
45
+
46
+ // ── Legacy plain-form fallback (Rust/WASM renderer) ──────────────────
47
+ // The Vue form mounted the strip on HkScrollContainer (above), but the
48
+ // Rust/WASM CalendarHeatmap (packages/components/src/data/calendar_heatmap.rs)
49
+ // still renders the pre-migration plain -.hk-calendar-heatmap-scroll div and
50
+ // reads THIS sheet through the generated vue-mirror. The mirror sheet no
51
+ // longer owns the plain form's overflow/centering, so restore the previous
52
+ // horizontal scrolling here for the plain form ONLY: a Vue strip carries
53
+ // .hk-scroll-container on the same element and never matches, so the
54
+ // overlay chrome can never be re-claimed by this rule. The platform bar is
55
+ // suppressed (the chart family's no-native-bar invariant) — until the Rust
56
+ // renderer migrates onto the shared scroll-container script this form
57
+ // scrolls by wheel/touch/keyboard without a visible bar, the same
58
+ // bar-less trade the suppression-only scrollers take.
59
+ &-scroll:not(.hk-scroll-container) {
33
60
  overflow-x: auto;
34
- padding-bottom: 4px;
61
+ scrollbar-width: none;
62
+
63
+ &::-webkit-scrollbar {
64
+ display: none;
65
+ }
66
+
67
+ > .hk-calendar-heatmap-body {
68
+ margin-inline: auto;
69
+ }
35
70
  }
36
71
 
72
+ // width: max-content keeps the strip's natural extent (the container's
73
+ // aligner owns the centering, so no auto margins here — they would be
74
+ // resolved inside a box sized exactly to this element's content).
37
75
  &-body {
38
76
  display: flex;
39
77
  width: max-content;
40
- margin-inline: auto;
41
78
  gap: 8px;
42
79
  }
43
80
 
@@ -1,9 +1,23 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
1
5
  import { afterEach, describe, expect, it, vi } from "vitest";
2
6
  import { createApp, defineComponent, h, nextTick, reactive } from "vue";
3
7
 
4
8
  import HkCalendarHeatmap from "./HkCalendarHeatmap";
5
9
  import { HEATMAP_LEVEL_OPACITY, shiftIso } from "./calendarHeatmap";
6
10
 
11
+ const here = dirname(fileURLToPath(import.meta.url));
12
+
13
+ /** Resolve after two animation frames: the scroll container coalesces its
14
+ * sensing pass through the shared animationBus, whose rAF is scheduled one
15
+ * frame ahead of the awaited callback. */
16
+ const flushFrames = async () => {
17
+ await new Promise<void>((r) => requestAnimationFrame(() => r()));
18
+ await new Promise<void>((r) => requestAnimationFrame(() => r()));
19
+ };
20
+
7
21
  const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
8
22
 
9
23
  function mount(node: ReturnType<typeof h>) {
@@ -381,3 +395,125 @@ describe("HkCalendarHeatmap", () => {
381
395
  }
382
396
  });
383
397
  });
398
+ // ── Overlay scroll strip (2026-11-08) ────────────────────────────────────
399
+ // The calendar body rides HkScrollContainer now: hikari's auto-hiding
400
+ // overlay bar is the only bar, the strip opens focused on the NEWEST day
401
+ // (anchor="end"), a host can opt back to the start, and a series
402
+ // replacement re-parks at the end. happy-dom performs no layout, so the
403
+ // strip's extent is stubbed and a dispatched scroll event runs the
404
+ // container's coalesced sensing pass.
405
+ describe("HkCalendarHeatmap overlay scroll strip", () => {
406
+ const stripOf = (c: HTMLElement): HTMLElement =>
407
+ c.querySelector<HTMLElement>(".hk-calendar-heatmap-scroll")!;
408
+ const viewportOf = (c: HTMLElement): HTMLElement =>
409
+ c.querySelector<HTMLElement>(".hk-scroll-container-viewport")!;
410
+
411
+ /** Mount a live render function (reactive props re-render — the range
412
+ * switch test replaces the series). */
413
+ function mountLive(render: () => ReturnType<typeof h>): HTMLElement {
414
+ const container = document.createElement("div");
415
+ document.body.appendChild(container);
416
+ const app = createApp({ render });
417
+ app.mount(container);
418
+ mounts.push({ app, container });
419
+ return container;
420
+ }
421
+
422
+ /** Give the viewport a scrollable extent. Only the SIZES are stubbed:
423
+ * the position stays happy-dom's settable scrollLeft, so the park's
424
+ * write is observable exactly as a reader's scroll would be. */
425
+ function stubStrip(el: HTMLElement, scrollWidth: number, clientWidth: number) {
426
+ Object.defineProperties(el, {
427
+ scrollWidth: { configurable: true, get: () => scrollWidth },
428
+ clientWidth: { configurable: true, get: () => clientWidth },
429
+ });
430
+ }
431
+
432
+ it("mounts the overlay chrome over the strip, with the body still inside", () => {
433
+ const c = mount(
434
+ h(HkCalendarHeatmap, { days: daysFrom(CLEAN_HEAD_MON, 7), first: null }),
435
+ );
436
+ const strip = stripOf(c);
437
+ // The class contract survives (consumers style .hk-calendar-heatmap-scroll)
438
+ // and the element IS the scroll container root.
439
+ expect(strip.classList.contains("hk-scroll-container")).toBe(true);
440
+ // hikari's auto-hiding overlay track is the only bar…
441
+ expect(
442
+ c.querySelector('.hk-scrollbar-track[data-axis="horizontal"]'),
443
+ ).not.toBeNull();
444
+ // …the overflow glow is opted in (the strip hides days at its edges)…
445
+ expect(strip.getAttribute("data-overflow-glow")).toBe("true");
446
+ // …and the body still rides inside the scrolling viewport.
447
+ expect(viewportOf(c).querySelector(".hk-calendar-heatmap-body")).not.toBeNull();
448
+ });
449
+
450
+ it("keeps native overflow out of the Vue form (source contract)", () => {
451
+ const sheet = readFileSync(join(here, "HkCalendarHeatmap.scss"), "utf8");
452
+ // The -rail/-cells contract is pinned by calendarHeatmapRail.contract;
453
+ // this pin owns the STRIP rule the migration rewrote.
454
+ const scrollRule = sheet.match(/&-scroll \{[\s\S]*?\n \}/)?.[0] ?? "";
455
+ expect(scrollRule, "the &-scroll rule still owns the strip box").toContain("&-scroll {");
456
+ // The Vue strip IS the HkScrollContainer root. Any overflow declaration
457
+ // in this rule would bring a NATIVE bar back on top of the overlay chrome
458
+ // (the 2026-11-08 regression this migration fixed).
459
+ expect(scrollRule).not.toMatch(/overflow/);
460
+ // The Rust/WASM renderer still renders the plain div, so the sheet keeps
461
+ // a fallback scroller for it — but ONLY off the container form, and the
462
+ // platform bar stays suppressed (the chart family's native-bar invariant;
463
+ // the Rust form is bar-less until it migrates onto the shared script).
464
+ const fallback =
465
+ sheet.match(/&-scroll:not\(\.hk-scroll-container\) \{[\s\S]*?\n \}/)?.[0] ?? "";
466
+ expect(fallback, "the plain-form fallback must exist for the Rust mirror").not.toBe("");
467
+ expect(fallback).toContain("overflow-x: auto");
468
+ expect(fallback).toContain("scrollbar-width: none");
469
+ expect(fallback).toMatch(/&::-webkit-scrollbar\s*\{[\s\S]*?display:\s*none/);
470
+ expect(fallback, "the plain form keeps its centering").toContain("margin-inline: auto");
471
+ });
472
+
473
+ it("opens focused on the newest end (anchor=end parks the strip)", async () => {
474
+ const c = mount(
475
+ h(HkCalendarHeatmap, { days: daysFrom(CLEAN_HEAD_MON, 7), first: null }),
476
+ );
477
+ const viewport = viewportOf(c);
478
+ stubStrip(viewport, 300, 100);
479
+ viewport.dispatchEvent(new Event("scroll"));
480
+ await flushFrames();
481
+ expect(viewport.scrollLeft, "parked at max = 300 - 100").toBe(200);
482
+ });
483
+
484
+ it("forwards anchor=start so a host can opt back to the oldest end", async () => {
485
+ const c = mount(
486
+ h(HkCalendarHeatmap, {
487
+ days: daysFrom(CLEAN_HEAD_MON, 7),
488
+ first: null,
489
+ anchor: "start",
490
+ }),
491
+ );
492
+ const viewport = viewportOf(c);
493
+ stubStrip(viewport, 300, 100);
494
+ viewport.dispatchEvent(new Event("scroll"));
495
+ await flushFrames();
496
+ expect(viewport.scrollLeft).toBe(0);
497
+ });
498
+
499
+ it("re-parks at the end when the series is replaced (range switch)", async () => {
500
+ const state = reactive({ days: daysFrom(CLEAN_HEAD_MON, 7) });
501
+ const c = mountLive(() => h(HkCalendarHeatmap, { days: state.days, first: null }));
502
+ const viewport = viewportOf(c);
503
+ stubStrip(viewport, 300, 100);
504
+ viewport.dispatchEvent(new Event("scroll"));
505
+ await flushFrames();
506
+ expect(viewport.scrollLeft, "initial end focus").toBe(200);
507
+
508
+ // A reader scrolls back to the oldest day…
509
+ viewport.scrollLeft = 0;
510
+ // …then switches range: the new series must land on ITS newest end
511
+ // (the container's sticky anchor deliberately would not, since the
512
+ // reader is no longer parked — the days watcher is the deterministic
513
+ // re-park).
514
+ state.days = daysFrom("2025-11-01", 30);
515
+ await nextTick();
516
+ await nextTick();
517
+ expect(viewport.scrollLeft).toBe(200);
518
+ });
519
+ });
@@ -1,6 +1,7 @@
1
- import { computed, defineComponent, type PropType } from "vue";
1
+ import { computed, defineComponent, ref, watch, type PropType } from "vue";
2
2
 
3
3
  import { useI18n } from "../i18n/context";
4
+ import HkScrollContainer from "./HkScrollContainer";
4
5
  import {
5
6
  calendarGrid,
6
7
  heatmapCellFill,
@@ -53,6 +54,18 @@ function fill(template: string, vars: Record<string, string | number>): string {
53
54
  * null = never). All math lives in ./calendarHeatmap (pure, pinned by
54
55
  * its own tests). The `toolbar` slot rides right of the legend — the
55
56
  * consumer's range tabs / controls belong there.
57
+ *
58
+ * Scrolling (2026-11-08): the calendar body rides HkScrollContainer
59
+ * (axis="horizontal", align="center") so the strip uses hikari's own
60
+ * auto-hiding overlay scrollbar and NEVER a native bar — a chart that
61
+ * paints a platform scrollbar reads as an embedded widget, and the
62
+ * battle heatmap's bar was the last one in the family. The container's
63
+ * `anchor` prop defaults to "end" here: the newest day is the reading
64
+ * interest, so the strip opens focused on it (and the `anchor` prop lets
65
+ * a host opt back to "start"), while the container's overflow glow marks
66
+ * the side that still hides days. A series replacement (range switch,
67
+ * refetch across midnight) re-parks at the end explicitly — see the
68
+ * days watcher.
56
69
  */
57
70
  export default defineComponent({
58
71
  name: "HkCalendarHeatmap",
@@ -72,6 +85,15 @@ export default defineComponent({
72
85
  /** Square-cell scale (GitHub-scale: a 53-week grid stays compact). */
73
86
  cellSize: { type: Number, default: 14 },
74
87
  cellGap: { type: Number, default: 3 },
88
+ /** Which end of the horizontal strip is focused first: the default
89
+ * "end" opens on the NEWEST day (the reading interest — a year-wide
90
+ * series otherwise starts at the oldest, faded edge, which is exactly
91
+ * the "why is the chart empty-looking?" report this default answers).
92
+ * Forwarded to HkScrollContainer's `anchor`; the days/range watcher
93
+ * still re-parks at the end on a data change, so this prop changes the
94
+ * INITIAL focus (and the sticky behaviour) rather than the re-park
95
+ * contract. Pass "start" to restore the old GitHub-style origin. */
96
+ anchor: { type: String as PropType<"start" | "end">, default: "end" },
75
97
  },
76
98
  setup(props, { slots }) {
77
99
  const { t } = useI18n();
@@ -160,6 +182,38 @@ export default defineComponent({
160
182
  : undefined;
161
183
  });
162
184
 
185
+ // The strip's scroll host. Only the two handle members the heatmap
186
+ // uses are named (structural typing, the consumers' house convention):
187
+ // the end park and the sensing refresh.
188
+ const scroller = ref<{
189
+ scrollToEnd: (behavior?: ScrollBehavior) => void;
190
+ refresh: () => void;
191
+ } | null>(null);
192
+
193
+ /** Re-park at the newest day whenever the caller replaces the series —
194
+ * a range switch (the toolbar tabs), a refetch across UTC midnight, a
195
+ * totalled window. The container's `anchor="end"` only keeps a
196
+ * viewport that ALREADY sits at the end, deliberately (it must never
197
+ * fight a reader who scrolled back); switching range from mid-strip is
198
+ * a data replacement, so "land on the newest" is made unconditional
199
+ * here instead.
200
+ *
201
+ * flush: "post" is load-bearing — the grid must have re-rendered (new
202
+ * scrollWidth, possibly a narrower strip) before the target is
203
+ * measured, or the park would aim at the previous range's extent.
204
+ * refresh() then re-derives the overlay chrome from the same new
205
+ * extent: a range that shrinks the series from overflowing to fitting
206
+ * fires no scroll event, so without it the auto-hiding track could
207
+ * stay painted over content that no longer overflows. */
208
+ watch(
209
+ () => props.days,
210
+ () => {
211
+ scroller.value?.scrollToEnd("auto");
212
+ scroller.value?.refresh();
213
+ },
214
+ { flush: "post" },
215
+ );
216
+
163
217
  return () => (
164
218
  <div class="hk-calendar-heatmap">
165
219
  {/* The legend row: the scale in the caller's one unit on the
@@ -191,10 +245,23 @@ export default defineComponent({
191
245
  {slots.toolbar?.({})}
192
246
  </div>
193
247
 
194
- {/* The scroll wrapper centers the whole calendar (rail included)
195
- * on wide cards while letting narrow screens scroll from the
196
- * left edge (see the SCSS). */}
197
- <div class="hk-calendar-heatmap-scroll">
248
+ {/* The strip rides HkScrollContainer: hikari's own auto-hiding
249
+ * overlay scrollbar is the ONLY bar (a native one would read as an
250
+ * embedded platform widget — the battle heatmap's last regression),
251
+ * align="center" keeps the whole calendar (rail included) centered
252
+ * while it fits and lets it scroll once it does not, and
253
+ * anchor="end" opens it focused on the newest day. The class stays
254
+ * on the container root, so consumers styling
255
+ * .hk-calendar-heatmap-scroll keep working; overflowGlow lights the
256
+ * side that still hides days. */}
257
+ <HkScrollContainer
258
+ ref={scroller}
259
+ class="hk-calendar-heatmap-scroll"
260
+ axis="horizontal"
261
+ align="center"
262
+ anchor={props.anchor}
263
+ overflowGlow
264
+ >
198
265
  <div class="hk-calendar-heatmap-body">
199
266
  {/* Weekday rail: the first (Monday) and last (Sunday) rows
200
267
  * carry the labels, the wowsp convention. */}
@@ -271,7 +338,7 @@ export default defineComponent({
271
338
  </div>
272
339
  </div>
273
340
  </div>
274
- </div>
341
+ </HkScrollContainer>
275
342
  </div>
276
343
  );
277
344
  },
@@ -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 = {
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import * as barrel from "../index";
3
+ import { asIndexable } from "../utils/typing";
4
+
5
+ /**
6
+ * The public barrel guard for the edge-glow painter (same shape as
7
+ * HkCalendarHeatmap.barrel.test.ts): deleting the export lines from
8
+ * index.ts kept the ENTIRE suite green while the component's own tests
9
+ * reach the module through "./HkEdgeGlow" — only an external consumer
10
+ * would have noticed. HkScrollContainer imports the module directly too,
11
+ * so this file is the only thing pinning the public names.
12
+ *
13
+ * This file deliberately does NOT mock "@celestia-island/hikari": the
14
+ * package's self-reference resolves to the very same src/index.ts module
15
+ * as "../index", so a vi.mock here would replace the barrel under test
16
+ * with the mock's own namespace.
17
+ */
18
+ describe("the public barrel", () => {
19
+ it("exports the edge-glow painter under both family names", () => {
20
+ const hk = asIndexable(barrel.HkEdgeGlow);
21
+ expect(hk, "HkEdgeGlow must be re-exported").toBeTruthy();
22
+ expect(hk.name).toBe("HkEdgeGlow");
23
+ // The H- alias rides the HCard/HkCard family convention: consumers
24
+ // import either name, one component underneath.
25
+ expect(barrel.HEdgeGlow).toBe(barrel.HkEdgeGlow);
26
+ });
27
+ });