@celestia-island/hikari 0.55.244 → 0.55.246

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.244",
3
+ "version": "0.55.246",
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",
@@ -0,0 +1,33 @@
1
+ // AI-generated: developed with the DeepSeek Harness agent (model DeepSeek V4.1 Flash); human-directed.
2
+
3
+ /* HkDateTag — the locale-aware stacked date capsule (see the component
4
+ * docblock for the contract). Rows are calendar segments; the year and
5
+ * the optional time row read as context (muted, a touch smaller), the
6
+ * month/day figures carry the full text color. */
7
+
8
+ .hk-date-tag {
9
+ display: inline-flex;
10
+ flex-direction: column;
11
+ align-items: center;
12
+ padding: var(--space-2, 2px) var(--space-10, 0.625rem);
13
+ border-radius: var(--radius-full, 999px);
14
+ background: rgb(var(--color-text) / 6%);
15
+ white-space: nowrap;
16
+ }
17
+
18
+ .hk-date-tag__row {
19
+ display: block;
20
+ text-align: center;
21
+ line-height: 1.25;
22
+ font-size: var(--text-2xs, 0.625rem);
23
+ font-variant-numeric: tabular-nums;
24
+ color: rgb(var(--color-text));
25
+ }
26
+
27
+ /* Context rows: the year (wherever the locale puts it) and the optional
28
+ * time row — muted and a touch smaller than the month/day figures. */
29
+ .hk-date-tag__row--year,
30
+ .hk-date-tag__row--time {
31
+ color: rgb(var(--color-text) / 55%);
32
+ font-size: calc(var(--text-2xs, 0.625rem) - 1px);
33
+ }
@@ -0,0 +1,135 @@
1
+ // AI-generated: developed with the DeepSeek Harness agent (model DeepSeek V4.1 Flash); human-directed.
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { createApp, h, nextTick } from "vue";
4
+
5
+ import HkDateTagDefault, { HkDateTag } from "./HkDateTag";
6
+ import { setLocale } from "../i18n/context";
7
+
8
+ /**
9
+ * HkDateTag — the locale-aware date capsule. These cases pin the
10
+ * COMPONENT contract: the row order follows the ACTIVE hikari locale
11
+ * (year on top for zh-Hans, year LAST for en), the order re-derives
12
+ * live when the host flips the locale, `time` appends the muted
13
+ * fourth row, and unparseable input renders the empty capsule shell.
14
+ */
15
+
16
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
17
+
18
+ // Built in LOCAL time so the calendar fields (2024-10-06 14:30) survive
19
+ // every CI time zone; the capsule renders local calendar segments.
20
+ const SHIP_DATE = new Date(2024, 9, 6, 14, 30);
21
+
22
+ function mountTag(props: { value: string | number | Date; time?: boolean }): HTMLElement {
23
+ const container = document.createElement("div");
24
+ document.body.appendChild(container);
25
+ const app = createApp({ render: () => h(HkDateTag, props) });
26
+ app.mount(container);
27
+ mounts.push({ app, container });
28
+ return container;
29
+ }
30
+
31
+ function rowsOf(container: HTMLElement): HTMLElement[] {
32
+ return [...container.querySelectorAll<HTMLElement>(".hk-date-tag__row")];
33
+ }
34
+
35
+ function capsuleOf(container: HTMLElement): HTMLElement {
36
+ const el = container.querySelector<HTMLElement>(".hk-date-tag");
37
+ expect(el, "capsule renders").toBeTruthy();
38
+ return el!;
39
+ }
40
+
41
+ // setLocale mutates the module-global i18n state — restore the default
42
+ // after every case (the HkSolarLocationSetting suite's idiom).
43
+ afterEach(async () => {
44
+ for (const { app, container } of mounts.splice(0)) {
45
+ app.unmount();
46
+ container.remove();
47
+ }
48
+ await setLocale("en");
49
+ });
50
+
51
+ describe("HkDateTag locale ordering", () => {
52
+ it("renders year/month/day rows top to bottom for zh-Hans", async () => {
53
+ await setLocale("zh-Hans");
54
+ const container = mountTag({ value: SHIP_DATE });
55
+
56
+ const capsule = capsuleOf(container);
57
+ expect(capsule.classList.contains("hk-date-tag")).toBe(true);
58
+ const rows = rowsOf(container);
59
+ expect(rows.map((r) => r.textContent)).toEqual(["2024", "10", "06"]);
60
+ // The year row carries its modifier wherever it lands.
61
+ expect(rows[0].classList.contains("hk-date-tag__row--year")).toBe(true);
62
+ expect(rows[1].classList.contains("hk-date-tag__row--year")).toBe(false);
63
+ });
64
+
65
+ it("orders month/day/year for en — the year goes LAST", async () => {
66
+ await setLocale("en");
67
+ const container = mountTag({ value: SHIP_DATE });
68
+
69
+ const rows = rowsOf(container);
70
+ expect(rows.map((r) => r.textContent)).toEqual(["10", "06", "2024"]);
71
+ expect(rows[2].classList.contains("hk-date-tag__row--year")).toBe(true);
72
+ });
73
+
74
+ it("re-orders the rows live when the locale flips under a mounted capsule", async () => {
75
+ await setLocale("zh-Hans");
76
+ const container = mountTag({ value: SHIP_DATE });
77
+ expect(rowsOf(container).map((r) => r.textContent)).toEqual(["2024", "10", "06"]);
78
+
79
+ await setLocale("en");
80
+ await nextTick();
81
+ expect(rowsOf(container).map((r) => r.textContent)).toEqual(["10", "06", "2024"]);
82
+
83
+ await setLocale("zh-Hans");
84
+ await nextTick();
85
+ expect(rowsOf(container).map((r) => r.textContent)).toEqual(["2024", "10", "06"]);
86
+ });
87
+ });
88
+
89
+ describe("HkDateTag time row", () => {
90
+ it("appends the muted time row only when asked", async () => {
91
+ await setLocale("zh-Hans");
92
+ const withTime = rowsOf(mountTag({ value: SHIP_DATE, time: true }));
93
+ expect(withTime).toHaveLength(4);
94
+ expect(withTime[3].classList.contains("hk-date-tag__row--time")).toBe(true);
95
+ expect(withTime[3].textContent).toBe("14:30");
96
+
97
+ const withoutTime = rowsOf(mountTag({ value: SHIP_DATE }));
98
+ expect(withoutTime).toHaveLength(3);
99
+ expect(withoutTime.some((r) => r.classList.contains("hk-date-tag__row--time"))).toBe(false);
100
+ });
101
+
102
+ it("formats the time row for the active locale", async () => {
103
+ await setLocale("en");
104
+ const rows = rowsOf(mountTag({ value: SHIP_DATE, time: true }));
105
+ expect(rows).toHaveLength(4);
106
+ expect(rows[3].textContent).toBe("02:30 PM");
107
+ });
108
+ });
109
+
110
+ describe("HkDateTag value normalization", () => {
111
+ it("accepts epoch milliseconds and date strings like the Date form", async () => {
112
+ await setLocale("en");
113
+ const expected = ["10", "06", "2024"];
114
+ expect(rowsOf(mountTag({ value: SHIP_DATE })).map((r) => r.textContent)).toEqual(expected);
115
+ // A number is epoch MILLISECONDS.
116
+ expect(rowsOf(mountTag({ value: SHIP_DATE.getTime() })).map((r) => r.textContent)).toEqual(expected);
117
+ expect(rowsOf(mountTag({ value: "2024-10-06T14:30:00" })).map((r) => r.textContent)).toEqual(expected);
118
+ });
119
+
120
+ it("renders the empty capsule shell for unparseable input", async () => {
121
+ await setLocale("zh-Hans");
122
+ const container = mountTag({ value: "not-a-date" });
123
+
124
+ const capsule = capsuleOf(container);
125
+ expect(capsule.children).toHaveLength(0);
126
+ expect(rowsOf(container)).toHaveLength(0);
127
+ });
128
+ });
129
+
130
+ describe("HkDateTag module surface", () => {
131
+ it("exports the same component as the default and named binding", () => {
132
+ expect(HkDateTagDefault).toBe(HkDateTag);
133
+ expect((HkDateTag as unknown as { name: string }).name).toBe("HkDateTag");
134
+ });
135
+ });
@@ -0,0 +1,99 @@
1
+ // AI-generated: developed with the DeepSeek Harness agent (model DeepSeek V4.1 Flash); human-directed.
2
+ import { computed, defineComponent, type PropType } from "vue";
3
+
4
+ import { activeLocale } from "../i18n/context";
5
+ import "./HkDateTag.scss";
6
+
7
+ /**
8
+ * HkDateTag — a locale-aware date capsule: a small pill whose calendar
9
+ * segments are stacked vertically, ONE PER ROW, each centered, ordered
10
+ * the way the ACTIVE hikari locale orders dates (zh: year → month → day
11
+ * top to bottom; en: month → day → year, the year LAST; de: day first …).
12
+ * With the `time` flag a fourth, muted row carries the locale time
13
+ * (hour + minute, 2-digit each).
14
+ *
15
+ * Generic business part (peer of HkTemplateText / HkMarkdownRenderer,
16
+ * NOT of the HkInput family): hosts render compact per-row dates in data
17
+ * tables and cards — wowsp's water-meter ship tables are the first
18
+ * consumer.
19
+ *
20
+ * Contract:
21
+ * - `value` accepts a Date, an epoch-MILLISECONDS number or a
22
+ * date-ish string, normalized with `new Date(value)` (so a number is
23
+ * NEVER seconds — pass what `Date.now()` returns). Unparseable input
24
+ * renders NOTHING but the empty capsule shell; the caller owns the
25
+ * empty state.
26
+ * - The row order is derived from the locale itself, never a hardcoded
27
+ * locale→order map: `Intl.DateTimeFormat(activeLocale(), { year,
28
+ * month, day }).formatToParts(date)` supplies the year/month/day
29
+ * segments in the order that locale writes them. The component is a
30
+ * computed over the reactive i18n state, so a host `setLocale`
31
+ * re-orders the rows live.
32
+ * - `time` (default false) appends the muted time row
33
+ * (`hk-date-tag__row--time`) under the date rows.
34
+ */
35
+ export const HkDateTag = defineComponent({
36
+ name: "HkDateTag",
37
+ props: {
38
+ /** The date to render — Date, epoch milliseconds or a parseable
39
+ * string. Invalid input renders the empty capsule. */
40
+ value: {
41
+ type: [String, Number, Date] as PropType<string | number | Date>,
42
+ required: true,
43
+ },
44
+ /** Append the muted locale time row (hour + minute, 2-digit). */
45
+ time: { type: Boolean, default: false },
46
+ },
47
+ setup(props) {
48
+ const date = computed<Date | null>(() => {
49
+ const d = new Date(props.value);
50
+ return Number.isNaN(d.getTime()) ? null : d;
51
+ });
52
+
53
+ // The locale is read through the reactive i18n state (activeLocale
54
+ // reads it on every evaluation), so this computed re-runs — and the
55
+ // rows re-order — whenever the host calls setLocale. One formatter
56
+ // per shape per locale, rebuilt only on a locale/value change (the
57
+ // HkCalendarHeatmap idiom).
58
+ const locale = computed(() => activeLocale());
59
+
60
+ /** The year/month/day segments in the locale's own order. */
61
+ const rows = computed<Intl.DateTimeFormatPart[]>(() => {
62
+ const d = date.value;
63
+ if (d === null) return [];
64
+ return new Intl.DateTimeFormat(locale.value, {
65
+ year: "numeric",
66
+ month: "2-digit",
67
+ day: "2-digit",
68
+ })
69
+ .formatToParts(d)
70
+ .filter((part) => part.type === "year" || part.type === "month" || part.type === "day");
71
+ });
72
+
73
+ /** The locale time (hour + minute, 2-digit) when the capsule opts
74
+ * in — null without `time` or for an invalid date. */
75
+ const timeText = computed<string | null>(() => {
76
+ const d = date.value;
77
+ if (!props.time || d === null) return null;
78
+ return new Intl.DateTimeFormat(locale.value, { hour: "2-digit", minute: "2-digit" }).format(d);
79
+ });
80
+
81
+ return () => (
82
+ <span class="hk-date-tag">
83
+ {rows.value.map((part) => (
84
+ <span
85
+ key={part.type}
86
+ class={part.type === "year" ? "hk-date-tag__row hk-date-tag__row--year" : "hk-date-tag__row"}
87
+ >
88
+ {part.value}
89
+ </span>
90
+ ))}
91
+ {timeText.value !== null && (
92
+ <span key="time" class="hk-date-tag__row hk-date-tag__row--time">{timeText.value}</span>
93
+ )}
94
+ </span>
95
+ );
96
+ },
97
+ });
98
+
99
+ export default HkDateTag;
@@ -542,4 +542,36 @@ describe("HkSidebar standalone rail: the menu group fold survives its own swap",
542
542
  "the expanded sidebar inherits the closed group",
543
543
  ).toBe("false");
544
544
  });
545
+
546
+ it("keeps the scroll position across density flips (the #44 P3 audit finding)", async () => {
547
+ vi.useFakeTimers();
548
+ const c = mountSidebar({ width: "240px", collapsible: true, collapsedWidth: "64px" });
549
+
550
+ // Collapse to the rail and settle the dissolve.
551
+ c.toggle().click();
552
+ await vi.advanceTimersByTimeAsync(500);
553
+ const bodies = () => c.container.querySelectorAll<HTMLElement>(".hk-sidebar-body");
554
+ const active = () => bodies()[bodies().length - 1]!;
555
+ expect(bodies().length, "one settled generation").toBe(1);
556
+
557
+ // The user scrolls the rail partway down…
558
+ active().scrollTop = 120;
559
+
560
+ // …then a peek flips the density (the shell keys the swap on
561
+ // `railActive && !peeking`, so every peek open/close is a flip).
562
+ c.aside().dispatchEvent(new Event("pointerenter"));
563
+ await vi.advanceTimersByTimeAsync(900);
564
+ expect(active().scrollTop, "the peek's full generation resumes the scroll").toBe(120);
565
+
566
+ // Leaving the peek flips back to the rail dress; the position must
567
+ // survive that remount too.
568
+ c.aside().dispatchEvent(new Event("pointerleave"));
569
+ await vi.advanceTimersByTimeAsync(900);
570
+ expect(active().scrollTop, "the rail generation resumes the scroll").toBe(120);
571
+
572
+ // And a full expand is the same crossing.
573
+ c.toggle().click();
574
+ await vi.advanceTimersByTimeAsync(900);
575
+ expect(active().scrollTop, "the expanded body resumes the scroll").toBe(120);
576
+ });
545
577
  });
@@ -1,4 +1,4 @@
1
- import { computed, defineComponent, onBeforeUnmount, onMounted, ref, watch } from "vue";
1
+ import { computed, defineComponent, onBeforeUnmount, onMounted, ref, watch, nextTick } from "vue";
2
2
 
3
3
  import "./HkSidebar.scss";
4
4
  import { attachOverlayScrollbars, type OverlayScrollbarHandle } from "../composables/useOverlayScrollbar";
@@ -143,6 +143,34 @@ export default defineComponent({
143
143
  onMounted(attachBodyScrollbar);
144
144
  watch([bodyRef, bodyWrapRef], attachBodyScrollbar);
145
145
 
146
+ // Density flips remount the body band (the rail swap renders each
147
+ // generation's body FRESH), which used to snap the scroll back to the
148
+ // top on every flip — including each hover-peek open/close, because
149
+ // the shell keys the swap on `railActive && !peeking` (the #44 P3
150
+ // audit finding; probed: scrollTop 120 -> peek -> 0 -> back -> 0).
151
+ // The sidebar's own instance SURVIVES the flip (the swap lives inside
152
+ // it), so the position only needs to cross the remount. A flip patches
153
+ // the shared ref old -> null -> new: the null step still sees the
154
+ // LEAVING element as the old value (its DOM is alive for the
155
+ // dissolve), which is where the position is read from — an event-only
156
+ // save would miss programmatic scrolls (no `scroll` event fires for
157
+ // them), so the read happens on the ref hand-off itself and the
158
+ // onScroll below merely keeps the value fresh between flips. Raw
159
+ // scrollTop is an approximation across dresses (rail rows and full
160
+ // rows have different heights) — an exact restore would need an
161
+ // item-keyed anchor, not worth it for a nav list; the browser clamps
162
+ // any overshoot.
163
+ const savedScrollTop = ref(0);
164
+ const onBodyScroll = (e: Event): void => {
165
+ savedScrollTop.value = (e.currentTarget as HTMLElement).scrollTop;
166
+ };
167
+ watch(bodyRef, async (body, leaving) => {
168
+ if (leaving) savedScrollTop.value = leaving.scrollTop;
169
+ if (!body) return;
170
+ await nextTick();
171
+ body.scrollTop = savedScrollTop.value;
172
+ });
173
+
146
174
  onBeforeUnmount(() => {
147
175
  bodyScrollbar?.detach();
148
176
  bodyScrollbar = null;
@@ -199,7 +227,7 @@ export default defineComponent({
199
227
  <header class="hk-sidebar-header">{slots.header()}</header>
200
228
  ) : null}
201
229
  <div ref={bodyWrapRef} class="hk-sidebar-body-wrap">
202
- <div ref={bodyRef} class="hk-sidebar-body">
230
+ <div ref={bodyRef} class="hk-sidebar-body" onScroll={onBodyScroll}>
203
231
  {/* Scoped params (ignored by plain slots): the density that
204
232
  owns THIS generation so a host renders icon-only rows
205
233
  while collapsed and full rows during a peek, plus the
package/src/index.ts CHANGED
@@ -555,6 +555,7 @@ export { HkNavSidebar as HNavSidebar } from "./components/HkNavSidebar";
555
555
  export { HkThemeToggle as HThemeToggle, type ThemeItemScope } from "./components/HkThemeToggle";
556
556
  export { HkThemePanelContent as HThemePanelContent } from "./components/HkThemePanelContent";
557
557
  export { HkSolarLocationSetting as HSolarLocationSetting } from "./components/HkSolarLocationSetting";
558
+ export { HkDateTag as HDateTag } from "./components/HkDateTag";
558
559
  export { HkAuthCard as HAuthCard } from "./components/HkAuthCard";
559
560
  export { default as HAuthMethodList } from "./components/HkAuthMethodList";
560
561
  export { HkSignInCard as HSignInCard } from "./components/HkSignInCard";
@@ -836,6 +837,7 @@ export { HkNavSidebar as HkNavSidebar } from "./components/HkNavSidebar";
836
837
  export { HkThemeToggle as HkThemeToggle } from "./components/HkThemeToggle";
837
838
  export { HkThemePanelContent as HkThemePanelContent } from "./components/HkThemePanelContent";
838
839
  export { HkSolarLocationSetting as HkSolarLocationSetting } from "./components/HkSolarLocationSetting";
840
+ export { HkDateTag as HkDateTag } from "./components/HkDateTag";
839
841
  export { HkAuthCard as HkAuthCard } from "./components/HkAuthCard";
840
842
  export { HkSignInCard as HkSignInCard } from "./components/HkSignInCard";
841
843
  export { HkThirdPartySignInCard as HkThirdPartySignInCard } from "./components/HkThirdPartySignInCard";