@wildo-ai/presets-components-3d 1.1.6

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.
Files changed (102) hide show
  1. package/LICENSE +34 -0
  2. package/dist/esm/frame/frame-3d-style.d.ts +156 -0
  3. package/dist/esm/frame/frame-3d-style.d.ts.map +1 -0
  4. package/dist/esm/frame/frame-3d-style.js +229 -0
  5. package/dist/esm/frame/frame-3d-style.js.map +1 -0
  6. package/dist/esm/frame/frame-appearance.d.ts +99 -0
  7. package/dist/esm/frame/frame-appearance.d.ts.map +1 -0
  8. package/dist/esm/frame/frame-appearance.js +207 -0
  9. package/dist/esm/frame/frame-appearance.js.map +1 -0
  10. package/dist/esm/frame/frame-canvas.d.ts +63 -0
  11. package/dist/esm/frame/frame-canvas.d.ts.map +1 -0
  12. package/dist/esm/frame/frame-canvas.js +134 -0
  13. package/dist/esm/frame/frame-canvas.js.map +1 -0
  14. package/dist/esm/frame/frame-canvas.lazy.d.ts +21 -0
  15. package/dist/esm/frame/frame-canvas.lazy.d.ts.map +1 -0
  16. package/dist/esm/frame/frame-canvas.lazy.js +31 -0
  17. package/dist/esm/frame/frame-canvas.lazy.js.map +1 -0
  18. package/dist/esm/frame/frame-color.d.ts +24 -0
  19. package/dist/esm/frame/frame-color.d.ts.map +1 -0
  20. package/dist/esm/frame/frame-color.js +52 -0
  21. package/dist/esm/frame/frame-color.js.map +1 -0
  22. package/dist/esm/frame/frame-driver.d.ts +60 -0
  23. package/dist/esm/frame/frame-driver.d.ts.map +1 -0
  24. package/dist/esm/frame/frame-driver.js +207 -0
  25. package/dist/esm/frame/frame-driver.js.map +1 -0
  26. package/dist/esm/frame/frame-renderer.d.ts +141 -0
  27. package/dist/esm/frame/frame-renderer.d.ts.map +1 -0
  28. package/dist/esm/frame/frame-renderer.js +635 -0
  29. package/dist/esm/frame/frame-renderer.js.map +1 -0
  30. package/dist/esm/frame/frame-shaders.d.ts +30 -0
  31. package/dist/esm/frame/frame-shaders.d.ts.map +1 -0
  32. package/dist/esm/frame/frame-shaders.js +97 -0
  33. package/dist/esm/frame/frame-shaders.js.map +1 -0
  34. package/dist/esm/frame/use-frame-appearance.d.ts +17 -0
  35. package/dist/esm/frame/use-frame-appearance.d.ts.map +1 -0
  36. package/dist/esm/frame/use-frame-appearance.js +38 -0
  37. package/dist/esm/frame/use-frame-appearance.js.map +1 -0
  38. package/dist/esm/index.d.ts +14 -0
  39. package/dist/esm/index.d.ts.map +1 -0
  40. package/dist/esm/index.js +14 -0
  41. package/dist/esm/index.js.map +1 -0
  42. package/dist/esm/layout/header-first-shell-container.d.ts +22 -0
  43. package/dist/esm/layout/header-first-shell-container.d.ts.map +1 -0
  44. package/dist/esm/layout/header-first-shell-container.js +31 -0
  45. package/dist/esm/layout/header-first-shell-container.js.map +1 -0
  46. package/dist/esm/layout/layout-3d-definition.d.ts +54 -0
  47. package/dist/esm/layout/layout-3d-definition.d.ts.map +1 -0
  48. package/dist/esm/layout/layout-3d-definition.js +106 -0
  49. package/dist/esm/layout/layout-3d-definition.js.map +1 -0
  50. package/dist/esm/layout/layout-3d-transition-choreography.d.ts +30 -0
  51. package/dist/esm/layout/layout-3d-transition-choreography.d.ts.map +1 -0
  52. package/dist/esm/layout/layout-3d-transition-choreography.js +43 -0
  53. package/dist/esm/layout/layout-3d-transition-choreography.js.map +1 -0
  54. package/dist/esm/layout/layout-3d-vocabulary.d.ts +118 -0
  55. package/dist/esm/layout/layout-3d-vocabulary.d.ts.map +1 -0
  56. package/dist/esm/layout/layout-3d-vocabulary.js +104 -0
  57. package/dist/esm/layout/layout-3d-vocabulary.js.map +1 -0
  58. package/dist/esm/layout/shell-layout-placement.d.ts +26 -0
  59. package/dist/esm/layout/shell-layout-placement.d.ts.map +1 -0
  60. package/dist/esm/layout/shell-layout-placement.js +18 -0
  61. package/dist/esm/layout/shell-layout-placement.js.map +1 -0
  62. package/dist/esm/layout-3d-main-container.d.ts +73 -0
  63. package/dist/esm/layout-3d-main-container.d.ts.map +1 -0
  64. package/dist/esm/layout-3d-main-container.js +124 -0
  65. package/dist/esm/layout-3d-main-container.js.map +1 -0
  66. package/dist/esm/register-3d-layout.d.ts +40 -0
  67. package/dist/esm/register-3d-layout.d.ts.map +1 -0
  68. package/dist/esm/register-3d-layout.js +72 -0
  69. package/dist/esm/register-3d-layout.js.map +1 -0
  70. package/dist/esm/shell-region-measurement.d.ts +75 -0
  71. package/dist/esm/shell-region-measurement.d.ts.map +1 -0
  72. package/dist/esm/shell-region-measurement.js +75 -0
  73. package/dist/esm/shell-region-measurement.js.map +1 -0
  74. package/dist/esm/spatial/spatial-camera.d.ts +106 -0
  75. package/dist/esm/spatial/spatial-camera.d.ts.map +1 -0
  76. package/dist/esm/spatial/spatial-camera.js +151 -0
  77. package/dist/esm/spatial/spatial-camera.js.map +1 -0
  78. package/dist/esm/spatial/spatial-environment.d.ts +6 -0
  79. package/dist/esm/spatial/spatial-environment.d.ts.map +1 -0
  80. package/dist/esm/spatial/spatial-environment.js +166 -0
  81. package/dist/esm/spatial/spatial-environment.js.map +1 -0
  82. package/dist/esm/spatial/spatial-environment.lazy.d.ts +10 -0
  83. package/dist/esm/spatial/spatial-environment.lazy.d.ts.map +1 -0
  84. package/dist/esm/spatial/spatial-environment.lazy.js +13 -0
  85. package/dist/esm/spatial/spatial-environment.lazy.js.map +1 -0
  86. package/dist/esm/spatial/spatial-shell-container.d.ts +21 -0
  87. package/dist/esm/spatial/spatial-shell-container.d.ts.map +1 -0
  88. package/dist/esm/spatial/spatial-shell-container.js +231 -0
  89. package/dist/esm/spatial/spatial-shell-container.js.map +1 -0
  90. package/dist/esm/spatial/spatial-stage-store.d.ts +27 -0
  91. package/dist/esm/spatial/spatial-stage-store.d.ts.map +1 -0
  92. package/dist/esm/spatial/spatial-stage-store.js +33 -0
  93. package/dist/esm/spatial/spatial-stage-store.js.map +1 -0
  94. package/dist/esm/tokens/design-token-resolution.d.ts +110 -0
  95. package/dist/esm/tokens/design-token-resolution.d.ts.map +1 -0
  96. package/dist/esm/tokens/design-token-resolution.js +410 -0
  97. package/dist/esm/tokens/design-token-resolution.js.map +1 -0
  98. package/dist/esm/tokens/use-design-token-resolution.d.ts +22 -0
  99. package/dist/esm/tokens/use-design-token-resolution.d.ts.map +1 -0
  100. package/dist/esm/tokens/use-design-token-resolution.js +49 -0
  101. package/dist/esm/tokens/use-design-token-resolution.js.map +1 -0
  102. package/package.json +61 -0
@@ -0,0 +1,635 @@
1
+ import { CustomBlending, Mesh, MinEquation, PlaneGeometry, ShaderMaterial, Vector2, Vector3 } from 'three';
2
+ import { ShellRegion } from '@wildo-ai/presets-components-models';
3
+ import { FRAME_BODY_FRAGMENT_SHADER, FRAME_SHADOW_FRAGMENT_SHADER, FRAME_VERTEX_SHADER } from './frame-shaders.js';
4
+ import { regionCentreInFrameUnits } from '../shell-region-measurement.js';
5
+ import { frameChoreography, rimsFocusedZone } from '../layout/layout-3d-transition-choreography.js';
6
+ import { Layout3DFrameChoreography, Layout3DZoneFocusTransition } from '../layout/layout-3d-vocabulary.js';
7
+ /** Draw order: the background, then every shadow, then every body — so no shadow lands on a slab. */
8
+ var FrameRenderOrder;
9
+ (function (FrameRenderOrder) {
10
+ FrameRenderOrder[FrameRenderOrder["BACKGROUND"] = 0] = "BACKGROUND";
11
+ FrameRenderOrder[FrameRenderOrder["SHADOW"] = 1] = "SHADOW";
12
+ FrameRenderOrder[FrameRenderOrder["BODY"] = 2] = "BODY";
13
+ /** The focused zone's rim, over the content slab it sits in. */
14
+ FrameRenderOrder[FrameRenderOrder["ZONE_RIM"] = 3] = "ZONE_RIM";
15
+ })(FrameRenderOrder || (FrameRenderOrder = {}));
16
+ /**
17
+ * What starts a transition of the frame (#1826). The driver reports each start to its host, which is how a live
18
+ * probe tells that a choreography ran rather than inferring it from draws.
19
+ */
20
+ export var FrameTransitionTrigger;
21
+ (function (FrameTransitionTrigger) {
22
+ /** A structural zone's top entry became another place. Plays the layout's `navigation` choreography. */
23
+ FrameTransitionTrigger["NAVIGATION"] = "navigation";
24
+ /** The sidebar's width changed while it stayed on screen. Plays the layout's `sidebarResize` choreography. */
25
+ FrameTransitionTrigger["SIDEBAR_RESIZE"] = "sidebar-resize";
26
+ /** The focused zone of a split changed, and the layout rims it. */
27
+ FrameTransitionTrigger["ZONE_FOCUS"] = "zone-focus";
28
+ })(FrameTransitionTrigger || (FrameTransitionTrigger = {}));
29
+ /** What a {@link FrameRenderer.pulse} call did. */
30
+ export var FramePulseOutcome;
31
+ (function (FramePulseOutcome) {
32
+ /** Nothing plays: no choreography for the trigger, reduced motion, or no appearance yet. */
33
+ FramePulseOutcome["NOT_PLAYED"] = "not-played";
34
+ /** A new pulse started (from rest, or a new rise out of a settle): a transition the driver reports. */
35
+ FramePulseOutcome["STARTED"] = "started";
36
+ /** The trigger's pulse was already rising and keeps rising: nothing new to report. */
37
+ FramePulseOutcome["ALREADY_RISING"] = "already-rising";
38
+ })(FramePulseOutcome || (FramePulseOutcome = {}));
39
+ const PRESENCE = 18;
40
+ const SLAB_SIZE = PRESENCE + 1;
41
+ /** A spring is considered at rest once it is this close to its target and this slow. */
42
+ const SPRING_REST = 1e-3;
43
+ /** Integration step for a spring, in seconds: small enough to stay stable for any stiffness a theme declares. */
44
+ const SPRING_STEP_SECONDS = 1 / 240;
45
+ /**
46
+ * The frame's scene, driven imperatively (#1823).
47
+ *
48
+ * It owns the meshes and their uniforms and nothing else: WHEN to draw is the canvas driver's decision. Every
49
+ * method is a plain state change plus {@link tick}, which applies the state for an instant and says whether
50
+ * anything is still animating — so the whole scene logic runs, and is tested, without a WebGL context.
51
+ *
52
+ * Hover emphasises the RIM only, and its progress is clamped to 0–1 even when a spring overshoots. The frost never
53
+ * changes on hover, so hover cannot take a colour outside the contrast bound the appearance derivation proved.
54
+ */
55
+ export class FrameRenderer {
56
+ group;
57
+ geometry = new PlaneGeometry(1, 1);
58
+ backgroundMesh;
59
+ slabs = [];
60
+ boxes = [];
61
+ viewport = { width: 0, height: 0 };
62
+ layoutSignature = '';
63
+ /** What is on screen now: the last frame applied, mid-cross-fade included. A new appearance fades FROM this. */
64
+ shown = null;
65
+ tween = null;
66
+ appearance = null;
67
+ hovered = null;
68
+ hover = new Map();
69
+ choreographies;
70
+ rimsFocusedZone;
71
+ pulses = new Map();
72
+ /** The zone the rim is moving toward, or null. */
73
+ rimmed = null;
74
+ zones = [];
75
+ zoneSignature = '';
76
+ focusedZone = null;
77
+ /** Each zone's rim emphasis, 0 to 1, stepped exactly like a hover emphasis. */
78
+ zoneEmphasis = new Map();
79
+ zoneRims = [];
80
+ /**
81
+ * @param transitions What the layout plays on a navigation, a sidebar resize and a zone focus. Defaults to nothing:
82
+ * a renderer with no layout behind it (a test, a probe) draws the regions and moves only on appearance and hover.
83
+ */
84
+ constructor(group, transitions = {
85
+ navigation: Layout3DFrameChoreography.NONE,
86
+ sidebarResize: Layout3DFrameChoreography.NONE,
87
+ zoneFocus: Layout3DZoneFocusTransition.NONE,
88
+ }) {
89
+ this.group = group;
90
+ this.choreographies = {
91
+ [FrameTransitionTrigger.NAVIGATION]: frameChoreography(transitions.navigation),
92
+ [FrameTransitionTrigger.SIDEBAR_RESIZE]: frameChoreography(transitions.sidebarResize),
93
+ };
94
+ this.rimsFocusedZone = rimsFocusedZone(transitions.zoneFocus);
95
+ this.backgroundMesh = new Mesh(this.geometry, bodyMaterial());
96
+ this.backgroundMesh.renderOrder = FrameRenderOrder.BACKGROUND;
97
+ this.backgroundMesh.visible = false;
98
+ group.add(this.backgroundMesh);
99
+ }
100
+ /**
101
+ * The appearance to draw. With `animate`, the change cross-fades over the appearance's transition time —
102
+ * except when the FOREGROUND changed (a light/dark switch): text switches colour at once, and a backdrop
103
+ * halfway between two modes is bounded against neither foreground, so the frame switches at once too.
104
+ */
105
+ setAppearance(appearance, options) {
106
+ const previous = this.appearance;
107
+ const target = displayedOf(appearance, this.shown);
108
+ if (appearance)
109
+ this.appearance = appearance;
110
+ // Reduced motion turned on mid-hover: the frame is static from now on, so the emphasis goes at once.
111
+ if (appearance && !appearance.hoverResponse) {
112
+ this.hovered = null;
113
+ this.hover.clear();
114
+ this.pulses.clear();
115
+ this.zoneEmphasis.clear();
116
+ }
117
+ // A reduced-motion switch (either way) or a new gesture spring changes what the focused zone's rim should do.
118
+ this.retargetZoneEmphasis();
119
+ const seconds = appearance?.transitionSeconds ?? 0;
120
+ const foregroundChanged = Boolean(previous && appearance && !sameColor(previous.foreground, appearance.foreground));
121
+ if (!options.animate || !this.shown || seconds <= 0 || foregroundChanged) {
122
+ this.shown = target;
123
+ this.tween = null;
124
+ return;
125
+ }
126
+ this.tween = { from: fadeInFrom(this.shown, target), to: target, start: null, seconds, easing: appearance?.transitionEasing ?? [0, 0, 1, 1] };
127
+ }
128
+ /** The measured regions. Returns false when nothing moved, so the caller can skip the frame entirely. */
129
+ setLayout(boxes, viewport) {
130
+ const signature = `${viewport.width}x${viewport.height}|${boxes.map((b) => `${b.region}:${b.left},${b.top},${b.width},${b.height},${b.glass ? 'g' : ''}`).join(';')}`;
131
+ this.boxes = boxes;
132
+ this.viewport = viewport;
133
+ // A region that left takes its hover state with it (an organization switch remounts regions).
134
+ const present = new Set(boxes.map((box) => box.element));
135
+ for (const key of this.hover.keys())
136
+ if (!present.has(key))
137
+ this.hover.delete(key);
138
+ if (this.hovered && !present.has(this.hovered))
139
+ this.hovered = null;
140
+ if (signature === this.layoutSignature)
141
+ return false;
142
+ this.layoutSignature = signature;
143
+ return true;
144
+ }
145
+ /**
146
+ * The region element under the pointer, or null. Its rim moves toward the hover opacity — unless the
147
+ * appearance says the frame is static (reduced motion). Returns whether anything will change on screen.
148
+ */
149
+ setHovered(element) {
150
+ const next = this.appearance?.hoverResponse ? element : null;
151
+ if (next === this.hovered)
152
+ return false;
153
+ for (const [key, target] of [[this.hovered, 0], [next, 1]]) {
154
+ if (!key)
155
+ continue;
156
+ const state = this.hover.get(key) ?? { value: 0, target: 0, velocity: 0, tween: null, spring: null, lastTick: null };
157
+ state.target = target;
158
+ state.spring = this.appearance?.hoverSpring ?? null;
159
+ state.tween = state.spring ? null : { from: state.value, to: target, start: null, seconds: this.appearance?.hoverSeconds ?? 0, easing: this.appearance?.hoverEasing ?? [0, 0, 1, 1] };
160
+ state.lastTick = null;
161
+ this.hover.set(key, state);
162
+ }
163
+ this.hovered = next;
164
+ return true;
165
+ }
166
+ /**
167
+ * A shell state changed (#1826): play the layout's choreography for that trigger. Nothing plays when the layout
168
+ * declares none for it, under reduced motion (the frame is static), or when the theme's motion duration is zero.
169
+ * Says whether a pulse STARTED, was already rising, or nothing plays.
170
+ */
171
+ pulse(trigger) {
172
+ const appearance = this.appearance;
173
+ const choreography = this.choreographies[trigger];
174
+ if (!choreography.plays || !appearance || !appearance.hoverResponse || appearance.transitionSeconds <= 0)
175
+ return FramePulseOutcome.NOT_PLAYED;
176
+ // Already rising: let it rise. Restarting would re-anchor its clock on every call, and a sidebar calls on every
177
+ // frame of its width animation, so the pulse would never leave where it stood until the sidebar stopped.
178
+ if (this.pulses.get(trigger)?.rising)
179
+ return FramePulseOutcome.ALREADY_RISING;
180
+ const halfSeconds = (appearance.transitionSeconds * choreography.durationFactor) / 2;
181
+ const from = this.pulses.get(trigger)?.value ?? 0;
182
+ this.pulses.set(trigger, { rising: true, value: from, tween: { from, to: 1, start: null, seconds: halfSeconds, easing: appearance.transitionEasing } });
183
+ return FramePulseOutcome.STARTED;
184
+ }
185
+ /** The zone whose rim is shown or moving in, or null: what the driver compares to report a zone-focus transition. */
186
+ rimmedZone() {
187
+ return this.rimmed;
188
+ }
189
+ /**
190
+ * The navigation zones inside the content region, and the focused one (the navigation state's `focusedZone`), or
191
+ * null. When the layout rims the focused zone and two or more zones are side by side, the focused one's rim moves
192
+ * toward full emphasis and every other zone's back to none. Returns whether anything will change on screen.
193
+ */
194
+ setZones(zones, focused) {
195
+ const signature = zones.map((zone) => `${zone.left},${zone.top},${zone.width},${zone.height}`).join(';');
196
+ const moved = signature !== this.zoneSignature;
197
+ this.zoneSignature = signature;
198
+ this.zones = zones;
199
+ const present = new Set(zones.map((zone) => zone.element));
200
+ for (const key of this.zoneEmphasis.keys())
201
+ if (!present.has(key))
202
+ this.zoneEmphasis.delete(key);
203
+ this.focusedZone = focused && present.has(focused) ? focused : null;
204
+ return this.retargetZoneEmphasis() || moved;
205
+ }
206
+ /** Whether a cross-fade, a hover or zone emphasis, or a pulse is still moving: the only reasons to draw when no region moved. */
207
+ hasPendingMotion() {
208
+ if (this.tween || this.pulses.size > 0)
209
+ return true;
210
+ for (const state of this.hover.values())
211
+ if (!hoverSettled(state))
212
+ return true;
213
+ for (const state of this.zoneEmphasis.values())
214
+ if (!hoverSettled(state))
215
+ return true;
216
+ return false;
217
+ }
218
+ /** Apply the state for instant `now` (ms). Returns true while a cross-fade or a hover emphasis is still moving. */
219
+ tick(now) {
220
+ if (this.tween) {
221
+ const { eased, done } = advance(this.tween, now);
222
+ if (done) {
223
+ this.shown = this.tween.to;
224
+ this.tween = null;
225
+ }
226
+ else {
227
+ this.shown = interpolateFrame(this.tween.from, this.tween.to, eased);
228
+ }
229
+ }
230
+ for (const [key, state] of this.hover) {
231
+ stepHover(state, now);
232
+ if (hoverSettled(state) && state.target === 0)
233
+ this.hover.delete(key);
234
+ }
235
+ for (const [key, state] of this.zoneEmphasis) {
236
+ stepHover(state, now);
237
+ if (hoverSettled(state) && state.target === 0)
238
+ this.zoneEmphasis.delete(key);
239
+ }
240
+ for (const [trigger, pulse] of this.pulses) {
241
+ const { eased, done } = advance(pulse.tween, now);
242
+ const { from, to } = pulse.tween;
243
+ pulse.value = from + (to - from) * eased;
244
+ if (done && pulse.rising) {
245
+ const easing = this.appearance?.transitionEasing ?? [0, 0, 1, 1];
246
+ this.pulses.set(trigger, { rising: false, value: 1, tween: { from: 1, to: 0, start: now, seconds: pulse.tween.seconds, easing } });
247
+ }
248
+ else if (done) {
249
+ this.pulses.delete(trigger);
250
+ }
251
+ }
252
+ this.apply(this.shown);
253
+ return this.hasPendingMotion();
254
+ }
255
+ dispose() {
256
+ this.group.remove(this.backgroundMesh);
257
+ this.backgroundMesh.material.dispose();
258
+ for (const slab of this.slabs) {
259
+ this.group.remove(slab.body, slab.shadow);
260
+ slab.body.material.dispose();
261
+ slab.shadow.material.dispose();
262
+ }
263
+ this.slabs.length = 0;
264
+ for (const rim of this.zoneRims) {
265
+ this.group.remove(rim);
266
+ rim.material.dispose();
267
+ }
268
+ this.zoneRims.length = 0;
269
+ this.geometry.dispose();
270
+ }
271
+ apply(frame) {
272
+ const { width, height } = this.viewport;
273
+ // Each pulse's strength, 0 at rest: `sin` shapes the rise-and-settle into one smooth swell. Two pulses at once (a
274
+ // navigation that also resized the sidebar) combine by their MAXIMUM, never by summing: the frame never moves
275
+ // further than one choreography would move it.
276
+ let lightSweepDeg = 0;
277
+ let shadowSpread = 0;
278
+ let rimLift = 0;
279
+ for (const [trigger, pulse] of this.pulses) {
280
+ const strength = Math.sin((Math.PI / 2) * clamp01(pulse.value));
281
+ const choreography = this.choreographies[trigger];
282
+ lightSweepDeg = Math.max(lightSweepDeg, choreography.lightSweepDeg * strength);
283
+ shadowSpread = Math.max(shadowSpread, choreography.shadowSpread * strength);
284
+ rimLift = Math.max(rimLift, choreography.rimLift * strength);
285
+ }
286
+ const light = frame ? (frame.lightAngleDeg + lightSweepDeg) * (Math.PI / 180) : 0;
287
+ const lightDir = new Vector2(Math.cos(light), Math.sin(light));
288
+ const background = frame?.background;
289
+ this.backgroundMesh.visible = Boolean(background && background[6] > 0 && width > 0 && height > 0);
290
+ if (background && this.backgroundMesh.visible) {
291
+ setBody(this.backgroundMesh.material, {
292
+ size: [width, height],
293
+ quad: [width + 2, height + 2],
294
+ radius: 0,
295
+ fillFrom: background.slice(0, 3),
296
+ fillTo: background.slice(3, 6),
297
+ fillAlpha: 1,
298
+ rim: [0, 0, 0],
299
+ rimAlpha: 0,
300
+ rimWidth: 0,
301
+ lightDir,
302
+ presence: background[6],
303
+ });
304
+ this.backgroundMesh.scale.set(width + 2, height + 2, 1);
305
+ this.backgroundMesh.position.set(0, 0, 0);
306
+ }
307
+ while (this.slabs.length < this.boxes.length)
308
+ this.slabs.push(this.createSlab());
309
+ this.slabs.forEach((slab, index) => {
310
+ const box = this.boxes[index];
311
+ const params = box && frame ? frame.regions[box.region] : undefined;
312
+ const presence = params && box && !box.glass ? params[PRESENCE] : 0;
313
+ slab.body.visible = presence > 0;
314
+ slab.shadow.visible = presence > 0 && params[17] > 0 && this.backgroundMesh.visible;
315
+ if (!box || !params || presence <= 0)
316
+ return;
317
+ const centre = regionCentreInFrameUnits(box, this.viewport);
318
+ const radius = params[13];
319
+ // The content slab carries the lift: its rim toward the hover opacity (never past it), its shadow further.
320
+ const isContent = box.region === ShellRegion.CONTENT;
321
+ const hover = Math.max(clamp01(this.hover.get(box.element)?.value ?? 0), isContent ? rimLift : 0);
322
+ setBody(slab.body.material, {
323
+ size: [box.width, box.height],
324
+ quad: [box.width + 2, box.height + 2],
325
+ radius,
326
+ fillFrom: params.slice(0, 3),
327
+ fillTo: params.slice(3, 6),
328
+ fillAlpha: params[6],
329
+ rim: params.slice(7, 10),
330
+ rimAlpha: params[10] + (params[11] - params[10]) * hover,
331
+ rimWidth: params[12],
332
+ lightDir,
333
+ presence,
334
+ });
335
+ slab.body.scale.set(box.width + 2, box.height + 2, 1);
336
+ slab.body.position.set(centre.x, centre.y, 0);
337
+ if (!slab.shadow.visible)
338
+ return;
339
+ // Blur and offset grow with the lift; the opacity never does (the frost bound covers the strongest shadow's opacity anywhere).
340
+ const spread = 1 + (isContent ? shadowSpread : 0);
341
+ const [offsetX, offsetY, blur, alpha] = [params[14] * spread, params[15] * spread, params[16] * spread, params[17]];
342
+ const margin = blur + 2;
343
+ const u = slab.shadow.material.uniforms;
344
+ u.uSize.value.set(box.width, box.height);
345
+ u.uQuad.value.set(box.width + 2 * margin, box.height + 2 * margin);
346
+ u.uRadius.value = radius;
347
+ u.uBlur.value = blur;
348
+ u.uAlpha.value = alpha;
349
+ u.uPresence.value = presence;
350
+ u.uBgFrom.value.set(background[0], background[1], background[2]);
351
+ u.uBgTo.value.set(background[3], background[4], background[5]);
352
+ u.uBgSize.value.set(width, height);
353
+ u.uLightDir.value.copy(lightDir);
354
+ slab.shadow.scale.set(box.width + 2 * margin, box.height + 2 * margin, 1);
355
+ // The DOM's y axis points down and three.js' up, so a downward CSS offset is a negative y.
356
+ slab.shadow.position.set(centre.x + offsetX, centre.y - offsetY, 0);
357
+ });
358
+ this.applyZoneRims(frame, lightDir);
359
+ }
360
+ /**
361
+ * The focused zone's rim, drawn over the content slab: the content slab's rim colour and width, at most its HOVER
362
+ * opacity, and no fill. Each rim is inset from the content box by the content rim's own width, so it never lands
363
+ * on the content slab's rim band — where it is drawn, it is exactly the case the contrast bound was proved for.
364
+ */
365
+ applyZoneRims(frame, lightDir) {
366
+ const content = this.boxes.find((box) => box.region === ShellRegion.CONTENT);
367
+ const params = frame?.regions[ShellRegion.CONTENT];
368
+ const drawable = Boolean(content && params && !content.glass && params[PRESENCE] > 0);
369
+ const shown = drawable ? this.zones.filter((zone) => (this.zoneEmphasis.get(zone.element)?.value ?? 0) > 0) : [];
370
+ while (this.zoneRims.length < shown.length) {
371
+ const rim = new Mesh(this.geometry, bodyMaterial());
372
+ rim.renderOrder = FrameRenderOrder.ZONE_RIM;
373
+ this.group.add(rim);
374
+ this.zoneRims.push(rim);
375
+ }
376
+ this.zoneRims.forEach((rim, index) => {
377
+ const zone = shown[index];
378
+ rim.visible = false;
379
+ if (!zone || !content || !params)
380
+ return;
381
+ const inset = params[12] + 1;
382
+ const left = Math.max(zone.left, content.left + inset);
383
+ const top = Math.max(zone.top, content.top + inset);
384
+ const right = Math.min(zone.left + zone.width, content.left + content.width - inset);
385
+ const bottom = Math.min(zone.top + zone.height, content.top + content.height - inset);
386
+ if (right - left <= 0 || bottom - top <= 0)
387
+ return;
388
+ const emphasis = clamp01(this.zoneEmphasis.get(zone.element)?.value ?? 0);
389
+ const box = { left, top, width: right - left, height: bottom - top };
390
+ const centre = regionCentreInFrameUnits(box, this.viewport);
391
+ rim.visible = true;
392
+ setBody(rim.material, {
393
+ size: [box.width, box.height],
394
+ quad: [box.width + 2, box.height + 2],
395
+ radius: Math.max(0, params[13] - inset),
396
+ fillFrom: params.slice(0, 3),
397
+ fillTo: params.slice(3, 6),
398
+ fillAlpha: 0,
399
+ rim: params.slice(7, 10),
400
+ rimAlpha: params[11] * emphasis,
401
+ rimWidth: params[12],
402
+ lightDir,
403
+ presence: params[PRESENCE],
404
+ });
405
+ rim.scale.set(box.width + 2, box.height + 2, 1);
406
+ rim.position.set(centre.x, centre.y, 0);
407
+ });
408
+ }
409
+ /** Point every zone's rim emphasis at its target: 1 for the focused zone of a split, 0 otherwise. Returns whether any target moved. */
410
+ retargetZoneEmphasis() {
411
+ const active = this.rimsFocusedZone && Boolean(this.appearance?.hoverResponse) && this.zones.length >= 2 ? this.focusedZone : null;
412
+ this.rimmed = active;
413
+ let changed = false;
414
+ for (const zone of this.zones) {
415
+ const target = zone.element === active ? 1 : 0;
416
+ const state = this.zoneEmphasis.get(zone.element);
417
+ if ((state?.target ?? 0) === target)
418
+ continue;
419
+ const next = state ?? { value: 0, target: 0, velocity: 0, tween: null, spring: null, lastTick: null };
420
+ next.target = target;
421
+ next.spring = this.appearance?.hoverSpring ?? null;
422
+ next.tween = next.spring ? null : { from: next.value, to: target, start: null, seconds: this.appearance?.hoverSeconds ?? 0, easing: this.appearance?.hoverEasing ?? [0, 0, 1, 1] };
423
+ next.lastTick = null;
424
+ this.zoneEmphasis.set(zone.element, next);
425
+ changed = true;
426
+ }
427
+ return changed;
428
+ }
429
+ createSlab() {
430
+ const body = new Mesh(this.geometry, bodyMaterial());
431
+ body.renderOrder = FrameRenderOrder.BODY;
432
+ const shadow = new Mesh(this.geometry, shadowMaterial());
433
+ shadow.renderOrder = FrameRenderOrder.SHADOW;
434
+ this.group.add(shadow, body);
435
+ return { body, shadow };
436
+ }
437
+ }
438
+ /** A cubic-Bézier easing evaluated at `x` in 0–1, as CSS `cubic-bezier()` defines it (x → y, solved by Newton then bisection). */
439
+ export function evaluateEasing([x1, y1, x2, y2], x) {
440
+ if (x <= 0)
441
+ return 0;
442
+ if (x >= 1)
443
+ return 1;
444
+ const sample = (a, b, t) => ((1 - 3 * b + 3 * a) * t + (3 * b - 6 * a)) * t * t + 3 * a * t;
445
+ const slope = (a, b, t) => 3 * (1 - 3 * b + 3 * a) * t * t + 2 * (3 * b - 6 * a) * t + 3 * a;
446
+ let t = x;
447
+ for (let i = 0; i < 8; i += 1) {
448
+ const error = sample(x1, x2, t) - x;
449
+ if (Math.abs(error) < 1e-6)
450
+ return sample(y1, y2, t);
451
+ const d = slope(x1, x2, t);
452
+ if (Math.abs(d) < 1e-6)
453
+ break;
454
+ t -= error / d;
455
+ }
456
+ let low = 0;
457
+ let high = 1;
458
+ t = x;
459
+ for (let i = 0; i < 30; i += 1) {
460
+ const value = sample(x1, x2, t);
461
+ if (Math.abs(value - x) < 1e-6)
462
+ break;
463
+ if (value < x)
464
+ low = t;
465
+ else
466
+ high = t;
467
+ t = (low + high) / 2;
468
+ }
469
+ return sample(y1, y2, t);
470
+ }
471
+ /**
472
+ * A tween's progress at `now`.
473
+ *
474
+ * - `eased` is CLAMPED to 0–1. A theme's easing token may overshoot (a y control point above 1 — the engine's own
475
+ * `emphasized` easing does), and an overshooting cross-fade would pass its target: colours outside 0–1, a frost
476
+ * more opaque than the bound was computed for. The frame follows the curve's shape inside the range only.
477
+ * - `done` is decided by ELAPSED TIME, never by the eased value: an overshooting curve reaches 1 early, and ending
478
+ * the tween there would cut the transition short.
479
+ */
480
+ function advance(tween, now) {
481
+ if (tween.seconds <= 0)
482
+ return { eased: 1, done: true };
483
+ if (tween.start === null)
484
+ tween.start = now;
485
+ const linear = Math.min(1, Math.max(0, (now - tween.start) / (tween.seconds * 1000)));
486
+ return linear >= 1 ? { eased: 1, done: true } : { eased: clamp01(evaluateEasing(tween.easing, linear)), done: false };
487
+ }
488
+ function stepHover(state, now) {
489
+ if (state.tween) {
490
+ const { eased, done } = advance(state.tween, now);
491
+ state.value = state.tween.from + (state.tween.to - state.tween.from) * eased;
492
+ if (done)
493
+ state.tween = null;
494
+ return;
495
+ }
496
+ if (!state.spring)
497
+ return;
498
+ const last = state.lastTick ?? now;
499
+ state.lastTick = now;
500
+ let remaining = Math.min(0.25, Math.max(0, (now - last) / 1000));
501
+ const { stiffness, damping, mass } = state.spring;
502
+ while (remaining > 0) {
503
+ const dt = Math.min(SPRING_STEP_SECONDS, remaining);
504
+ const acceleration = (-stiffness * (state.value - state.target) - damping * state.velocity) / mass;
505
+ state.velocity += acceleration * dt;
506
+ state.value += state.velocity * dt;
507
+ remaining -= dt;
508
+ }
509
+ if (Math.abs(state.value - state.target) < SPRING_REST && Math.abs(state.velocity) < SPRING_REST) {
510
+ state.value = state.target;
511
+ state.velocity = 0;
512
+ }
513
+ }
514
+ function hoverSettled(state) {
515
+ if (state.tween)
516
+ return false;
517
+ return state.value === state.target && state.velocity === 0;
518
+ }
519
+ function clamp01(value) {
520
+ return Math.min(1, Math.max(0, value));
521
+ }
522
+ function sameColor(a, b) {
523
+ return a.every((channel, i) => Math.abs(channel - b[i]) < 1e-9);
524
+ }
525
+ function slabVector(slab) {
526
+ return [
527
+ ...slab.fillFrom, ...slab.fillTo, slab.fillAlpha,
528
+ ...slab.rim, slab.rimAlpha, slab.rimHoverAlpha, slab.rimWidthPx, slab.radiusPx,
529
+ slab.shadowOffsetXPx, slab.shadowOffsetYPx, slab.shadowBlurPx, slab.shadowAlpha,
530
+ slab.visible ? 1 : 0,
531
+ ];
532
+ }
533
+ /**
534
+ * The numbers to display for an appearance. A null appearance (or an invisible slab) keeps the PREVIOUS colours
535
+ * with presence 0, so a fade-out fades the colours that were on screen instead of flashing to black.
536
+ */
537
+ function displayedOf(appearance, previous) {
538
+ const regions = {};
539
+ for (const region of Object.values(ShellRegion)) {
540
+ const slab = appearance?.regions[region];
541
+ const prior = previous?.regions[region];
542
+ if (slab?.visible)
543
+ regions[region] = slabVector(slab);
544
+ else
545
+ regions[region] = prior ? [...prior.slice(0, PRESENCE), 0] : new Array(SLAB_SIZE).fill(0);
546
+ }
547
+ const background = appearance?.background
548
+ ? [...appearance.background.from, ...appearance.background.to, 1]
549
+ : previous ? [...previous.background.slice(0, 6), 0] : [0, 0, 0, 0, 0, 0, 0];
550
+ return { regions, background, lightAngleDeg: appearance?.lightAngleDeg ?? previous?.lightAngleDeg ?? 0 };
551
+ }
552
+ /**
553
+ * The start of a cross-fade: what is on screen, except that anything NOT on screen (presence 0) starts from the
554
+ * TARGET's colours. A slab appearing then fades in as itself — only its presence moves — instead of passing
555
+ * through black, which no contrast bound covers.
556
+ */
557
+ function fadeInFrom(shown, target) {
558
+ const regions = {};
559
+ for (const region of Object.values(ShellRegion)) {
560
+ const current = shown.regions[region];
561
+ regions[region] = current[PRESENCE] > 0 ? current : [...target.regions[region].slice(0, PRESENCE), 0];
562
+ }
563
+ const background = shown.background[6] > 0 ? shown.background : [...target.background.slice(0, 6), 0];
564
+ return { regions, background, lightAngleDeg: shown.lightAngleDeg };
565
+ }
566
+ function interpolateFrame(from, to, t) {
567
+ const lerp = (a, b) => a.map((value, i) => value + (b[i] - value) * t);
568
+ const regions = {};
569
+ for (const region of Object.values(ShellRegion))
570
+ regions[region] = lerp(from.regions[region], to.regions[region]);
571
+ return { regions, background: lerp(from.background, to.background), lightAngleDeg: from.lightAngleDeg + (to.lightAngleDeg - from.lightAngleDeg) * t };
572
+ }
573
+ function bodyMaterial() {
574
+ return new ShaderMaterial({
575
+ vertexShader: FRAME_VERTEX_SHADER,
576
+ fragmentShader: FRAME_BODY_FRAGMENT_SHADER,
577
+ transparent: true,
578
+ depthTest: false,
579
+ depthWrite: false,
580
+ toneMapped: false,
581
+ uniforms: {
582
+ uSize: { value: new Vector2() },
583
+ uQuad: { value: new Vector2() },
584
+ uRadius: { value: 0 },
585
+ uFillFrom: { value: new Vector3() },
586
+ uFillTo: { value: new Vector3() },
587
+ uFillAlpha: { value: 0 },
588
+ uRim: { value: new Vector3() },
589
+ uRimAlpha: { value: 0 },
590
+ uRimWidth: { value: 0 },
591
+ uLightDir: { value: new Vector2(0, 1) },
592
+ uPresence: { value: 0 },
593
+ },
594
+ });
595
+ }
596
+ /** The shadow writes an opaque, darkened background with MIN blending: overlapping shadows never stack. */
597
+ function shadowMaterial() {
598
+ return new ShaderMaterial({
599
+ vertexShader: FRAME_VERTEX_SHADER,
600
+ fragmentShader: FRAME_SHADOW_FRAGMENT_SHADER,
601
+ transparent: true,
602
+ depthTest: false,
603
+ depthWrite: false,
604
+ toneMapped: false,
605
+ blending: CustomBlending,
606
+ blendEquation: MinEquation,
607
+ uniforms: {
608
+ uSize: { value: new Vector2() },
609
+ uQuad: { value: new Vector2() },
610
+ uRadius: { value: 0 },
611
+ uBlur: { value: 0 },
612
+ uAlpha: { value: 0 },
613
+ uPresence: { value: 0 },
614
+ uBgFrom: { value: new Vector3() },
615
+ uBgTo: { value: new Vector3() },
616
+ uBgSize: { value: new Vector2() },
617
+ uLightDir: { value: new Vector2(0, 1) },
618
+ },
619
+ });
620
+ }
621
+ function setBody(material, values) {
622
+ const u = material.uniforms;
623
+ u.uSize.value.set(...values.size);
624
+ u.uQuad.value.set(...values.quad);
625
+ u.uRadius.value = values.radius;
626
+ u.uFillFrom.value.set(values.fillFrom[0], values.fillFrom[1], values.fillFrom[2]);
627
+ u.uFillTo.value.set(values.fillTo[0], values.fillTo[1], values.fillTo[2]);
628
+ u.uFillAlpha.value = values.fillAlpha;
629
+ u.uRim.value.set(values.rim[0], values.rim[1], values.rim[2]);
630
+ u.uRimAlpha.value = values.rimAlpha;
631
+ u.uRimWidth.value = values.rimWidth;
632
+ u.uLightDir.value.copy(values.lightDir);
633
+ u.uPresence.value = values.presence;
634
+ }
635
+ //# sourceMappingURL=frame-renderer.js.map