@celestia-island/hikari 0.55.117 → 0.55.118

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.117",
3
+ "version": "0.55.118",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -0,0 +1,22 @@
1
+ /* HkSectionCard — the standard titled section: heading + hint above one
2
+ * card. The ONLY spacing definition for that anatomy; consumers must not
3
+ * add their own margins/wrappers around it (2026-10-05 unification: two
4
+ * tabs of one page had drifted apart in hint position and body padding). */
5
+ .hk-section-card {
6
+ display: flex;
7
+ flex-direction: column;
8
+ /* Heading block → body. One token, both axes of the rhythm (the header
9
+ carries its own 4px title→hint gap). */
10
+ gap: var(--space-12, 0.75rem);
11
+ min-width: 0;
12
+ }
13
+
14
+ /* Trailing notes under the card (the section's own footnote slot). No font
15
+ override: a footer may carry prose or a control — it only owns the
16
+ rhythm, so consumers stop inventing their own top margin. */
17
+ .hk-section-card-footer {
18
+ display: flex;
19
+ flex-direction: column;
20
+ gap: var(--space-4, 0.25rem);
21
+ min-width: 0;
22
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Style contract (house pattern: HkAboutModal.styles.test.ts) — the
3
+ * section card's class↔rule wiring. `sheetCompile.test.ts` proves every
4
+ * sheet COMPILES; nothing else proves the classes the component emits are
5
+ * the ones the sheet styles, so a rename on either side would silently
6
+ * orphan the rule (the #493 failure mode).
7
+ */
8
+ import { describe, expect, it } from "vitest";
9
+ import { readFileSync } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ const here = dirname(fileURLToPath(import.meta.url));
14
+ const tsx = readFileSync(join(here, "HkSectionCard.tsx"), "utf-8");
15
+ const scss = readFileSync(join(here, "HkSectionCard.scss"), "utf-8");
16
+
17
+ /** Every `hk-section-card*` class the component emits. */
18
+ function emittedClasses(): string[] {
19
+ const found = new Set<string>();
20
+ for (const m of tsx.matchAll(/"(hk-section-card[a-z-]*)"/g)) found.add(m[1]!);
21
+ for (const m of tsx.matchAll(/\bhk-section-card[a-z-]*\b/g)) found.add(m[0]);
22
+ return [...found].sort();
23
+ }
24
+
25
+ describe("HkSectionCard styles", () => {
26
+ it("emits at least the wrapper and the footer (the guard is not vacuous)", () => {
27
+ const classes = emittedClasses();
28
+ expect(classes).toContain("hk-section-card");
29
+ expect(classes).toContain("hk-section-card-footer");
30
+ });
31
+
32
+ it("styles every class the component emits", () => {
33
+ for (const cls of emittedClasses()) {
34
+ expect(scss, `${cls} must have a rule`).toMatch(new RegExp(`\\.${cls}\\s*\\{`));
35
+ }
36
+ });
37
+
38
+ it("keeps the intra-section rhythm token on the wrapper and the footer", () => {
39
+ expect(scss).toMatch(/\.hk-section-card\s*\{[^}]*gap:\s*var\(--space-12/);
40
+ expect(scss).toMatch(/\.hk-section-card-footer\s*\{[^}]*gap:\s*var\(--space-4/);
41
+ });
42
+ });
@@ -0,0 +1,156 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createApp, h } from "vue";
3
+
4
+ import { HkSectionCard } from "./HkSectionCard";
5
+
6
+ /**
7
+ * HkSectionCard contract — the section-level chrome standard:
8
+ * - the heading block carries the title and the hint (its ONLY place: the
9
+ * drift this component exists to end was one section printing its hint
10
+ * above the table and another below it)
11
+ * - the body is ONE card: flush for tables (padded default false), padded
12
+ * when the body is not a table
13
+ * - `error` replaces the body with an alert and `loading` with a spinner,
14
+ * both inside a PADDED card — a state is content, not a table
15
+ * - the optional `actions` slot reaches the header (the heading row's
16
+ * trailing affordances), not the card
17
+ *
18
+ * (Repo test convention: raw createApp + document queries, no
19
+ * @vue/test-utils.)
20
+ */
21
+
22
+ const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
23
+
24
+ afterEach(() => {
25
+ while (mounts.length) {
26
+ const { app, container } = mounts.pop()!;
27
+ app.unmount();
28
+ container.remove();
29
+ }
30
+ });
31
+
32
+ function mountCard(props: Record<string, unknown>, slots: Record<string, () => unknown> = {}) {
33
+ const container = document.createElement("div");
34
+ document.body.appendChild(container);
35
+ const app = createApp({
36
+ render: () => h(HkSectionCard, props as never, slots as never),
37
+ });
38
+ mounts.push({ app, container });
39
+ app.mount(container);
40
+ return container;
41
+ }
42
+
43
+ describe("HkSectionCard", () => {
44
+ it("renders the title and the hint in the heading block, above the card", () => {
45
+ const c = mountCard({ title: "Public domains", hint: "Where this instance is reachable." }, {
46
+ default: () => h("table", { class: "body-table" }, h("tbody")),
47
+ });
48
+ const header = c.querySelector(".hk-section-header")!;
49
+ expect(header.textContent).toContain("Public domains");
50
+ expect(header.textContent).toContain("Where this instance is reachable.");
51
+ // The hint belongs to the HEADER, never to the card body.
52
+ const card = c.querySelector(".hk-card")!;
53
+ expect(card.textContent).not.toContain("Where this instance is reachable.");
54
+ expect(card.querySelectorAll(".body-table")).toHaveLength(1);
55
+ // Header precedes the card in document order (the one rhythm).
56
+ expect(header.compareDocumentPosition(card) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
57
+ });
58
+
59
+ it("keeps a table body flush in the card and pads a non-table body on request", () => {
60
+ const flush = mountCard({ title: "T" }, { default: () => h("span", "row") });
61
+ // HkCard spells "no padding" as a body modifier — the body carries the
62
+ // padding decision, not the card shell.
63
+ expect(flush.querySelector(".hk-card-body")!.className).toContain("hk-card-body-unpadded");
64
+ const padded = mountCard({ title: "T", padded: true }, { default: () => h("span", "row") });
65
+ // HkCard carries the padding itself — no caller wrapper.
66
+ expect(padded.querySelector(".hk-card-body")!.className).not.toContain("hk-card-body-unpadded");
67
+ });
68
+
69
+ it("replaces the body with an alert on error, inside a padded card", () => {
70
+ const c = mountCard({ title: "T", error: "boom" }, { default: () => h("span", "row") });
71
+ expect(c.querySelector(".hk-alert")).not.toBeNull();
72
+ expect(c.textContent).toContain("boom");
73
+ expect(c.querySelector(".hk-card-body")!.className).not.toContain("hk-card-body-unpadded");
74
+ expect(c.textContent).not.toContain("row");
75
+ });
76
+
77
+ it("replaces the body with a centred spinner while loading", () => {
78
+ const c = mountCard({ title: "T", loading: true }, { default: () => h("span", "row") });
79
+ expect(c.querySelector(".hk-spinner")).not.toBeNull();
80
+ expect(c.querySelector(".hk-card-body")!.className).not.toContain("hk-card-body-unpadded");
81
+ expect(c.textContent).not.toContain("row");
82
+ });
83
+
84
+ it("lets error win when both error and loading are set", () => {
85
+ const c = mountCard({ title: "T", error: "boom", loading: true }, {
86
+ default: () => h("span", "row"),
87
+ });
88
+ expect(c.querySelector(".hk-alert")).not.toBeNull();
89
+ expect(c.querySelector(".hk-spinner")).toBeNull();
90
+ expect(c.textContent).toContain("boom");
91
+ });
92
+
93
+ it("forwards the description slot into the hint position", () => {
94
+ const c = mountCard({ title: "T" }, { description: () => h("span", "own hint") });
95
+ const header = c.querySelector(".hk-section-header")!;
96
+ expect(header.querySelector(".hk-section-header-description")!.textContent).toContain("own hint");
97
+ expect(c.querySelector(".hk-card")!.textContent).not.toContain("own hint");
98
+ });
99
+
100
+ it("forwards icon, level and dense to the heading", () => {
101
+ const Icon = () => h("svg", { class: "own-icon" });
102
+ const c = mountCard({ title: "T", icon: Icon, level: "h2", dense: true });
103
+ const header = c.querySelector(".hk-section-header")!;
104
+ expect(header.classList.contains("hk-section-header-dense")).toBe(true);
105
+ expect(header.querySelector("h2")).not.toBeNull();
106
+ expect(header.querySelector(".own-icon")).not.toBeNull();
107
+ const plain = mountCard({ title: "T" });
108
+ expect(plain.querySelector(".hk-section-header h3")).not.toBeNull();
109
+ });
110
+
111
+ it("forwards the actions slot into the heading row", () => {
112
+ const c = mountCard({ title: "T" }, { actions: () => h("button", { class: "hdr-act" }, "x") });
113
+ expect(c.querySelector(".hk-section-header")!.querySelector(".hdr-act")).not.toBeNull();
114
+ expect(c.querySelector(".hk-card")!.querySelector(".hdr-act")).toBeNull();
115
+ });
116
+
117
+ it("renders the footer under the card — and nothing when no footer is given", () => {
118
+ const c = mountCard({ title: "T" }, {
119
+ default: () => h("span", "row"),
120
+ footer: () => h("p", { class: "note" }, "keep this"),
121
+ });
122
+ const footer = c.querySelector(".hk-section-card-footer")!;
123
+ expect(footer.querySelector(".note")!.textContent).toBe("keep this");
124
+ // Under the CARD, not inside it, and after it in document order.
125
+ const card = c.querySelector(".hk-card")!;
126
+ expect(card.querySelector(".note")).toBeNull();
127
+ expect(card.compareDocumentPosition(footer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
128
+
129
+ const bare = mountCard({ title: "T" });
130
+ expect(bare.querySelector(".hk-section-card-footer")).toBeNull();
131
+ });
132
+
133
+ it("renders no empty header wrapper when only one of the two slots is given", () => {
134
+ // R3 finding: forwarding an absent slot as a function returning
135
+ // undefined left an empty `.hk-section-header-actions` /
136
+ // `.hk-section-header-description` node behind — and in the column-flex
137
+ // heading a zero-height item still eats a gap slot.
138
+ const actionsOnly = mountCard({ title: "T" }, { actions: () => h("button", "x") });
139
+ expect(actionsOnly.querySelector(".hk-section-header-actions")).not.toBeNull();
140
+ expect(actionsOnly.querySelector(".hk-section-header-description")).toBeNull();
141
+
142
+ const descriptionOnly = mountCard({ title: "T" }, { description: () => h("span", "hint") });
143
+ expect(descriptionOnly.querySelector(".hk-section-header-description")).not.toBeNull();
144
+ expect(descriptionOnly.querySelector(".hk-section-header-actions")).toBeNull();
145
+
146
+ const neither = mountCard({ title: "T" });
147
+ expect(neither.querySelector(".hk-section-header-actions")).toBeNull();
148
+ expect(neither.querySelector(".hk-section-header-description")).toBeNull();
149
+ });
150
+
151
+ it("carries the count into the title and renders no hint paragraph when none is given", () => {
152
+ const c = mountCard({ title: "T", count: "3" });
153
+ expect(c.querySelector(".hk-section-header-count")!.textContent).toBe("3");
154
+ expect(c.querySelector(".hk-section-header-description")).toBeNull();
155
+ });
156
+ });
@@ -0,0 +1,122 @@
1
+ import { defineComponent, type Component, type PropType } from "vue";
2
+
3
+ import HkAlert from "./HkAlert";
4
+ import HkCard from "./HkCard";
5
+ import { HkSectionHeader } from "./HkSectionHeader";
6
+ import HkSpinner from "./HkSpinner";
7
+ import "./HkSectionCard.scss";
8
+
9
+ /**
10
+ * HkSectionCard — the standard titled SECTION: a heading (with an optional
11
+ * hint line) above ONE card that carries the body.
12
+ *
13
+ * This is the section-level twin of the page-level scaffold
14
+ * (`HkAdminTablePage`): a page whose content is several lists (settings
15
+ * categories, tab panels, dashboards) renders each as an HkSectionCard, so
16
+ * the heading→body rhythm, the hint typography and the body padding come
17
+ * from ONE definition instead of every caller hand-rolling `mt-3`,
18
+ * `p-4`/`padded={false}` wrappers and its own hint classes — the drift the
19
+ * 2026-10-05 audit found between two tabs of the same page (one table flush
20
+ * in the card with its hint above, the other wrapped in a padded div with
21
+ * its hint below).
22
+ *
23
+ * The body states follow `HkAdminTablePage`'s contract: `error` replaces
24
+ * the body with an alert, `loading` with a centred spinner — both inside a
25
+ * PADDED card (a state is content, not a table), while the normal body
26
+ * honours `padded` (default false: tables carry their own cell padding and
27
+ * sit flush in the card). `error` wins when both are set, as on the
28
+ * scaffold.
29
+ *
30
+ * The section owns the INTRA-section rhythm only (heading block → card). The
31
+ * page owns the gap BETWEEN sections: several siblings on one page need the
32
+ * parent's own `gap` (a flex/grid column), because this component adds no
33
+ * outer margin by design — otherwise a page that renders three sections
34
+ * would stack them flush.
35
+ *
36
+ * A `footer` slot renders trailing notes under the card (placeholder
37
+ * grammars, "what this list feeds" details) with the same rhythm, so those
38
+ * notes stop being a per-consumer margin.
39
+ *
40
+ * One deliberate difference from the scaffold: `loading` replaces the body
41
+ * whenever it is true, where `HkAdminTablePage` only replaces an EMPTY body
42
+ * (`loading && !rows.length`) and keeps populated rows on screen through a
43
+ * refresh. A section therefore passes `loading` for its FIRST load, not for
44
+ * every refetch — or it blanks a table the user is reading.
45
+ *
46
+ * ```tsx
47
+ * <HkSectionCard title={t("…")} hint={t("…")} loading={loading}>
48
+ * <HkTable columns={columns} rows={rows} rowKey="id">{…}</HkTable>
49
+ * </HkSectionCard>
50
+ * ```
51
+ */
52
+ export const HkSectionCard = defineComponent({
53
+ name: "HkSectionCard",
54
+ props: {
55
+ title: { type: String, required: true },
56
+ /** Explanatory line under the heading — the standard place for the
57
+ * "what this list is for" copy every section used to spell its own. */
58
+ hint: { type: String, default: undefined },
59
+ icon: { type: [Object, Function] as PropType<Component>, default: undefined },
60
+ /** Heading level only; the visual size is fixed by the header styles. */
61
+ level: { type: String as PropType<"h2" | "h3" | "div">, default: "h3" },
62
+ /** Trailing count beside the title (e.g. "3"). */
63
+ count: { type: String, default: undefined },
64
+ /** Tighter header spacing for dense panels. */
65
+ dense: { type: Boolean, default: false },
66
+ /** Hard failure: replaces the body with an alert. */
67
+ error: { type: String, default: undefined },
68
+ /** In-flight: replaces the body with a centred spinner. */
69
+ loading: { type: Boolean, default: false },
70
+ /** Body padding for non-table content (tables stay flush). */
71
+ padded: { type: Boolean, default: false },
72
+ },
73
+ setup(props, { slots }) {
74
+ return () => {
75
+ const state = props.error ? "error" : props.loading ? "loading" : "body";
76
+ // Hoisted to an identifier: the CONDITIONAL inline form
77
+ // (`{slots.actions ? { actions: … } : undefined}` as children) lands in
78
+ // a child array, where the plain object is no longer normalized to
79
+ // slots and the button silently disappears. Hoisting fixes it; the
80
+ // bare inline literal happens to work, which is exactly why the
81
+ // conditional one is a trap.
82
+ // Each slot is spread CONDITIONALLY: forwarding an absent one as a
83
+ // function that returns undefined defeats HkSectionHeader's own
84
+ // empty-node guard, which renders an empty wrapper (and, in the
85
+ // column-flex heading, a phantom gap slot) — a whitespace-only
86
+ // regression the R3 review caught.
87
+ const headerSlots =
88
+ slots.actions || slots.description
89
+ ? {
90
+ ...(slots.actions ? { actions: () => slots.actions?.() } : {}),
91
+ ...(slots.description ? { description: () => slots.description?.() } : {}),
92
+ }
93
+ : undefined;
94
+ return (
95
+ <section class="hk-section-card">
96
+ <HkSectionHeader
97
+ title={props.title}
98
+ description={props.hint}
99
+ icon={props.icon}
100
+ level={props.level}
101
+ count={props.count}
102
+ dense={props.dense}
103
+ >
104
+ {headerSlots}
105
+ </HkSectionHeader>
106
+ <HkCard padded={state === "body" ? props.padded : true}>
107
+ {state === "error" ? (
108
+ <HkAlert message={props.error!} />
109
+ ) : state === "loading" ? (
110
+ <HkSpinner center />
111
+ ) : (
112
+ slots.default?.()
113
+ )}
114
+ </HkCard>
115
+ {slots.footer ? <div class="hk-section-card-footer">{slots.footer()}</div> : null}
116
+ </section>
117
+ );
118
+ };
119
+ },
120
+ });
121
+
122
+ export default HkSectionCard;
package/src/index.ts CHANGED
@@ -818,6 +818,8 @@ export { HkCardList, HkListRow } from "./components/HkCardList";
818
818
  export { HkCardList as HCardList, HkListRow as HListRow } from "./components/HkCardList";
819
819
  export { HkSectionHeader, type SectionHeaderVariant } from "./components/HkSectionHeader";
820
820
  export { HkSectionHeader as HSectionHeader } from "./components/HkSectionHeader";
821
+ export { HkSectionCard } from "./components/HkSectionCard";
822
+ export { HkSectionCard as HSectionCard } from "./components/HkSectionCard";
821
823
  export { default as HFilterBar } from "./components/HkFilterBar";
822
824
  export { HkShareBar as HkShareBar } from "./components/HkShareBar";
823
825
  export { HkSecretRevealModal as HkSecretRevealModal } from "./components/HkSecretRevealModal";