@cueplusplus/ui 0.19.0 → 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.
- package/CHANGELOG.md +39 -0
- package/dist/brand/index.d.ts +3 -1
- package/dist/brand/particle-logo.d.ts +50 -0
- package/dist/brand/particle-logo.js +437 -0
- package/dist/brand/text-scramble.d.ts +42 -0
- package/dist/brand/text-scramble.js +160 -0
- package/dist/chat/delegation-card.js +2 -1
- package/dist/chat/message.js +12 -13
- package/dist/color/color-area.js +2 -1
- package/dist/forms/_chassis.d.ts +1 -1
- package/dist/forms/_chassis.js +2 -2
- package/dist/forms/env-var-input.js +2 -1
- package/dist/forms/file-upload.js +2 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/instruments/data-table.js +3 -2
- package/dist/instruments/table.js +1 -1
- package/dist/layout/index.d.ts +2 -1
- package/dist/layout/index.js +2 -1
- package/dist/layout/reveal.d.ts +44 -0
- package/dist/layout/reveal.js +106 -0
- package/dist/lib/cn.js +23 -5
- package/dist/lib/particle-field-shaders.js +236 -0
- package/dist/lib/particle-field.js +210 -0
- package/dist/midi/spectrum-visualizer.js +2 -1
- package/dist/primitives/chip.d.ts +1 -1
- package/dist/primitives/chip.js +4 -3
- package/dist/system/_style-field-chassis.js +14 -1
- package/dist/system/frame-clock.d.ts +33 -0
- package/dist/system/frame-clock.js +55 -0
- package/dist/system/index.d.ts +2 -1
- package/dist/system/index.js +2 -1
- package/dist/system/still.js +14 -1
- package/manifest/components/autocomplete.json +1 -0
- package/manifest/components/bento.json +4 -4
- package/manifest/components/color-area.json +10 -0
- package/manifest/components/color-field.json +3 -0
- package/manifest/components/color-picker.json +3 -0
- package/manifest/components/combobox.json +1 -0
- package/manifest/components/composer.json +3 -0
- package/manifest/components/data-table-pagination.json +3 -0
- package/manifest/components/data-table-toolbar.json +3 -0
- package/manifest/components/date-field.json +3 -0
- package/manifest/components/date-picker.json +3 -0
- package/manifest/components/date-range-picker.json +3 -0
- package/manifest/components/delegation-card.json +6 -0
- package/manifest/components/env-var-input.json +3 -0
- package/manifest/components/file-upload.json +5 -0
- package/manifest/components/font-scope.json +3 -2
- package/manifest/components/grid.json +2 -2
- package/manifest/components/input-group.json +3 -0
- package/manifest/components/input.json +3 -0
- package/manifest/components/ledger.json +1 -1
- package/manifest/components/markdown-text.json +1 -1
- package/manifest/components/message.json +8 -0
- package/manifest/components/multi-select.json +1 -0
- package/manifest/components/musical-time-input.json +3 -0
- package/manifest/components/number-field.json +3 -0
- package/manifest/components/otp-field.json +3 -0
- package/manifest/components/particle-logo.json +140 -0
- package/manifest/components/password-input.json +3 -0
- package/manifest/components/preview.json +3 -2
- package/manifest/components/reveal.json +106 -0
- package/manifest/components/scrub-input.json +3 -0
- package/manifest/components/search-input.json +3 -0
- package/manifest/components/select.json +3 -0
- package/manifest/components/spectrum-visualizer.json +10 -0
- package/manifest/components/stack.json +1 -1
- package/manifest/components/table.json +1 -1
- package/manifest/components/tags-input.json +1 -0
- package/manifest/components/text-scramble.json +90 -0
- package/manifest/components/textarea.json +3 -0
- package/manifest/components/theme-provider.json +3 -2
- package/manifest/components/time-field.json +3 -0
- package/manifest/manifest.json +116 -40
- package/manifest/tokens.json +4 -2
- package/package.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
# @cueplusplus/ui
|
|
2
2
|
|
|
3
|
+
## 0.20.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 487642c: Three motion pieces and a pairing, taken from cueplusplus.com.
|
|
8
|
+
|
|
9
|
+
- **`TextScramble`** cycles phrases and scrambles each one in. It has `matrix` and `flap` effects, and runs to the site's timings. It pauses off screen and in a hidden tab, on a whole phrase.
|
|
10
|
+
- **`Reveal`** fades its `children` up once, the first time they scroll into view. It can stagger the items you mark with `data-reveal-item`. Anything wholly on screen is revealed even if it never crosses the entry line, and content stays revealed when a prop changes.
|
|
11
|
+
- **`useFrameClock`** is a frame counter for loop illustrations, the equivalent of Remotion's `useCurrentFrame`. Time spent in a hidden tab is not counted.
|
|
12
|
+
- **The `jetbrains-mono` pairing** sets everything in JetBrains Mono: sans, display and mono.
|
|
13
|
+
|
|
14
|
+
All three components hold still in a thumbnail. Under reduced motion, `TextScramble` shows each phrase whole, `Reveal` shows its content in place, and `useFrameClock` stays on a fixed frame.
|
|
15
|
+
|
|
16
|
+
A theme's manifest reserves the new `jetbrains-mono` pairing's name, and may override its stacks like any base pairing's.
|
|
17
|
+
|
|
18
|
+
- 487642c: `ParticleLogo` draws a mark as a field of particles. The particles wiggle, take on colour in waves that sweep across the mark, and scatter away from the pointer. It is cueplusplus.com's hero logo, ported to plain WebGL2 with the site's shaders unchanged.
|
|
19
|
+
|
|
20
|
+
It adds no dependency: there is no three.js.
|
|
21
|
+
|
|
22
|
+
It shows the source image, standing still, in four cases:
|
|
23
|
+
- in a thumbnail;
|
|
24
|
+
- for a reader who prefers reduced motion;
|
|
25
|
+
- where WebGL2 is not available;
|
|
26
|
+
- while its WebGL context is lost, until the browser restores it.
|
|
27
|
+
|
|
28
|
+
The server and the first client render agree, whatever the reader's GPU, and an unmounted logo releases its context at once rather than waiting for collection.
|
|
29
|
+
|
|
30
|
+
- 33c4fa8: A style set can give text-entry fields their own corner. The new `style-field-radius` name falls back to the control radius, so a set that doesn't name it draws exactly what it drew before. Prism names it `12px` and Ledger `8px`. Both keep their pill buttons and chips, but their inputs, selects, steppers, textareas, drop zones, colour areas, delegation cards, data panes and message-bubble tails are no longer pills. Prism's surfaces go from `24px` to `16px`. `DataTable`'s toolbar, pager and caption are now inset to match the table's cells, so a filter field never sits in a rounded panel's corner, in any Look.
|
|
31
|
+
|
|
32
|
+
The studio's generated tables carry the new catalogue identity, so a scene validates against the vocabulary that now includes the field's corner.
|
|
33
|
+
|
|
34
|
+
### Patch Changes
|
|
35
|
+
|
|
36
|
+
- 487642c: A caller's `className` now overrides a component's control height. `cn` knew the bridge's radius rungs but not its spacing names, so `h-control-md`, `size-icon-sm`, `min-h-control-lg`, `h-chip` and their kin stayed beside a caller's `h-auto` or `size-8`, and the bridge utility won by stylesheet order: `<Button className="h-auto py-4">` drew at the control height. They are now members of `tailwind-merge`'s spacing scale, so the last class passed wins, as it does everywhere else.
|
|
37
|
+
- Updated dependencies [487642c]
|
|
38
|
+
- Updated dependencies [33c4fa8]
|
|
39
|
+
- @cueplusplus/tokens@0.20.0
|
|
40
|
+
- @cueplusplus/theme-base@1.3.0
|
|
41
|
+
|
|
3
42
|
## 0.19.0
|
|
4
43
|
|
|
5
44
|
### Minor Changes
|
package/dist/brand/index.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { Plussie, PlussieExpression, PlussieProps } from "./plussie.js";
|
|
|
5
5
|
import { GlossObject, GlossObjectProps } from "./gloss-object.js";
|
|
6
6
|
import { Halftone, HalftoneProps } from "./halftone.js";
|
|
7
7
|
import { Motif, MotifProps, MotifVariant } from "./motif.js";
|
|
8
|
+
import { ParticleLogo, ParticleLogoProps } from "./particle-logo.js";
|
|
8
9
|
import { ParticleMark, ParticleMarkProps } from "./particle-mark.js";
|
|
9
10
|
import { Ribbon, RibbonProps } from "./ribbon.js";
|
|
10
|
-
|
|
11
|
+
import { TextScramble, TextScrambleProps } from "./text-scramble.js";
|
|
12
|
+
export { type BrandAssetProps, type BrandSize, type BrandTone, CueLogotype, type CueLogotypeProps, CueMark, type CueMarkProps, GlossObject, type GlossObjectProps, Halftone, type HalftoneProps, Motif, type MotifProps, type MotifVariant, ParticleLogo, type ParticleLogoProps, ParticleMark, type ParticleMarkProps, Plussie, type PlussieExpression, type PlussieProps, Ribbon, type RibbonProps, TextScramble, type TextScrambleProps };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
//#region src/brand/particle-logo.d.ts
|
|
3
|
+
interface ParticleLogoProps extends Omit<React.ComponentPropsWithoutRef<"div">, "children"> {
|
|
4
|
+
/** The mark to draw in particles: an image URL, PNG or SVG, light on transparent or on black. */
|
|
5
|
+
src: string;
|
|
6
|
+
/**
|
|
7
|
+
* `logo` (the default): the mark is 26% of the canvas's height, as cueplusplus.com's hero
|
|
8
|
+
* draws it. `contain`: the mark fits inside the canvas, scaled by `containFill`.
|
|
9
|
+
*/
|
|
10
|
+
fit?: "logo" | "contain";
|
|
11
|
+
/** How much of the canvas a `contain` mark fills. Defaults to `0.85`. */
|
|
12
|
+
containFill?: number;
|
|
13
|
+
/** Each particle's size. Defaults to `0.5`. */
|
|
14
|
+
particleSize?: number;
|
|
15
|
+
/** How far particles scatter from their pixel. Defaults to `2`. */
|
|
16
|
+
randomSpread?: number;
|
|
17
|
+
/** One particle every this many pixels of the 600px-wide raster. Defaults to `36`. */
|
|
18
|
+
skipRatio?: number;
|
|
19
|
+
/**
|
|
20
|
+
* The colours a wave sweeps the mark into, as hex. Defaults to the Look's own accent and
|
|
21
|
+
* signal colours, read off the element; cueplusplus.com passes its eight. A palette with
|
|
22
|
+
* nothing different to offer falls back to the default.
|
|
23
|
+
*/
|
|
24
|
+
waveColors?: readonly string[];
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* A mark drawn as a field of particles that wiggle, flip colour in sweeping waves and scatter
|
|
28
|
+
* from the pointer — cueplusplus.com's hero logo, ported from three.js to raw WebGL2 with its
|
|
29
|
+
* shaders unchanged (spec 2026-10-04-cue-site-verbatim §4.1).
|
|
30
|
+
*
|
|
31
|
+
* The camera, the mesh scale, the textures, the blending and the wave and touch timings are the
|
|
32
|
+
* source's, copied: a 50° camera at z = 300, the mark 26% of the canvas's height, a wave every
|
|
33
|
+
* 4–8s that sweeps in 2s and fades to white over 3s, a pointer trail that ages over 80 rendered
|
|
34
|
+
* updates. It renders at about 30fps and costs nothing off screen or in a hidden tab.
|
|
35
|
+
*
|
|
36
|
+
* **Without WebGL2, or held still** (a thumbnail, or a reader who prefers reduced motion), it
|
|
37
|
+
* draws the source image itself, still, at the same fit — a picture of the mark rather than of
|
|
38
|
+
* the particles, which the docs say. A lost context draws the still until it is restored.
|
|
39
|
+
*
|
|
40
|
+
* Decorative: the canvas is hidden from assistive technology; name the mark on the element
|
|
41
|
+
* around it.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* <div role="img" aria-label="CUE++" className="h-[460px]">
|
|
45
|
+
* <ParticleLogo src="/logo.png" />
|
|
46
|
+
* </div>
|
|
47
|
+
*/
|
|
48
|
+
declare const ParticleLogo: React.ForwardRefExoticComponent<ParticleLogoProps & React.RefAttributes<HTMLDivElement>>;
|
|
49
|
+
//#endregion
|
|
50
|
+
export { ParticleLogo, ParticleLogoProps };
|
|
@@ -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,42 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
//#region src/brand/text-scramble.d.ts
|
|
3
|
+
interface TextScrambleProps extends Omit<React.ComponentPropsWithoutRef<"p">, "children"> {
|
|
4
|
+
/** The phrases to cycle through, in order, looping. The first is what renders before any script runs. */
|
|
5
|
+
phrases: readonly string[];
|
|
6
|
+
/**
|
|
7
|
+
* What assistive technology reads instead of the animation: one sentence that
|
|
8
|
+
* names the whole cycle. The animated text is hidden from it, because a
|
|
9
|
+
* scramble read aloud is noise.
|
|
10
|
+
*/
|
|
11
|
+
label: string;
|
|
12
|
+
/**
|
|
13
|
+
* `matrix` (the default): letters, digits and symbols at 28ms a frame, one
|
|
14
|
+
* character locking per frame. `flap`: upper-cased, a departure board's
|
|
15
|
+
* letters at 55ms a frame, one character locking every two frames.
|
|
16
|
+
*/
|
|
17
|
+
effect?: "matrix" | "flap";
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* A line that cycles through phrases, scrambling each one in — cueplusplus.com's
|
|
21
|
+
* "what we do" cycler, ported verbatim (`what-we-do.tsx`; spec
|
|
22
|
+
* 2026-10-04-cue-site-verbatim §4.2).
|
|
23
|
+
*
|
|
24
|
+
* Each frame, every character not yet locked shows a random glyph from the
|
|
25
|
+
* effect's set; spaces are never scrambled; characters lock left to right. A
|
|
26
|
+
* phrase holds for 1.8s once it has revealed, so a long phrase and a slow effect
|
|
27
|
+
* still finish before the next begins: the next starts after
|
|
28
|
+
* `length × revealEvery × speed + 1800ms`.
|
|
29
|
+
*
|
|
30
|
+
* **Reduced motion** shows each phrase whole, one every 2.6s, as the site does.
|
|
31
|
+
* **Held still** (a gallery thumbnail, under a still `StillProvider`) shows the
|
|
32
|
+
* first phrase and schedules nothing, which is this library's departure from the
|
|
33
|
+
* source: a thumbnail costs nothing at rest. The text is written straight into
|
|
34
|
+
* the DOM node, as the source does, so thirty-five frames a second never
|
|
35
|
+
* re-render React.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* <TextScramble phrases={["we build software", "we design stages"]} label="We build software and design stages." />
|
|
39
|
+
*/
|
|
40
|
+
declare const TextScramble: React.ForwardRefExoticComponent<TextScrambleProps & React.RefAttributes<HTMLParagraphElement>>;
|
|
41
|
+
//#endregion
|
|
42
|
+
export { TextScramble, TextScrambleProps };
|