@celestia-island/hikari 0.55.321 → 0.55.323

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.321",
3
+ "version": "0.55.323",
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",
@@ -50,6 +50,34 @@
50
50
  font-weight: 600;
51
51
  }
52
52
 
53
+ /* Optical vertical centering for the single-glyph fallback (2026-11-08
54
+ * user report: 头像首字母偏下). Flex centers the LINE box, but a capital
55
+ * glyph sits on the baseline — below the line box's geometric center on
56
+ * every UI font whose ascent exceeds its descent (Noto Sans CJK ≈0.065em,
57
+ * Windows Segoe UI ≈0.064em: ≈1px at md's 16px, visible in a circle).
58
+ * `text-box: trim-both cap alphabetic` trims the line box to
59
+ * [cap-top, baseline], so the centered box hugs the glyph ink — the
60
+ * avatar-initial case the property was designed for. Trim only reaches
61
+ * BLOCK-container line boxes (Chromium ignores it on a flex container's
62
+ * anonymous text block — verified headless), so under the guard the span
63
+ * swaps flex for block + align-content/text-align centering: same box,
64
+ * the trimmed line is centered on the block axis instead. Engines without
65
+ * text-box (pre-133 Chromium / pre-18.2 Safari / pre-154 Firefox) fail
66
+ * @supports and keep the flex anatomy unchanged — zero regression.
67
+ * CJK initials stay centered: the ideograph ink center lands on the
68
+ * [cap, baseline] band center (德 verified side by side, headless
69
+ * Chrome). Support note: the guard tests the `text-box` SHORTHAND,
70
+ * which Chromium parses from 133 (the longhands land in 128) — engines
71
+ * older than that fail @supports and keep the flex anatomy. */
72
+ @supports (text-box: trim-both cap alphabetic) {
73
+ .hk-avatar-fallback {
74
+ display: block;
75
+ align-content: center;
76
+ text-align: center;
77
+ text-box: trim-both cap alphabetic;
78
+ }
79
+ }
80
+
53
81
  [data-theme="dark"] .hk-avatar {
54
82
  background-color: var(--hi-surface);
55
83
  color: var(--hi-color-text-secondary, rgba(102,102,102,0.6));
@@ -0,0 +1,147 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { compile } from "sass";
6
+ import { describe, expect, it } from "vitest";
7
+
8
+ /**
9
+ * Compiled-stylesheet guard for the avatar letter-fallback optical
10
+ * centering (2026-11-08 user report: 头像首字母偏下).
11
+ *
12
+ * Flex centering centers the LINE box, but a capital glyph sits on the
13
+ * baseline — below the line box's geometric center on every UI font
14
+ * whose ascent exceeds its descent (Noto Sans CJK ≈0.065em, Windows
15
+ * Segoe UI ≈0.064em). The fix trims the line box to [cap-top, baseline]
16
+ * with `text-box: trim-both cap alphabetic` under an @supports guard
17
+ * (trim only reaches BLOCK-container line boxes — Chromium ignores it on
18
+ * a flex container's anonymous text block, verified headless — so the
19
+ * height:100% fallbacks swap flex for block + align-content centering
20
+ * under the guard; the auto-height menu fallback only adds the trim).
21
+ *
22
+ * Vitest stubs CSS, so the runtime suite can never catch a lost rule;
23
+ * this guard pins the COMPILED sheets. All three sheets are mirrored
24
+ * verbatim into the Rust crate (vue-mirror drift tests), which catches
25
+ * one-sided edits — this file catches the block disappearing from BOTH
26
+ * copies at once. Supported engines: Chromium 133+, Safari 18.2+,
27
+ * Firefox 154+ (the guard tests the text-box shorthand; the longhands
28
+ * exist in Chromium from 128) — anything older fails @supports and keeps
29
+ * the flex anatomy (the pre-fix behavior), which is why the base rules
30
+ * below are also pinned to keep their flex centering.
31
+ */
32
+
33
+ const componentDir = resolve(dirname(fileURLToPath(import.meta.url)));
34
+
35
+ const SUPPORTS_PREAMBLE = "@supports (text-box: trim-both cap alphabetic)";
36
+
37
+ const css = {
38
+ avatar: compile(resolve(componentDir, "HkAvatar.scss"), { style: "expanded" }).css,
39
+ menuIdentity: compile(resolve(componentDir, "HkMenuIdentityItem.scss"), { style: "expanded" }).css,
40
+ adminTokens: compile(resolve(componentDir, "../styles/admin-tokens.scss"), { style: "expanded" }).css,
41
+ };
42
+
43
+ /** The body of the FIRST @supports block with exactly `preamble`, brace-matched. */
44
+ function supportsBody(sheetCss: string, preamble: string, label: string): string {
45
+ const start = sheetCss.indexOf(preamble);
46
+ expect(start, `${label}: ${preamble} block present in the compiled sheet`).toBeGreaterThanOrEqual(0);
47
+ let depth = 0;
48
+ let open = -1;
49
+ for (let at = start + preamble.length; at < sheetCss.length; at += 1) {
50
+ const ch = sheetCss[at];
51
+ if (ch === "{") {
52
+ if (open < 0) open = at;
53
+ depth += 1;
54
+ } else if (ch === "}") {
55
+ depth -= 1;
56
+ if (depth === 0) return sheetCss.slice(open + 1, at);
57
+ }
58
+ }
59
+ throw new Error(`${label}: unterminated @supports block`);
60
+ }
61
+
62
+ /** The prelude of the FIRST rule inside the first `@supports` block with
63
+ * exactly `preamble` — the selector the guard actually targets. */
64
+ function guardedSelector(sheetCss: string, preamble: string, label: string): string {
65
+ const body = supportsBody(sheetCss, preamble, label);
66
+ const open = body.indexOf("{");
67
+ return body.slice(0, open).trim();
68
+ }
69
+
70
+ /** The body of the top-level rule whose prelude is exactly `selector`. */
71
+ function ruleFor(sheetCss: string, selector: string, label: string): string {
72
+ const needle = `${selector} {`;
73
+ const start = sheetCss.indexOf(needle);
74
+ expect(start, `${label}: ${selector} rule present in the compiled sheet`).toBeGreaterThanOrEqual(0);
75
+ const close = sheetCss.indexOf("}", start);
76
+ return sheetCss.slice(start, close + 1);
77
+ }
78
+
79
+ describe("avatar letter-fallback optical centering (text-box guard)", () => {
80
+ it("positive control: the extractor finds the known base anatomy", () => {
81
+ // If this stops matching, ruleFor/supportsBody are broken — fix the
82
+ // extractor, not the assertions below.
83
+ expect(ruleFor(css.avatar, ".hk-avatar", "avatar")).toContain("align-items: center");
84
+ expect(ruleFor(css.adminTokens, ".s-user-avatar", "admin-tokens")).toContain("display: flex");
85
+ });
86
+
87
+ it("hk-avatar-fallback: guard block swaps flex for block + centered trimmed line", () => {
88
+ const body = supportsBody(css.avatar, SUPPORTS_PREAMBLE, "avatar");
89
+ expect(guardedSelector(css.avatar, SUPPORTS_PREAMBLE, "avatar")).toBe(".hk-avatar-fallback");
90
+ expect(body).toContain("display: block");
91
+ expect(body).toContain("align-content: center");
92
+ expect(body).toContain("text-align: center");
93
+ expect(body).toContain("text-box: trim-both cap alphabetic");
94
+ });
95
+
96
+ it("hk-avatar-fallback: base rule keeps the flex anatomy (no-trim engines)", () => {
97
+ const rule = ruleFor(css.avatar, ".hk-avatar-fallback", "avatar");
98
+ expect(rule).toContain("display: flex");
99
+ expect(rule).toContain("align-items: center");
100
+ expect(rule).toContain("justify-content: center");
101
+ });
102
+
103
+ it("s-user-avatar: guard block swaps flex for block + centered trimmed line", () => {
104
+ const body = supportsBody(css.adminTokens, SUPPORTS_PREAMBLE, "admin-tokens");
105
+ expect(guardedSelector(css.adminTokens, SUPPORTS_PREAMBLE, "admin-tokens")).toBe(".s-user-avatar");
106
+ expect(body).toContain("display: block");
107
+ expect(body).toContain("align-content: center");
108
+ expect(body).toContain("text-align: center");
109
+ expect(body).toContain("text-box: trim-both cap alphabetic");
110
+ });
111
+
112
+ it("s-user-avatar: base rule keeps the flex anatomy (no-trim engines)", () => {
113
+ const rule = ruleFor(css.adminTokens, ".s-user-avatar", "admin-tokens");
114
+ expect(rule).toContain("display: flex");
115
+ expect(rule).toContain("align-items: center");
116
+ expect(rule).toContain("justify-content: center");
117
+ });
118
+
119
+ it("menu identity fallback: trim only — auto-height flex item needs no anatomy swap", () => {
120
+ const body = supportsBody(css.menuIdentity, SUPPORTS_PREAMBLE, "menu-identity");
121
+ expect(guardedSelector(css.menuIdentity, SUPPORTS_PREAMBLE, "menu-identity")).toBe(
122
+ ".hk-menu-identity-item__fallback",
123
+ );
124
+ expect(body).toContain("text-box: trim-both cap alphabetic");
125
+ expect(body).not.toContain("display:");
126
+ });
127
+ });
128
+
129
+ describe("avatar letter-fallback sheets stay mirrored", () => {
130
+ // The Rust crate compiles vendored copies of these sheets; the drift
131
+ // tests over there fail on one-sided edits. This re-reads the SOURCE
132
+ // files to pin the mirror files' presence next to the pinned behavior,
133
+ // so deleting the guard from BOTH copies cannot slip through silently
134
+ // (drift tests compare copies — they stay green when both sides lose
135
+ // the block together).
136
+ it("vendored mirror copies still carry the guard (double-sided delete tripwire)", () => {
137
+ const crateRoot = resolve(componentDir, "../../../components/src/styles/vue-mirror");
138
+ for (const rel of [
139
+ "components/HkAvatar.scss",
140
+ "components/HkMenuIdentityItem.scss",
141
+ "styles/admin-tokens.scss",
142
+ ]) {
143
+ const mirror = readFileSync(resolve(crateRoot, rel), "utf8");
144
+ expect(mirror, `${rel} mirror carries the guard`).toContain("text-box: trim-both cap alphabetic");
145
+ }
146
+ });
147
+ });
@@ -1,4 +1,4 @@
1
- import { computed, defineComponent, h, onBeforeUnmount, onMounted, ref, type Component, type PropType } from "vue";
1
+ import { computed, defineComponent, h, inject, onBeforeUnmount, onMounted, ref, type Component, type PropType } from "vue";
2
2
  import { MoreHorizontal } from "lucide-vue-next";
3
3
 
4
4
  import HkCard from "./HkCard";
@@ -9,6 +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
13
  import "./HkCardList.scss";
13
14
 
14
15
  /**
@@ -43,7 +44,43 @@ const renderedRows = hasRenderedContent;
43
44
  */
44
45
  export const HkCardList = defineComponent({
45
46
  name: "HkCardList",
46
- setup(_props, { slots }) {
47
+ props: {
48
+ /** Authoritative row total the scroll-progress pill uses as its
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). */
55
+ total: { type: Number, default: undefined },
56
+ },
57
+ setup(props, { slots }) {
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).
66
+ const listRef = ref<HTMLElement | null>(null);
67
+ const progressRegistrar = inject(HK_SCROLL_PROGRESS_REGISTRAR, null);
68
+ const progressHandle: HkScrollProgressHandle = {
69
+ getRoot: () => listRef.value,
70
+ getTotal: () => props.total,
71
+ getRows: () =>
72
+ listRef.value
73
+ ? Array.from(listRef.value.children).filter((el) =>
74
+ el.classList.contains("hk-list-row"),
75
+ )
76
+ : [],
77
+ };
78
+ onMounted(() => {
79
+ progressRegistrar?.register(progressHandle);
80
+ });
81
+ onBeforeUnmount(() => {
82
+ progressRegistrar?.unregister(progressHandle);
83
+ });
47
84
  return () => {
48
85
  const rows = slots.default?.();
49
86
  return (
@@ -51,7 +88,7 @@ export const HkCardList = defineComponent({
51
88
  {slots.empty && !renderedRows(rows) ? (
52
89
  slots.empty()
53
90
  ) : (
54
- <div class="hk-card-list" role="list">
91
+ <div class="hk-card-list" role="list" ref={listRef}>
55
92
  {rows}
56
93
  </div>
57
94
  )}
@@ -42,6 +42,16 @@
42
42
  color: rgb(var(--color-primary) / 70%);
43
43
  }
44
44
 
45
+ /* The fallback span is a blockified flex item with auto height, so flex
46
+ centering of the trimmed box is exact — no anatomy swap needed here
47
+ (the height:100% fallbacks swap flex for block under the same guard;
48
+ full rationale: HkAvatar.scss). */
49
+ @supports (text-box: trim-both cap alphabetic) {
50
+ .hk-menu-identity-item__fallback {
51
+ text-box: trim-both cap alphabetic;
52
+ }
53
+ }
54
+
45
55
  .hk-menu-identity-item__text {
46
56
  display: flex;
47
57
  flex-direction: column;
@@ -12,7 +12,12 @@ import { createApp, defineComponent, h, inject, nextTick, onMounted, onUnmounted
12
12
  import HkScrollContainer from "./HkScrollContainer";
13
13
  import { HkAdminTablePage } from "./HkAdminTablePage";
14
14
  import HTable from "./HkTable";
15
- import { HK_SCROLL_PROGRESS_REGISTRAR, type HkScrollProgressRegistrar } from "./HkScrollProgress";
15
+ import { HkCardList, HkListRow } from "./HkCardList";
16
+ import {
17
+ HK_SCROLL_PROGRESS_REGISTRAR,
18
+ type HkScrollProgressHandle,
19
+ type HkScrollProgressRegistrar,
20
+ } from "./HkScrollProgress";
16
21
 
17
22
  const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
18
23
  const rects: Array<ReturnType<typeof vi.spyOn>> = [];
@@ -485,6 +490,149 @@ describe("HkAdminTablePage scroll-progress registration", () => {
485
490
  });
486
491
 
487
492
 
493
+ describe("HkCardList scroll-progress registration", () => {
494
+ it("registers its list root, rows and total with the provided registrar", async () => {
495
+ const registered: Array<{ getRoot: () => HTMLElement | null; getTotal: () => number | undefined; getRows?: () => readonly Element[] | undefined }> = [];
496
+ const unregistered: unknown[] = [];
497
+ const registrar: HkScrollProgressRegistrar = {
498
+ register: (handle) => registered.push(handle),
499
+ unregister: (handle) => unregistered.push(handle),
500
+ };
501
+ const Provider = defineComponent({
502
+ setup() {
503
+ provide(HK_SCROLL_PROGRESS_REGISTRAR, registrar);
504
+ return () =>
505
+ h(HkCardList, { total: 42 }, {
506
+ default: () => [
507
+ h(HkListRow, { key: "a", title: "a" }),
508
+ h(HkListRow, { key: "b", title: "b" }),
509
+ // A non-row child (a bare div a host slipped in) must not
510
+ // count as a data row — getRows filters to .hk-list-row.
511
+ h("div", { key: "spacer", class: "h-2" }),
512
+ ],
513
+ });
514
+ },
515
+ });
516
+ const container = mountTree(h(Provider));
517
+ expect(registered.length).toBe(1);
518
+ expect(registered[0].getTotal()).toBe(42);
519
+ // The list element is the row root — the element containing the rows.
520
+ const root = registered[0].getRoot()!;
521
+ expect(root).not.toBeNull();
522
+ expect(root.classList.contains("hk-card-list")).toBe(true);
523
+ expect(root.querySelectorAll(".hk-list-row")).toHaveLength(2);
524
+ // getRows hands the pill the HkListRow children only (the spacer
525
+ // div is excluded) — the section-band precision HkTable carries,
526
+ // mirrored for card lists.
527
+ const rows = registered[0].getRows!();
528
+ expect(rows).toBeDefined();
529
+ expect(rows!).toHaveLength(2);
530
+ expect(rows!.every((el) => el.classList.contains("hk-list-row"))).toBe(true);
531
+
532
+ // Unmount unregisters the SAME handle.
533
+ mounts[0].app.unmount();
534
+ expect(unregistered.length).toBe(1);
535
+ expect(unregistered[0]).toBe(registered[0]);
536
+ });
537
+
538
+ it("keeps the handle honest across the empty-slot lifecycle (rows→empty→rows)", async () => {
539
+ // The list element unmounts when the empty slot renders: the handle
540
+ // must read a NULL root (the pill's hidden precondition — never a
541
+ // stale detached element), and a re-render must recover a fresh
542
+ // connected list under the SAME handle.
543
+ const registered: Array<HkScrollProgressHandle> = [];
544
+ const registrar: HkScrollProgressRegistrar = {
545
+ register: (handle) => registered.push(handle),
546
+ unregister: () => {},
547
+ };
548
+ const hasRows = ref(true);
549
+ const Provider = defineComponent({
550
+ setup() {
551
+ provide(HK_SCROLL_PROGRESS_REGISTRAR, registrar);
552
+ return () =>
553
+ h(HkCardList, { total: 9 }, {
554
+ default: () => (hasRows.value ? [h(HkListRow, { key: "a", title: "a" })] : []),
555
+ empty: () => h("div", {}, "empty"),
556
+ });
557
+ },
558
+ });
559
+ mountTree(h(Provider));
560
+ const handle = registered[0]!;
561
+ expect(handle.getRoot()).not.toBeNull();
562
+ expect(handle.getRows!()).toHaveLength(1);
563
+
564
+ // Rows → empty: the ref clears with the element (no stale root).
565
+ hasRows.value = false;
566
+ await nextTick();
567
+ expect(handle.getRoot()).toBeNull();
568
+ expect(handle.getRows!()).toHaveLength(0);
569
+ expect(handle.getTotal()).toBe(9); // the prop stays live
570
+
571
+ // Empty → rows: the same handle recovers a fresh connected list.
572
+ hasRows.value = true;
573
+ await nextTick();
574
+ const root = handle.getRoot()!;
575
+ expect(root).not.toBeNull();
576
+ expect(root.isConnected).toBe(true);
577
+ expect(root.classList.contains("hk-card-list")).toBe(true);
578
+ expect(handle.getRows!()).toHaveLength(1);
579
+ });
580
+
581
+ it("renders its rows into the shell viewport's pill denominator end-to-end", async () => {
582
+ // The real provide/inject path: a card list inside a
583
+ // scrollProgress-on container registers itself, and the pill's
584
+ // denominator is the list's declared total (not the delivered rows)
585
+ // — the same end-to-end contract the table page carries.
586
+ const list = h(HkCardList, {
587
+ total: 500,
588
+ }, {
589
+ default: () => Array.from({ length: 30 }, (_, i) =>
590
+ h(HkListRow, { key: i, title: `r${i}` })),
591
+ });
592
+ const instance = ref<{ refresh: () => void } | null>(null);
593
+ let scrollTop = 0;
594
+ const Wrapper = defineComponent({
595
+ setup() {
596
+ return () =>
597
+ h(HkScrollContainer, { ref: instance as never, scrollProgress: true }, { default: () => list });
598
+ },
599
+ });
600
+ const container = mountTree(h(Wrapper));
601
+ const viewport = container.querySelector<HTMLElement>(".hk-scroll-container-viewport")!;
602
+ Object.defineProperties(viewport, {
603
+ scrollHeight: { configurable: true, get: () => 4000 },
604
+ clientHeight: { configurable: true, get: () => 400 },
605
+ scrollWidth: { configurable: true, get: () => 300 },
606
+ clientWidth: { configurable: true, get: () => 300 },
607
+ scrollLeft: { configurable: true, get: () => 0 },
608
+ scrollTop: { configurable: true, get: () => scrollTop },
609
+ });
610
+ rects.push(vi.spyOn(viewport, "getBoundingClientRect").mockReturnValue({
611
+ left: 0, top: 0, right: 300, bottom: 400, width: 300, height: 400, x: 0, y: 0,
612
+ } as DOMRect));
613
+ const listRoot = container.querySelector<HTMLElement>(".hk-card-list")!;
614
+ expect(listRoot).not.toBeNull();
615
+ rects.push(vi.spyOn(listRoot, "getBoundingClientRect").mockReturnValue({
616
+ left: 0, top: -200, right: 300, bottom: 3800, width: 300, height: 4000, x: 0, y: -200,
617
+ } as DOMRect));
618
+ container.querySelectorAll<HTMLElement>(".hk-list-row").forEach((row, i) => {
619
+ const top = i * 40 - 200;
620
+ rects.push(vi.spyOn(row, "getBoundingClientRect").mockReturnValue({
621
+ left: 0, top, right: 300, bottom: top + 40, width: 300, height: 40, x: 0, y: top,
622
+ } as DOMRect));
623
+ });
624
+ scrollTop = 200;
625
+ instance.value!.refresh();
626
+ await nextTick();
627
+ const pill = container.querySelector<HTMLElement>(".hk-scroll-progress-pill");
628
+ expect(pill).not.toBeNull();
629
+ // First visible row #6 over the DECLARED total — the delivered 30
630
+ // rows never override the list's server count.
631
+ expect(pill!.textContent).toBe("6 / 500");
632
+ });
633
+ });
634
+
635
+
488
636
  // ── SCSS contract (textual pins — happy-dom does not lay out) ────────
489
637
  // The house style pins sheet claims with readFileSync probes
490
638
  // (HkButton.test.tsx icon-only precedent): these guard the paint
@@ -897,6 +897,22 @@
897
897
  letter-spacing: 0.02em;
898
898
  }
899
899
 
900
+ /* Optical vertical centering for the letter fallback (2026-11-08 user
901
+ report: 头像首字母偏下) — same mechanism and guard as HkAvatar.scss:
902
+ flex centers the line box while the capital sits on the baseline
903
+ (≈0.065em low on CJK-metric fonts), and trim only reaches block
904
+ containers, so under @supports the span swaps flex for
905
+ block + align-content/text-align centering; unsupported engines keep
906
+ the flex anatomy unchanged. */
907
+ @supports (text-box: trim-both cap alphabetic) {
908
+ .s-user-avatar {
909
+ display: block;
910
+ align-content: center;
911
+ text-align: center;
912
+ text-box: trim-both cap alphabetic;
913
+ }
914
+ }
915
+
900
916
  .s-user-avatar-img {
901
917
  width: 100%;
902
918
  height: 100%;