@vitreajs/vitrea 0.1.0

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/dist/index.js ADDED
@@ -0,0 +1,1001 @@
1
+ import { MOTION_DRIVER_BY_CHANNEL, INTERACTION_STATES, SHAPE_FAMILIES } from './chunk-ST6GFEQT.js';
2
+
3
+ // src/accessibility.ts
4
+ var ACCESSIBILITY_FLAGS = [
5
+ "reducedTransparency",
6
+ "reducedMotion",
7
+ "increasedContrast",
8
+ "forcedColors"
9
+ ];
10
+ var OVERRIDABLE_ACCESSIBILITY_FLAGS = [
11
+ "reducedTransparency",
12
+ "reducedMotion",
13
+ "increasedContrast"
14
+ ];
15
+ var NOMINAL_ACCESSIBILITY_POLICY = {
16
+ reducedTransparency: false,
17
+ reducedMotion: false,
18
+ increasedContrast: false,
19
+ forcedColors: false,
20
+ material: {
21
+ glass: "material",
22
+ colorSource: "material",
23
+ frost: "nominal",
24
+ refraction: "nominal",
25
+ occlusion: "nominal",
26
+ border: "nominal",
27
+ ambientTint: "nominal",
28
+ foreground: "adaptive"
29
+ },
30
+ motion: {
31
+ overshoot: "elastic",
32
+ deformation: "nominal",
33
+ shimmer: "travel",
34
+ morph: "elastic",
35
+ crossfade: "never",
36
+ positionalContinuity: true
37
+ }
38
+ };
39
+ var ACCESSIBILITY_BEHAVIOR_TABLE = {
40
+ reducedTransparency: {
41
+ material: { frost: "increased", refraction: "reduced", occlusion: "increased" }
42
+ },
43
+ increasedContrast: {
44
+ material: { border: "strong", foreground: "near-monochrome", ambientTint: "reduced" }
45
+ },
46
+ reducedMotion: {
47
+ motion: {
48
+ overshoot: "none",
49
+ deformation: "none",
50
+ shimmer: "none",
51
+ morph: "non-elastic",
52
+ crossfade: "large-plane-shifts"
53
+ }
54
+ },
55
+ forcedColors: {
56
+ // "system colors, borders, no glass" — and with the glass gone, every
57
+ // optical axis follows it: nothing to frost, lens, or tint, and a flat
58
+ // system fill that hides the backdrop entirely.
59
+ material: {
60
+ glass: "none",
61
+ colorSource: "system",
62
+ frost: "none",
63
+ refraction: "none",
64
+ occlusion: "opaque",
65
+ border: "strong",
66
+ ambientTint: "none",
67
+ foreground: "near-monochrome"
68
+ }
69
+ }
70
+ };
71
+ var ACCESSIBILITY_PRECEDENCE = [
72
+ "reducedMotion",
73
+ "reducedTransparency",
74
+ "increasedContrast",
75
+ "forcedColors"
76
+ ];
77
+ var resolveFlag = (detected, override) => override === void 0 || override === "system" ? detected : override;
78
+ var REDUCED_TRANSPARENCY_UNDETECTABLE = {
79
+ code: "reduced-transparency-undetectable",
80
+ severity: "warning",
81
+ subjects: [],
82
+ message: 'This platform cannot query `prefers-reduced-transparency`, so leaving `reducedTransparency` on "system" silently resolves it to false and the preference is lost. Set the GlassRoot `reducedTransparency` prop to an explicit boolean (\xA7Accessibility policy).'
83
+ };
84
+ function resolveAccessibilityPolicy(system, overrides = {}, diagnostics) {
85
+ const active = {
86
+ reducedTransparency: resolveFlag(system.reducedTransparency, overrides.reducedTransparency),
87
+ reducedMotion: resolveFlag(system.reducedMotion, overrides.reducedMotion),
88
+ increasedContrast: resolveFlag(system.increasedContrast, overrides.increasedContrast),
89
+ // Deliberately not overridable — see OverridableAccessibilityFlag.
90
+ forcedColors: system.forcedColors
91
+ };
92
+ const leftToTheSystem = overrides.reducedTransparency === void 0 || overrides.reducedTransparency === "system";
93
+ if (leftToTheSystem && !system.reducedTransparencySupported) {
94
+ diagnostics?.report(REDUCED_TRANSPARENCY_UNDETECTABLE);
95
+ }
96
+ let material = NOMINAL_ACCESSIBILITY_POLICY.material;
97
+ let motion = NOMINAL_ACCESSIBILITY_POLICY.motion;
98
+ for (const flag of ACCESSIBILITY_PRECEDENCE) {
99
+ if (!active[flag]) continue;
100
+ const row = ACCESSIBILITY_BEHAVIOR_TABLE[flag];
101
+ if (row.material !== void 0) material = { ...material, ...row.material };
102
+ if (row.motion !== void 0) motion = { ...motion, ...row.motion };
103
+ }
104
+ return { ...active, material, motion };
105
+ }
106
+
107
+ // src/backdrop-hint.ts
108
+ function normalizeChannel(value) {
109
+ if (value === void 0) return { corrected: false };
110
+ if (!Number.isFinite(value)) return { corrected: true };
111
+ if (value < 0) return { value: 0, corrected: true };
112
+ if (value > 1) return { value: 1, corrected: true };
113
+ return { value, corrected: false };
114
+ }
115
+ function normalizeHint(hint, groupId, diagnostics) {
116
+ const luminance = normalizeChannel(hint.luminance);
117
+ const complexity = normalizeChannel(hint.complexity);
118
+ if (luminance.corrected || complexity.corrected) {
119
+ diagnostics?.report({
120
+ code: "backdrop-hint-out-of-range",
121
+ severity: "warning",
122
+ subjects: [groupId],
123
+ message: `X6: the backdrop hint for group "${groupId}" carried a luminance or complexity outside 0..1. Both are normalised fractions; out-of-range values were clamped and non-finite ones dropped.`
124
+ });
125
+ }
126
+ return {
127
+ tone: hint.tone,
128
+ ...luminance.value === void 0 ? {} : { luminance: luminance.value },
129
+ ...complexity.value === void 0 ? {} : { complexity: complexity.value }
130
+ };
131
+ }
132
+ function resolveBackdropHint(request) {
133
+ const { groupId, backdrop, estimator, diagnostics } = request;
134
+ if (backdrop !== void 0) {
135
+ if (estimator !== void 0) {
136
+ diagnostics?.report({
137
+ code: "backdrop-hint-redundant-estimator",
138
+ severity: "warning",
139
+ subjects: [groupId],
140
+ message: `X6: group "${groupId}" has both an explicit backdrop hint and the estimator "${estimator.id}". The explicit hint wins; remove one so the source of adaptation is unambiguous.`
141
+ });
142
+ }
143
+ return { availability: "author-hint", hint: normalizeHint(backdrop, groupId, diagnostics) };
144
+ }
145
+ const estimated = estimator?.estimate(groupId);
146
+ if (estimated === void 0) return { availability: "none" };
147
+ return { availability: "estimator", hint: normalizeHint(estimated, groupId, diagnostics) };
148
+ }
149
+
150
+ // src/capability.ts
151
+ var WEBGPU_AVAILABILITIES = [
152
+ "not-requested",
153
+ "pending",
154
+ "unavailable",
155
+ "available"
156
+ ];
157
+ var GOVERNOR_PRESSURES = ["none", "degrade-in-tier", "demote-tier"];
158
+ var HINT_AVAILABILITIES = ["none", "author-hint", "estimator"];
159
+ var REASON_PRECEDENCE = [
160
+ "no-webgpu",
161
+ "device-lost",
162
+ "no-texture-supplied",
163
+ "tainted-source",
164
+ "incompatible-texture",
165
+ "no-backdrop-filter",
166
+ "probe-failed",
167
+ "governor"
168
+ ];
169
+ var DEMOTION_RECOVERY = {
170
+ "no-webgpu": {
171
+ trigger: "none",
172
+ explanation: "WebGPU is unavailable in this browser session. Nothing the app can do recovers it; a user enabling support means a new session."
173
+ },
174
+ "no-backdrop-filter": {
175
+ trigger: "probe-repassed",
176
+ explanation: "The backdrop-filter probe failed. Re-running it after the page's filter context changes can pass."
177
+ },
178
+ "tainted-source": {
179
+ trigger: "source-replaced",
180
+ explanation: "A CORS-tainted source cannot be read into a GPU texture. Register a same-origin or CORS-permitted source."
181
+ },
182
+ "incompatible-texture": {
183
+ trigger: "source-replaced",
184
+ explanation: "The supplied texture view does not satisfy the declared usage, format or dimension requirements. Register a conforming source."
185
+ },
186
+ "no-texture-supplied": {
187
+ trigger: "source-replaced",
188
+ explanation: "The source is declared as a texture and no pixels have been handed over for it yet, so there is nothing to sample. Supply the canvas, image or video behind it \u2014 the group keeps drawing tint, rim and glow until then."
189
+ },
190
+ "device-lost": {
191
+ trigger: "device-restored",
192
+ explanation: "The GPUDevice was lost. vitrea-owned devices re-request automatically; an app-owned device needs the replacement-device callback and the resource re-registration handshake."
193
+ },
194
+ "probe-failed": {
195
+ trigger: "probe-repassed",
196
+ explanation: "The backdrop-proxy conformance probe found this engine's proxy sampling non-equivalent. It clears if the probe passes on a later run."
197
+ },
198
+ governor: {
199
+ trigger: "pressure-released",
200
+ explanation: "The quality governor switched tiers under sustained pressure. It restores after its hysteresis and cooldown elapse."
201
+ }
202
+ };
203
+ function applicableFaults(inputs) {
204
+ const { platform, governor, configuredSource } = inputs;
205
+ const faults = /* @__PURE__ */ new Set();
206
+ if (platform.webgpu === "unavailable") faults.add("no-webgpu");
207
+ if (platform.webgpu === "available" && platform.deviceHealth === "lost") {
208
+ faults.add("device-lost");
209
+ }
210
+ if (configuredSource === "texture") {
211
+ if (inputs.source.supply === "absent") {
212
+ faults.add("no-texture-supplied");
213
+ } else {
214
+ if (inputs.source.taint === "tainted") faults.add("tainted-source");
215
+ if (inputs.source.textureCompatibility === "incompatible") faults.add("incompatible-texture");
216
+ }
217
+ } else {
218
+ if (!platform.backdropFilter) {
219
+ faults.add("no-backdrop-filter");
220
+ } else if (platform.backdropProxyConformance === "fail") {
221
+ faults.add("probe-failed");
222
+ }
223
+ }
224
+ if (governor === "demote-tier") faults.add("governor");
225
+ return REASON_PRECEDENCE.filter((reason) => faults.has(reason));
226
+ }
227
+ var RENDERER_FAULTS = [
228
+ "no-webgpu",
229
+ "device-lost",
230
+ "probe-failed",
231
+ "governor"
232
+ ];
233
+ var SAMPLING_FAULTS = [
234
+ "tainted-source",
235
+ "incompatible-texture",
236
+ "no-texture-supplied",
237
+ "no-backdrop-filter"
238
+ ];
239
+ function resolveGlassGroupState(inputs) {
240
+ const faults = applicableFaults(inputs);
241
+ const configuredSource = inputs.configuredSource;
242
+ const rendererDemoted = faults.some((fault) => RENDERER_FAULTS.includes(fault));
243
+ const samplingDemoted = faults.some((fault) => SAMPLING_FAULTS.includes(fault));
244
+ const cssWithoutFault = inputs.platform.webgpu === "not-requested" || inputs.platform.webgpu === "pending";
245
+ const activeRenderer = cssWithoutFault || rendererDemoted ? "css" : "webgpu";
246
+ const sampling = (() => {
247
+ if (activeRenderer === "css") {
248
+ return {
249
+ samplingBackend: inputs.platform.backdropFilter ? "css-backdrop" : "none",
250
+ refraction: "none"
251
+ };
252
+ }
253
+ if (samplingDemoted) {
254
+ return { samplingBackend: "none", refraction: "none" };
255
+ }
256
+ return configuredSource === "texture" ? { samplingBackend: "gpu-texture", refraction: "true" } : { samplingBackend: "css-backdrop", refraction: "approximate" };
257
+ })();
258
+ const analysis = sampling.samplingBackend === "gpu-texture" ? "exact" : inputs.hint === "none" ? "none" : "hint";
259
+ const reason = faults[0];
260
+ return {
261
+ configuredSource,
262
+ activeRenderer,
263
+ ...sampling,
264
+ analysis,
265
+ health: reason === void 0 ? "ok" : "demoted",
266
+ ...reason === void 0 ? {} : { demotionReason: reason }
267
+ };
268
+ }
269
+ var STATE_KEYS = [
270
+ "configuredSource",
271
+ "activeRenderer",
272
+ "samplingBackend",
273
+ "refraction",
274
+ "analysis",
275
+ "health",
276
+ "demotionReason"
277
+ ];
278
+ var sameState = (a, b) => STATE_KEYS.every((key) => a[key] === b[key]);
279
+ function classifyStateChange(previous, next) {
280
+ if (previous === void 0) {
281
+ return {
282
+ kind: "initial",
283
+ ...next.demotionReason === void 0 ? {} : { reason: next.demotionReason }
284
+ };
285
+ }
286
+ if (sameState(previous, next)) return { kind: "unchanged" };
287
+ if (previous.health === "ok" && next.demotionReason !== void 0) {
288
+ return { kind: "demoted", reason: next.demotionReason };
289
+ }
290
+ if (previous.demotionReason !== void 0 && next.health === "ok") {
291
+ return { kind: "recovered", from: previous.demotionReason };
292
+ }
293
+ return {
294
+ kind: "changed",
295
+ ...next.demotionReason === void 0 ? {} : { reason: next.demotionReason }
296
+ };
297
+ }
298
+
299
+ // src/diagnostics.ts
300
+ var DIAGNOSTIC_CODES = [
301
+ /** Two glass surfaces overlap inside one plane — the sandwich cannot express it (X1). */
302
+ "same-plane-overlap",
303
+ /** `regular` and `clear` nodes share one GlassGroup (§Material variants). */
304
+ "variant-mixing",
305
+ /** A group's `mergeDistance` is below its `samplingPadding`, so proxies can double-filter (X1). */
306
+ "merge-distance-below-padding",
307
+ /** Two groups' padded proxies cover the same pixels, so the filter applies twice (X1). */
308
+ "group-proxy-overlap",
309
+ /** A `clear` node has no dimming policy, so it resolved to `regular` instead. */
310
+ "clear-variant-needs-dimming",
311
+ /** A foreground mode the resolved state cannot support; the nearest legal mode was used. */
312
+ "foreground-mode-illegal",
313
+ /** A `sampled-async` rate or hysteresis outside the supported range was clamped. */
314
+ "foreground-rate-clamped",
315
+ /** A backdrop hint carried a luminance/complexity outside 0..1. */
316
+ "backdrop-hint-out-of-range",
317
+ /** Both an explicit hint and an estimator provider are configured; the explicit hint wins (X6). */
318
+ "backdrop-hint-redundant-estimator",
319
+ /** `reducedTransparency` is left on "system" where the platform cannot detect it. */
320
+ "reduced-transparency-undetectable",
321
+ /** A frame-phase operation was performed in the wrong phase. */
322
+ "frame-phase-violation"
323
+ ];
324
+ var KEY_SEPARATOR = "\u241F";
325
+ var keyOf = (diagnostic) => [diagnostic.code, ...diagnostic.subjects].join(KEY_SEPARATOR);
326
+ function createDiagnosticsChannel(options = {}) {
327
+ const { sink, dedupe = true } = options;
328
+ const retained = [];
329
+ const seen = /* @__PURE__ */ new Set();
330
+ return {
331
+ report(diagnostic) {
332
+ if (dedupe) {
333
+ const key = keyOf(diagnostic);
334
+ if (seen.has(key)) return;
335
+ seen.add(key);
336
+ }
337
+ retained.push(diagnostic);
338
+ sink?.(diagnostic);
339
+ },
340
+ get reported() {
341
+ return retained;
342
+ },
343
+ clear() {
344
+ retained.length = 0;
345
+ seen.clear();
346
+ }
347
+ };
348
+ }
349
+
350
+ // src/foreground.ts
351
+ var FOREGROUND_MODES = ["sampled-async", "author-hint", "fixed"];
352
+ var SAMPLED_ASYNC_RATE_LIMITS = {
353
+ minHz: 1,
354
+ maxHz: 15,
355
+ minHysteresis: 0.02,
356
+ maxHysteresis: 0.5
357
+ };
358
+ var SAMPLED_ASYNC_DEFAULTS = { rateHz: 4, hysteresis: 0.06 };
359
+ function legalModes(analysis) {
360
+ switch (analysis) {
361
+ case "exact":
362
+ return FOREGROUND_MODES;
363
+ case "hint":
364
+ return ["author-hint", "fixed"];
365
+ case "none":
366
+ return ["fixed"];
367
+ }
368
+ }
369
+ var clamp = (value, min, max) => Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : min;
370
+ function validateSampled(requested, options) {
371
+ const { minHz, maxHz, minHysteresis, maxHysteresis } = SAMPLED_ASYNC_RATE_LIMITS;
372
+ const rateHz = clamp(requested.rateHz, minHz, maxHz);
373
+ const hysteresis = clamp(requested.hysteresis, minHysteresis, maxHysteresis);
374
+ if (rateHz !== requested.rateHz || hysteresis !== requested.hysteresis) {
375
+ options.diagnostics?.report({
376
+ code: "foreground-rate-clamped",
377
+ severity: "warning",
378
+ subjects: [options.subject ?? "*"],
379
+ message: `Foreground sampled-async parameters were clamped to the supported range (rateHz ${minHz}..${maxHz}, hysteresis ${minHysteresis}..${maxHysteresis}). Readback is low-frequency by contract, never per-frame.`
380
+ });
381
+ }
382
+ return { mode: "sampled-async", rateHz, hysteresis };
383
+ }
384
+ function resolveForegroundAdaptation(requested, state, options = {}) {
385
+ const legal = legalModes(state.analysis);
386
+ if (legal.includes(requested.mode)) {
387
+ return {
388
+ adaptation: requested.mode === "sampled-async" ? validateSampled(requested, options) : requested
389
+ };
390
+ }
391
+ const from = requested.mode;
392
+ const to = FOREGROUND_MODES.slice(FOREGROUND_MODES.indexOf(from) + 1).find(
393
+ (mode) => legal.includes(mode)
394
+ );
395
+ const target = to ?? "fixed";
396
+ options.diagnostics?.report({
397
+ code: "foreground-mode-illegal",
398
+ severity: "warning",
399
+ subjects: [options.subject ?? "*"],
400
+ message: `Foreground mode "${from}" needs a state this group does not have \u2014 sampled-async requires analysis: exact, author-hint requires a backdrop hint or estimator (X6). Resolved analysis is "${state.analysis}", so "${target}" was used instead.`
401
+ });
402
+ return {
403
+ adaptation: target === "sampled-async" ? { ...SAMPLED_ASYNC_DEFAULTS, mode: target } : { mode: target },
404
+ downgraded: { from, to: target }
405
+ };
406
+ }
407
+ function defaultForegroundAdaptation(state) {
408
+ switch (state.analysis) {
409
+ case "exact":
410
+ return { mode: "sampled-async", ...SAMPLED_ASYNC_DEFAULTS };
411
+ case "hint":
412
+ return { mode: "author-hint" };
413
+ case "none":
414
+ return { mode: "fixed" };
415
+ }
416
+ }
417
+
418
+ // src/frame.ts
419
+ var FRAME_PHASES = ["collect", "read", "update", "write", "render"];
420
+
421
+ // src/material.ts
422
+ var MATERIAL_VARIANTS = ["regular", "clear"];
423
+ var DEFAULT_CLEAR_DIMMING = { scrim: 0.28, direction: "darken" };
424
+ function resolveMaterial(request) {
425
+ const { variant, dimming, nodeId, diagnostics } = request;
426
+ if (variant === "regular") return { variant: "regular", adaptation: "adaptive" };
427
+ if (dimming === void 0) {
428
+ diagnostics?.report({
429
+ code: "clear-variant-needs-dimming",
430
+ severity: "error",
431
+ subjects: [nodeId ?? "*"],
432
+ message: `The clear variant requires a dimming policy (\xA7Material variants); without one its foreground is not guaranteed legible. This surface rendered as regular instead. Supply one on the group's material profile \u2014 DEFAULT_CLEAR_DIMMING is a usable starting point.`
433
+ });
434
+ return { variant: "regular", adaptation: "adaptive" };
435
+ }
436
+ return { variant: "clear", adaptation: "constrained", dimming };
437
+ }
438
+ function checkVariantMixing(check) {
439
+ const { groupId, members, diagnostics } = check;
440
+ const regular = members.filter((member) => member.variant === "regular");
441
+ const clear = members.filter((member) => member.variant === "clear");
442
+ if (regular.length === 0 || clear.length === 0) return false;
443
+ const name = (list) => list.map((member) => member.nodeId).join(", ");
444
+ diagnostics?.report({
445
+ code: "variant-mixing",
446
+ severity: "warning",
447
+ subjects: [groupId],
448
+ message: `Group "${groupId}" mixes material variants, which Apple's guidance advises against: regular on ${name(regular)}, clear on ${name(clear)}. Both render as authored \u2014 split them into separate groups if the mix was not deliberate.`
449
+ });
450
+ return true;
451
+ }
452
+
453
+ // src/planes.ts
454
+ var GLASS_PLANES = ["base", "overlay"];
455
+ var planeIndex = (plane) => GLASS_PLANES.indexOf(plane);
456
+ function compareZSlot(a, b) {
457
+ const byPlane = planeIndex(a.plane) - planeIndex(b.plane);
458
+ return byPlane !== 0 ? byPlane : a.order - b.order;
459
+ }
460
+ function unionRect(a, b) {
461
+ const x = Math.min(a.x, b.x);
462
+ const y = Math.min(a.y, b.y);
463
+ return {
464
+ x,
465
+ y,
466
+ width: Math.max(a.x + a.width, b.x + b.width) - x,
467
+ height: Math.max(a.y + a.height, b.y + b.height) - y
468
+ };
469
+ }
470
+ function inflateRect(rect, by) {
471
+ return {
472
+ x: rect.x - by,
473
+ y: rect.y - by,
474
+ width: rect.width + by * 2,
475
+ height: rect.height + by * 2
476
+ };
477
+ }
478
+ function rectsOverlap(a, b) {
479
+ if (a.width <= 0 || a.height <= 0 || b.width <= 0 || b.height <= 0) return false;
480
+ return a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
481
+ }
482
+
483
+ // src/scene.ts
484
+ var GlassSceneError = class extends Error {
485
+ code;
486
+ constructor(code, message) {
487
+ super(message);
488
+ this.name = "GlassSceneError";
489
+ this.code = code;
490
+ }
491
+ };
492
+ var DEFAULT_BACKDROP_RESOLUTION = {
493
+ scale: 1,
494
+ maxDimension: 2048
495
+ };
496
+ var DEFAULT_GROUP_SAMPLING = {
497
+ samplingPadding: 24,
498
+ mergeDistance: 24
499
+ };
500
+ function applyPatch(base, patch) {
501
+ const next = { ...base };
502
+ for (const [key, value] of Object.entries(patch)) {
503
+ if (value === void 0) delete next[key];
504
+ else next[key] = value;
505
+ }
506
+ return next;
507
+ }
508
+ var unknown = (kind, id) => new GlassSceneError("unknown-id", `Unknown ${kind} "${id}".`);
509
+ var duplicate = (kind, id) => new GlassSceneError("duplicate-id", `Duplicate ${kind} id "${id}" \u2014 ids must be unique.`);
510
+ function createGlassScene(options) {
511
+ const diagnostics = options.diagnostics ?? createDiagnosticsChannel();
512
+ const devMode = options.devMode ?? true;
513
+ const sources = /* @__PURE__ */ new Map();
514
+ const groups = /* @__PURE__ */ new Map();
515
+ const nodes = /* @__PURE__ */ new Map();
516
+ let platform = options.platform;
517
+ let governor = "none";
518
+ let system = options.accessibility ?? {
519
+ reducedTransparency: false,
520
+ reducedMotion: false,
521
+ increasedContrast: false,
522
+ forcedColors: false,
523
+ reducedTransparencySupported: true
524
+ };
525
+ let overrides = options.accessibilityOverrides ?? {};
526
+ let consumedFrameId;
527
+ let consumedEpochs = [];
528
+ let framePhase;
529
+ const FROZEN_PHASES = ["update", "write", "render"];
530
+ const guardFrozenScene = (subject) => {
531
+ if (framePhase === void 0 || !FROZEN_PHASES.includes(framePhase)) return;
532
+ diagnostics.report({
533
+ code: "frame-phase-violation",
534
+ severity: "error",
535
+ subjects: [subject],
536
+ message: `The scene was changed during the "${framePhase}" phase, after this frame resolved. Register, remove and patch in the "collect" phase or outside a frame \u2014 from "update" onward the resolution and the scene must agree.`
537
+ });
538
+ };
539
+ const requireSource = (id) => {
540
+ const record = sources.get(id);
541
+ if (record === void 0) throw unknown("backdrop source", id);
542
+ return record;
543
+ };
544
+ const requireGroup = (id) => {
545
+ const record = groups.get(id);
546
+ if (record === void 0) throw unknown("glass group", id);
547
+ return record;
548
+ };
549
+ const requireNode = (id) => {
550
+ const record = nodes.get(id);
551
+ if (record === void 0) throw unknown("glass node", id);
552
+ return record;
553
+ };
554
+ const groupsOfSource = (sourceId) => [...groups.values()].filter((group) => group.descriptor.backdropSourceId === sourceId);
555
+ const nodesOfGroup = (groupId) => [...nodes.values()].filter((node) => node.descriptor.groupId === groupId);
556
+ const isRebuildable = (record) => record.descriptor.kind === "texture" && record.dirtyEpoch > record.builtEpoch;
557
+ function capabilityInputs(group, hint) {
558
+ const source = requireSource(group.descriptor.backdropSourceId);
559
+ const pressure = group.governor ?? governor;
560
+ return source.descriptor.kind === "texture" ? {
561
+ configuredSource: "texture",
562
+ platform,
563
+ source: source.descriptor.probe,
564
+ governor: pressure,
565
+ hint
566
+ } : { configuredSource: "dom", platform, governor: pressure, hint };
567
+ }
568
+ const paddingOf = (group) => group.descriptor.samplingPadding ?? DEFAULT_GROUP_SAMPLING.samplingPadding;
569
+ function samplingOf(group) {
570
+ const samplingPadding = paddingOf(group);
571
+ const mergeDistance = group.descriptor.mergeDistance ?? samplingPadding;
572
+ if (devMode && mergeDistance < samplingPadding) {
573
+ diagnostics.report({
574
+ code: "merge-distance-below-padding",
575
+ severity: "warning",
576
+ subjects: [group.descriptor.id],
577
+ message: `Group "${group.descriptor.id}" has mergeDistance ${mergeDistance} below samplingPadding ${samplingPadding}, which X1 forbids: two members can then sit close enough for their padded proxies to overlap without having merged, and the backdrop filter applies twice over the overlap \u2014 paint-order dependent, and measured drifting up to 17/255. Raise mergeDistance to at least the padding.`
578
+ });
579
+ }
580
+ return { samplingPadding, mergeDistance };
581
+ }
582
+ const hintOf = (group) => resolveBackdropHint({
583
+ groupId: group.descriptor.id,
584
+ ...group.descriptor.backdrop === void 0 ? {} : { backdrop: group.descriptor.backdrop },
585
+ ...group.descriptor.estimator === void 0 ? {} : { estimator: group.descriptor.estimator },
586
+ diagnostics
587
+ });
588
+ return {
589
+ diagnostics,
590
+ get framePhase() {
591
+ return framePhase;
592
+ },
593
+ setFramePhase(phase) {
594
+ framePhase = phase;
595
+ },
596
+ registerBackdropSource(descriptor) {
597
+ if (sources.has(descriptor.id)) throw duplicate("backdrop source", descriptor.id);
598
+ guardFrozenScene(descriptor.id);
599
+ sources.set(descriptor.id, { descriptor, dirtyEpoch: 0, builtEpoch: 0 });
600
+ },
601
+ updateBackdropSource(id, patch) {
602
+ const record = requireSource(id);
603
+ guardFrozenScene(id);
604
+ if (record.descriptor.kind !== "texture") {
605
+ throw new GlassSceneError(
606
+ "wrong-source-kind",
607
+ `Backdrop source "${id}" is a dom source: the compositor owns its blur, so it has no resolution policy.`
608
+ );
609
+ }
610
+ sources.set(id, {
611
+ ...record,
612
+ descriptor: { ...record.descriptor, resolution: patch.resolution }
613
+ });
614
+ },
615
+ removeBackdropSource(id) {
616
+ requireSource(id);
617
+ guardFrozenScene(id);
618
+ const dependents = groupsOfSource(id);
619
+ if (dependents.length > 0) {
620
+ throw new GlassSceneError(
621
+ "in-use",
622
+ `Backdrop source "${id}" is in use by ${dependents.map((group) => `"${group.descriptor.id}"`).join(", ")}. Remove those groups first.`
623
+ );
624
+ }
625
+ sources.delete(id);
626
+ },
627
+ backdropSource(id) {
628
+ return sources.get(id);
629
+ },
630
+ registerGlassGroup(descriptor) {
631
+ if (groups.has(descriptor.id)) throw duplicate("glass group", descriptor.id);
632
+ requireSource(descriptor.backdropSourceId);
633
+ guardFrozenScene(descriptor.id);
634
+ groups.set(descriptor.id, { descriptor });
635
+ },
636
+ updateGlassGroup(id, patch) {
637
+ const record = requireGroup(id);
638
+ const descriptor = applyPatch(record.descriptor, patch);
639
+ requireSource(descriptor.backdropSourceId);
640
+ guardFrozenScene(id);
641
+ groups.set(id, { ...record, descriptor });
642
+ },
643
+ removeGlassGroup(id) {
644
+ requireGroup(id);
645
+ guardFrozenScene(id);
646
+ const members = nodesOfGroup(id);
647
+ if (members.length > 0) {
648
+ throw new GlassSceneError(
649
+ "in-use",
650
+ `Glass group "${id}" still holds ${members.map((node) => `"${node.descriptor.id}"`).join(", ")}. Remove those nodes first.`
651
+ );
652
+ }
653
+ groups.delete(id);
654
+ },
655
+ glassGroup(id) {
656
+ return groups.get(id);
657
+ },
658
+ groupsOfSource,
659
+ registerGlassNode(descriptor) {
660
+ if (nodes.has(descriptor.id)) throw duplicate("glass node", descriptor.id);
661
+ requireGroup(descriptor.groupId);
662
+ guardFrozenScene(descriptor.id);
663
+ nodes.set(descriptor.id, { descriptor });
664
+ },
665
+ updateGlassNode(id, patch) {
666
+ const record = requireNode(id);
667
+ const descriptor = applyPatch(record.descriptor, patch);
668
+ requireGroup(descriptor.groupId);
669
+ guardFrozenScene(id);
670
+ nodes.set(id, { ...record, descriptor });
671
+ },
672
+ removeGlassNode(id) {
673
+ requireNode(id);
674
+ guardFrozenScene(id);
675
+ nodes.delete(id);
676
+ },
677
+ glassNode(id) {
678
+ return nodes.get(id);
679
+ },
680
+ nodesOfGroup,
681
+ setNodeBounds(id, bounds, clip) {
682
+ const record = requireNode(id);
683
+ if (framePhase !== void 0 && framePhase !== "read") {
684
+ diagnostics.report({
685
+ code: "frame-phase-violation",
686
+ severity: "warning",
687
+ subjects: [id],
688
+ message: `Bounds for "${id}" were set during the "${framePhase}" phase. Batch every layout read into the "read" phase so the steady state performs none.`
689
+ });
690
+ }
691
+ nodes.set(id, { ...record, bounds, ...clip === void 0 ? {} : { clip } });
692
+ },
693
+ setPlatformProbe(probe) {
694
+ platform = probe;
695
+ },
696
+ setSourceProbe(sourceId, probe) {
697
+ const record = requireSource(sourceId);
698
+ if (record.descriptor.kind !== "texture") {
699
+ throw new GlassSceneError(
700
+ "wrong-source-kind",
701
+ `Backdrop source "${sourceId}" is a dom source; only texture sources carry a source probe.`
702
+ );
703
+ }
704
+ sources.set(sourceId, { ...record, descriptor: { ...record.descriptor, probe } });
705
+ },
706
+ setGovernorPressure(pressure, groupId) {
707
+ if (groupId === void 0) {
708
+ governor = pressure;
709
+ return;
710
+ }
711
+ const record = requireGroup(groupId);
712
+ groups.set(groupId, { ...record, governor: pressure });
713
+ },
714
+ setSystemAccessibility(preferences) {
715
+ system = preferences;
716
+ },
717
+ setAccessibilityOverrides(next) {
718
+ overrides = next;
719
+ },
720
+ accessibilityPolicy() {
721
+ return resolveAccessibilityPolicy(system, overrides, diagnostics);
722
+ },
723
+ markBackdropSourceDirty(id) {
724
+ const record = requireSource(id);
725
+ sources.set(id, { ...record, dirtyEpoch: record.dirtyEpoch + 1 });
726
+ },
727
+ dirtyBackdropSources() {
728
+ return [...sources.values()].filter(isRebuildable);
729
+ },
730
+ consumeDirtyBackdropSources(frameId) {
731
+ if (consumedFrameId === frameId) return [];
732
+ consumedFrameId = frameId;
733
+ const requests = [];
734
+ const built = [];
735
+ for (const record of sources.values()) {
736
+ if (!isRebuildable(record)) continue;
737
+ const consumers = groupsOfSource(record.descriptor.id).map((group) => group.descriptor.id);
738
+ if (consumers.length === 0) continue;
739
+ requests.push({
740
+ sourceId: record.descriptor.id,
741
+ epoch: record.dirtyEpoch,
742
+ resolution: record.descriptor.resolution ?? DEFAULT_BACKDROP_RESOLUTION,
743
+ groupIds: consumers
744
+ });
745
+ built.push(record);
746
+ }
747
+ consumedEpochs = built.map((record) => ({
748
+ id: record.descriptor.id,
749
+ builtEpoch: record.builtEpoch
750
+ }));
751
+ for (const record of built) {
752
+ sources.set(record.descriptor.id, { ...record, builtEpoch: record.dirtyEpoch });
753
+ }
754
+ return requests;
755
+ },
756
+ rollbackDirtyBackdropSources(frameId) {
757
+ if (consumedFrameId !== frameId) return [];
758
+ const restored = [];
759
+ for (const { id, builtEpoch } of consumedEpochs) {
760
+ const record = sources.get(id);
761
+ if (record === void 0) continue;
762
+ sources.set(id, { ...record, builtEpoch });
763
+ restored.push(id);
764
+ }
765
+ consumedEpochs = [];
766
+ return restored;
767
+ },
768
+ resolve() {
769
+ const resolvedGroups = [];
770
+ const resolvedNodes = [];
771
+ const changes = [];
772
+ const settled = [];
773
+ for (const group of groups.values()) {
774
+ const groupId = group.descriptor.id;
775
+ const hint = hintOf(group);
776
+ const state = resolveGlassGroupState(capabilityInputs(group, hint.availability));
777
+ const requested = group.descriptor.foreground ?? defaultForegroundAdaptation(state);
778
+ const foreground = resolveForegroundAdaptation(requested, state, { subject: groupId, diagnostics });
779
+ const previous = group.state;
780
+ const change = classifyStateChange(previous, state);
781
+ if (change.kind !== "unchanged") {
782
+ changes.push({
783
+ groupId,
784
+ ...previous === void 0 ? {} : { previous },
785
+ next: state,
786
+ change
787
+ });
788
+ }
789
+ settled.push({ ...group, state });
790
+ resolvedGroups.push({ groupId, state, hint, foreground, sampling: samplingOf(group) });
791
+ const profile = group.descriptor.material ?? { variant: "regular" };
792
+ const members = nodesOfGroup(groupId);
793
+ for (const node of members) {
794
+ const nodeId = node.descriptor.id;
795
+ const material = resolveMaterial({
796
+ variant: node.descriptor.variant ?? profile.variant,
797
+ ...profile.dimming === void 0 ? {} : { dimming: profile.dimming },
798
+ nodeId,
799
+ diagnostics
800
+ });
801
+ resolvedNodes.push({
802
+ nodeId,
803
+ groupId,
804
+ material,
805
+ foreground: resolveForegroundAdaptation(node.descriptor.foreground ?? requested, state, {
806
+ subject: nodeId,
807
+ diagnostics
808
+ })
809
+ });
810
+ }
811
+ if (devMode) {
812
+ checkVariantMixing({
813
+ groupId,
814
+ members: members.map((node) => ({
815
+ nodeId: node.descriptor.id,
816
+ variant: node.descriptor.variant ?? profile.variant
817
+ })),
818
+ diagnostics
819
+ });
820
+ }
821
+ }
822
+ for (const group of settled) groups.set(group.descriptor.id, group);
823
+ return {
824
+ groups: resolvedGroups,
825
+ nodes: resolvedNodes,
826
+ changes,
827
+ accessibility: resolveAccessibilityPolicy(system, overrides, diagnostics)
828
+ };
829
+ },
830
+ checkSamePlaneOverlap() {
831
+ if (!devMode) return [];
832
+ const measured = [...nodes.values()].filter(
833
+ (node) => node.bounds !== void 0
834
+ );
835
+ const overlaps = [];
836
+ for (let i = 0; i < measured.length; i += 1) {
837
+ for (let j = i + 1; j < measured.length; j += 1) {
838
+ const a = measured[i];
839
+ const b = measured[j];
840
+ if (a === void 0 || b === void 0) continue;
841
+ const plane = a.descriptor.zSlot.plane;
842
+ if (plane !== b.descriptor.zSlot.plane) continue;
843
+ if (!rectsOverlap(a.bounds, b.bounds)) continue;
844
+ const nodeIds = [a.descriptor.id, b.descriptor.id];
845
+ overlaps.push({ plane, nodeIds });
846
+ diagnostics.report({
847
+ code: "same-plane-overlap",
848
+ severity: "error",
849
+ subjects: [...nodeIds],
850
+ message: `Glass surfaces "${nodeIds[0]}" and "${nodeIds[1]}" overlap inside the "${plane}" plane (X1). The paint sandwich cannot put one surface's body above the other's DOM label \u2014 put the upper one on the overlay plane instead.`
851
+ });
852
+ }
853
+ }
854
+ return overlaps;
855
+ },
856
+ checkGroupProxyOverlap() {
857
+ if (!devMode) return [];
858
+ const boxes = [];
859
+ for (const group of groups.values()) {
860
+ const groupId = group.descriptor.id;
861
+ const padding = paddingOf(group);
862
+ const byPlane = /* @__PURE__ */ new Map();
863
+ for (const node of nodesOfGroup(groupId)) {
864
+ const { bounds } = node;
865
+ if (bounds === void 0) continue;
866
+ const plane = node.descriptor.zSlot.plane;
867
+ const grown = byPlane.get(plane);
868
+ byPlane.set(plane, grown === void 0 ? bounds : unionRect(grown, bounds));
869
+ }
870
+ for (const [plane, union] of byPlane) {
871
+ boxes.push({ groupId, plane, box: inflateRect(union, padding) });
872
+ }
873
+ }
874
+ const overlaps = [];
875
+ for (let i = 0; i < boxes.length; i += 1) {
876
+ for (let j = i + 1; j < boxes.length; j += 1) {
877
+ const a = boxes[i];
878
+ const b = boxes[j];
879
+ if (a === void 0 || b === void 0) continue;
880
+ if (a.plane !== b.plane || a.groupId === b.groupId) continue;
881
+ if (!rectsOverlap(a.box, b.box)) continue;
882
+ const groupIds = [a.groupId, b.groupId];
883
+ overlaps.push({ plane: a.plane, groupIds });
884
+ diagnostics.report({
885
+ code: "group-proxy-overlap",
886
+ severity: "warning",
887
+ subjects: [...groupIds],
888
+ message: `Groups "${groupIds[0]}" and "${groupIds[1]}" sit close enough in the "${a.plane}" plane that their padded backdrop proxies overlap, and X1 says the filter then applies twice over that region \u2014 paint-order dependent, measured drifting up to 17/255. mergeDistance cannot help: it only unions members inside one group. Either put these surfaces in one group so they share a proxy, or separate them by more than the sum of their samplingPadding.`
889
+ });
890
+ }
891
+ }
892
+ return overlaps;
893
+ }
894
+ };
895
+ }
896
+
897
+ // src/scheduler.ts
898
+ function createFrameScheduler(options) {
899
+ const { scene } = options;
900
+ const participants = /* @__PURE__ */ new Map();
901
+ return {
902
+ addParticipant(participant) {
903
+ participants.set(participant.id, participant);
904
+ },
905
+ removeParticipant(id) {
906
+ participants.delete(id);
907
+ },
908
+ get participants() {
909
+ return [...participants.values()];
910
+ },
911
+ runFrame(frame) {
912
+ let resolution;
913
+ let overlaps = [];
914
+ let proxyOverlaps = [];
915
+ const rebuilds = [];
916
+ const contextFor = (phase) => ({
917
+ frame,
918
+ phase,
919
+ scene,
920
+ ...resolution === void 0 ? {} : { resolution },
921
+ consumeDirtyBackdropSources: () => {
922
+ if (phase !== "write") {
923
+ scene.diagnostics.report({
924
+ code: "frame-phase-violation",
925
+ severity: "error",
926
+ subjects: [phase],
927
+ message: `The dirty backdrop set was consumed during the "${phase}" phase. Pyramid rebuilds belong to the "write" phase, after the scene has resolved and before anything draws.`
928
+ });
929
+ return [];
930
+ }
931
+ const handed = scene.consumeDirtyBackdropSources(frame.id);
932
+ rebuilds.push(...handed);
933
+ return handed;
934
+ }
935
+ });
936
+ try {
937
+ for (const phase of FRAME_PHASES) {
938
+ scene.setFramePhase(phase);
939
+ if (phase === "update") resolution = scene.resolve();
940
+ for (const participant of [...participants.values()]) {
941
+ participant[phase]?.(contextFor(phase));
942
+ }
943
+ if (phase === "read") {
944
+ overlaps = scene.checkSamePlaneOverlap();
945
+ proxyOverlaps = scene.checkGroupProxyOverlap();
946
+ }
947
+ }
948
+ } catch (error) {
949
+ scene.rollbackDirtyBackdropSources(frame.id);
950
+ throw error;
951
+ } finally {
952
+ scene.setFramePhase(void 0);
953
+ }
954
+ return {
955
+ frame,
956
+ // `update` always runs, so this is always assigned by the time it is read.
957
+ resolution: resolution ?? scene.resolve(),
958
+ overlaps,
959
+ proxyOverlaps,
960
+ rebuilds,
961
+ pendingSources: scene.dirtyBackdropSources().map((source) => source.descriptor.id)
962
+ };
963
+ }
964
+ };
965
+ }
966
+
967
+ // src/state.ts
968
+ var DEMOTION_REASONS = [
969
+ "no-webgpu",
970
+ "no-backdrop-filter",
971
+ "tainted-source",
972
+ "incompatible-texture",
973
+ "no-texture-supplied",
974
+ "device-lost",
975
+ "probe-failed",
976
+ "governor"
977
+ ];
978
+ function isHealthy(state) {
979
+ return state.health === "ok" && state.demotionReason === void 0;
980
+ }
981
+
982
+ // src/renderer-seam.ts
983
+ async function loadWebGPURendererModule() {
984
+ return import('./dist-FGJI5LQM.js');
985
+ }
986
+ async function loadWebGPURenderer() {
987
+ const { createWebGPURenderer } = await loadWebGPURendererModule();
988
+ return createWebGPURenderer();
989
+ }
990
+
991
+ // src/index.ts
992
+ var VITREA_CONTRACTS = {
993
+ shapeFamilies: SHAPE_FAMILIES,
994
+ interactionStates: INTERACTION_STATES,
995
+ motionDrivers: MOTION_DRIVER_BY_CHANNEL
996
+ };
997
+ var RENDERER_TIERS = ["webgpu", "css"];
998
+
999
+ export { ACCESSIBILITY_BEHAVIOR_TABLE, ACCESSIBILITY_FLAGS, ACCESSIBILITY_PRECEDENCE, DEFAULT_BACKDROP_RESOLUTION, DEFAULT_CLEAR_DIMMING, DEFAULT_GROUP_SAMPLING, DEMOTION_REASONS, DEMOTION_RECOVERY, DIAGNOSTIC_CODES, FOREGROUND_MODES, FRAME_PHASES, GLASS_PLANES, GOVERNOR_PRESSURES, GlassSceneError, HINT_AVAILABILITIES, MATERIAL_VARIANTS, NOMINAL_ACCESSIBILITY_POLICY, OVERRIDABLE_ACCESSIBILITY_FLAGS, RENDERER_TIERS, SAMPLED_ASYNC_DEFAULTS, SAMPLED_ASYNC_RATE_LIMITS, VITREA_CONTRACTS, WEBGPU_AVAILABILITIES, checkVariantMixing, classifyStateChange, compareZSlot, createDiagnosticsChannel, createFrameScheduler, createGlassScene, defaultForegroundAdaptation, inflateRect, isHealthy, loadWebGPURenderer, loadWebGPURendererModule, rectsOverlap, resolveAccessibilityPolicy, resolveBackdropHint, resolveForegroundAdaptation, resolveGlassGroupState, resolveMaterial, unionRect };
1000
+ //# sourceMappingURL=index.js.map
1001
+ //# sourceMappingURL=index.js.map