@celestia-island/hikari 0.55.65 → 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.65",
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",
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
+ });
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Wallpaper shader preset registry — the HOST-facing half of the pipeline
3
+ * wallpaper layer. A host registers its fragment shaders (plus optional
4
+ * texture / scale / overlay config) under runtime preset ids; the shared
5
+ * renderer (`wallpaperShaderRenderer.ts`) and the wallpaper logic layer
6
+ * (via `WallpaperPipelineLookup`) resolve those ids through this registry.
7
+ *
8
+ * Why a registry and not an import: hikari ships TS source with zero path
9
+ * aliases, and shader sources are application assets that live in each
10
+ * host's generated bundle (chest's `@shaders/shaders`, erp's
11
+ * `.generated/shaders`) — an alias import here would force every consumer
12
+ * to configure the same vite/tsconfig mapping. The same design language as
13
+ * `registerTokenGroup` / `registerThemeDecor`: hikari owns the mechanism,
14
+ * the host owns the content, and the built-in default is an EMPTY table —
15
+ * hikari deliberately bundles no GLSL of its own.
16
+ *
17
+ * Hard rules this module holds:
18
+ *
19
+ * - **Zero alias imports.** Nothing here may reference `@shaders/*`,
20
+ * `@wallpapers/*` or any other consumer-side alias (guarded by
21
+ * `wallpaperShaderAliasGuard.test.ts`).
22
+ * - **Registration is not a side effect of importing.** A host that never
23
+ * calls `registerShaderPresets` gets an untouched (empty) registry and
24
+ * every pipeline lookup degrades to `null` — the "no pipeline
25
+ * wallpapers" state the backdrop renders as the solid floor.
26
+ * - **Invalid input fails loudly** (themeDecor's rule): an empty preset
27
+ * id or an empty fragment throws at registration time. The silent
28
+ * alternative is a wallpaper that renders nothing with no error
29
+ * anywhere.
30
+ * - **Re-registration overrides.** Registering the same id again
31
+ * replaces the previous entry — the escape hatch for hosts that swap
32
+ * preset tables at runtime (a fresh codegen, an A/B experiment).
33
+ */
34
+
35
+ /** Normalized render-scale config: flat on landscape, a curve on portrait. */
36
+ export interface WallpaperShaderScaleConfig {
37
+ /** Uniform value at aspect >= 1.0. */
38
+ desktop: number;
39
+ /** Uniform value below aspect 1.0, given the live canvas aspect. */
40
+ mobile: (aspect: number) => number;
41
+ }
42
+
43
+ /** Host input for `render`: the mobile scale as a safe-evaluated formula
44
+ * string (the generated theme manifests carry `"Math.min(4.0, 2.88 /
45
+ * aspect)"`-style expressions) or a ready-made closure. */
46
+ export interface WallpaperShaderScaleInput {
47
+ desktop: number;
48
+ mobile: string | ((aspect: number) => number);
49
+ }
50
+
51
+ /** Scrim colors the host's page CSS mixes over the canvas, per mode. */
52
+ export interface WallpaperShaderOverlayConfig {
53
+ light: string;
54
+ dark: string;
55
+ }
56
+
57
+ /** What a host hands to `registerShaderPresets` — the record KEY is the
58
+ * preset id (`omphalos.dark`, `single`, …); no `id` field to drift. */
59
+ export interface WallpaperShaderPresetInput {
60
+ /** Fragment-shader source (GLSL ES 3.00). Must be non-empty. */
61
+ fragment: string;
62
+ /** Optional texture URL (data: or asset import) bound to `u_texture`. */
63
+ texture?: string;
64
+ /** Optional scale config (see `WallpaperShaderScaleInput`). */
65
+ render?: WallpaperShaderScaleInput;
66
+ /** Optional overlay colors for the host's scrim. */
67
+ overlay?: WallpaperShaderOverlayConfig;
68
+ }
69
+
70
+ /** The stored preset `getShaderPreset` resolves to — the input with `id`
71
+ * filled from the registration key and `render` normalized to a
72
+ * `WallpaperShaderScaleConfig`. */
73
+ export interface WallpaperShaderPreset {
74
+ id: string;
75
+ fragment: string;
76
+ texture?: string;
77
+ render?: WallpaperShaderScaleConfig;
78
+ overlay?: WallpaperShaderOverlayConfig;
79
+ }
80
+
81
+ // The registry itself. A Map (not a plain object) so preset ids cannot
82
+ // collide with Object.prototype members ("constructor", "toString", …) —
83
+ // a generated manifest containing such an id would otherwise shadow a
84
+ // prototype member and misbehave in surprising ways.
85
+ const shaderPresets = new Map<string, WallpaperShaderPreset>();
86
+
87
+ /**
88
+ * Safe arithmetic evaluator for mobile-scale formula strings — a
89
+ * hand-rolled recursive-descent parser, NOT `eval`/`Function`: generated
90
+ * theme manifests are build inputs, and feeding those to `eval` would
91
+ * make one poisoned codegen artifact a code-execution vector.
92
+ *
93
+ * Supported grammar: `+ - * /`, parentheses, unary minus, number
94
+ * literals, the registered variable names (the live aspect), and exactly
95
+ * `Math.min(a, b)` / `Math.max(a, b)` as the only function calls.
96
+ */
97
+ function safeEvalArithmetic(
98
+ expr: string,
99
+ vars: Record<string, number>,
100
+ ): number {
101
+ const s = expr.trim();
102
+ let pos = 0;
103
+
104
+ function skip() {
105
+ while (pos < s.length && s[pos] === " ") pos++;
106
+ }
107
+
108
+ function parseAtom(): number {
109
+ skip();
110
+ if (s[pos] === "(") {
111
+ pos++;
112
+ const v = parseExpr();
113
+ skip();
114
+ pos++;
115
+ return v;
116
+ }
117
+ for (const name of ["Math.min", "Math.max"] as const) {
118
+ if (s.slice(pos, pos + name.length) === name) {
119
+ pos += name.length;
120
+ skip();
121
+ pos++;
122
+ const a = parseExpr();
123
+ skip();
124
+ pos++;
125
+ const b = parseExpr();
126
+ skip();
127
+ pos++;
128
+ return name === "Math.min" ? Math.min(a, b) : Math.max(a, b);
129
+ }
130
+ }
131
+ for (const [name, val] of Object.entries(vars)) {
132
+ if (
133
+ s.slice(pos, pos + name.length) === name &&
134
+ !/[a-zA-Z0-9_]/.test(s[pos + name.length] ?? "")
135
+ ) {
136
+ pos += name.length;
137
+ return val;
138
+ }
139
+ }
140
+ const start = pos;
141
+ while (pos < s.length && /[\d.]/.test(s[pos])) pos++;
142
+ return parseFloat(s.slice(start, pos));
143
+ }
144
+
145
+ function parseUnary(): number {
146
+ skip();
147
+ if (s[pos] === "-") {
148
+ pos++;
149
+ return -parseUnary();
150
+ }
151
+ return parseAtom();
152
+ }
153
+
154
+ function parseMul(): number {
155
+ let left = parseUnary();
156
+ skip();
157
+ while (pos < s.length && (s[pos] === "*" || s[pos] === "/")) {
158
+ const op = s[pos++];
159
+ const right = parseUnary();
160
+ left = op === "*" ? left * right : left / right;
161
+ skip();
162
+ }
163
+ return left;
164
+ }
165
+
166
+ function parseExpr(): number {
167
+ let left = parseMul();
168
+ skip();
169
+ while (pos < s.length && (s[pos] === "+" || s[pos] === "-")) {
170
+ const op = s[pos++];
171
+ const right = parseMul();
172
+ left = op === "+" ? left + right : left - right;
173
+ skip();
174
+ }
175
+ return left;
176
+ }
177
+
178
+ return parseExpr();
179
+ }
180
+
181
+ /** The sanctioned charset for a formula string — reject anything outside
182
+ * arithmetic, parentheses, whitespace and identifier letters up front. */
183
+ const FORMULA_CHARSET = /^[\d\s+\-*/().,a-zA-Z]+$/;
184
+
185
+ /**
186
+ * Normalize a host's `render` input into the stored scale config.
187
+ *
188
+ * Two fallback tiers, both degrading to the constant desktop scale (never
189
+ * to NaN — a NaN `u_scale` poisons the vertex math and the canvas goes
190
+ * black):
191
+ *
192
+ * 1. a formula string with characters outside the arithmetic charset is
193
+ * rejected unread;
194
+ * 2. a formula that passes the charset gate but still fails to parse
195
+ * (`Math.pow(aspect, 2)`) evaluates to NaN and is dropped at call
196
+ * time (the `Number.isFinite` guard carried from chest's registry).
197
+ */
198
+ function normalizeScaleConfig(cfg: WallpaperShaderScaleInput): WallpaperShaderScaleConfig {
199
+ if (typeof cfg.mobile === "function") {
200
+ // A host-provided closure needs no evaluation — but its output still
201
+ // must not leak NaN into the uniform, so it gets the same finite gate.
202
+ const mobileFn = cfg.mobile;
203
+ return {
204
+ desktop: cfg.desktop,
205
+ mobile: (a) => {
206
+ const v = mobileFn(a);
207
+ return Number.isFinite(v) ? v : cfg.desktop;
208
+ },
209
+ };
210
+ }
211
+ const mobileSrc = cfg.mobile.trim();
212
+ if (!FORMULA_CHARSET.test(mobileSrc)) {
213
+ return { desktop: cfg.desktop, mobile: () => cfg.desktop };
214
+ }
215
+ return {
216
+ desktop: cfg.desktop,
217
+ mobile: (a) => {
218
+ const v = safeEvalArithmetic(mobileSrc, { aspect: a });
219
+ return Number.isFinite(v) ? v : cfg.desktop;
220
+ },
221
+ };
222
+ }
223
+
224
+ function assertPresetInput(id: string, input: WallpaperShaderPresetInput): void {
225
+ if (typeof id !== "string" || id.length === 0) {
226
+ throw new Error(`[wallpaperShaderPresets] preset registered with an empty id`);
227
+ }
228
+ if (!input || typeof input.fragment !== "string" || input.fragment.length === 0) {
229
+ throw new Error(
230
+ `[wallpaperShaderPresets] preset "${id}" registered without a fragment shader`,
231
+ );
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Register (or override) wallpaper shader presets. The record key is the
237
+ * runtime preset id — dual-mode themes register two entries
238
+ * (`"omphalos.dark"`, `"omphalos.light"`); the wallpaper logic layer
239
+ * resolves the mode suffix itself before looking up.
240
+ *
241
+ * Must run before the wallpaper state hydrates for the stored ids to
242
+ * resolve (host boot order: register → `initWallpaper`), same as
243
+ * `registerWallpaperPack`.
244
+ */
245
+ export function registerShaderPresets(presets: Record<string, WallpaperShaderPresetInput>): void {
246
+ for (const [id, input] of Object.entries(presets ?? {})) {
247
+ assertPresetInput(id, input);
248
+ shaderPresets.set(id, {
249
+ id,
250
+ fragment: input.fragment,
251
+ ...(input.texture !== undefined && input.texture !== null
252
+ ? { texture: input.texture }
253
+ : {}),
254
+ ...(input.render ? { render: normalizeScaleConfig(input.render) } : {}),
255
+ // Shallow-copied so later host mutation cannot desync the registry
256
+ // (themeDecor holds the same line for its props bag).
257
+ ...(input.overlay ? { overlay: { ...input.overlay } } : {}),
258
+ });
259
+ }
260
+ }
261
+
262
+ /** Resolve a preset id, or `null` when nothing is registered under it —
263
+ * the value the backdrop's pipeline branch stands down on. */
264
+ export function getShaderPreset(id: string): WallpaperShaderPreset | null {
265
+ return shaderPresets.get(id) ?? null;
266
+ }
267
+
268
+ /** Every registered preset id, in registration order — introspection for
269
+ * hosts and tests; mutation goes through `registerShaderPresets`. */
270
+ export function listShaderPresetIds(): string[] {
271
+ return [...shaderPresets.keys()];
272
+ }
@@ -0,0 +1,292 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import type { HkWallpaperSurfaceContext } from "../components/HkWallpaperBackdrop";
4
+ import { registerShaderPresets } from "./wallpaperShaderPresets";
5
+ import {
6
+ WallpaperShaderPipeline,
7
+ createWallpaperShaderSurface,
8
+ } from "./wallpaperShaderRenderer";
9
+
10
+ // WallpaperShaderPipeline.attach() compiles real GLSL through WebGL2;
11
+ // happy-dom has no WebGL at all, so the GPU side is stubbed at its seam —
12
+ // the preset registry — and only the pure selection logic runs against
13
+ // real numbers. (The WebGL2-unavailable branch itself lives in
14
+ // HkWallpaperBackdrop, which owns the context probe now.)
15
+ registerShaderPresets({
16
+ configured: {
17
+ fragment: "// frag",
18
+ render: { desktop: 2.5, mobile: (a) => 10 * a },
19
+ },
20
+ bare: { fragment: "// frag" },
21
+ });
22
+
23
+ /** Private members the render loop reads; reached structurally, no `any`. */
24
+ type PipelineInternals = {
25
+ canvas: HTMLCanvasElement | null;
26
+ currentPreset: string;
27
+ computeScale(): number;
28
+ };
29
+
30
+ const internals = (p: WallpaperShaderPipeline): PipelineInternals =>
31
+ p as unknown as PipelineInternals;
32
+
33
+ /** Bind a preset + canvas size, then read the scale the shader would get. */
34
+ function scaleFor(presetId: string, width: number, height: number): number {
35
+ const p = new WallpaperShaderPipeline();
36
+ const i = internals(p);
37
+ i.currentPreset = presetId;
38
+ const canvas = document.createElement("canvas");
39
+ canvas.width = width;
40
+ canvas.height = height;
41
+ i.canvas = canvas;
42
+ return i.computeScale();
43
+ }
44
+
45
+ /** Minimal gl stub for the failure paths: clearColor no-ops, the first
46
+ * createShader answers null so createProgram bails before any real GL. */
47
+ const failingGl = () =>
48
+ ({ clearColor: () => {}, createShader: () => null }) as unknown as WebGL2RenderingContext;
49
+
50
+ const ctx = (overrides: Partial<HkWallpaperSurfaceContext> = {}): HkWallpaperSurfaceContext => ({
51
+ canvas: document.createElement("canvas"),
52
+ gl: failingGl(),
53
+ presetId: "configured",
54
+ period: "day",
55
+ powerPreference: "high-performance",
56
+ ...overrides,
57
+ });
58
+
59
+ describe("createWallpaperShaderSurface (HkWallpaperBackdrop driver)", () => {
60
+ /** A canvas attached to the document, as the component hands it over —
61
+ * the driver must never remove it (the component owns the DOM). */
62
+ const ownedCanvas = (): HTMLCanvasElement => {
63
+ const canvas = document.createElement("canvas");
64
+ document.body.append(canvas);
65
+ return canvas;
66
+ };
67
+
68
+ it("declines (null) on an unknown preset without touching the canvas", () => {
69
+ const canvas = ownedCanvas();
70
+ expect(createWallpaperShaderSurface(ctx({ presetId: "unknown", canvas }))).toBeNull();
71
+ expect(canvas.isConnected).toBe(true);
72
+ });
73
+
74
+ it("declines (null) when the program cannot build, leaving the canvas in place", () => {
75
+ const canvas = ownedCanvas();
76
+ expect(createWallpaperShaderSurface(ctx({ canvas }))).toBeNull();
77
+ expect(canvas.isConnected).toBe(true);
78
+ });
79
+
80
+ it("delegates setPeriod/dispose to one pipeline per surface", async () => {
81
+ const attachSpy = vi
82
+ .spyOn(WallpaperShaderPipeline.prototype, "attach")
83
+ .mockReturnValue(true);
84
+ const setPeriodSpy = vi.spyOn(WallpaperShaderPipeline.prototype, "setPeriod").mockImplementation(() => {});
85
+ const destroySpy = vi.spyOn(WallpaperShaderPipeline.prototype, "destroy").mockImplementation(() => {});
86
+
87
+ const surface = createWallpaperShaderSurface(ctx());
88
+ expect(surface).not.toBeNull();
89
+ expect(attachSpy).toHaveBeenCalledTimes(1);
90
+
91
+ surface!.setPeriod?.("night");
92
+ expect(setPeriodSpy).toHaveBeenCalledWith("night");
93
+
94
+ surface!.dispose();
95
+ expect(destroySpy).toHaveBeenCalledTimes(1);
96
+
97
+ attachSpy.mockRestore();
98
+ setPeriodSpy.mockRestore();
99
+ destroySpy.mockRestore();
100
+ });
101
+ });
102
+
103
+ describe("attach failure modes", () => {
104
+ it("rolls back to idle on failure", () => {
105
+ const p = new WallpaperShaderPipeline();
106
+ expect(p.attach(ctx().canvas, failingGl(), "configured", "night")).toBe(false);
107
+ expect(p.isActive).toBe(false);
108
+ expect(p.presetId).toBe("");
109
+ });
110
+ });
111
+
112
+ describe("WallpaperShaderPipeline.computeScale (render-config selection)", () => {
113
+ it("routes portrait aspects through the mobile curve with the real ratio", () => {
114
+ // 1080×2400 phone → aspect 0.45; the stub mobile curve (10 × aspect)
115
+ // echoes the exact ratio the pipeline derived from the canvas.
116
+ expect(scaleFor("configured", 1080, 2400)).toBeCloseTo(4.5, 10);
117
+ // 999×1000 → aspect 0.999: still strictly below 1.0, still mobile.
118
+ expect(scaleFor("configured", 999, 1000)).toBeCloseTo(9.99, 10);
119
+ });
120
+
121
+ it("uses the flat desktop scale at aspect 1.0 and above", () => {
122
+ // Exactly square is desktop (the mobile branch is aspect < 1.0 only).
123
+ expect(scaleFor("configured", 1000, 1000)).toBe(2.5);
124
+ // 16:9 and 16:9-at-4K landscape monitors.
125
+ expect(scaleFor("configured", 1920, 1080)).toBe(2.5);
126
+ expect(scaleFor("configured", 3840, 2160)).toBe(2.5);
127
+ });
128
+
129
+ it("falls back to 1.0 without a canvas, a render config, or a preset", () => {
130
+ const p = new WallpaperShaderPipeline();
131
+ const i = internals(p);
132
+ i.currentPreset = "configured";
133
+ i.canvas = null; // never attached / already destroyed
134
+ expect(i.computeScale()).toBe(1.0);
135
+ // A preset without a render config keeps the neutral scale.
136
+ expect(scaleFor("bare", 1080, 2400)).toBe(1.0);
137
+ // An unknown preset id likewise.
138
+ expect(scaleFor("unknown", 1920, 1080)).toBe(1.0);
139
+ });
140
+ });
141
+
142
+ // ── Resize policy (2026-09-21 phone-IME flicker mitigation) ──────────
143
+ //
144
+ // The drawing buffer sizes against the LAYOUT viewport
145
+ // (documentElement client size) and reallocs only after the viewport
146
+ // settles (RESIZE_SETTLE_MS debounce): keyboard animations step the
147
+ // visual viewport many times without moving the canvas' CSS box
148
+ // (fixed, 100% of the ICB), and each intermediate realloc discarded the
149
+ // GPU texture → black flash. These tests pin both halves.
150
+ type PipelineResizeInternals = {
151
+ canvas: HTMLCanvasElement | null;
152
+ gl: WebGL2RenderingContext | null;
153
+ onResize: () => void;
154
+ };
155
+
156
+ describe("WallpaperShaderPipeline resize policy", () => {
157
+ beforeEach(() => {
158
+ vi.useFakeTimers();
159
+ });
160
+
161
+ const restoreFns: Array<() => void> = [];
162
+
163
+ /** Override a getter with a fixed value; restored in afterEach. */
164
+ function stubGetter(obj: object, prop: string, value: number): void {
165
+ const desc = Object.getOwnPropertyDescriptor(obj, prop);
166
+ Object.defineProperty(obj, prop, {
167
+ configurable: true,
168
+ get: () => value,
169
+ });
170
+ restoreFns.push(() => {
171
+ if (desc) Object.defineProperty(obj, prop, desc);
172
+ else delete (obj as Record<string, unknown>)[prop];
173
+ });
174
+ }
175
+
176
+ function stubViewport(opts: {
177
+ innerWidth?: number;
178
+ innerHeight?: number;
179
+ icbWidth: number;
180
+ icbHeight: number;
181
+ dpr?: number;
182
+ }): void {
183
+ if (opts.innerWidth !== undefined) stubGetter(window, "innerWidth", opts.innerWidth);
184
+ if (opts.innerHeight !== undefined) stubGetter(window, "innerHeight", opts.innerHeight);
185
+ if (opts.dpr !== undefined) stubGetter(window, "devicePixelRatio", opts.dpr);
186
+ stubGetter(document.documentElement, "clientWidth", opts.icbWidth);
187
+ stubGetter(document.documentElement, "clientHeight", opts.icbHeight);
188
+ }
189
+
190
+ /** Bind a canvas + truthy gl onto the pipeline (resize gates on both). */
191
+ function bind(): { p: PipelineResizeInternals; canvas: HTMLCanvasElement } {
192
+ const p = new WallpaperShaderPipeline() as unknown as PipelineResizeInternals;
193
+ const canvas = document.createElement("canvas");
194
+ p.canvas = canvas;
195
+ p.gl = {} as WebGL2RenderingContext;
196
+ return { p, canvas };
197
+ }
198
+
199
+ /** Count writes to the canvas backing-store width (realloc proxy). */
200
+ function countWidthWrites(canvas: HTMLCanvasElement): () => number {
201
+ const desc = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, "width")!;
202
+ let n = 0;
203
+ Object.defineProperty(canvas, "width", {
204
+ configurable: true,
205
+ get: () => desc.get!.call(canvas) as number,
206
+ set: (v: number) => {
207
+ n += 1;
208
+ desc.set!.call(canvas, v);
209
+ },
210
+ });
211
+ restoreFns.push(() => delete (canvas as unknown as Record<string, unknown>).width);
212
+ return () => n;
213
+ }
214
+
215
+ afterEach(() => {
216
+ vi.useRealTimers();
217
+ while (restoreFns.length > 0) restoreFns.pop()!();
218
+ });
219
+
220
+ it("sizes the buffer from the layout viewport, not the visual one", () => {
221
+ // Keyboard open (resizes-visual engines): window.innerHeight
222
+ // shrinks to 400 while the ICB stays 390×844.
223
+ stubViewport({ innerWidth: 390, innerHeight: 400, icbWidth: 390, icbHeight: 844, dpr: 2 });
224
+ const { p, canvas } = bind();
225
+ // Buffer already at the ICB-derived size (dpr 2, scale 0.5 → 1:1).
226
+ canvas.width = 390;
227
+ canvas.height = 844;
228
+ const writes = countWidthWrites(canvas);
229
+
230
+ p.onResize();
231
+ vi.advanceTimersByTime(1000);
232
+
233
+ // The CSS box never moved → no realloc, no GPU-texture discard.
234
+ expect(writes()).toBe(0);
235
+ expect(canvas.width).toBe(390);
236
+ expect(canvas.height).toBe(844);
237
+ });
238
+
239
+ it("reallocs once, after the settle window, on a real geometry change", () => {
240
+ stubViewport({ icbWidth: 390, icbHeight: 700, dpr: 2 });
241
+ const { p, canvas } = bind();
242
+ canvas.width = 390;
243
+ canvas.height = 844;
244
+ const writes = countWidthWrites(canvas);
245
+
246
+ p.onResize();
247
+ expect(writes()).toBe(0); // nothing before the settle window
248
+ vi.advanceTimersByTime(140);
249
+ expect(writes()).toBe(1);
250
+ expect(canvas.width).toBe(390);
251
+ expect(canvas.height).toBe(700);
252
+ });
253
+
254
+ it("coalesces a resize storm into exactly one realloc", () => {
255
+ // Five stepped viewport sizes (keyboard/drag animation) inside the
256
+ // settle window must end in ONE final realloc, not five.
257
+ const { p, canvas } = bind();
258
+ canvas.width = 390;
259
+ canvas.height = 844;
260
+ const writes = countWidthWrites(canvas);
261
+
262
+ const heights = [820, 800, 780, 760, 700];
263
+ for (const h of heights) {
264
+ stubViewport({ icbWidth: 390, icbHeight: h, dpr: 2 });
265
+ p.onResize();
266
+ vi.advanceTimersByTime(50); // below the settle window every time
267
+ }
268
+ expect(writes()).toBe(0);
269
+ vi.advanceTimersByTime(140);
270
+ expect(writes()).toBe(1);
271
+ expect(canvas.height).toBe(700);
272
+ });
273
+
274
+ it("cancels a pending realloc when the surface is disposed", () => {
275
+ stubViewport({ icbWidth: 390, icbHeight: 700, dpr: 2 });
276
+ const { p, canvas } = bind();
277
+ canvas.width = 390;
278
+ canvas.height = 844;
279
+ const writes = countWidthWrites(canvas);
280
+
281
+ p.onResize();
282
+ expect(vi.getTimerCount()).toBe(1); // the settle timer is armed
283
+ (p as unknown as WallpaperShaderPipeline).destroy();
284
+ // destroy must CLEAR the timer, not merely orphan it: a live timer
285
+ // that no-ops against a nulled canvas still holds the event loop
286
+ // hostage in embedders and reads as "cancelled" to a canvas-write
287
+ // probe — the timer count is the load-bearing probe.
288
+ expect(vi.getTimerCount()).toBe(0);
289
+ vi.advanceTimersByTime(1000);
290
+ expect(writes()).toBe(0);
291
+ });
292
+ });
@@ -0,0 +1,362 @@
1
+ import { onFrame, type AnimationHandle } from "../runtime/animationBus";
2
+ import type {
3
+ HkWallpaperSurface,
4
+ HkWallpaperSurfaceContext,
5
+ HkWallpaperSurfaceFactory,
6
+ } from "../components/HkWallpaperBackdrop";
7
+ import type { TimePeriod } from "./useSolarTime";
8
+ import { getShaderPreset } from "./wallpaperShaderPresets";
9
+
10
+ /**
11
+ * The shared WebGL2 pipeline renderer for wallpaper backdrops — the
12
+ * attach()-form driver `HkWallpaperBackdrop` hands a canvas to. Ported
13
+ * from the per-app copies chest (#1123) and erp (#123) carried after the
14
+ * component-layer adoption; those two repos kept identical 280-line
15
+ * renderers, which is exactly the duplication this module retires.
16
+ *
17
+ * The shape is the surface contract, not the pre-component singleton:
18
+ *
19
+ * - the COMPONENT owns the canvas and the GL context (it created the
20
+ * context with the `powerPreference` it was configured with — that
21
+ * attribute was consumed at `getContext` time and is therefore not
22
+ * something this driver passes again; it rides
23
+ * `HkWallpaperSurfaceContext` for factories that create their own
24
+ * context);
25
+ * - ONE driver instance per attach — the module-level singleton both
26
+ * forks once kept was the two-layouts-share-one-canvas bug the
27
+ * component layer was built to kill (either layout's teardown
28
+ * destroyed the other's pipeline);
29
+ * - `attach()` never writes the DOM and never removes the canvas on
30
+ * failure — it returns `false` fully rolled back, and the component
31
+ * stands down to the solid floor.
32
+ *
33
+ * Uniform contract (what a registered fragment shader may rely on):
34
+ * `u_time` (seconds since attach), `u_resolution` (drawing-buffer px),
35
+ * `u_scale` (see `WallpaperShaderScaleConfig`), `u_period` (0 day,
36
+ * 1 dusk, 2 night) and `u_texture` (sampler2D, unit 0 — bound only when
37
+ * the preset declares a texture). Attributes: `a_position` (fullscreen
38
+ * quad, triangle strip).
39
+ */
40
+
41
+ /** The fixed vertex stage every preset fragment pairs with. */
42
+ export const SHADER_VERTEX = `#version 300 es
43
+ in vec2 a_position;
44
+ out vec2 v_position;
45
+ void main() {
46
+ gl_Position = vec4(a_position, 0.0, 1.0);
47
+ v_position = a_position;
48
+ }`;
49
+
50
+ /** Viewport-resize settle window (ms) before the drawing buffer is
51
+ * reallocated — see WallpaperShaderPipeline.onResize. */
52
+ const RESIZE_SETTLE_MS = 140;
53
+
54
+ function compileShader(
55
+ gl: WebGL2RenderingContext,
56
+ type: number,
57
+ source: string,
58
+ ): WebGLShader | null {
59
+ const shader = gl.createShader(type);
60
+ if (!shader) return null;
61
+ gl.shaderSource(shader, source);
62
+ gl.compileShader(shader);
63
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
64
+ gl.deleteShader(shader);
65
+ return null;
66
+ }
67
+ return shader;
68
+ }
69
+
70
+ function createProgram(
71
+ gl: WebGL2RenderingContext,
72
+ vertSrc: string,
73
+ fragSrc: string,
74
+ ): WebGLProgram | null {
75
+ const vs = compileShader(gl, gl.VERTEX_SHADER, vertSrc);
76
+ const fs = compileShader(gl, gl.FRAGMENT_SHADER, fragSrc);
77
+ if (!vs || !fs) return null;
78
+ const prog = gl.createProgram();
79
+ if (!prog) return null;
80
+ gl.attachShader(prog, vs);
81
+ gl.attachShader(prog, fs);
82
+ gl.linkProgram(prog);
83
+ if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
84
+ gl.deleteProgram(prog);
85
+ return null;
86
+ }
87
+ return prog;
88
+ }
89
+
90
+ /**
91
+ * One pipeline lifetime: one canvas attach, one program, one frame loop.
92
+ * Hosts normally reach this through {@link createWallpaperShaderSurface};
93
+ * the class is exported for hosts that need to drive the lifecycle
94
+ * themselves (embedding the canvas outside the backdrop component).
95
+ */
96
+ export class WallpaperShaderPipeline {
97
+ private gl: WebGL2RenderingContext | null = null;
98
+ private program: WebGLProgram | null = null;
99
+ private vao: WebGLVertexArrayObject | null = null;
100
+ private startTime: number = 0;
101
+ private uTime: WebGLUniformLocation | null = null;
102
+ private uResolution: WebGLUniformLocation | null = null;
103
+ private uScale: WebGLUniformLocation | null = null;
104
+ private uPeriod: WebGLUniformLocation | null = null;
105
+ private uTexture: WebGLUniformLocation | null = null;
106
+ private canvas: HTMLCanvasElement | null = null;
107
+ private currentPreset: string = "";
108
+ private currentPeriod: TimePeriod = "day";
109
+ private handle: AnimationHandle | null = null;
110
+ private resizeTimer: ReturnType<typeof setTimeout> | null = null;
111
+ private texture: WebGLTexture | null = null;
112
+ private pendingTexImg: HTMLImageElement | null = null;
113
+
114
+ /**
115
+ * Attach to a component-owned canvas + live WebGL2 context (the
116
+ * HkWallpaperBackdrop surface contract: the component owns the CSS box
117
+ * and the context, the driver owns the drawing buffer and the pixels).
118
+ * No DOM writes, no canvas removal on failure — the caller's canvas
119
+ * stays exactly where it was. Returns false (fully rolled back) when
120
+ * the preset is unknown or the program cannot build.
121
+ */
122
+ attach(
123
+ canvas: HTMLCanvasElement,
124
+ gl: WebGL2RenderingContext,
125
+ presetId: string,
126
+ period: TimePeriod,
127
+ ): boolean {
128
+ this.destroy();
129
+
130
+ const preset = getShaderPreset(presetId);
131
+ if (!preset) return false;
132
+
133
+ this.currentPreset = presetId;
134
+ this.currentPeriod = period;
135
+
136
+ this.canvas = canvas;
137
+ this.gl = gl;
138
+ gl.clearColor(0, 0, 0, 0);
139
+
140
+ const prog = createProgram(gl, SHADER_VERTEX, preset.fragment);
141
+ if (!prog) {
142
+ this.gl = null;
143
+ this.canvas = null;
144
+ this.currentPreset = "";
145
+ return false;
146
+ }
147
+ this.program = prog;
148
+
149
+ gl.useProgram(prog);
150
+
151
+ this.uTime = gl.getUniformLocation(prog, "u_time");
152
+ this.uResolution = gl.getUniformLocation(prog, "u_resolution");
153
+ this.uScale = gl.getUniformLocation(prog, "u_scale");
154
+ this.uPeriod = gl.getUniformLocation(prog, "u_period");
155
+ this.uTexture = gl.getUniformLocation(prog, "u_texture");
156
+
157
+ if (preset.texture) {
158
+ this.loadTexture(preset.texture);
159
+ }
160
+
161
+ const vao = gl.createVertexArray();
162
+ this.vao = vao;
163
+ gl.bindVertexArray(vao);
164
+
165
+ const buf = gl.createBuffer();
166
+ gl.bindBuffer(gl.ARRAY_BUFFER, buf);
167
+ gl.bufferData(
168
+ gl.ARRAY_BUFFER,
169
+ new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
170
+ gl.STATIC_DRAW,
171
+ );
172
+
173
+ const aPos = gl.getAttribLocation(prog, "a_position");
174
+ gl.enableVertexAttribArray(aPos);
175
+ gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
176
+
177
+ this.startTime = performance.now();
178
+ this.resize();
179
+
180
+ this.handle = onFrame((ctx) => this.render(ctx), "normal");
181
+
182
+ window.addEventListener("resize", this.onResize);
183
+ return true;
184
+ }
185
+
186
+ setPeriod(period: TimePeriod) {
187
+ this.currentPeriod = period;
188
+ }
189
+
190
+ private render(ctx: { now: number }) {
191
+ const gl = this.gl;
192
+ if (!gl || !this.canvas) return;
193
+
194
+ const elapsed = (ctx.now - this.startTime) / 1000;
195
+ gl.viewport(0, 0, this.canvas.width, this.canvas.height);
196
+ gl.clear(gl.COLOR_BUFFER_BIT);
197
+ gl.uniform1f(this.uTime, elapsed);
198
+ gl.uniform2f(this.uResolution, this.canvas.width, this.canvas.height);
199
+ if (this.uScale) gl.uniform1f(this.uScale, this.computeScale());
200
+
201
+ let periodVal = 2.0;
202
+ if (this.currentPeriod === "day") periodVal = 0.0;
203
+ else if (this.currentPeriod === "dusk") periodVal = 1.0;
204
+ gl.uniform1f(this.uPeriod, periodVal);
205
+
206
+ if (this.texture && this.uTexture) {
207
+ gl.activeTexture(gl.TEXTURE0);
208
+ gl.bindTexture(gl.TEXTURE_2D, this.texture);
209
+ gl.uniform1i(this.uTexture, 0);
210
+ }
211
+
212
+ gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
213
+ }
214
+
215
+ private computeScale(): number {
216
+ if (!this.canvas) return 1.0;
217
+ const preset = getShaderPreset(this.currentPreset);
218
+ const cfg = preset?.render;
219
+ if (!cfg) return 1.0;
220
+ const aspect = this.canvas.width / this.canvas.height;
221
+ if (aspect < 1.0) {
222
+ return cfg.mobile(aspect);
223
+ }
224
+ return cfg.desktop;
225
+ }
226
+
227
+ private loadTexture(url: string) {
228
+ const gl = this.gl;
229
+ if (!gl) return;
230
+
231
+ const tex = gl.createTexture();
232
+ if (!tex) return;
233
+ this.texture = tex;
234
+ gl.bindTexture(gl.TEXTURE_2D, tex);
235
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([0, 0, 0, 0]));
236
+
237
+ const img = new Image();
238
+ this.pendingTexImg = img;
239
+ img.onload = () => {
240
+ if (!this.gl || !this.texture || this.pendingTexImg !== img) return;
241
+ this.gl.bindTexture(gl.TEXTURE_2D, this.texture);
242
+ this.gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
243
+ this.gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
244
+ this.gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
245
+ this.gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
246
+ this.gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
247
+ this.pendingTexImg = null;
248
+ };
249
+ img.src = url;
250
+ }
251
+
252
+ private onResize = () => {
253
+ // Settle-debounce: viewport-resize storms (phone keyboard animations
254
+ // step the viewport every frame for ~250ms; window drags/rotations
255
+ // step it too) must not reallocate the drawing buffer per step —
256
+ // each realloc discards the GPU texture and the gap before the next
257
+ // submitted frame renders as a black flash. While a debounce is
258
+ // pending the canvas keeps drawing into the old buffer; CSS
259
+ // (width/height 100%) stretches it, which background art survives
260
+ // fine for the ~140ms settle window.
261
+ if (this.resizeTimer != null) clearTimeout(this.resizeTimer);
262
+ this.resizeTimer = setTimeout(() => {
263
+ this.resizeTimer = null;
264
+ this.resize();
265
+ }, RESIZE_SETTLE_MS);
266
+ };
267
+
268
+ private resize() {
269
+ if (!this.canvas || !this.gl) return;
270
+ // Size against the LAYOUT viewport (documentElement client size),
271
+ // not window.innerWidth/innerHeight (visual viewport). Under the
272
+ // default interactive-widget=resizes-visual the soft keyboard
273
+ // shrinks only the visual viewport — this canvas' CSS box (fixed,
274
+ // 100% of the ICB) never moves, so reallocating the buffer on an
275
+ // innerHeight change produced zero visual change while discarding
276
+ // the GPU texture mid-animation (the 2026-09-21 phone IME black
277
+ // flicker report). The layout viewport only moves for real
278
+ // geometry changes (URL-bar collapse, rotation, window resize),
279
+ // which is exactly when the buffer SHOULD follow.
280
+ const vw = document.documentElement.clientWidth || window.innerWidth;
281
+ const vh = document.documentElement.clientHeight || window.innerHeight;
282
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
283
+ const scale = 0.5;
284
+ const w = Math.floor(vw * dpr * scale);
285
+ const h = Math.floor(vh * dpr * scale);
286
+ if (this.canvas.width !== w || this.canvas.height !== h) {
287
+ this.canvas.width = w;
288
+ this.canvas.height = h;
289
+ this.canvas.style.width = "100%";
290
+ this.canvas.style.height = "100%";
291
+ }
292
+ }
293
+
294
+ destroy() {
295
+ if (this.handle) {
296
+ this.handle.disconnect();
297
+ this.handle = null;
298
+ }
299
+ if (this.resizeTimer != null) {
300
+ clearTimeout(this.resizeTimer);
301
+ this.resizeTimer = null;
302
+ }
303
+ window.removeEventListener("resize", this.onResize);
304
+ if (this.gl) {
305
+ if (this.vao) this.gl.deleteVertexArray(this.vao);
306
+ if (this.program) this.gl.deleteProgram(this.program);
307
+ if (this.texture) this.gl.deleteTexture(this.texture);
308
+ this.gl = null;
309
+ }
310
+ this.program = null;
311
+ this.vao = null;
312
+ this.uTime = null;
313
+ this.uResolution = null;
314
+ this.uScale = null;
315
+ this.uPeriod = null;
316
+ this.uTexture = null;
317
+ this.texture = null;
318
+ this.pendingTexImg = null;
319
+ // The canvas is component-owned (HkWallpaperBackdrop removes it with
320
+ // its own tree); the driver only forgets it.
321
+ this.canvas = null;
322
+ this.currentPreset = "";
323
+ }
324
+
325
+ get isActive(): boolean {
326
+ return this.handle !== null;
327
+ }
328
+
329
+ get presetId(): string {
330
+ return this.currentPreset;
331
+ }
332
+ }
333
+
334
+ /**
335
+ * The ready-made pipeline driver for `HkWallpaperBackdrop`: one
336
+ * {@link WallpaperShaderPipeline} per surface attach, resolved against
337
+ * the shared preset registry ({@link getShaderPreset}). Hand it to the
338
+ * component through the backdrop decor registration's props bag:
339
+ *
340
+ * ```ts
341
+ * registerThemeDecor({
342
+ * themeId: "*", slot: "backdrop", component: HkWallpaperBackdrop,
343
+ * props: { createSurface: createWallpaperShaderSurface },
344
+ * });
345
+ * ```
346
+ *
347
+ * Returning `null` on an unknown preset or a failed program build is the
348
+ * contract's "decline" — the component then stands down to the solid
349
+ * floor instead of leaving an invisible canvas on screen.
350
+ */
351
+ export const createWallpaperShaderSurface: HkWallpaperSurfaceFactory = (
352
+ ctx: HkWallpaperSurfaceContext,
353
+ ): HkWallpaperSurface | null => {
354
+ const pipeline = new WallpaperShaderPipeline();
355
+ if (!pipeline.attach(ctx.canvas, ctx.gl, ctx.presetId, ctx.period)) {
356
+ return null;
357
+ }
358
+ return {
359
+ setPeriod: (period) => pipeline.setPeriod(period),
360
+ dispose: () => pipeline.destroy(),
361
+ };
362
+ };