@celestia-island/hikari 0.55.202 → 0.55.203

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.202",
3
+ "version": "0.55.203",
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",
@@ -66,6 +66,9 @@ export function isCssAnimationsEnabled(): boolean {
66
66
  registerCssAnimation("s-nav-item-badge-pulse", { infinite: true });
67
67
  // styles/admin-tokens.scss — auth card entrance (plays once).
68
68
  registerCssAnimation("s-auth-card-in");
69
+ // HkPaneHolder's reveal (opacity-only, one shot) — the keep-mounted pane
70
+ // slot's entry, the dissolve-family language for persistent panes.
71
+ registerCssAnimation("hk-pane-holder-in");
69
72
  // styles/admin-tokens.scss — status-bar recovery flash (plays once):
70
73
  // the traffic light's green background blink when a dropped connection
71
74
  // comes back.
@@ -1,5 +1,5 @@
1
- import { computed, defineComponent, ref } from "vue";
2
- import { HDrawer, HScrollContainer, useBreakpoint } from "@celestia-island/hikari";
1
+ import { computed, defineComponent, ref, type PropType } from "vue";
2
+ import { HkCrossfade, HDrawer, HScrollContainer, useBreakpoint } from "@celestia-island/hikari";
3
3
  import { provideActionBar } from "../composables/useActionBar";
4
4
  import { useI18n } from "../i18n/context";
5
5
 
@@ -162,6 +162,13 @@ export const HkAdminShell = defineComponent({
162
162
  * verbatim at every width unless the page opts into
163
163
  * `contentBleedOnMobile`. */
164
164
  contentPadding: { type: String, default: "1.5rem" },
165
+ /** Identity of the content page on stage (task-4, 2026-10-24):
166
+ * pass the current view/route key and the content area swaps pages
167
+ * through the family crossfade — the library owns the page-slot
168
+ * mechanism instead of every host hand-wrapping it. Omitted (or
169
+ * null) renders the slot bare: canvas-like hosts that mount one
170
+ * persistent stage opt out. */
171
+ contentKey: { type: [String, Number] as PropType<string | number | null>, default: null },
165
172
  /** Whether the content on screen is a CANVAS — a 2D board or the 3D
166
173
  * scene — that must span the full phone width. OPT-IN, declared by
167
174
  * the page that renders the canvas (2026-10-04 user direction: the
@@ -257,7 +264,15 @@ export const HkAdminShell = defineComponent({
257
264
  {/* Padding lives INSIDE the scroll viewport (an inner
258
265
  wrapper) so card box-shadows are not clipped at the
259
266
  viewport edges. */}
260
- <div style={contentStyle.value}>{slots.content?.()}</div>
267
+ <div style={contentStyle.value}>
268
+ {props.contentKey != null ? (
269
+ <HkCrossfade swapKey={props.contentKey} class="s-admin-shell-swap">
270
+ {slots.content?.()}
271
+ </HkCrossfade>
272
+ ) : (
273
+ slots.content?.()
274
+ )}
275
+ </div>
261
276
  </HScrollContainer>
262
277
  </main>
263
278
 
@@ -1,5 +1,6 @@
1
1
  import { defineComponent, type Component, type PropType } from "vue";
2
2
  import { HAlert, HCard, HEmptyState, HSpinner, HTable, useI18n } from "@celestia-island/hikari";
3
+ import HkCrossfade from "./HkCrossfade";
3
4
  import { HkPageHeader } from "./HkPageHeader";
4
5
  import type { HkRowManageGate } from "./HkTable";
5
6
 
@@ -98,37 +99,53 @@ export const HkAdminTablePage = defineComponent({
98
99
  * render alongside the error branch (its precondition — rows on
99
100
  * screen — is exactly what the error branch removes). */}
100
101
  {props.staleError && !props.error ? <HAlert message={props.staleError} /> : null}
101
- {props.error ? (
102
- <HAlert message={props.error} />
103
- ) : props.loading && !props.rows.length ? (
104
- <HSpinner center />
105
- ) : !props.rows.length ? (
106
- <HEmptyState
107
- boxed
108
- title={emptyTitle}
109
- description={props.emptyDescription}
110
- >
111
- {emptySlots}
112
- </HEmptyState>
113
- ) : slots.body ? (
114
- <div class="hk-admin-table-page-body">{slots.body()}</div>
115
- ) : (
116
- <HCard padded={false}>
117
- <HTable
118
- columns={props.columns}
119
- rows={props.rows}
120
- rowKey={props.rowKey}
121
- draggable={props.draggable}
122
- manageable={props.manageable}
123
- onReorder={(from: number, to: number) => emit("reorder", from, to)}
124
- onManage={(row: Record<string, unknown>, opener: HTMLElement) =>
125
- emit("manage", row, opener)
126
- }
102
+ {/* The body is a page slot with three generations — error,
103
+ loading/empty, content (task-4, 2026-10-24): the family
104
+ crossfade owns the exchange, so every admin table page in
105
+ every host dissolves between them instead of popping. The
106
+ key collapses loading-vs-empty into one "placeholder"
107
+ generation: spinner → empty is a data arrival, not a slot
108
+ identity change worth animating. */}
109
+ <HkCrossfade
110
+ swapKey={
111
+ props.error ? "error"
112
+ : props.loading && !props.rows.length ? "placeholder"
113
+ : !props.rows.length ? "placeholder"
114
+ : "content"
115
+ }
116
+ >
117
+ {props.error ? (
118
+ <HAlert message={props.error} />
119
+ ) : props.loading && !props.rows.length ? (
120
+ <HSpinner center />
121
+ ) : !props.rows.length ? (
122
+ <HEmptyState
123
+ boxed
124
+ title={emptyTitle}
125
+ description={props.emptyDescription}
127
126
  >
128
- {slots}
129
- </HTable>
130
- </HCard>
131
- )}
127
+ {emptySlots}
128
+ </HEmptyState>
129
+ ) : slots.body ? (
130
+ <div class="hk-admin-table-page-body">{slots.body()}</div>
131
+ ) : (
132
+ <HCard padded={false}>
133
+ <HTable
134
+ columns={props.columns}
135
+ rows={props.rows}
136
+ rowKey={props.rowKey}
137
+ draggable={props.draggable}
138
+ manageable={props.manageable}
139
+ onReorder={(from: number, to: number) => emit("reorder", from, to)}
140
+ onManage={(row: Record<string, unknown>, opener: HTMLElement) =>
141
+ emit("manage", row, opener)
142
+ }
143
+ >
144
+ {slots}
145
+ </HTable>
146
+ </HCard>
147
+ )}
148
+ </HkCrossfade>
132
149
  {slots["create-modal"]?.()}
133
150
  {slots["edit-modal"]?.()}
134
151
  </div>
@@ -0,0 +1,33 @@
1
+ // HkPaneHolder — the keep-mounted pane slot (see the TSX doc for when to
2
+ // choose it over the dissolve primitives).
3
+
4
+ .hk-pane-holder {
5
+ display: none;
6
+
7
+ // Active: back in the layout with the family dissolve's own language —
8
+ // a one-shot opacity reveal. No transform: a pane sliding in would
9
+ // disagree with every crossfade around it.
10
+ &[data-active] {
11
+ display: block;
12
+ animation: hk-pane-holder-in var(--duration-normal, 0.3s) ease-out;
13
+ }
14
+ }
15
+
16
+ @keyframes hk-pane-holder-in {
17
+ from { opacity: 0; }
18
+ to { opacity: 1; }
19
+ }
20
+
21
+ // Opt-outs: the global animation switch is the host's, reduced motion is
22
+ // the OS's — the holder obeys both (an inline-less rule; the animation is
23
+ // declared here in the sheet, so unlike HkTransitionContainer's inline
24
+ // duration a media query CAN reach it).
25
+ html[data-css-animations="0"] .hk-pane-holder[data-active] {
26
+ animation: none;
27
+ }
28
+
29
+ @media (prefers-reduced-motion: reduce) {
30
+ .hk-pane-holder[data-active] {
31
+ animation-duration: var(--duration-fast, 0.15s);
32
+ }
33
+ }
@@ -0,0 +1,99 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import sass from "sass";
5
+
6
+ import { afterEach, describe, expect, it } from "vitest";
7
+ import { createApp, defineComponent, h, nextTick, ref, type App } from "vue";
8
+
9
+ import HkPaneHolder from "./HkPaneHolder";
10
+
11
+ const here = dirname(fileURLToPath(import.meta.url));
12
+
13
+ const containers: HTMLElement[] = [];
14
+
15
+ function mountHolder(active: boolean): {
16
+ app: App;
17
+ root: HTMLElement;
18
+ child: HTMLElement;
19
+ setActive: (v: boolean) => void;
20
+ } {
21
+ const container = document.createElement("div");
22
+ document.body.appendChild(container);
23
+ containers.push(container);
24
+ const isActive = ref(active);
25
+ const Child = defineComponent({
26
+ setup() {
27
+ // The point of the holder: the pane's instance state SURVIVES
28
+ // deactivation. A counter proves the component never unmounted.
29
+ const mounts = ref(0);
30
+ const tick = () => { mounts.value += 1; };
31
+ return () => h("button", { type: "button", onClick: tick, "data-mounts": String(mounts.value) }, "pane");
32
+ },
33
+ });
34
+ const Wrapper = defineComponent({
35
+ render: () =>
36
+ h(HkPaneHolder, { active: isActive.value }, { default: () => h(Child) }),
37
+ });
38
+ const app = createApp(Wrapper);
39
+ app.mount(container);
40
+ const root = container.firstElementChild as HTMLElement;
41
+ const child = root.firstElementChild as HTMLElement;
42
+ return { app, root, child, setActive: (v: boolean) => { isActive.value = v; } };
43
+ }
44
+
45
+ afterEach(() => {
46
+ for (const c of containers.splice(0)) c.remove();
47
+ });
48
+
49
+ describe("HkPaneHolder — DOM contract", () => {
50
+ it("renders the holder class and the data-active attribute only when active", async () => {
51
+ const t = mountHolder(false);
52
+ expect(t.root.classList.contains("hk-pane-holder")).toBe(true);
53
+ expect(t.root.hasAttribute("data-active")).toBe(false);
54
+ expect(t.child).toBeDefined(); // mounted, not unmounted
55
+ t.setActive(true);
56
+ await nextTick();
57
+ expect(t.root.hasAttribute("data-active")).toBe(true);
58
+ t.app.unmount();
59
+ });
60
+
61
+ it("keeps component state across deactivate/reactivate (the entire point)", async () => {
62
+ const t = mountHolder(true);
63
+ (t.child as HTMLButtonElement).click();
64
+ (t.child as HTMLButtonElement).click();
65
+ await new Promise((r) => setTimeout(r, 0));
66
+ expect(t.child.getAttribute("data-mounts")).toBe("2");
67
+ t.setActive(false);
68
+ await new Promise((r) => setTimeout(r, 0));
69
+ t.setActive(true);
70
+ await new Promise((r) => setTimeout(r, 0));
71
+ const childAfter = t.root.firstElementChild as HTMLElement;
72
+ // Same instance: the counter did not reset to 0.
73
+ expect(childAfter.getAttribute("data-mounts")).toBe("2");
74
+ t.app.unmount();
75
+ });
76
+ });
77
+
78
+ describe("HkPaneHolder — stylesheet contract", () => {
79
+ const css = sass.compile(join(here, "HkPaneHolder.scss")).css;
80
+ const scss = readFileSync(join(here, "HkPaneHolder.scss"), "utf-8");
81
+
82
+ it("hides the base holder and reveals on [data-active]", () => {
83
+ expect(css).toMatch(/\.hk-pane-holder\s*\{[^}]*display:\s*none/s);
84
+ expect(css).toMatch(/\.hk-pane-holder\[data-active\]\s*\{[^}]*display:\s*block/s);
85
+ });
86
+
87
+ it("reveals with opacity only — no transform, ever", () => {
88
+ const keyframe = /@keyframes\s+hk-pane-holder-in\s*\{([^}]*)\}/s.exec(css);
89
+ expect(keyframe).not.toBeNull();
90
+ expect(keyframe![1]).toContain("opacity");
91
+ expect(keyframe![1]).not.toContain("transform");
92
+ expect(keyframe![1]).not.toContain("translate");
93
+ });
94
+
95
+ it("obeys the global animation switch and reduced motion", () => {
96
+ expect(css).toMatch(/html\[data-css-animations="0"\][^{]*\{[^}]*animation:\s*none/s);
97
+ expect(css).toMatch(/prefers-reduced-motion[^{]*\{[^}]*animation-duration/s);
98
+ });
99
+ });
@@ -0,0 +1,57 @@
1
+ import { defineComponent } from "vue";
2
+
3
+ import "./HkPaneHolder.scss";
4
+
5
+ /**
6
+ * HkPaneHolder — the keep-mounted pane slot.
7
+ *
8
+ * The third pane-swap surface, for the class of content the dissolve
9
+ * primitives cannot serve: PERSISTENT panes whose instance must survive
10
+ * while another pane is active — draft stores (an editor whose unsaved
11
+ * state lives in the component), canvases/WebGL stages that are too
12
+ * expensive to rebuild, or sockets that must not unplug.
13
+ *
14
+ * `HkCrossfade` / `HkTransitionContainer` swap by keying: the leaving
15
+ * generation UNMOUNTS when the dissolve completes — exactly wrong for a
16
+ * draft store. This holder instead keeps every pane in the tree and
17
+ * toggles reachability: `display: none` while inactive (out of layout,
18
+ * inert), `display: block` plus a one-shot opacity reveal when it
19
+ * becomes active. The reveal is opacity-ONLY at the family's
20
+ * `--duration-normal`, matching the dissolve language — no movement,
21
+ * ever.
22
+ *
23
+ * Contract:
24
+ * - `active` is a boolean, not a key: the holder IS one pane. A tab
25
+ * surface renders one holder per persistent pane beside the ordinary
26
+ * panel slots it already has.
27
+ * - The inactive pane is `display: none` (inert, out of the layout,
28
+ * unreadable by assistive tech) but its component instances, state
29
+ * and watchers all LIVE — that is the entire point.
30
+ * - The reveal animation is `hk-pane-holder-in` (opacity 0→1, one
31
+ * shot, registered with the animation registry); reduced motion and
32
+ * the global `data-css-animations="0"` switch clamp it to instant,
33
+ * so the holder never animates for users who opted out.
34
+ * - No leave animation: the outgoing pane simply hides (it is usually
35
+ * being covered by an incoming ordinary panel whose own dissolve is
36
+ * the visible exchange).
37
+ */
38
+ export const HkPaneHolder = defineComponent({
39
+ name: "HkPaneHolder",
40
+ props: {
41
+ /** Whether THIS pane is the active one. */
42
+ active: { type: Boolean, required: true },
43
+ tag: { type: String, default: "div" },
44
+ },
45
+ setup(props, { slots }) {
46
+ return () => {
47
+ const Tag = props.tag as "div" | "section";
48
+ return (
49
+ <Tag class="hk-pane-holder" data-active={props.active || undefined}>
50
+ {slots.default?.()}
51
+ </Tag>
52
+ );
53
+ };
54
+ },
55
+ });
56
+
57
+ export default HkPaneHolder;
@@ -51,6 +51,11 @@ const STANDARD_PANE_HOSTS: Array<{ file: string; marker: string; why: string }>
51
51
  marker: "<HkTransitionContainer",
52
52
  why: "settings sections are a page-scale pane slot (migrated 2026-10-23)",
53
53
  },
54
+ {
55
+ file: "HkAdminTablePage.tsx",
56
+ marker: "<HkCrossfade",
57
+ why: "the error/placeholder/content body is a page slot every admin table page inherits (2026-10-24)",
58
+ },
54
59
  ];
55
60
 
56
61
  /** Justified bespoke mechanisms: "<scss file>: <animation identifier>" → reason. */
@@ -66,6 +71,14 @@ const SANCTIONED_SWAP_KEYFRAMES: Record<string, string> = {
66
71
  * animation must be argued, decorative pulses and spinners do not. */
67
72
  const SWAP_VOCABULARY = /(pane|swap|crossfade|morph|sheet)/;
68
73
 
74
+ /** The primitives' own stylesheets ARE the sanctioned mechanism — their
75
+ * keyframes are the family language itself, not bespoke swaps. */
76
+ const PRIMITIVE_SHEETS = new Set([
77
+ "HkCrossfade.scss",
78
+ "HkTransitionContainer.scss",
79
+ "HkPaneHolder.scss",
80
+ ]);
81
+
69
82
  function readComponent(file: string): string {
70
83
  return readFileSync(join(componentsDir, file), "utf-8");
71
84
  }
@@ -124,6 +137,7 @@ describe("pane-swap contract", () => {
124
137
  for (const match of text.matchAll(/@keyframes\s+([\w-]+)/g)) {
125
138
  const name = match[1];
126
139
  if (!SWAP_VOCABULARY.test(name)) continue;
140
+ if (PRIMITIVE_SHEETS.has(file)) continue;
127
141
  const key = `${file}: ${name}`;
128
142
  if (!SANCTIONED_SWAP_KEYFRAMES[key]) offenders.push(`@keyframes ${key}`);
129
143
  }
package/src/index.ts CHANGED
@@ -614,6 +614,7 @@ export { default as HkCopyBadge } from "./components/HkCopyBadge";
614
614
  export { default as HkConfirmDialog } from "./components/HkConfirmDialog";
615
615
  export { default as HkCrossfade } from "./components/HkCrossfade";
616
616
  export { default as HkTransitionContainer } from "./components/HkTransitionContainer";
617
+ export { default as HkPaneHolder } from "./components/HkPaneHolder";
617
618
  export { default as HkWindowControls, isApplePlatform } from "./components/HkWindowControls";
618
619
  export { default as HkStatusRow } from "./components/HkStatusRow";
619
620
  export type { HkStatusRowTone } from "./components/HkStatusRow";