@cueplusplus/ui 0.18.2 → 0.20.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.
Files changed (189) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/dist/brand/_motif-seed.js +20 -0
  3. package/dist/brand/gloss-object.d.ts +23 -0
  4. package/dist/brand/gloss-object.js +108 -0
  5. package/dist/brand/halftone.d.ts +23 -0
  6. package/dist/brand/halftone.js +54 -0
  7. package/dist/brand/index.d.ts +8 -1
  8. package/dist/brand/motif.d.ts +32 -0
  9. package/dist/brand/motif.js +71 -0
  10. package/dist/brand/particle-logo.d.ts +50 -0
  11. package/dist/brand/particle-logo.js +437 -0
  12. package/dist/brand/particle-mark.d.ts +29 -0
  13. package/dist/brand/particle-mark.js +137 -0
  14. package/dist/brand/ribbon.d.ts +20 -0
  15. package/dist/brand/ribbon.js +54 -0
  16. package/dist/brand/text-scramble.d.ts +42 -0
  17. package/dist/brand/text-scramble.js +160 -0
  18. package/dist/chat/delegation-card.js +2 -1
  19. package/dist/chat/message.js +12 -13
  20. package/dist/chrome/announcement-bar.d.ts +30 -0
  21. package/dist/chrome/announcement-bar.js +38 -0
  22. package/dist/chrome/app-bar.d.ts +4 -1
  23. package/dist/chrome/app-bar.js +7 -2
  24. package/dist/chrome/app-shell.js +8 -1
  25. package/dist/chrome/band.d.ts +3 -1
  26. package/dist/chrome/band.js +4 -3
  27. package/dist/chrome/hero.d.ts +61 -0
  28. package/dist/chrome/hero.js +108 -0
  29. package/dist/chrome/index.d.ts +6 -1
  30. package/dist/chrome/index.js +6 -1
  31. package/dist/chrome/keyword.d.ts +22 -0
  32. package/dist/chrome/keyword.js +28 -0
  33. package/dist/chrome/logo-strip.d.ts +23 -0
  34. package/dist/chrome/logo-strip.js +43 -0
  35. package/dist/chrome/page-shell.js +13 -1
  36. package/dist/chrome/stat-band.d.ts +32 -0
  37. package/dist/chrome/stat-band.js +39 -0
  38. package/dist/color/color-area.js +2 -1
  39. package/dist/dmx/channel-matrix.js +5 -3
  40. package/dist/dmx/dmx-strip.js +5 -3
  41. package/dist/elements/animated-number.js +4 -1
  42. package/dist/elements/replay.d.ts +1 -1
  43. package/dist/elements/replay.js +5 -2
  44. package/dist/elements/scroll-anchor.js +4 -1
  45. package/dist/forms/_chassis.d.ts +1 -1
  46. package/dist/forms/_chassis.js +2 -2
  47. package/dist/forms/env-var-input.js +2 -1
  48. package/dist/forms/file-upload.js +2 -1
  49. package/dist/index.d.ts +17 -1
  50. package/dist/index.js +18 -2
  51. package/dist/instruments/data-table.js +3 -2
  52. package/dist/instruments/index.js +1 -1
  53. package/dist/instruments/stat.js +1 -1
  54. package/dist/instruments/table.js +1 -1
  55. package/dist/instruments/terminal-frame.js +5 -2
  56. package/dist/layout/grid.js +4 -2
  57. package/dist/layout/index.d.ts +2 -1
  58. package/dist/layout/index.js +2 -1
  59. package/dist/layout/preview.js +4 -188
  60. package/dist/layout/reveal.d.ts +44 -0
  61. package/dist/layout/reveal.js +106 -0
  62. package/dist/layout/sidebar.js +88 -52
  63. package/dist/lib/cn.js +23 -5
  64. package/dist/lib/particle-field-shaders.js +236 -0
  65. package/dist/lib/particle-field.js +210 -0
  66. package/dist/midi/spectrum-visualizer.js +2 -1
  67. package/dist/overlays/_surface.d.ts +2 -2
  68. package/dist/primitives/button.js +22 -3
  69. package/dist/primitives/chip.d.ts +1 -1
  70. package/dist/primitives/chip.js +63 -6
  71. package/dist/styles.css +26 -0
  72. package/dist/system/_scoped-pairing.js +18 -0
  73. package/dist/system/_stage-scope.js +199 -0
  74. package/dist/system/_style-check.js +1 -1
  75. package/dist/system/_style-chip.js +1 -1
  76. package/dist/system/_style-field-chassis.js +14 -1
  77. package/dist/system/_style-group-label.js +7 -3
  78. package/dist/system/_style-hero-title.js +41 -0
  79. package/dist/system/_style-kbd.js +1 -1
  80. package/dist/system/_style-label-ink.js +12 -5
  81. package/dist/system/_style-menu.js +1 -1
  82. package/dist/system/_style-page-title.js +1 -1
  83. package/dist/system/_style-segmented-control.js +1 -1
  84. package/dist/system/_style-skeleton.js +1 -1
  85. package/dist/system/_style-tab.js +1 -1
  86. package/dist/system/font-scope.d.ts +38 -0
  87. package/dist/system/font-scope.js +69 -0
  88. package/dist/system/frame-clock.d.ts +33 -0
  89. package/dist/system/frame-clock.js +55 -0
  90. package/dist/system/index.d.ts +4 -1
  91. package/dist/system/index.js +4 -1
  92. package/dist/system/portal.js +7 -1
  93. package/dist/system/still.d.ts +32 -0
  94. package/dist/system/still.js +60 -0
  95. package/manifest/components/alert-dialog.json +1 -1
  96. package/manifest/components/announcement-bar.json +82 -0
  97. package/manifest/components/app-bar.json +3 -2
  98. package/manifest/components/app-shell.json +3 -0
  99. package/manifest/components/autocomplete.json +2 -0
  100. package/manifest/components/band.json +2 -1
  101. package/manifest/components/bento.json +13 -13
  102. package/manifest/components/button.json +1 -0
  103. package/manifest/components/channel-matrix.json +1 -0
  104. package/manifest/components/chart-tooltip-content.json +1 -0
  105. package/manifest/components/checkbox-group.json +1 -1
  106. package/manifest/components/checkbox.json +1 -1
  107. package/manifest/components/chip.json +1 -0
  108. package/manifest/components/color-area.json +10 -0
  109. package/manifest/components/color-field.json +3 -0
  110. package/manifest/components/color-picker.json +3 -0
  111. package/manifest/components/combobox.json +2 -0
  112. package/manifest/components/command-palette.json +1 -0
  113. package/manifest/components/composer.json +3 -0
  114. package/manifest/components/context-menu.json +1 -1
  115. package/manifest/components/data-table-pagination.json +3 -0
  116. package/manifest/components/data-table-toolbar.json +3 -0
  117. package/manifest/components/date-field.json +3 -0
  118. package/manifest/components/date-picker.json +3 -0
  119. package/manifest/components/date-range-picker.json +3 -0
  120. package/manifest/components/delegation-card.json +6 -0
  121. package/manifest/components/density.json +6 -2
  122. package/manifest/components/dialog.json +1 -1
  123. package/manifest/components/dmx-bar.json +1 -1
  124. package/manifest/components/dmx-strip.json +1 -1
  125. package/manifest/components/drawer.json +1 -1
  126. package/manifest/components/dropdown-menu.json +1 -0
  127. package/manifest/components/env-var-input.json +3 -0
  128. package/manifest/components/file-upload.json +5 -0
  129. package/manifest/components/font-scope.json +82 -0
  130. package/manifest/components/footer.json +0 -1
  131. package/manifest/components/gloss-object.json +81 -0
  132. package/manifest/components/grid.json +6 -6
  133. package/manifest/components/halftone.json +79 -0
  134. package/manifest/components/hero.json +204 -0
  135. package/manifest/components/hover-card.json +1 -1
  136. package/manifest/components/input-group.json +3 -0
  137. package/manifest/components/input.json +3 -0
  138. package/manifest/components/kbd.json +1 -0
  139. package/manifest/components/keyword.json +67 -0
  140. package/manifest/components/ledger.json +5 -5
  141. package/manifest/components/logo-strip.json +93 -0
  142. package/manifest/components/markdown-text.json +3 -3
  143. package/manifest/components/menubar.json +1 -1
  144. package/manifest/components/message.json +8 -0
  145. package/manifest/components/motif.json +90 -0
  146. package/manifest/components/multi-select.json +2 -0
  147. package/manifest/components/musical-time-input.json +3 -0
  148. package/manifest/components/navigation-menu.json +1 -0
  149. package/manifest/components/number-field.json +3 -0
  150. package/manifest/components/otp-field.json +3 -0
  151. package/manifest/components/page-shell.json +5 -0
  152. package/manifest/components/particle-logo.json +140 -0
  153. package/manifest/components/particle-mark.json +75 -0
  154. package/manifest/components/password-input.json +3 -0
  155. package/manifest/components/popover.json +1 -1
  156. package/manifest/components/preview.json +10 -4
  157. package/manifest/components/radio-group.json +0 -1
  158. package/manifest/components/radio.json +0 -1
  159. package/manifest/components/reveal.json +106 -0
  160. package/manifest/components/ribbon.json +65 -0
  161. package/manifest/components/scrollable-tabs-list.json +1 -0
  162. package/manifest/components/scrub-input.json +3 -0
  163. package/manifest/components/search-input.json +3 -0
  164. package/manifest/components/segmented-control.json +1 -0
  165. package/manifest/components/select.json +3 -0
  166. package/manifest/components/sheet.json +1 -1
  167. package/manifest/components/sidebar.json +1 -0
  168. package/manifest/components/skeleton.json +1 -0
  169. package/manifest/components/spectrum-visualizer.json +10 -0
  170. package/manifest/components/stack.json +4 -4
  171. package/manifest/components/stat-band.json +68 -0
  172. package/manifest/components/stat.json +1 -1
  173. package/manifest/components/still-provider.json +60 -0
  174. package/manifest/components/switch.json +0 -1
  175. package/manifest/components/table.json +2 -2
  176. package/manifest/components/tabs.json +1 -0
  177. package/manifest/components/tags-input.json +2 -0
  178. package/manifest/components/text-scramble.json +90 -0
  179. package/manifest/components/textarea.json +3 -0
  180. package/manifest/components/theme-configurator.json +1 -0
  181. package/manifest/components/theme-provider.json +10 -4
  182. package/manifest/components/time-field.json +3 -0
  183. package/manifest/components/toast.json +1 -0
  184. package/manifest/components/tooltip.json +1 -1
  185. package/manifest/fixtures.json +18 -0
  186. package/manifest/manifest.json +368 -80
  187. package/manifest/tokens.json +93 -3
  188. package/package.json +6 -6
  189. package/dist/system/_paint-ground-pattern.js +0 -30
@@ -0,0 +1,437 @@
1
+ "use client";
2
+ import { cn } from "../lib/cn.js";
3
+ import { usePrefersReducedMotion } from "../instruments/_motion.js";
4
+ import { useStill } from "../system/still.js";
5
+ import { FAR, NEAR, TouchTrail, logoScale, modelView, normaliseColour, perspective, pickWaveColor, rasterise, sampleParticles, srgbToLinear, viewportAt } from "../lib/particle-field.js";
6
+ import { FRAGMENT_SHADER, VERTEX_SHADER } from "../lib/particle-field-shaders.js";
7
+ import * as React from "react";
8
+ import { jsx, jsxs } from "react/jsx-runtime";
9
+ //#region src/brand/particle-logo.tsx
10
+ /**
11
+ * The roles a wave sweeps through when no `waveColors` are given: the Look's own accent and
12
+ * signal colours, read off the element at run time, so the waves are the theme's.
13
+ */
14
+ const ROLE_COLOURS = [
15
+ "--cue-accent",
16
+ "--cue-info",
17
+ "--cue-busy",
18
+ "--cue-ok",
19
+ "--cue-warn",
20
+ "--cue-danger",
21
+ "--cue-stream",
22
+ "--cue-fg"
23
+ ];
24
+ /** The Look's role colours as linear-light triples, read through the element's own cascade. */
25
+ function rolePalette(element) {
26
+ const style = getComputedStyle(element);
27
+ return ROLE_COLOURS.map((name) => normaliseColour(style.getPropertyValue(name).trim())).filter((hex) => hex !== null).map(srgbToLinear);
28
+ }
29
+ function compile(gl, type, source) {
30
+ const shader = gl.createShader(type);
31
+ if (shader === null) return null;
32
+ gl.shaderSource(shader, source);
33
+ gl.compileShader(shader);
34
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
35
+ gl.deleteShader(shader);
36
+ return null;
37
+ }
38
+ return shader;
39
+ }
40
+ function build(gl, raster, sampled) {
41
+ const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
42
+ const fragment = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
43
+ if (vertex === null || fragment === null) return null;
44
+ const program = gl.createProgram();
45
+ gl.attachShader(program, vertex);
46
+ gl.attachShader(program, fragment);
47
+ gl.linkProgram(program);
48
+ gl.deleteShader(vertex);
49
+ gl.deleteShader(fragment);
50
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
51
+ gl.deleteProgram(program);
52
+ return null;
53
+ }
54
+ const vao = gl.createVertexArray();
55
+ gl.bindVertexArray(vao);
56
+ const buffers = [];
57
+ const attribute = (name, data, size, type, divisor) => {
58
+ const location = gl.getAttribLocation(program, name);
59
+ const buffer = gl.createBuffer();
60
+ buffers.push(buffer);
61
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
62
+ gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
63
+ if (location < 0) return;
64
+ gl.enableVertexAttribArray(location);
65
+ gl.vertexAttribPointer(location, size, type, false, 0, 0);
66
+ gl.vertexAttribDivisor(location, divisor);
67
+ };
68
+ attribute("position", new Float32Array([
69
+ -.5,
70
+ .5,
71
+ 0,
72
+ .5,
73
+ .5,
74
+ 0,
75
+ -.5,
76
+ -.5,
77
+ 0,
78
+ .5,
79
+ -.5,
80
+ 0
81
+ ]), 3, gl.FLOAT, 0);
82
+ attribute("uv", new Float32Array([
83
+ 0,
84
+ 0,
85
+ 1,
86
+ 0,
87
+ 0,
88
+ 1,
89
+ 1,
90
+ 1
91
+ ]), 2, gl.FLOAT, 0);
92
+ attribute("pindex", sampled.indices, 1, gl.UNSIGNED_SHORT, 1);
93
+ attribute("offset", sampled.offsets, 3, gl.FLOAT, 1);
94
+ attribute("angle", sampled.angles, 1, gl.FLOAT, 1);
95
+ const elements = gl.createBuffer();
96
+ buffers.push(elements);
97
+ gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, elements);
98
+ gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array([
99
+ 0,
100
+ 2,
101
+ 1,
102
+ 2,
103
+ 3,
104
+ 1
105
+ ]), gl.STATIC_DRAW);
106
+ gl.bindVertexArray(null);
107
+ const image = gl.createTexture();
108
+ gl.bindTexture(gl.TEXTURE_2D, image);
109
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
110
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, raster.width, raster.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array(raster.data.buffer));
111
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
112
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
113
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
114
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
115
+ const touch = gl.createTexture();
116
+ gl.bindTexture(gl.TEXTURE_2D, touch);
117
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, TouchTrail.SIZE, TouchTrail.SIZE, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
118
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
119
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
120
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
121
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
122
+ const locations = /* @__PURE__ */ new Map();
123
+ const uniform = (name) => {
124
+ if (!locations.has(name)) locations.set(name, gl.getUniformLocation(program, name));
125
+ return locations.get(name);
126
+ };
127
+ return {
128
+ program,
129
+ vao,
130
+ buffers,
131
+ image,
132
+ touch,
133
+ count: sampled.count,
134
+ uniform
135
+ };
136
+ }
137
+ function release(gl, scene) {
138
+ for (const buffer of scene.buffers) gl.deleteBuffer(buffer);
139
+ gl.deleteVertexArray(scene.vao);
140
+ gl.deleteTexture(scene.image);
141
+ gl.deleteTexture(scene.touch);
142
+ gl.deleteProgram(scene.program);
143
+ }
144
+ /**
145
+ * A mark drawn as a field of particles that wiggle, flip colour in sweeping waves and scatter
146
+ * from the pointer — cueplusplus.com's hero logo, ported from three.js to raw WebGL2 with its
147
+ * shaders unchanged (spec 2026-10-04-cue-site-verbatim §4.1).
148
+ *
149
+ * The camera, the mesh scale, the textures, the blending and the wave and touch timings are the
150
+ * source's, copied: a 50° camera at z = 300, the mark 26% of the canvas's height, a wave every
151
+ * 4–8s that sweeps in 2s and fades to white over 3s, a pointer trail that ages over 80 rendered
152
+ * updates. It renders at about 30fps and costs nothing off screen or in a hidden tab.
153
+ *
154
+ * **Without WebGL2, or held still** (a thumbnail, or a reader who prefers reduced motion), it
155
+ * draws the source image itself, still, at the same fit — a picture of the mark rather than of
156
+ * the particles, which the docs say. A lost context draws the still until it is restored.
157
+ *
158
+ * Decorative: the canvas is hidden from assistive technology; name the mark on the element
159
+ * around it.
160
+ *
161
+ * @example
162
+ * <div role="img" aria-label="CUE++" className="h-[460px]">
163
+ * <ParticleLogo src="/logo.png" />
164
+ * </div>
165
+ */
166
+ const ParticleLogo = React.forwardRef(function ParticleLogo({ src, fit = "logo", containFill = .85, particleSize = .5, randomSpread = 2, skipRatio = 36, waveColors, className, style, ...props }, ref) {
167
+ const held = useStill();
168
+ const reduced = usePrefersReducedMotion();
169
+ const still = held || reduced;
170
+ const canvas = React.useRef(null);
171
+ const [failed, setFailed] = React.useState(false);
172
+ const [lost, setLost] = React.useState(false);
173
+ const paletteKey = (waveColors ?? []).join(",");
174
+ const drawStill = still || failed;
175
+ React.useEffect(() => {
176
+ if (drawStill) return;
177
+ const element = canvas.current;
178
+ if (element === null) return;
179
+ const gl = typeof WebGL2RenderingContext === "undefined" ? null : element.getContext("webgl2", {
180
+ antialias: true,
181
+ alpha: true,
182
+ premultipliedAlpha: true
183
+ });
184
+ if (gl === null) {
185
+ setFailed(true);
186
+ return;
187
+ }
188
+ const given = paletteKey === "" ? [] : paletteKey.split(",").map(srgbToLinear);
189
+ const varied = (colours) => colours.some((colour) => colour.join() !== colours[0].join());
190
+ let palette = varied(given) ? given : rolePalette(element);
191
+ if (palette.length === 0) palette = [[
192
+ 1,
193
+ 1,
194
+ 1
195
+ ]];
196
+ let disposed = false;
197
+ let contextLost = gl.isContextLost();
198
+ setLost(contextLost);
199
+ let scene = null;
200
+ let raster = null;
201
+ let raf = 0;
202
+ let visible = true;
203
+ const trail = new TouchTrail();
204
+ const touchCanvas = document.createElement("canvas");
205
+ touchCanvas.width = TouchTrail.SIZE;
206
+ touchCanvas.height = TouchTrail.SIZE;
207
+ const touchContext = touchCanvas.getContext("2d");
208
+ const pointer = {
209
+ x: 0,
210
+ y: 0
211
+ };
212
+ const onPointer = (event) => {
213
+ const box = element.getBoundingClientRect();
214
+ pointer.x = (event.clientX - box.left) / box.width * 2 - 1;
215
+ pointer.y = -((event.clientY - box.top) / box.height) * 2 + 1;
216
+ };
217
+ element.addEventListener("pointermove", onPointer);
218
+ const state = {
219
+ time: 0,
220
+ uTime: 0,
221
+ waving: false,
222
+ waveStart: 0,
223
+ waveDuration: 2,
224
+ nextWaveDelay: 5,
225
+ lastWaveEnd: -3,
226
+ direction: [1, 0],
227
+ position: 0,
228
+ waveTime: 0,
229
+ colour: palette[Math.floor(Math.random() * palette.length)],
230
+ previous: [
231
+ 1,
232
+ 1,
233
+ 1
234
+ ],
235
+ fade: 1
236
+ };
237
+ const resize = () => {
238
+ const dpr = Math.min(Math.max(window.devicePixelRatio || 1, 1), 2);
239
+ const width = Math.max(1, Math.round(element.clientWidth * dpr));
240
+ const height = Math.max(1, Math.round(element.clientHeight * dpr));
241
+ if (element.width !== width || element.height !== height) {
242
+ element.width = width;
243
+ element.height = height;
244
+ }
245
+ gl.viewport(0, 0, width, height);
246
+ return element.clientWidth / Math.max(1, element.clientHeight);
247
+ };
248
+ const frame = (delta) => {
249
+ if (scene === null || raster === null) return;
250
+ const s = state;
251
+ s.uTime += delta;
252
+ if (!s.waving) {
253
+ if (s.time - s.lastWaveEnd > s.nextWaveDelay) {
254
+ s.waving = true;
255
+ s.waveStart = s.time;
256
+ const angle = Math.random() * Math.PI * 2;
257
+ s.direction = [Math.cos(angle), Math.sin(angle)];
258
+ s.position = 0;
259
+ s.previous = [
260
+ s.colour[0] + (1 - s.colour[0]) * s.fade,
261
+ s.colour[1] + (1 - s.colour[1]) * s.fade,
262
+ s.colour[2] + (1 - s.colour[2]) * s.fade
263
+ ];
264
+ s.colour = pickWaveColor(palette, s.colour);
265
+ s.nextWaveDelay = 4 + Math.random() * 4;
266
+ }
267
+ } else {
268
+ const progress = (s.time - s.waveStart) / s.waveDuration;
269
+ s.position = Math.min(progress * 1.3, 1.3);
270
+ s.waveTime = Math.min(progress, 1);
271
+ s.fade = 0;
272
+ if (progress >= 1) {
273
+ s.waving = false;
274
+ s.lastWaveEnd = s.time;
275
+ s.waveTime = 1;
276
+ s.position = 1.3;
277
+ }
278
+ }
279
+ if (!s.waving) s.fade = Math.min((s.time - s.lastWaveEnd) / 3, 1);
280
+ trail.age();
281
+ trail.draw(touchContext);
282
+ const aspect = resize();
283
+ const scale = logoScale(fit, aspect, raster.width, raster.height, containFill);
284
+ const viewport = viewportAt(aspect);
285
+ const x = pointer.x * viewport.width * .5 / (raster.width * scale) + .5;
286
+ const y = pointer.y * viewport.height * .5 / (raster.height * scale) + .5;
287
+ if (x >= -.2 && x <= 1.2 && y >= -.2 && y <= 1.2) trail.add(Math.max(0, Math.min(1, x)), Math.max(0, Math.min(1, y)));
288
+ gl.clearColor(0, 0, 0, 0);
289
+ gl.clear(gl.COLOR_BUFFER_BIT);
290
+ gl.disable(gl.DEPTH_TEST);
291
+ gl.enable(gl.BLEND);
292
+ gl.blendEquation(gl.FUNC_ADD);
293
+ gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
294
+ gl.useProgram(scene.program);
295
+ gl.bindVertexArray(scene.vao);
296
+ gl.uniformMatrix4fv(scene.uniform("projectionMatrix"), false, perspective(50, aspect, NEAR, FAR));
297
+ gl.uniformMatrix4fv(scene.uniform("modelViewMatrix"), false, modelView(scale));
298
+ gl.uniform1f(scene.uniform("uTime"), s.uTime);
299
+ gl.uniform1f(scene.uniform("uRandom"), randomSpread);
300
+ gl.uniform1f(scene.uniform("uDepth"), .8);
301
+ gl.uniform1f(scene.uniform("uSize"), particleSize);
302
+ gl.uniform2f(scene.uniform("uTextureSize"), raster.width, raster.height);
303
+ gl.uniform2f(scene.uniform("uWaveDirection"), s.direction[0], s.direction[1]);
304
+ gl.uniform1f(scene.uniform("uWavePosition"), s.position);
305
+ gl.uniform1f(scene.uniform("uWaveTime"), s.waveTime);
306
+ gl.uniform3f(scene.uniform("uWaveColor"), ...s.colour);
307
+ gl.uniform3f(scene.uniform("uPrevColor"), ...s.previous);
308
+ gl.uniform1f(scene.uniform("uColorFade"), s.fade);
309
+ gl.activeTexture(gl.TEXTURE0);
310
+ gl.bindTexture(gl.TEXTURE_2D, scene.image);
311
+ gl.uniform1i(scene.uniform("uTexture"), 0);
312
+ gl.activeTexture(gl.TEXTURE1);
313
+ gl.bindTexture(gl.TEXTURE_2D, scene.touch);
314
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
315
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, touchCanvas);
316
+ gl.generateMipmap(gl.TEXTURE_2D);
317
+ gl.uniform1i(scene.uniform("uTouch"), 1);
318
+ gl.drawElementsInstanced(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0, scene.count);
319
+ gl.bindVertexArray(null);
320
+ };
321
+ let last = performance.now();
322
+ let accumulated = 0;
323
+ const tick = () => {
324
+ const now = performance.now();
325
+ const delta = (now - last) / 1e3;
326
+ last = now;
327
+ if (!visible || document.hidden) {
328
+ raf = 0;
329
+ return;
330
+ }
331
+ raf = requestAnimationFrame(tick);
332
+ accumulated += delta;
333
+ state.time += delta;
334
+ if (accumulated < 1 / 30) return;
335
+ frame(accumulated);
336
+ accumulated = 0;
337
+ };
338
+ const start = () => {
339
+ if (raf !== 0 || disposed || contextLost || scene === null || !visible || document.hidden) return;
340
+ last = performance.now();
341
+ raf = requestAnimationFrame(tick);
342
+ };
343
+ const observer = typeof IntersectionObserver === "undefined" ? null : new IntersectionObserver(([entry]) => {
344
+ visible = entry?.isIntersecting ?? true;
345
+ start();
346
+ });
347
+ observer?.observe(element);
348
+ const onVisibility = () => start();
349
+ document.addEventListener("visibilitychange", onVisibility);
350
+ const buildScene = () => {
351
+ if (disposed || contextLost || raster === null) return;
352
+ scene = build(gl, raster, sampleParticles(raster, skipRatio));
353
+ if (scene === null) {
354
+ setFailed(true);
355
+ return;
356
+ }
357
+ start();
358
+ };
359
+ const onLost = (event) => {
360
+ event.preventDefault();
361
+ contextLost = true;
362
+ if (raf !== 0) cancelAnimationFrame(raf);
363
+ raf = 0;
364
+ scene = null;
365
+ setLost(true);
366
+ };
367
+ const onRestored = () => {
368
+ contextLost = false;
369
+ setLost(false);
370
+ buildScene();
371
+ };
372
+ element.addEventListener("webglcontextlost", onLost);
373
+ element.addEventListener("webglcontextrestored", onRestored);
374
+ const image = new Image();
375
+ image.crossOrigin = "anonymous";
376
+ image.onload = () => {
377
+ (image.decode?.() ?? Promise.resolve()).catch(() => {}).then(() => {
378
+ if (disposed) return;
379
+ raster = rasterise(image);
380
+ buildScene();
381
+ });
382
+ };
383
+ image.onerror = () => {
384
+ if (!disposed) setFailed(true);
385
+ };
386
+ image.src = src;
387
+ return () => {
388
+ disposed = true;
389
+ if (raf !== 0) cancelAnimationFrame(raf);
390
+ observer?.disconnect();
391
+ document.removeEventListener("visibilitychange", onVisibility);
392
+ element.removeEventListener("pointermove", onPointer);
393
+ element.removeEventListener("webglcontextlost", onLost);
394
+ element.removeEventListener("webglcontextrestored", onRestored);
395
+ if (scene !== null && !contextLost) release(gl, scene);
396
+ if (!element.isConnected) gl.getExtension("WEBGL_lose_context")?.loseContext();
397
+ };
398
+ }, [
399
+ drawStill,
400
+ src,
401
+ fit,
402
+ containFill,
403
+ particleSize,
404
+ randomSpread,
405
+ skipRatio,
406
+ paletteKey
407
+ ]);
408
+ return /* @__PURE__ */ jsxs("div", {
409
+ ref,
410
+ "data-slot": "particle-logo",
411
+ className: cn("relative h-full w-full", className),
412
+ style,
413
+ ...props,
414
+ children: [drawStill ? null : /* @__PURE__ */ jsx("canvas", {
415
+ ref: canvas,
416
+ "aria-hidden": "true",
417
+ "data-slot": "particle-logo-canvas",
418
+ className: "block h-full w-full",
419
+ style: {
420
+ background: "transparent",
421
+ touchAction: "none"
422
+ }
423
+ }), drawStill || lost ? /* @__PURE__ */ jsx("img", {
424
+ "data-slot": "particle-logo-still",
425
+ src,
426
+ alt: "",
427
+ "aria-hidden": "true",
428
+ className: "absolute inset-0 m-auto object-contain",
429
+ style: {
430
+ height: fit === "logo" ? "26%" : `${containFill * 100}%`,
431
+ width: "auto"
432
+ }
433
+ }) : null]
434
+ });
435
+ });
436
+ //#endregion
437
+ export { ParticleLogo };
@@ -0,0 +1,29 @@
1
+ import * as React from "react";
2
+ //#region src/brand/particle-mark.d.ts
3
+ interface ParticleMarkProps extends React.ComponentPropsWithoutRef<"canvas"> {
4
+ /** The word the dots draw, short: a name or a mark. */
5
+ word: string;
6
+ /** Which scatter. Defaults to `1`. */
7
+ seed?: number;
8
+ }
9
+ /**
10
+ * A word drawn as a field of dots that drift and settle — a studio site's mark
11
+ * (spec 2026-10-02 §3.4). The dots are the accent, read from the canvas's own
12
+ * computed style, so they follow the Look.
13
+ *
14
+ * **It costs nothing at rest.** It animates only while its element is displayed,
15
+ * on screen and in a visible tab, and while {@link useStill} is false (a still
16
+ * thumbnail, or a reader who prefers reduced motion). Otherwise it draws one
17
+ * still frame. That frame is redrawn whenever any theme axis changes anywhere above
18
+ * it, the system colour scheme flips, a face finishes loading, or it resizes, so a
19
+ * paused mark is never left in an old palette. Unmounting disconnects every
20
+ * observer and cancels every frame.
21
+ *
22
+ * Decorative: hidden from assistive technology.
23
+ *
24
+ * @example
25
+ * <ParticleMark word="CUE" className="h-40 w-full" />
26
+ */
27
+ declare function ParticleMark({ word, seed, ...canvasProps }: ParticleMarkProps): React.JSX.Element;
28
+ //#endregion
29
+ export { ParticleMark, ParticleMarkProps };
@@ -0,0 +1,137 @@
1
+ "use client";
2
+ import { seeded } from "./_motif-seed.js";
3
+ import { usePrefersReducedMotion } from "../instruments/_motion.js";
4
+ import { useStill } from "../system/still.js";
5
+ import * as React from "react";
6
+ import { jsx } from "react/jsx-runtime";
7
+ //#region src/brand/particle-mark.tsx
8
+ /** The six axes a motif's colours follow (spec 2026-10-02 §3.4). */
9
+ const AXES = [
10
+ "data-theme",
11
+ "data-font",
12
+ "data-mode",
13
+ "data-style",
14
+ "data-paint",
15
+ "data-density"
16
+ ];
17
+ /**
18
+ * A word drawn as a field of dots that drift and settle — a studio site's mark
19
+ * (spec 2026-10-02 §3.4). The dots are the accent, read from the canvas's own
20
+ * computed style, so they follow the Look.
21
+ *
22
+ * **It costs nothing at rest.** It animates only while its element is displayed,
23
+ * on screen and in a visible tab, and while {@link useStill} is false (a still
24
+ * thumbnail, or a reader who prefers reduced motion). Otherwise it draws one
25
+ * still frame. That frame is redrawn whenever any theme axis changes anywhere above
26
+ * it, the system colour scheme flips, a face finishes loading, or it resizes, so a
27
+ * paused mark is never left in an old palette. Unmounting disconnects every
28
+ * observer and cancels every frame.
29
+ *
30
+ * Decorative: hidden from assistive technology.
31
+ *
32
+ * @example
33
+ * <ParticleMark word="CUE" className="h-40 w-full" />
34
+ */
35
+ function ParticleMark({ word, seed = 1, ...canvasProps }) {
36
+ const ref = React.useRef(null);
37
+ const held = useStill();
38
+ const reduced = usePrefersReducedMotion();
39
+ const still = held || reduced;
40
+ React.useEffect(() => {
41
+ const canvas = ref.current;
42
+ if (canvas === null) return;
43
+ const ctx = canvas.getContext("2d");
44
+ if (ctx === null) return;
45
+ let frame = 0;
46
+ let visible = false;
47
+ let points = [];
48
+ const layout = () => {
49
+ const rect = canvas.getBoundingClientRect();
50
+ const scale = window.devicePixelRatio || 1;
51
+ canvas.width = Math.max(1, Math.round(rect.width * scale));
52
+ canvas.height = Math.max(1, Math.round(rect.height * scale));
53
+ const off = document.createElement("canvas");
54
+ off.width = canvas.width;
55
+ off.height = canvas.height;
56
+ const o = off.getContext("2d");
57
+ if (o === null) return;
58
+ o.fillStyle = getComputedStyle(canvas).getPropertyValue("--cue-accent").trim() || "currentColor";
59
+ const family = getComputedStyle(canvas).fontFamily;
60
+ o.font = `700 100px ${family}`;
61
+ const advance = o.measureText(word).width / 100;
62
+ const size = Math.min(canvas.height * .62, advance > 0 ? canvas.width * .9 / advance : canvas.height * .62);
63
+ o.font = `700 ${Math.round(size)}px ${family}`;
64
+ o.textAlign = "center";
65
+ o.textBaseline = "middle";
66
+ o.fillText(word, canvas.width / 2, canvas.height / 2);
67
+ const data = o.getImageData(0, 0, off.width, off.height).data;
68
+ const rand = seeded(seed);
69
+ const step = Math.max(3, Math.round(4 * scale));
70
+ points = [];
71
+ for (let y = 0; y < off.height; y += step) for (let x = 0; x < off.width; x += step) if (data[(y * off.width + x) * 4 + 3] > 128) points.push({
72
+ x,
73
+ y,
74
+ dx: (rand() - .5) * step,
75
+ dy: (rand() - .5) * step
76
+ });
77
+ };
78
+ const draw = (t) => {
79
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
80
+ ctx.fillStyle = getComputedStyle(canvas).getPropertyValue("--cue-accent").trim() || "currentColor";
81
+ const size = Math.max(1, (window.devicePixelRatio || 1) * 1.4);
82
+ const k = still ? 0 : Math.sin(t / 900);
83
+ for (const p of points) ctx.fillRect(p.x + p.dx * k, p.y + p.dy * k, size, size);
84
+ };
85
+ const rendered = () => typeof canvas.checkVisibility === "function" ? canvas.checkVisibility() : canvas.getClientRects().length > 0;
86
+ const shown = () => rendered() && visible && document.visibilityState === "visible";
87
+ const tick = (t) => {
88
+ draw(t);
89
+ frame = shown() && !still ? requestAnimationFrame(tick) : 0;
90
+ };
91
+ const refresh = () => {
92
+ cancelAnimationFrame(frame);
93
+ frame = 0;
94
+ layout();
95
+ draw(0);
96
+ if (shown() && !still) frame = requestAnimationFrame(tick);
97
+ };
98
+ const intersection = new IntersectionObserver(([entry]) => {
99
+ visible = entry?.isIntersecting ?? false;
100
+ refresh();
101
+ });
102
+ intersection.observe(canvas);
103
+ const resize = new ResizeObserver(refresh);
104
+ resize.observe(canvas);
105
+ const axes = new MutationObserver(refresh);
106
+ axes.observe(document.documentElement, {
107
+ subtree: true,
108
+ attributes: true,
109
+ attributeFilter: AXES
110
+ });
111
+ const scheme = window.matchMedia("(prefers-color-scheme: dark)");
112
+ scheme.addEventListener("change", refresh);
113
+ document.addEventListener("visibilitychange", refresh);
114
+ document.fonts?.addEventListener?.("loadingdone", refresh);
115
+ refresh();
116
+ return () => {
117
+ cancelAnimationFrame(frame);
118
+ intersection.disconnect();
119
+ resize.disconnect();
120
+ axes.disconnect();
121
+ scheme.removeEventListener("change", refresh);
122
+ document.removeEventListener("visibilitychange", refresh);
123
+ document.fonts?.removeEventListener?.("loadingdone", refresh);
124
+ };
125
+ }, [
126
+ word,
127
+ seed,
128
+ still
129
+ ]);
130
+ return /* @__PURE__ */ jsx("canvas", {
131
+ ref,
132
+ "aria-hidden": "true",
133
+ ...canvasProps
134
+ });
135
+ }
136
+ //#endregion
137
+ export { ParticleMark };
@@ -0,0 +1,20 @@
1
+ import * as React from "react";
2
+ //#region src/brand/ribbon.d.ts
3
+ interface RibbonProps extends React.SVGProps<SVGSVGElement> {
4
+ /** Which ribbon: the same seed draws the same bands. Defaults to `1`. */
5
+ seed?: number;
6
+ }
7
+ /**
8
+ * A flowing ribbon of layered bands in the theme's own inks — the payments-page
9
+ * motif (spec 2026-10-02 §3.4). Five bezier bands cross the frame on a diagonal,
10
+ * each a gradient from the accent to the foreground's mix, so it reads in every
11
+ * theme and both modes. Decorative: hidden from assistive technology.
12
+ *
13
+ * Static markup — no `"use client"`.
14
+ *
15
+ * @example
16
+ * <Ribbon seed={3} className="h-64 w-full" />
17
+ */
18
+ declare function Ribbon({ seed, ...svgProps }: RibbonProps): React.JSX.Element;
19
+ //#endregion
20
+ export { Ribbon, RibbonProps };
@@ -0,0 +1,54 @@
1
+ import { r2, seeded } from "./_motif-seed.js";
2
+ import * as React from "react";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ //#region src/brand/ribbon.tsx
5
+ /**
6
+ * A flowing ribbon of layered bands in the theme's own inks — the payments-page
7
+ * motif (spec 2026-10-02 §3.4). Five bezier bands cross the frame on a diagonal,
8
+ * each a gradient from the accent to the foreground's mix, so it reads in every
9
+ * theme and both modes. Decorative: hidden from assistive technology.
10
+ *
11
+ * Static markup — no `"use client"`.
12
+ *
13
+ * @example
14
+ * <Ribbon seed={3} className="h-64 w-full" />
15
+ */
16
+ function Ribbon({ seed = 1, ...svgProps }) {
17
+ const id = React.useId().replace(/:/gu, "");
18
+ const rand = seeded(seed);
19
+ const bands = Array.from({ length: 5 }, (_, i) => {
20
+ const y0 = r2(40 + i * 14 + rand() * 10);
21
+ const c1 = r2(rand() * 60 - 10);
22
+ const c2 = r2(70 + rand() * 40);
23
+ const y1 = r2(i * 8 + rand() * 12);
24
+ const w = r2(10 + rand() * 8);
25
+ return `M0 ${y0} C 30 ${c1} 60 ${c2} 100 ${y1} L 100 ${r2(y1 + w)} C 60 ${r2(c2 + w)} 30 ${r2(c1 + w)} 0 ${r2(y0 + w)} Z`;
26
+ });
27
+ return /* @__PURE__ */ jsxs("svg", {
28
+ viewBox: "0 0 100 100",
29
+ preserveAspectRatio: "none",
30
+ "aria-hidden": "true",
31
+ focusable: "false",
32
+ ...svgProps,
33
+ children: [/* @__PURE__ */ jsx("defs", { children: /* @__PURE__ */ jsxs("linearGradient", {
34
+ id: `${id}-g`,
35
+ x1: "0",
36
+ y1: "0",
37
+ x2: "1",
38
+ y2: "1",
39
+ children: [/* @__PURE__ */ jsx("stop", {
40
+ offset: "0%",
41
+ stopColor: "var(--cue-accent)"
42
+ }), /* @__PURE__ */ jsx("stop", {
43
+ offset: "100%",
44
+ stopColor: "color-mix(in oklch, var(--cue-accent) 40%, var(--cue-fg))"
45
+ })]
46
+ }) }), bands.map((d, i) => /* @__PURE__ */ jsx("path", {
47
+ d,
48
+ fill: `url(#${id}-g)`,
49
+ opacity: r2(.35 + i * .12)
50
+ }, i))]
51
+ });
52
+ }
53
+ //#endregion
54
+ export { Ribbon };