@celestia-island/hikari 0.55.200 → 0.55.201

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.200",
3
+ "version": "0.55.201",
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",
@@ -8,6 +8,7 @@ import {
8
8
  type PropType,
9
9
  } from "vue";
10
10
 
11
+ import HkTransitionContainer from "./HkTransitionContainer";
11
12
  import HModal from "./HkModal";
12
13
  import "./HkSettingsDialog.scss";
13
14
 
@@ -124,7 +125,14 @@ export const HkSettingsBody = defineComponent({
124
125
  })}
125
126
  </nav>
126
127
  <div class="hk-settings__pane" ref={paneRef}>
127
- {slots[active()]?.()}
128
+ {/* The section swap runs through the family's standard morph
129
+ container (audit 2026-10-23): the pane's frame follows the
130
+ incoming section's height and the contents dissolve, the
131
+ same mechanism HkTabs panels use. A settings pane is a page
132
+ slot — it must not hard-cut. */}
133
+ <HkTransitionContainer swapKey={active()} axis="height">
134
+ {slots[active()]?.()}
135
+ </HkTransitionContainer>
128
136
  </div>
129
137
  </div>
130
138
  );
@@ -102,8 +102,12 @@ export default defineComponent({
102
102
  pendingKey = null;
103
103
  if (key !== null) {
104
104
  emit("swap", { key, previous: previousKey, durationMs: props.duration });
105
+ // The handoff consumer reads `previous` to scroll the pane back;
106
+ // it must be the key we just left, not the one we mounted with
107
+ // (audit 2026-10-23: it was never reassigned).
108
+ previousKey = key;
105
109
  }
106
- }, props.duration + 40);
110
+ }, motionDuration() + 40);
107
111
  });
108
112
  observer.observe(stageRef.value);
109
113
  }
@@ -119,6 +123,26 @@ export default defineComponent({
119
123
  void nextTick(() => observe());
120
124
  });
121
125
 
126
+ /**
127
+ * Reduced motion: the morph runs on an INLINE `transitionDuration`,
128
+ * which no stylesheet `@media (prefers-reduced-motion)` can override
129
+ * (inline beats non-important rules) and which the host's
130
+ * `html[data-css-animations="0"]` pin only covers if the host wired
131
+ * useReducedMotion() at boot. So the gate lives HERE (audit
132
+ * 2026-10-23): zero the morph when the OS asks for reduced motion or
133
+ * the host turned the animation switch off. The dissolve side is
134
+ * already handled by HkCrossfade's own media query.
135
+ */
136
+ function motionDuration(): number {
137
+ if (typeof window === "undefined") return props.duration;
138
+ const reduced =
139
+ typeof window.matchMedia === "function" &&
140
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
141
+ const switchedOff =
142
+ document.documentElement.dataset.cssAnimations === "0";
143
+ return reduced || switchedOff ? 0 : props.duration;
144
+ }
145
+
122
146
  onBeforeUnmount(() => {
123
147
  observer?.disconnect();
124
148
  observer = null;
@@ -127,7 +151,7 @@ export default defineComponent({
127
151
 
128
152
  return () => {
129
153
  const Tag = props.tag as "div" | "section" | "main" | "aside";
130
- const d = `${props.duration}ms`;
154
+ const d = `${motionDuration()}ms`;
131
155
  return (
132
156
  <Tag
133
157
  ref={hostRef}
@@ -0,0 +1,148 @@
1
+ import { readFileSync, readdirSync } from "node:fs";
2
+ import { dirname, join, relative } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ const here = dirname(fileURLToPath(import.meta.url));
8
+ const componentsDir = here;
9
+
10
+ /**
11
+ * Pane-swap contract — the enforcement half of the family's transition
12
+ * primitives (user audit 2026-10-23).
13
+ *
14
+ * THE RULE. A "pane slot" is a container whose visible content is selected
15
+ * by a state key while the container stays put: tabs, settings sections,
16
+ * step bodies, view areas. Every such slot must swap through the standard
17
+ * mechanism — `HkTransitionContainer` (frame morph + dissolve) or
18
+ * `HkCrossfade` (same-box dissolve) — or a component that composes them
19
+ * (`HkTabs` panels). The library shipped both primitives and then used
20
+ * them in exactly ONE place, so the rule was advisory: each surface
21
+ * re-decided, and two page-scale slots hard-cut while a third
22
+ * re-implemented the primitives by hand.
23
+ *
24
+ * HOW IT IS ENFORCED. Two source scans, because the violation is only
25
+ * visible in source (the primitives are leaf components; the call site
26
+ * cannot tell you a div is a page slot):
27
+ *
28
+ * 1. every `slots[<state>]?.()` pane render must be either routed through
29
+ * HkTransitionContainer/HkCrossfade, or listed below with a reason —
30
+ * an explicit-exception list, not a ban;
31
+ * 2. every pane-vocabulary `@keyframes` (names carrying pane/swap/
32
+ * crossfade/morph/sheet vocabulary) must be allow-listed with a
33
+ * reason, so new bespoke swap animations cannot slip in.
34
+ *
35
+ * ALLOW-LIST DISCIPLINE. Each entry states WHY the standard cannot carry
36
+ * it. Nothing is grandfathered silently; a new entry needs a reason that
37
+ * would survive review.
38
+ */
39
+
40
+ /** Components whose slot render is a pane swap and MUST stay on the
41
+ * standard mechanism — pinned positively so a refactor cannot quietly
42
+ * drop back to a hard cut. */
43
+ const STANDARD_PANE_HOSTS: Array<{ file: string; marker: string; why: string }> = [
44
+ {
45
+ file: "HkTabs.tsx",
46
+ marker: "<HkTransitionContainer",
47
+ why: "tab panels are the canonical pane slot",
48
+ },
49
+ {
50
+ file: "HkSettingsDialog.tsx",
51
+ marker: "<HkTransitionContainer",
52
+ why: "settings sections are a page-scale pane slot (migrated 2026-10-23)",
53
+ },
54
+ ];
55
+
56
+ /** Justified bespoke mechanisms: "<scss file>: <animation identifier>" → reason. */
57
+ const SANCTIONED_SWAP_KEYFRAMES: Record<string, string> = {
58
+ "HkPickerPane.scss: hk-picker-pane-fwd-in": "fixed-stage picker with a direction axis; the stage size is known, so the measured morph container does not apply",
59
+ "HkPickerPane.scss: hk-picker-pane-fwd-out": "fixed-stage picker with a direction axis (see above)",
60
+ "HkPickerPane.scss: hk-picker-pane-back-in": "fixed-stage picker with a direction axis (see above)",
61
+ "HkPickerPane.scss: hk-picker-pane-back-out": "fixed-stage picker with a direction axis (see above)",
62
+ "HkModalBreadcrumb.scss: hk-modal-breadcrumb-in": "breadcrumb level change — a text strip, not a pane",
63
+ };
64
+
65
+ /** Swap-vocabulary keyframe names: anything that reads as a pane-swap
66
+ * animation must be argued, decorative pulses and spinners do not. */
67
+ const SWAP_VOCABULARY = /(pane|swap|crossfade|morph|sheet)/;
68
+
69
+ function readComponent(file: string): string {
70
+ return readFileSync(join(componentsDir, file), "utf-8");
71
+ }
72
+
73
+ function scssFiles(): string[] {
74
+ return readdirSync(componentsDir).filter((f) => f.endsWith(".scss"));
75
+ }
76
+
77
+ describe("pane-swap contract", () => {
78
+ it("keeps the known pane slots on the standard mechanism", () => {
79
+ for (const host of STANDARD_PANE_HOSTS) {
80
+ const src = readComponent(host.file);
81
+ expect(
82
+ src,
83
+ `${host.file} must swap through the standard pane mechanism (${host.why})`,
84
+ ).toContain(host.marker);
85
+ }
86
+ });
87
+
88
+ it("every keyed slot render is either standard or explicitly excepted", () => {
89
+ // `slots[active()]?.()`-style renders: the pattern a pane slot uses.
90
+ const slotRenders: Array<{ file: string; line: string }> = [];
91
+ for (const file of readdirSync(componentsDir).filter((f) => f.endsWith(".tsx"))) {
92
+ const text = readComponent(file);
93
+ for (const line of text.split("\n")) {
94
+ if (/slots\[[^\]]+\]\?\.\(\)/.test(line)) slotRenders.push({ file, line: line.trim() });
95
+ }
96
+ }
97
+ const EXCEPTIONS = new Set([
98
+ // These are hosts that MUST render their slot keyed by state — they
99
+ // are exactly the pane slots the rule is about, and each is either
100
+ // wrapped by the standard above or justified here.
101
+ "HkTabs.tsx", // wrapped by HkTransitionContainer (pinned above)
102
+ "HkSettingsDialog.tsx", // wrapped by HkTransitionContainer (pinned above)
103
+ "HkStepFlow.tsx", // justified: frozen-sheet two-window protocol + direction axis
104
+ "HkAdminShell.tsx", // content slot is host-owned; hosts key it (chest wraps HkCrossfade)
105
+ "HkThemePanelContent.tsx", // forwards to HTabs (HkTabs), inherits the mechanism
106
+ "HkMenuPanel.tsx", // menu items, not a pane swap
107
+ // Named slots that are not pane swaps at all: a header action strip
108
+ // (always-on actions, not a keyed slot) and a table page's modal /
109
+ // empty-action hooks (modals own their own enter/leave motion).
110
+ "HkAdminHeader.tsx",
111
+ "HkAdminTablePage.tsx",
112
+ ]);
113
+ const unexplained = slotRenders.filter((r) => !EXCEPTIONS.has(r.file));
114
+ expect(
115
+ unexplained.map((r) => `${r.file}: ${r.line}`),
116
+ "A keyed slot render appeared outside the standard mechanism. Route it through HkTransitionContainer/HkCrossfade, or add a reasoned exception.",
117
+ ).toEqual([]);
118
+ });
119
+
120
+ it("has no unsanctioned swap-vocabulary keyframes", () => {
121
+ const offenders: string[] = [];
122
+ for (const file of scssFiles()) {
123
+ const text = readFileSync(join(componentsDir, file), "utf-8");
124
+ for (const match of text.matchAll(/@keyframes\s+([\w-]+)/g)) {
125
+ const name = match[1];
126
+ if (!SWAP_VOCABULARY.test(name)) continue;
127
+ const key = `${file}: ${name}`;
128
+ if (!SANCTIONED_SWAP_KEYFRAMES[key]) offenders.push(`@keyframes ${key}`);
129
+ }
130
+ }
131
+ expect(
132
+ offenders,
133
+ "A bespoke pane-swap animation appeared. Use the standard primitives, or add a reasoned allow-list entry.",
134
+ ).toEqual([]);
135
+ });
136
+
137
+ it("allow-list entries reference real keyframes (no stale exceptions)", () => {
138
+ const present = new Set<string>();
139
+ for (const file of scssFiles()) {
140
+ const text = readFileSync(join(componentsDir, file), "utf-8");
141
+ for (const match of text.matchAll(/@keyframes\s+([\w-]+)/g)) {
142
+ present.add(`${file}: ${match[1]}`);
143
+ }
144
+ }
145
+ const stale = Object.keys(SANCTIONED_SWAP_KEYFRAMES).filter((k) => !present.has(k));
146
+ expect(stale, "Remove allow-list entries whose keyframe is gone.").toEqual([]);
147
+ });
148
+ });