@celestia-island/hikari 0.55.64 → 0.55.66

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.64",
3
+ "version": "0.55.66",
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",
@@ -1268,4 +1268,71 @@ describe("useSizeMorph heightMorph (round 18)", () => {
1268
1268
  expect(h.frame.style.transition).toBe("");
1269
1269
  h.stop();
1270
1270
  });
1271
+
1272
+ it("does not replay a clip conceal for the settle transient after a shrink morph", async () => {
1273
+ // Round-21 mechanism (rig REPORT-R21-shrink): a stepflow shrink's
1274
+ // heightMorph targets the natural height measured the instant the slide
1275
+ // settled — e.g. 503 — which sits a few px off the frame's settled rest
1276
+ // height (483; the chrome-allowance transient, the long-standing +20px
1277
+ // drift). The observer's settle-debounced remeasure then read that
1278
+ // residual as a fresh shrink and replayed a CLIP CONCEAL ~152ms into
1279
+ // the morph — re-promoting the riders and sweeping a moving edge for
1280
+ // 300ms, which is the shrink-time flash the phone kept reporting. The
1281
+ // remeasure must DEFER for the morph's duration, and the morph's
1282
+ // restore must absorb the drift silently (snap the pin to the settled
1283
+ // height, no clip sweep, no rider re-promotion).
1284
+ const rider = document.createElement("div");
1285
+ const h = mountHarness(786, 366, {
1286
+ collectRide: () => [{ el: rider }],
1287
+ });
1288
+ h.frame.style.setProperty("--hk-sheet-morph", "clip");
1289
+ h.content.appendChild(rider);
1290
+ h.start();
1291
+ expect(h.frame.style.height).toBe("786px");
1292
+ expect(rider.style.willChange).toBe("transform");
1293
+
1294
+ // The shrink morph: natural reads 503 at the settle instant.
1295
+ h.setNatural(503);
1296
+ h.heightMorph(300);
1297
+ expect(h.frame.style.height).toBe("503px");
1298
+ expect(h.frame.style.transition).toContain("height");
1299
+ expect(h.frame.hasAttribute("data-hk-morphing")).toBe(true);
1300
+ expect(rider.style.willChange).toBe("");
1301
+
1302
+ // Mid-morph the transient resolves: the settled rest height is 483.
1303
+ // The observer fires on the content change — it must NOT start a clip
1304
+ // conceal against the running morph.
1305
+ h.setNatural(483);
1306
+ FakeResizeObserver.instances[0]!.callback();
1307
+ await settle();
1308
+ // No clip was staged and the rider was NOT re-promoted: the remeasure
1309
+ // deferred instead of replaying a conceal.
1310
+ expect(h.frame.style.clipPath).toBe("");
1311
+ expect(rider.style.willChange).toBe("");
1312
+ // The morph's own height transition is untouched (still running to 503).
1313
+ expect(h.frame.style.transition).toContain("height");
1314
+ expect(h.frame.hasAttribute("data-hk-morphing")).toBe(true);
1315
+
1316
+ // The morph's transition completes: restore absorbs the drift — the pin
1317
+ // snaps to the settled 483 silently, with no clip sweep and no rider
1318
+ // re-promotion beyond the restore of the ORIGINAL promotion.
1319
+ const ev = new Event("transitionend");
1320
+ Object.defineProperty(ev, "propertyName", { value: "height" });
1321
+ h.frame.dispatchEvent(ev);
1322
+ expect(h.frame.hasAttribute("data-hk-morphing")).toBe(false);
1323
+ expect(h.frame.style.clipPath).toBe("");
1324
+ expect(h.frame.style.height).toBe("483px");
1325
+ // The resident promotions are restored to their armed values (the frame
1326
+ // is at rest), not left demoted and not re-swept.
1327
+ expect(h.frame.style.willChange).toBe("clip-path");
1328
+ expect(rider.style.willChange).toBe("transform");
1329
+
1330
+ // Nothing is left pending: a later observer tick finds the pin already
1331
+ // settled and stays quiet (no delayed conceal).
1332
+ FakeResizeObserver.instances[0]!.callback();
1333
+ await settle();
1334
+ expect(h.frame.style.clipPath).toBe("");
1335
+ expect(h.frame.style.height).toBe("483px");
1336
+ h.stop();
1337
+ });
1271
1338
  });
@@ -625,12 +625,23 @@ export function useSizeMorph(
625
625
 
626
626
  function remeasure(interrupt = true): void {
627
627
  if (!armed) return;
628
- if (!interrupt && revealEl !== null) {
628
+ if (!interrupt && (revealEl !== null || morphDemoted)) {
629
629
  // A background measurement must never tear down a live sweep: doing so
630
630
  // republished the sweep's own teardown as its LANDING, so a consumer
631
631
  // parking geometry for that fold released it ~2ms in and the sheet
632
632
  // undid and replayed the fold (real-engine finding). The change is
633
633
  // measured as soon as the sweep lands instead.
634
+ // The same holds while a heightMorph is in flight (morphDemoted): a
635
+ // stepflow's morph targets the natural height measured the instant the
636
+ // slide settled, which can sit a few px off the frame's settled rest
637
+ // height (the chrome-allowance transient, the long-standing +20px
638
+ // drift). If the observer's settle-debounced remeasure runs mid-morph
639
+ // it reads that residual as a fresh shrink and replays a CLIP CONCEAL
640
+ // — re-promoting the riders and sweeping a moving edge for 300ms,
641
+ // which is exactly the shrink-time flash the phone kept reporting
642
+ // (round-21 rig: the conceal fired ~152ms into the morph, clobbering
643
+ // the running height transition before transitionend). Defer it; the
644
+ // morph's own restore absorbs the drift silently instead.
634
645
  pendingMeasure = true;
635
646
  return;
636
647
  }
@@ -1011,6 +1022,32 @@ export function useSizeMorph(
1011
1022
  morphSavedRiderWills = [];
1012
1023
  morphSavedFrameWill = "";
1013
1024
  morphDemoted = false;
1025
+ // Absorb the settle transient silently. The morph targeted the natural
1026
+ // height measured the instant the slide settled; that figure can sit a
1027
+ // few px off the frame's settled rest height (the chrome-allowance
1028
+ // transient — the long-standing +20px drift). The observer's
1029
+ // settle-debounced remeasure was deferred for the morph's duration
1030
+ // (see the morphDemoted gate in remeasure); if it ran now against the
1031
+ // stale pin it would read the residual as a fresh shrink and replay a
1032
+ // CLIP CONCEAL (re-promoting riders, sweeping a moving edge) — the
1033
+ // shrink-time flash. Re-measure at rest and snap the pin to the
1034
+ // settled height in one transition-disabled task instead: the few-px
1035
+ // correction lands in the same frame the animation ends, so it is
1036
+ // imperceptible, and the deferred remeasure finds nothing to sweep.
1037
+ if (pendingMeasure) {
1038
+ pendingMeasure = false;
1039
+ const inlineT = f.style.transition;
1040
+ f.style.transition = "none";
1041
+ f.style.height = "";
1042
+ const settled = Math.round(f.offsetHeight);
1043
+ if (settled > 0) {
1044
+ f.style.height = `${settled}px`;
1045
+ pinned = settled;
1046
+ } else if (pinned > 0) {
1047
+ f.style.height = `${pinned}px`;
1048
+ }
1049
+ f.style.transition = inlineT;
1050
+ }
1014
1051
  };
1015
1052
  const onEnd = (ev: TransitionEvent): void => {
1016
1053
  if (ev.target === f && ev.propertyName === "height") {
package/src/index.ts CHANGED
@@ -261,6 +261,18 @@ export {
261
261
  type WallpaperPipelinePreset,
262
262
  } from "./theme";
263
263
 
264
+ // Wallpaper shader layer: the shared WebGL2 pipeline renderer + the host
265
+ // preset registry. hikari ships the mechanism, never the GLSL — hosts
266
+ // register their generated fragments through registerShaderPresets and
267
+ // hand createWallpaperShaderSurface to HkWallpaperBackdrop's props bag.
268
+ export {
269
+ registerShaderPresets, getShaderPreset, listShaderPresetIds,
270
+ SHADER_VERTEX, WallpaperShaderPipeline, createWallpaperShaderSurface,
271
+ type WallpaperShaderPreset, type WallpaperShaderPresetInput,
272
+ type WallpaperShaderScaleConfig, type WallpaperShaderScaleInput,
273
+ type WallpaperShaderOverlayConfig,
274
+ } from "./theme";
275
+
264
276
  // The wallpaper stack's SURFACE component. hikari does NOT put it on the
265
277
  // `backdrop` decor floor (that floor has no unregister — a library-owned
266
278
  // page-covering layer would be a decision the host cannot take back). The
@@ -64,3 +64,16 @@ export {
64
64
  resolveThemeFollowSwitch, setWallpaperPipelineLookup, geo, currentPeriod,
65
65
  } from "./useWallpaper";
66
66
  export type { WallpaperInitConfig, WallpaperPipelineLookup, WallpaperPipelinePreset } from "./useWallpaper";
67
+ // Wallpaper shader layer. The presets are host assets registered at
68
+ // runtime — hikari ships the renderer and the registry, never any GLSL
69
+ // and never a consumer-side alias import (see wallpaperShaderAliasGuard).
70
+ export {
71
+ registerShaderPresets, getShaderPreset, listShaderPresetIds,
72
+ } from "./wallpaperShaderPresets";
73
+ export type {
74
+ WallpaperShaderPreset, WallpaperShaderPresetInput,
75
+ WallpaperShaderScaleConfig, WallpaperShaderScaleInput, WallpaperShaderOverlayConfig,
76
+ } from "./wallpaperShaderPresets";
77
+ export {
78
+ SHADER_VERTEX, WallpaperShaderPipeline, createWallpaperShaderSurface,
79
+ } from "./wallpaperShaderRenderer";
@@ -0,0 +1,112 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { describe, expect, it } from "vitest";
5
+
6
+ // Why this guard exists: hikari ships TS source with ZERO path aliases.
7
+ // A module that imports a consumer-side alias (`@shaders/shaders`,
8
+ // `@wallpapers/wallpapers`, …) compiles fine inside the host that
9
+ // defines the alias and breaks every OTHER consumer's vite/tsconfig —
10
+ // the wallpaper shader adoption (#657's backdrop deliberately took its
11
+ // driver through a `createSurface` prop for exactly this reason). The
12
+ // shader modules added alongside this test are the first hikari modules
13
+ // that would be tempted; the rule is now mechanically enforced for the
14
+ // whole src tree.
15
+
16
+ const srcDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
17
+
18
+ /** A quoted module specifier that resolves through a path alias — the
19
+ * form every alias import must take (static, dynamic, require-like). */
20
+ const ALIAS_SPECIFIER_RE = /["']@(?:shaders|wallpapers)\//;
21
+
22
+ /** Strip full-line `//` comments and `/* … */` blocks so prose and
23
+ * commented-out code do not count (registerAnimations.test.ts holds
24
+ * the same line for its keyframe scan). */
25
+ function stripComments(src: string): string {
26
+ return src
27
+ .replace(/\/\*[\s\S]*?\*\//g, "")
28
+ .replace(/^[ \t]*\/\/.*$/gm, "");
29
+ }
30
+
31
+ /** Lines of a source file that reference an alias specifier, in order. */
32
+ function aliasLines(src: string): string[] {
33
+ return src
34
+ .split("\n")
35
+ .filter((line) => ALIAS_SPECIFIER_RE.test(line))
36
+ .map((line) => line.trim());
37
+ }
38
+
39
+ /** Recursively collect TS/TSX/Vue sources under the package src tree.
40
+ * This test's own file is skipped: its positive-control fixtures are
41
+ * RUNTIME strings (not comments), and the guard would flag itself —
42
+ * the thing under test is never its own subject (registerAnimations
43
+ * holds the same line for the animation dir). */
44
+ function collectSourceFiles(dir: string): string[] {
45
+ const out: string[] = [];
46
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
47
+ const p = path.join(dir, entry.name);
48
+ if (entry.isDirectory()) {
49
+ if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
50
+ out.push(...collectSourceFiles(p));
51
+ } else if (/\.(ts|tsx|vue)$/.test(entry.name)) {
52
+ if (p === path.resolve(fileURLToPath(import.meta.url))) continue;
53
+ out.push(p);
54
+ }
55
+ }
56
+ return out;
57
+ }
58
+
59
+ describe("hikari src carries no consumer-side alias imports", () => {
60
+ // ── Self-proofs (an extractive check with 0 hits must first prove it
61
+ // CAN hit — otherwise the pattern is silently broken and the guard
62
+ // is a tautology) ────────────────────────────────────────────────
63
+ it("self-proof: the matcher catches every alias-import shape it exists for", () => {
64
+ const positives = [
65
+ 'import * as s from "@shaders/shaders";',
66
+ "import meta from '@shaders/themes.json';",
67
+ 'import("@wallpapers/wallpapers").then((m) => m.pack());',
68
+ 'const r = require("@shaders/shaders");',
69
+ ];
70
+ for (const line of positives) {
71
+ expect(aliasLines(line)).toHaveLength(1);
72
+ }
73
+ });
74
+
75
+ it("self-proof: comment stripping removes prose mentions without touching live code", () => {
76
+ const sample = [
77
+ "/** Chest's `@shaders/shaders` alias is consumer-side. */",
78
+ "// import x from \"@shaders/shaders\"; // retired experiment",
79
+ 'import { onFrame } from "../runtime/animationBus";',
80
+ 'import x from "@shaders/shaders";',
81
+ ].join("\n");
82
+ const hits = aliasLines(stripComments(sample));
83
+ expect(hits).toEqual(['import x from "@shaders/shaders";']);
84
+ });
85
+
86
+ it("self-proof: the walk covers the tree and includes the shader modules", () => {
87
+ const files = collectSourceFiles(srcDir);
88
+ // The walk is not accidentally empty or rooted somewhere else: the
89
+ // baseline tree is hundreds of files and MUST contain the two
90
+ // modules this guard was written for (and, per the skip rule above,
91
+ // must NOT contain this test's own file).
92
+ expect(files.length).toBeGreaterThan(200);
93
+ const names = files.map((f) => path.basename(f));
94
+ for (const required of ["wallpaperShaderPresets.ts", "wallpaperShaderRenderer.ts"]) {
95
+ expect(names).toContain(required);
96
+ }
97
+ expect(names).not.toContain("wallpaperShaderAliasGuard.test.ts");
98
+ });
99
+
100
+ // ── The actual invariant ────────────────────────────────────────────
101
+ it("no src file references an @shaders/@wallpapers alias specifier", () => {
102
+ const offenders: string[] = [];
103
+ for (const file of collectSourceFiles(srcDir)) {
104
+ const stripped = stripComments(readFileSync(file, "utf-8"));
105
+ const hits = aliasLines(stripped);
106
+ if (hits.length > 0) {
107
+ offenders.push(`${path.relative(srcDir, file)}: ${hits[0]}`);
108
+ }
109
+ }
110
+ expect(offenders).toEqual([]);
111
+ });
112
+ });
@@ -0,0 +1,159 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ getShaderPreset,
5
+ listShaderPresetIds,
6
+ registerShaderPresets,
7
+ } from "./wallpaperShaderPresets";
8
+
9
+ // NOTE (test-order): the "ships an empty table" block relies on running
10
+ // before any registration below; vitest executes describes in file order.
11
+ // Later blocks use dedicated ids so an ordering change cannot alias them.
12
+ describe("wallpaperShaderPresets builtin default", () => {
13
+ it("ships an empty table — hikari bundles no GLSL of its own", () => {
14
+ expect(listShaderPresetIds()).toEqual([]);
15
+ expect(getShaderPreset("omphalos.dark")).toBeNull();
16
+ expect(getShaderPreset("anything")).toBeNull();
17
+ });
18
+ });
19
+
20
+ describe("registerShaderPresets", () => {
21
+ it("registers a preset under its record key and fills `id` from it", () => {
22
+ registerShaderPresets({
23
+ "reg.full": {
24
+ fragment: "// frag",
25
+ texture: "data:image/webp;base64,zz",
26
+ render: { desktop: 1.5, mobile: "1.0" },
27
+ overlay: { light: "rgb(255 255 255 / 55%)", dark: "rgb(0 0 0 / 35%)" },
28
+ },
29
+ });
30
+ const preset = getShaderPreset("reg.full");
31
+ expect(preset).not.toBeNull();
32
+ expect(preset!.id).toBe("reg.full");
33
+ expect(preset!.fragment).toBe("// frag");
34
+ expect(preset!.texture).toBe("data:image/webp;base64,zz");
35
+ expect(preset!.overlay).toEqual({
36
+ light: "rgb(255 255 255 / 55%)",
37
+ dark: "rgb(0 0 0 / 35%)",
38
+ });
39
+ expect(preset!.render!.desktop).toBe(1.5);
40
+ expect(listShaderPresetIds()).toContain("reg.full");
41
+ });
42
+
43
+ it("re-registering an id overrides the previous entry in place", () => {
44
+ registerShaderPresets({ "reg.over": { fragment: "// first" } });
45
+ registerShaderPresets({ "reg.over": { fragment: "// second" } });
46
+ const preset = getShaderPreset("reg.over");
47
+ expect(preset!.fragment).toBe("// second");
48
+ // Override, not append: the id appears exactly once.
49
+ expect(listShaderPresetIds().filter((id) => id === "reg.over")).toHaveLength(1);
50
+ });
51
+
52
+ it("accepts prototype-member ids without shadowing anything", () => {
53
+ // A plain-object registry would let "toString" collide with the
54
+ // prototype member; the Map must hold it as an ordinary key.
55
+ registerShaderPresets({ toString: { fragment: "// literal key" } });
56
+ expect(getShaderPreset("toString")!.fragment).toBe("// literal key");
57
+ expect(listShaderPresetIds()).toContain("toString");
58
+ });
59
+
60
+ it("throws loudly on an empty preset id", () => {
61
+ expect(() => registerShaderPresets({ "": { fragment: "// frag" } })).toThrow(
62
+ /empty id/,
63
+ );
64
+ });
65
+
66
+ it("throws loudly on a missing or empty fragment", () => {
67
+ expect(() =>
68
+ registerShaderPresets({ "reg.bad": { fragment: "" } }),
69
+ ).toThrow(/without a fragment shader/);
70
+ expect(() =>
71
+ registerShaderPresets({ "reg.bad2": {} as never }),
72
+ ).toThrow(/without a fragment shader/);
73
+ });
74
+
75
+ it("shallow-copies the overlay so later host mutation cannot desync the registry", () => {
76
+ const overlay = { light: "rgb(255 0 0 / 10%)", dark: "rgb(0 0 0 / 10%)" };
77
+ registerShaderPresets({ "reg.copy": { fragment: "// frag", overlay } });
78
+ overlay.light = "rgb(0 255 0 / 90%)";
79
+ expect(getShaderPreset("reg.copy")!.overlay!.light).toBe("rgb(255 0 0 / 10%)");
80
+ });
81
+ });
82
+
83
+ describe("mobile scale formula evaluation", () => {
84
+ it("evaluates an arithmetic formula with the live aspect", () => {
85
+ registerShaderPresets({
86
+ "scale.formula": {
87
+ fragment: "// frag",
88
+ render: { desktop: 1.8, mobile: "Math.min(4.0, 2.88 / aspect)" },
89
+ },
90
+ });
91
+ const render = getShaderPreset("scale.formula")!.render!;
92
+ expect(render.desktop).toBe(1.8);
93
+ // Below aspect 0.72 the raw curve (2.88 / aspect) exceeds the 4.0
94
+ // ceiling and clamps — 2.88 / 0.45 = 6.4 → 4.0.
95
+ expect(render.mobile(0.45)).toBe(4.0);
96
+ expect(render.mobile(0.8)).toBeCloseTo(2.88 / 0.8, 10);
97
+ // Wide aspects fall below the ceiling and follow the raw curve.
98
+ expect(render.mobile(2.0)).toBeCloseTo(2.88 / 2.0, 10);
99
+ });
100
+
101
+ it("evaluates plain arithmetic without function calls", () => {
102
+ registerShaderPresets({
103
+ "scale.arith": {
104
+ fragment: "// frag",
105
+ render: { desktop: 1.2, mobile: "1.0 + 0.5 * aspect" },
106
+ },
107
+ });
108
+ const render = getShaderPreset("scale.arith")!.render!;
109
+ expect(render.mobile(0.5)).toBeCloseTo(1.25, 10);
110
+ expect(render.mobile(0.72)).toBeCloseTo(1.36, 10);
111
+ });
112
+
113
+ it("falls back to the constant desktop scale when the charset is rejected", () => {
114
+ registerShaderPresets({
115
+ "scale.charset": {
116
+ fragment: "// frag",
117
+ render: { desktop: 0.9, mobile: "aspect ^ 2" },
118
+ },
119
+ });
120
+ const render = getShaderPreset("scale.charset")!.render!;
121
+ expect(render.mobile(0.3)).toBe(0.9);
122
+ expect(render.mobile(2.0)).toBe(0.9);
123
+ });
124
+
125
+ it("falls back to the constant desktop scale when the formula parses to NaN", () => {
126
+ // Passes the charset gate but the evaluator cannot parse it
127
+ // (function-call syntax) — the Number.isFinite guard carried from
128
+ // chest's registry keeps NaN out of the u_scale uniform.
129
+ registerShaderPresets({
130
+ "scale.nan": {
131
+ fragment: "// frag",
132
+ render: { desktop: 1.4, mobile: "Math.pow(aspect, 2)" },
133
+ },
134
+ });
135
+ const render = getShaderPreset("scale.nan")!.render!;
136
+ expect(render.mobile(0.5)).toBe(1.4);
137
+ expect(render.mobile(1.7)).toBe(1.4);
138
+ });
139
+
140
+ it("accepts a host closure and gates its NaN through the same finite guard", () => {
141
+ registerShaderPresets({
142
+ "scale.fn": {
143
+ fragment: "// frag",
144
+ render: {
145
+ desktop: 1.8,
146
+ mobile: (a) => (a > 0 ? 2.88 / a : Number.NaN),
147
+ },
148
+ },
149
+ });
150
+ const render = getShaderPreset("scale.fn")!.render!;
151
+ expect(render.mobile(0.5)).toBeCloseTo(5.76, 10);
152
+ expect(render.mobile(-1)).toBe(1.8);
153
+ });
154
+
155
+ it("leaves no render config when the host declares none", () => {
156
+ registerShaderPresets({ "scale.none": { fragment: "// frag" } });
157
+ expect(getShaderPreset("scale.none")!.render).toBeUndefined();
158
+ });
159
+ });