@celestia-island/hikari 0.55.65 → 0.55.67
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 +1 -1
- package/src/components/HkStepFlow.test.tsx +133 -1
- package/src/components/HkStepFlow.tsx +29 -0
- package/src/index.ts +12 -0
- package/src/theme/index.ts +13 -0
- package/src/theme/wallpaperShaderAliasGuard.test.ts +112 -0
- package/src/theme/wallpaperShaderPresets.test.ts +159 -0
- package/src/theme/wallpaperShaderPresets.ts +272 -0
- package/src/theme/wallpaperShaderRenderer.test.ts +292 -0
- package/src/theme/wallpaperShaderRenderer.ts +362 -0
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
|
2
|
+
import { createApp, defineComponent, h, nextTick, onMounted, ref } from "vue";
|
|
3
3
|
|
|
4
4
|
import HkStepFlow, { STEPFLOW_SWAP_EVENT } from "./HkStepFlow";
|
|
5
5
|
import type { StepFlowSlotProps } from "./HkStepFlow";
|
|
@@ -198,6 +198,138 @@ describe("HkStepFlow", () => {
|
|
|
198
198
|
});
|
|
199
199
|
});
|
|
200
200
|
|
|
201
|
+
describe("HkStepFlow focus handoff on swap", () => {
|
|
202
|
+
/** Mount with an input in `b`; `a` optionally autofocuses its own field
|
|
203
|
+
* the moment the entering body mounts. */
|
|
204
|
+
function mountFocusRig(options: {
|
|
205
|
+
initial: string;
|
|
206
|
+
autoFocusEntry?: boolean;
|
|
207
|
+
hideTimeline?: boolean;
|
|
208
|
+
timelineClickable?: boolean;
|
|
209
|
+
}): { container: HTMLElement; setCurrent: (key: string) => void } {
|
|
210
|
+
const container = document.createElement("div");
|
|
211
|
+
document.body.appendChild(container);
|
|
212
|
+
containers.push(container);
|
|
213
|
+
const current = ref(options.initial);
|
|
214
|
+
const AutoField = defineComponent({
|
|
215
|
+
setup() {
|
|
216
|
+
const el = ref<HTMLInputElement | null>(null);
|
|
217
|
+
onMounted(() => el.value?.focus());
|
|
218
|
+
return () => h("input", { ref: el, class: "auto-input" });
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
const Wrapper = defineComponent({
|
|
222
|
+
setup() {
|
|
223
|
+
return () =>
|
|
224
|
+
h(
|
|
225
|
+
HkStepFlow,
|
|
226
|
+
{
|
|
227
|
+
steps: STEPS,
|
|
228
|
+
modelValue: current.value,
|
|
229
|
+
hideTimeline: options.hideTimeline ?? true,
|
|
230
|
+
timelineClickable: options.timelineClickable ?? false,
|
|
231
|
+
"onUpdate:modelValue": (key: string) => { current.value = key; },
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
a: () =>
|
|
235
|
+
options.autoFocusEntry
|
|
236
|
+
? h(AutoField)
|
|
237
|
+
: h("p", { class: "step-body" }, "a-body"),
|
|
238
|
+
b: () => h("input", { class: "step-input" }),
|
|
239
|
+
c: () => h("p", { class: "step-body" }, "c-body"),
|
|
240
|
+
d: () => h("p", { class: "step-body" }, "d-body"),
|
|
241
|
+
},
|
|
242
|
+
);
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
const app = createApp(Wrapper);
|
|
246
|
+
mounts.push(app);
|
|
247
|
+
app.mount(container);
|
|
248
|
+
return { container, setCurrent: (key) => { current.value = key; } };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
it("drops focus out of the leaving body when an animated swap starts", async () => {
|
|
252
|
+
// Round-22 chest report: the leaving body keeps its DOM node — and so
|
|
253
|
+
// its focus — through the whole slide, so a focused endpoint field had
|
|
254
|
+
// Android re-anchoring its autofill suggestion panel on every frame of
|
|
255
|
+
// the height morph (the panel strobed for the length of the swap). The
|
|
256
|
+
// motion stub matters: on the instant path the node is recycled at
|
|
257
|
+
// once and the browser drops focus by itself, which would make this
|
|
258
|
+
// assertion pass for the wrong reason.
|
|
259
|
+
stubMotion("0.15s");
|
|
260
|
+
const t = mountFocusRig({ initial: "b" });
|
|
261
|
+
const input = t.container.querySelector<HTMLInputElement>(".step-input");
|
|
262
|
+
expect(input).not.toBeNull();
|
|
263
|
+
input!.focus();
|
|
264
|
+
expect(document.activeElement).toBe(input);
|
|
265
|
+
|
|
266
|
+
t.setCurrent("a");
|
|
267
|
+
await flushSwap();
|
|
268
|
+
|
|
269
|
+
// The slide is still in flight, so the leaving body — and its input —
|
|
270
|
+
// are still mounted; that is exactly the window that used to strobe.
|
|
271
|
+
expect(t.container.contains(input)).toBe(true);
|
|
272
|
+
expect(document.activeElement).not.toBe(input);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("keeps focus on the timeline step that drove the change", async () => {
|
|
276
|
+
// The timeline sits inside the flow but is NOT leaving the stage: a
|
|
277
|
+
// keyboard or pointer user who activated a completed step must keep
|
|
278
|
+
// their focus. Blurring it drops them to <body> — the next Tab
|
|
279
|
+
// restarts at the top of the document, and HkModal's Tab trap stops
|
|
280
|
+
// engaging, so focus can escape the dialog.
|
|
281
|
+
stubMotion("0.15s");
|
|
282
|
+
const t = mountFocusRig({
|
|
283
|
+
initial: "c",
|
|
284
|
+
hideTimeline: false,
|
|
285
|
+
timelineClickable: true,
|
|
286
|
+
});
|
|
287
|
+
const stepEl = t.container.querySelector<HTMLElement>(
|
|
288
|
+
".hk-timeline-step[data-clickable]",
|
|
289
|
+
);
|
|
290
|
+
expect(stepEl).not.toBeNull();
|
|
291
|
+
stepEl!.focus();
|
|
292
|
+
expect(document.activeElement).toBe(stepEl);
|
|
293
|
+
|
|
294
|
+
t.setCurrent("a");
|
|
295
|
+
await flushSwap();
|
|
296
|
+
expect(document.activeElement).toBe(stepEl);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("lets the entering step's own autofocus win over the blur", async () => {
|
|
300
|
+
// The blur runs at the top of the swap watcher, before the entering
|
|
301
|
+
// body mounts, so a step that focuses its own first field on entry
|
|
302
|
+
// still wins. Moving the call below the mount turns this red.
|
|
303
|
+
stubMotion("0.15s");
|
|
304
|
+
const t = mountFocusRig({ initial: "b", autoFocusEntry: true });
|
|
305
|
+
const leaving = t.container.querySelector<HTMLInputElement>(".step-input");
|
|
306
|
+
expect(leaving).not.toBeNull();
|
|
307
|
+
leaving!.focus();
|
|
308
|
+
expect(document.activeElement).toBe(leaving);
|
|
309
|
+
|
|
310
|
+
t.setCurrent("a");
|
|
311
|
+
await flushSwap();
|
|
312
|
+
|
|
313
|
+
const entered = t.container.querySelector<HTMLInputElement>(".auto-input");
|
|
314
|
+
expect(entered).not.toBeNull();
|
|
315
|
+
expect(document.activeElement).toBe(entered);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("leaves focus outside the flow alone", async () => {
|
|
319
|
+
const t = mountFocusRig({ initial: "a" });
|
|
320
|
+
const outside = document.createElement("input");
|
|
321
|
+
document.body.appendChild(outside);
|
|
322
|
+
containers.push(outside);
|
|
323
|
+
|
|
324
|
+
outside.focus();
|
|
325
|
+
expect(document.activeElement).toBe(outside);
|
|
326
|
+
t.setCurrent("b");
|
|
327
|
+
await flushSwap();
|
|
328
|
+
// Containment, not a blanket blur.
|
|
329
|
+
expect(document.activeElement).toBe(outside);
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
201
333
|
describe("HkStepFlow split-window swap (motion enabled)", () => {
|
|
202
334
|
it("slides both bodies across one window, then morphs the sheet after", async () => {
|
|
203
335
|
stubMotion();
|
|
@@ -287,10 +287,39 @@ export default defineComponent({
|
|
|
287
287
|
// Sticky-header whitespace strategy (2026-09-14): see HkScrollPin.
|
|
288
288
|
const pinStrategy = ref<"offset" | "bleed">("bleed");
|
|
289
289
|
|
|
290
|
+
/** Drop focus out of the step bodies the moment a swap starts.
|
|
291
|
+
*
|
|
292
|
+
* The leaving body keeps its DOM node — and therefore its focus —
|
|
293
|
+
* for the whole slide window, and its input rides the cell while
|
|
294
|
+
* the sheet morphs its height. Android re-anchors an open autofill
|
|
295
|
+
* suggestion panel on every layout change, so a focused endpoint
|
|
296
|
+
* field made the system's autofill sheet strobe at roughly the
|
|
297
|
+
* frame rate for the entire animation (round-22 chest report: the
|
|
298
|
+
* panel toggled over ~94% of the sheet body for ~250ms while the
|
|
299
|
+
* step changed). Blurring before the first animated frame dismisses
|
|
300
|
+
* the panel outright; the field the user was typing in is leaving
|
|
301
|
+
* the stage anyway, so nothing is taken from them.
|
|
302
|
+
*
|
|
303
|
+
* Containment is the BODIES, not the flow: HkTimeline renders the
|
|
304
|
+
* completed steps as `role="button" tabindex="0"` inside this same
|
|
305
|
+
* element, and a timeline step that drove the change is not leaving
|
|
306
|
+
* the stage. Blurring it would drop a keyboard or pointer user to
|
|
307
|
+
* `<body>` — Tab restarts at the top of the document, and HkModal's
|
|
308
|
+
* Tab trap stops engaging so focus can escape the dialog. */
|
|
309
|
+
function dropFlowFocus(): void {
|
|
310
|
+
if (typeof document === "undefined") return; // SSR guard
|
|
311
|
+
const active = document.activeElement;
|
|
312
|
+
if (!(active instanceof HTMLElement)) return;
|
|
313
|
+
const body = active.closest(".hk-stepflow-body");
|
|
314
|
+
if (!body || !flowRef.value?.contains(body)) return;
|
|
315
|
+
active.blur();
|
|
316
|
+
}
|
|
317
|
+
|
|
290
318
|
watch(
|
|
291
319
|
() => props.modelValue,
|
|
292
320
|
async (next, prev) => {
|
|
293
321
|
if (next === prev) return;
|
|
322
|
+
dropFlowFocus();
|
|
294
323
|
preemptSwap();
|
|
295
324
|
const leaving = bodies.value.find((b) => b.phase === "active");
|
|
296
325
|
if (!leaving) return;
|
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
|
package/src/theme/index.ts
CHANGED
|
@@ -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
|
+
});
|