craft-library 0.4.0 → 0.5.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.
@@ -0,0 +1,496 @@
1
+ // Panorama · Sara Lobo Library
2
+ // A hero for moving pictures. A clip opens full-bleed under letterbox bars while the title tracks in; then the clip shrinks
3
+ // into one screen of a dome of monitors that surrounds you, and the other screens arrive in a wave around it. The pointer
4
+ // turns your head. The dome is black and white: colour lives only in a soft circle around the pointer, and the screen under
5
+ // it lights the dark with its own colours while focus brackets lock on and name it.
6
+ //
7
+ // import { mountPanorama } from './panorama.js';
8
+ // const hero = mountPanorama(document.querySelector('#hero'), { // fills the element (give it a size)
9
+ // title: 'Lorem ipsum',
10
+ // text: 'Dolor sit amet,', textItalic: 'consectetur adipiscing.',
11
+ // video: { src: 'reel.mp4', poster: 'poster.webp', title: 'Wild Grass', year: 2026 },
12
+ // stills: [{ src: 'still-01.webp', title: 'The Coast Road', year: 2025 }, ...], // up to 16, cropped to 16:10
13
+ // });
14
+ // hero.replay(); hero.destroy();
15
+ //
16
+ // WebGL, no dependencies. The pictures and the clip come from the same site as the page, or from one that allows CORS.
17
+
18
+ const DEFAULTS = {
19
+ title: 'Lorem ipsum',
20
+ text: 'Dolor sit amet,',
21
+ textItalic: 'consectetur adipiscing.', // set in the display face, after text
22
+ video: null, // { src, poster, title, year }: the opening clip, then the screen in the middle of the dome
23
+ stills: [], // [{ src, title, year }]: the other screens, repeated around the dome
24
+ labels: true, // "CAM 07" on each screen
25
+ intro: true, // the opening plays the first time the dome comes on screen; false starts it settled
26
+ cursor: true, // the dot-and-ring pointer (only for a mouse or trackpad)
27
+ fonts: true, // load Cormorant Garamond, Geist and Geist Mono from Google Fonts
28
+ };
29
+
30
+ const FONTS = 'https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;1,300&family=Geist:wght@400;500&family=Geist+Mono:wght@400;500&display=swap';
31
+ const HOLD = 3.3, MORPH_V = 1.7, MORPH = 1.45; // s: the clip alone, the clip to its screen, each other screen in
32
+ const AR = 1.6; // every screen is 16:10
33
+ const D2R = Math.PI / 180, R2D = 180 / Math.PI;
34
+
35
+ const CSS = `
36
+ .cup { --cup-ink: #0b0a09; --cup-paper: #ece7de; --cup-soft: cubic-bezier(.22, .61, .21, 1);
37
+ --cup-display: "Cormorant Garamond", "Cormorant", Garamond, "Times New Roman", serif;
38
+ --cup-ui: "Geist", -apple-system, "Helvetica Neue", Arial, sans-serif; --cup-mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
39
+ position: relative; overflow: hidden; background: var(--cup-ink); color: var(--cup-paper); container-type: size; touch-action: pan-y;
40
+ -webkit-font-smoothing: antialiased; -webkit-user-select: none; user-select: none; }
41
+ .cup.cup-cur, .cup.cup-cur * { cursor: none; }
42
+ .cup-gl { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
43
+ .cup-src { position: absolute; left: 0; top: 0; width: 2px; height: 2px; opacity: 0; pointer-events: none; }
44
+ .cup-scrim { position: absolute; inset: 0; pointer-events: none; opacity: 0; transition: opacity 1.2s var(--cup-soft);
45
+ background: radial-gradient(34% 28% at 50% 50%, rgba(11, 10, 9, .9), rgba(11, 10, 9, .45) 60%, rgba(11, 10, 9, 0) 100%); }
46
+ .cup.is-settled .cup-scrim { opacity: 1; }
47
+
48
+ /* the title tracks in like an opening credit: the letters drift together from a wide spacing and out of a blur, centre
49
+ first; then a thin anamorphic streak crosses it and each letter catches the light as it passes */
50
+ .cup-title { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); text-align: center; white-space: nowrap; pointer-events: none; will-change: transform; }
51
+ /* padding-bottom keeps the descenders (the p of "ipsum") clear of the line below */
52
+ .cup-name { position: relative; margin: 0; padding-bottom: .15em; font-family: var(--cup-display); font-weight: 300; font-size: clamp(44px, 10.5cqw, 168px); line-height: .9; letter-spacing: -.01em; }
53
+ .cup-name span { display: inline-block; opacity: 0; transform: translateX(calc(var(--o) * .42em)); filter: blur(14px);
54
+ transition: opacity 1.6s var(--cup-soft) var(--d), transform 2.8s cubic-bezier(.16, 1, .3, 1) var(--d), filter 2.2s var(--cup-soft) var(--d); }
55
+ .cup-title.is-in .cup-name span { opacity: 1; transform: none; filter: blur(0); animation: cup-glint 1.5s ease-in-out var(--g) both; }
56
+ @keyframes cup-glint { 0%, 100% { text-shadow: 0 0 0 rgba(255, 238, 210, 0); } 45% { text-shadow: 0 0 22px rgba(255, 238, 210, .6), 0 0 2px rgba(255, 250, 240, .85); } }
57
+ .cup-flare { position: absolute; left: -20%; right: -20%; top: 54%; height: 1px; pointer-events: none; opacity: 0;
58
+ background: linear-gradient(90deg, transparent, rgba(255, 240, 215, .9) 50%, transparent); box-shadow: 0 0 14px 2px rgba(255, 226, 180, .3); }
59
+ .cup-title.is-in .cup-flare { animation: cup-flare 2.4s cubic-bezier(.45, 0, .2, 1) 1.5s both; }
60
+ @keyframes cup-flare { 0% { opacity: 0; transform: translateX(-55%) scaleX(.35); } 25% { opacity: 1; } 75% { opacity: .85; } 100% { opacity: 0; transform: translateX(55%) scaleX(.35); } }
61
+ .cup-text { margin: 18px 0 0; font: 400 clamp(13px, 1.15cqw, 16px)/1.4 var(--cup-ui); letter-spacing: .01em; opacity: .82; }
62
+ .cup-text em { font-family: var(--cup-display); font-size: 1.32em; }
63
+ /* each word rises from its own mask: a clip-path, not overflow, so every word keeps its own baseline (both faces sit on
64
+ one line), and wider than the word so the italic's leaning strokes stay whole */
65
+ .cup-text .w { display: inline-block; clip-path: inset(0 -.25em); }
66
+ .cup-text .w > span { display: inline-block; transform: translateY(110%); transition: transform 1.4s cubic-bezier(.16, 1, .3, 1) var(--d); }
67
+ .cup-title.is-in .cup-text .w > span { transform: none; }
68
+ .cup-title.is-reset, .cup-title.is-reset * { transition: none !important; animation: none !important; }
69
+
70
+ .cup-bars i { position: absolute; left: 0; right: 0; height: var(--cup-bar, 0px); background: #000; transition: transform 1.3s var(--cup-soft); }
71
+ .cup-bars i:first-child { top: 0; } .cup-bars i:last-child { bottom: 0; }
72
+ .cup.is-morph .cup-bars i:first-child { transform: translateY(-101%); } .cup.is-morph .cup-bars i:last-child { transform: translateY(101%); }
73
+
74
+ /* focus brackets on the screen under the pointer, and its name */
75
+ .cup-aim { position: absolute; left: 0; top: 0; pointer-events: none; opacity: 0; will-change: transform, width, height; }
76
+ .cup-aim > i { position: absolute; width: 16px; height: 16px; border: 0 solid var(--cup-paper); }
77
+ .cup-aim > i:nth-child(1) { left: -7px; top: -7px; border-width: 1px 0 0 1px; }
78
+ .cup-aim > i:nth-child(2) { right: -7px; top: -7px; border-width: 1px 1px 0 0; }
79
+ .cup-aim > i:nth-child(3) { left: -7px; bottom: -7px; border-width: 0 0 1px 1px; }
80
+ .cup-aim > i:nth-child(4) { right: -7px; bottom: -7px; border-width: 0 1px 1px 0; }
81
+ .cup-tag { position: absolute; left: -7px; top: calc(100% + 18px); white-space: nowrap; }
82
+ .cup-tag em { display: block; font: italic 300 28px/1 var(--cup-display); text-shadow: 0 1px 12px rgba(0, 0, 0, .6); }
83
+ .cup-tag small { display: block; margin-top: 8px; font: 10.5px/1 var(--cup-mono); letter-spacing: .1em; opacity: .6; }
84
+ .cup-tag.is-new em, .cup-tag.is-new small { animation: cup-tag .8s var(--cup-soft) both; }
85
+ .cup-tag.is-new small { animation-delay: .1s; }
86
+ @keyframes cup-tag { from { opacity: 0; transform: translateY(8px); filter: blur(5px); } }
87
+
88
+ /* the pointer: a dot, and a ring that trails it and stretches with speed */
89
+ .cup-cursor { position: absolute; left: 0; top: 0; pointer-events: none; opacity: 0; transition: opacity .35s var(--cup-soft); }
90
+ .cup-cursor.is-on { opacity: 1; }
91
+ .cup-cursor i { position: absolute; left: 0; top: 0; border-radius: 50%; will-change: transform; }
92
+ .cup-dot { width: 6px; height: 6px; margin: -3px 0 0 -3px; background: var(--cup-paper); }
93
+ .cup-ring { width: 38px; height: 38px; margin: -19px 0 0 -19px; border: 1px solid rgba(236, 231, 222, .5); }
94
+
95
+ @media (prefers-reduced-motion: reduce) {
96
+ .cup-name span, .cup-text .w > span, .cup-bars i, .cup-scrim { transition: none; }
97
+ .cup-title.is-in .cup-name span, .cup-title.is-in .cup-flare { animation: none; }
98
+ }`;
99
+
100
+ const VS = `
101
+ attribute vec3 aPos; attribute vec2 aUv; attribute vec4 aRect; attribute vec4 aMisc; attribute vec2 aPan; attribute vec2 aLab; attribute float aGlow;
102
+ uniform vec2 uHalf; uniform float uP;
103
+ varying vec2 vUv; varying vec4 vRect; varying vec4 vMisc; varying vec2 vPan; varying vec2 vLab; varying float vGlow;
104
+ void main() {
105
+ vUv = aUv; vRect = aRect; vMisc = aMisc; vPan = aPan; vLab = aLab; vGlow = aGlow;
106
+ gl_Position = vec4(aPos.x / uHalf.x, -aPos.y / uHalf.y, 0.0, (uP - aPos.z) / uP); // CSS-style perspective, divided on the GPU
107
+ }`;
108
+
109
+ const FS = `
110
+ #extension GL_OES_standard_derivatives : enable
111
+ precision mediump float;
112
+ varying vec2 vUv; varying vec4 vRect; varying vec4 vMisc; varying vec2 vPan; varying vec2 vLab; varying float vGlow;
113
+ uniform sampler2D uAtlas; uniform sampler2D uVideo; uniform sampler2D uLabels; uniform float uLab;
114
+ uniform highp vec2 uView; uniform highp float uDpr; uniform highp vec4 uColor;
115
+ vec3 grade(vec3 c) { // black and white, colour in a soft circle at the pointer
116
+ if (uColor.w <= 0.0) return c;
117
+ highp vec2 sp = vec2(gl_FragCoord.x / uDpr - uView.x, uView.y - gl_FragCoord.y / uDpr);
118
+ float k = 1.0 - smoothstep(uColor.z * 0.4, uColor.z, length(sp - uColor.xy));
119
+ vec3 g = vec3(dot(c, vec3(0.299, 0.587, 0.114))) * vec3(1.02, 1.0, 0.96);
120
+ return mix(c, mix(g * 0.92, c * (1.0 + 0.08 * k), k), uColor.w);
121
+ }
122
+ void main() {
123
+ vec2 uv = clamp((vUv - 0.5) / 1.16 + 0.5 + vPan, 0.002, 0.998); // the picture a little larger, panned
124
+ vec2 t = vRect.xy + uv * vRect.zw;
125
+ vec3 col = vMisc.z > 0.5 ? texture2D(uVideo, t).rgb : texture2D(uAtlas, t).rgb;
126
+ vec2 p = (vUv - 0.5) * vec2(1.6, 1.0);
127
+ float kb = vMisc.w, r = 0.035 * kb;
128
+ vec2 q = abs(p) - vec2(0.8, 0.5) + r;
129
+ float d = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r; // a rounded screen
130
+ col *= mix(0.5, 1.0, smoothstep(0.0, 0.14 * kb, -d)); // its dark inner edge
131
+ vec2 lp = (vUv - vec2(0.07, 0.836)) / vec2(0.42, 0.084);
132
+ if (uLab > 0.0 && lp.x > 0.0 && lp.x < 1.0 && lp.y > 0.0 && lp.y < 1.0) {
133
+ float a = texture2D(uLabels, vLab + lp * vec2(0.25, 0.03125)).a;
134
+ col = mix(col, vec3(0.925, 0.906, 0.871), a * 0.8 * uLab);
135
+ }
136
+ col *= 1.0 - vMisc.y;
137
+ col = grade(col);
138
+ float aa = fwidth(d);
139
+ float alpha = (1.0 - smoothstep(-aa, aa, d)) * vMisc.x;
140
+ vec4 outc = vec4(col * alpha, alpha);
141
+ if (vGlow > 0.001) { // the screen's own light on the dark around it
142
+ vec2 cu = clamp(vUv, 0.0, 1.0) * 0.86 + 0.07;
143
+ vec3 gc = vMisc.z > 0.5 ? texture2D(uVideo, vRect.xy + cu * vRect.zw).rgb : texture2D(uAtlas, vRect.xy + cu * vRect.zw, 5.0).rgb;
144
+ float ga = vGlow * 0.85 * exp(-max(d, 0.0) / (0.28 * kb)) * vMisc.x * (1.0 - alpha);
145
+ outc += vec4(grade(gc) * 1.6 * ga, ga);
146
+ }
147
+ gl_FragColor = outc;
148
+ }`;
149
+
150
+ const clamp = (v, a = 0, b = 1) => Math.min(b, Math.max(a, v));
151
+ const lerp = (a, b, u) => a + (b - a) * u;
152
+ const ease = (u) => (u < .5 ? 4 * u * u * u : 1 - Math.pow(-2 * u + 2, 3) / 2); // in-out, both ends at rest
153
+ const sstep = (a, b, v) => { const u = clamp((v - a) / (b - a)); return u * u * (3 - 2 * u); };
154
+ const damp = (cur, to, rate, dt) => cur + (to - cur) * (1 - Math.exp(-rate * dt));
155
+ const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
156
+ // rotate a point first about X (b), then about Y (a): the order of "rotateY(a) rotateX(b)" in CSS
157
+ function rot(x, y, z, a, b) {
158
+ const y1 = y * Math.cos(b) - z * Math.sin(b), z1 = y * Math.sin(b) + z * Math.cos(b);
159
+ return [x * Math.cos(a) + z1 * Math.sin(a), y1, -x * Math.sin(a) + z1 * Math.cos(a)];
160
+ }
161
+
162
+ let styled = false;
163
+ function injectStyle(fonts) {
164
+ if (styled) return; styled = true;
165
+ if (fonts && !document.querySelector(`link[href="${FONTS}"]`)) {
166
+ const l = document.createElement('link'); l.rel = 'stylesheet'; l.href = FONTS; document.head.append(l);
167
+ }
168
+ const s = document.createElement('style'); s.textContent = CSS; document.head.append(s);
169
+ }
170
+
171
+ function markup(o) {
172
+ const ch = [...String(o.title)], mid = (ch.length - 1) / 2;
173
+ const letters = ch.map((c, i) => `<span aria-hidden="true" style="--o:${(i - mid).toFixed(1)};--d:${(Math.abs(i - mid) * .07).toFixed(2)}s;--g:${(1.5 + i / Math.max(1, ch.length - 1) * 1.2).toFixed(2)}s">${c === ' ' ? '&nbsp;' : esc(c)}</span>`).join('');
174
+ let k = 0;
175
+ const words = (s) => String(s || '').split(/\s+/).filter(Boolean).map((w) => `<span class="w"><span style="--d:${(1.7 + k++ * .07).toFixed(2)}s">${esc(w)}</span></span>`).join(' ');
176
+ const text = words(o.text) + (o.textItalic ? ` <em>${words(o.textItalic)}</em>` : '');
177
+ return `<canvas class="cup-gl" aria-hidden="true"></canvas>
178
+ <div class="cup-scrim"></div>
179
+ <div class="cup-title"><div class="cup-name" role="heading" aria-level="1" aria-label="${esc(o.title)}">${letters}<i class="cup-flare" aria-hidden="true"></i></div><p class="cup-text">${text}</p></div>
180
+ <div class="cup-bars" aria-hidden="true"><i></i><i></i></div>
181
+ <div class="cup-aim" aria-hidden="true"><i></i><i></i><i></i><i></i><div class="cup-tag"></div></div>
182
+ <div class="cup-cursor" aria-hidden="true"><i class="cup-ring"></i><i class="cup-dot"></i></div>`;
183
+ }
184
+
185
+ export function mountPanorama(root, options = {}) {
186
+ const o = { ...DEFAULTS, ...options };
187
+ injectStyle(o.fonts);
188
+ const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
189
+ const fine = matchMedia('(pointer: fine)').matches;
190
+ root.classList.add('cup');
191
+ if (o.cursor && fine) root.classList.add('cup-cur');
192
+ root.innerHTML = markup(o);
193
+ const $ = (s) => root.querySelector(s);
194
+ const canvas = $('.cup-gl'), titleEl = $('.cup-title'), aim = $('.cup-aim'), tag = $('.cup-tag');
195
+ const cursor = $('.cup-cursor'), dot = $('.cup-dot'), ring = $('.cup-ring');
196
+
197
+ const stills = (o.stills || []).slice(0, 16);
198
+ let video = null;
199
+ if (o.video && o.video.src) {
200
+ video = document.createElement('video');
201
+ video.className = 'cup-src'; video.crossOrigin = 'anonymous';
202
+ Object.assign(video, { src: o.video.src, muted: true, loop: true, playsInline: true, preload: 'auto' });
203
+ if (o.video.poster) video.poster = o.video.poster;
204
+ video.setAttribute('muted', ''); video.setAttribute('playsinline', ''); video.setAttribute('aria-hidden', 'true');
205
+ root.append(video);
206
+ }
207
+ // screen 0 is the clip (or the first still); the rest repeat the stills, neighbours in the list kept apart
208
+ const S = Math.max(1, stills.length);
209
+ const filmFor = (i) => (i === 0 && video ? o.video : stills[(i * 7 + Math.floor(i / S) * 4) % S] || {});
210
+ const stillIndex = (i) => (i === 0 && video ? -1 : (i * 7 + Math.floor(i / S) * 4) % S);
211
+
212
+ // ---------- WebGL: every screen is one textured quad ----------
213
+ const gl = canvas.getContext('webgl', { alpha: true, premultipliedAlpha: true, antialias: true });
214
+ if (!gl || !gl.getExtension('OES_standard_derivatives')) {
215
+ // no WebGL: the opening picture and the title, still
216
+ const pic = (o.video && o.video.poster) || (stills[0] && stills[0].src);
217
+ if (pic) Object.assign(root.style, { backgroundImage: `url("${pic}")`, backgroundSize: 'cover', backgroundPosition: 'center' });
218
+ titleEl.classList.add('is-in');
219
+ return { replay() {}, destroy() { root.innerHTML = ''; root.classList.remove('cup', 'cup-cur'); } };
220
+ }
221
+ const compile = (type, src) => { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s)); return s; };
222
+ const prog = gl.createProgram();
223
+ gl.attachShader(prog, compile(gl.VERTEX_SHADER, VS)); gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FS));
224
+ gl.linkProgram(prog); gl.useProgram(prog);
225
+ const MAX = 100, F = 18; // screens at most, floats per vertex
226
+ const data = new Float32Array(MAX * 4 * F);
227
+ gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
228
+ const idx = new Uint16Array(MAX * 6);
229
+ for (let i = 0; i < MAX; i++) idx.set([i * 4, i * 4 + 1, i * 4 + 2, i * 4, i * 4 + 2, i * 4 + 3], i * 6);
230
+ gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, idx, gl.STATIC_DRAW);
231
+ [['aPos', 3, 0], ['aUv', 2, 3], ['aRect', 4, 5], ['aMisc', 4, 9], ['aPan', 2, 13], ['aLab', 2, 15], ['aGlow', 1, 17]].forEach(([name, size, off]) => {
232
+ const loc = gl.getAttribLocation(prog, name); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, size, gl.FLOAT, false, F * 4, off * 4);
233
+ });
234
+ const U = (n) => gl.getUniformLocation(prog, n);
235
+ const uHalf = U('uHalf'), uP = U('uP'), uLab = U('uLab'), uView = U('uView'), uDpr = U('uDpr'), uColor = U('uColor');
236
+ gl.uniform1i(U('uAtlas'), 0); gl.uniform1i(U('uVideo'), 1); gl.uniform1i(U('uLabels'), 2);
237
+ gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
238
+
239
+ const texture = (unit, mip) => {
240
+ const t = gl.createTexture(); gl.activeTexture(gl.TEXTURE0 + unit); gl.bindTexture(gl.TEXTURE_2D, t);
241
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
242
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, mip ? gl.LINEAR_MIPMAP_LINEAR : gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
243
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([21, 19, 17, 255]));
244
+ return { t, unit, mip };
245
+ };
246
+ const upload = (tx, src) => {
247
+ try {
248
+ gl.activeTexture(gl.TEXTURE0 + tx.unit); gl.bindTexture(gl.TEXTURE_2D, tx.t);
249
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, src);
250
+ if (tx.mip) gl.generateMipmap(gl.TEXTURE_2D);
251
+ } catch (e) { console.warn('Panorama: a picture could not be read (another site without CORS?)', e); }
252
+ };
253
+ const tAtlas = texture(0, true), tVideo = texture(1, false), tLab = texture(2, true);
254
+
255
+ // the stills in one 2048 × 1024 sheet: 400 × 250 each, cropped to 16:10, with a gutter so the mipmaps don't bleed
256
+ const sheet = document.createElement('canvas'); sheet.width = 2048; sheet.height = 1024;
257
+ const sctx = sheet.getContext('2d'); sctx.fillStyle = '#151311'; sctx.fillRect(0, 0, 2048, 1024);
258
+ let pending = stills.length;
259
+ stills.forEach((f, j) => {
260
+ const im = new Image(); im.crossOrigin = 'anonymous'; im.decoding = 'async';
261
+ im.onload = im.onerror = () => {
262
+ if (im.naturalWidth) {
263
+ const iw = im.naturalWidth, ih = im.naturalHeight, sw = Math.min(iw, ih * AR), sh = sw / AR;
264
+ sctx.drawImage(im, (iw - sw) / 2, (ih - sh) / 2, sw, sh, (j % 4) * 512, Math.floor(j / 4) * 256, 400, 250);
265
+ }
266
+ if (--pending === 0) upload(tAtlas, sheet);
267
+ };
268
+ im.src = f.src;
269
+ });
270
+ const cellRect = (j) => [(j % 4) * .25, Math.floor(j / 4) * .25, 400 / 2048, 250 / 1024];
271
+ let videoRect = [.05, 0, .9, 1], lastVT = -1;
272
+ if (video) {
273
+ if (o.video.poster) { const p = new Image(); p.crossOrigin = 'anonymous'; p.onload = () => upload(tVideo, p); p.src = o.video.poster; }
274
+ video.addEventListener('loadedmetadata', () => {
275
+ const a = video.videoWidth / video.videoHeight;
276
+ videoRect = a > AR ? [(1 - AR / a) / 2, 0, AR / a, 1] : [0, (1 - a / AR) / 2, 1, a / AR];
277
+ });
278
+ }
279
+ let labelsReady = false;
280
+ function makeLabels() {
281
+ const c = document.createElement('canvas'); c.width = c.height = 1024;
282
+ const x = c.getContext('2d'); x.fillStyle = '#fff'; x.font = '500 19px "Geist Mono", ui-monospace, Menlo, monospace'; x.textBaseline = 'middle';
283
+ try { x.letterSpacing = '1.5px'; } catch (e) {}
284
+ for (let i = 0; i < 128; i++) {
285
+ const ox = (i % 4) * 256, oy = Math.floor(i / 4) * 32;
286
+ x.globalAlpha = .7; x.beginPath(); x.arc(ox + 9, oy + 16, 4, 0, 7); x.fill();
287
+ x.globalAlpha = 1; x.fillText('CAM ' + String(i + 1).padStart(2, '0'), ox + 22, oy + 17);
288
+ }
289
+ upload(tLab, c); labelsReady = true;
290
+ }
291
+
292
+ // ---------- the dome ----------
293
+ let W = 0, H = 0, P = 600, R = 0, tw = 0, cells = [], n = 0;
294
+ let g = new Float32Array(MAX), hv = new Float32Array(MAX);
295
+ function layout() {
296
+ W = root.clientWidth; H = root.clientHeight; if (!W || !H) return;
297
+ P = Math.max(W * .42, 340); R = Math.max(W, H * 1.3) * .95;
298
+ const C = W < 720 ? 7 : 11, Rw = 7, da = (W < 720 ? 18 : 15) * D2R, db = 13 * D2R;
299
+ cells = [];
300
+ for (let r = 0; r < Rw; r++) for (let c = 0; c < C; c++) cells.push([(c - (C - 1) / 2) * da, (r - (Rw - 1) / 2) * db]);
301
+ cells.sort((p, q) => Math.hypot(p[0], p[1]) - Math.hypot(q[0], q[1])); // the clip takes the middle screen
302
+ n = Math.min(MAX, cells.length); tw = R * da * .84;
303
+ const dpr = Math.min(devicePixelRatio || 1, 2);
304
+ canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr);
305
+ root.style.setProperty('--cup-bar', (W > H ? Math.max(0, (H - W / 2.39) / 2) : 0) + 'px');
306
+ }
307
+
308
+ // the pointer, relative to the dome's element
309
+ let px = 0, py = 0, inside = false, mx = 0, my = 0;
310
+ const onMove = (e) => {
311
+ const b = root.getBoundingClientRect();
312
+ px = e.clientX - b.left; py = e.clientY - b.top; inside = true;
313
+ mx = clamp(px / W * 2 - 1, -1, 1); my = clamp(py / H * 2 - 1, -1, 1);
314
+ };
315
+ const onLeave = () => { inside = false; mx = my = 0; };
316
+ root.addEventListener('pointermove', onMove);
317
+ root.addEventListener('pointerdown', onMove);
318
+ root.addEventListener('pointerleave', onLeave);
319
+ root.addEventListener('pointercancel', onLeave);
320
+
321
+ // ---------- time ----------
322
+ let t0 = 0, last = 0, raf = 0, visible = false, started = false, ready = false, settledAt = 0;
323
+ let headH = 0, headP = 0, clock = 0, fx = 0, fy = 0, on = 0, mono = 0, labOn = 0, hover = -1;
324
+ const AIM = { x: 0, y: 0, w: 0, h: 0, o: 0, idx: -1 }, CUR = { x: 0, y: 0, s: 1, d: 1, seen: false };
325
+
326
+ function begin() {
327
+ started = true;
328
+ titleEl.classList.add('is-reset'); titleEl.classList.remove('is-in'); void titleEl.offsetWidth; titleEl.classList.remove('is-reset');
329
+ root.classList.remove('is-morph', 'is-settled');
330
+ settledAt = 0; mono = 0; labOn = 0; hover = -1; g.fill(0); hv.fill(0);
331
+ if (video) { try { video.currentTime = 0; video.play().catch(() => {}); } catch (e) {} }
332
+ const skip = reduce || !o.intro;
333
+ t0 = performance.now() / 1000 - (skip ? HOLD + 4 : 0);
334
+ if (skip) { titleEl.classList.add('is-in'); root.classList.add('is-morph', 'is-settled'); settledAt = 1; }
335
+ else setTimeout(() => titleEl.classList.add('is-in'), 450);
336
+ }
337
+
338
+ function pose(i, dt) {
339
+ const [a, b] = cells[i];
340
+ let x = Math.sin(a) * Math.cos(b), y = Math.sin(b), z = -Math.cos(a) * Math.cos(b);
341
+ [x, y, z] = rot(x, y, z, headH, 0); [x, y, z] = rot(x, y, z, 0, -headP);
342
+ const fwd = -z, Xs = x / Math.max(.05, fwd) * P, Ys = y / Math.max(.05, fwd) * P;
343
+ const h = (hv[i] = damp(hv[i], hover === i ? 1 : 0, 6, dt));
344
+ const near = (g[i] = damp(g[i], Math.exp(-((Math.hypot(Xs - fx, Ys - fy) / 240) ** 2)) * on, 4, dt));
345
+ const r = R * (1 - .07 * near - .12 * h);
346
+ return { x: x * r, y: y * r, z: P + z * r, rx: Math.asin(clamp(y, -1, 1)) * R2D, ry: Math.atan2(-x, -z) * R2D,
347
+ w: tw * (1 + .08 * h), o: sstep(.3, .52, fwd), sh: clamp((1 - fwd) * 1.6) * .55 * (1 - .6 * near) * (1 - h),
348
+ ix: -Xs / W * 6, iy: -Ys / H * 6, glow: h };
349
+ }
350
+ const mix = (a, b, u) => { const r = {}; for (const k in b) r[k] = lerp(a[k] ?? b[k], b[k], u); return r; };
351
+ const CORNERS = [[-.5, -.5, 0, 0], [.5, -.5, 1, 0], [.5, .5, 1, 1], [-.5, .5, 0, 1]];
352
+
353
+ function item(i, q, kb) {
354
+ const w = q.w, h = w / AR, cx = Math.cos(q.rx * D2R), sx = Math.sin(q.rx * D2R), cy = Math.cos(q.ry * D2R), sy = Math.sin(q.ry * D2R);
355
+ const pts = [];
356
+ for (const [lx, ly] of CORNERS) { // rotateX, then rotateY, then translate
357
+ const X = lx * w, Y = ly * h * cx, Z = ly * h * sx;
358
+ pts.push(X * cy + Z * sy + q.x, Y + q.y, -X * sy + Z * cy + q.z);
359
+ }
360
+ // a glowing screen gets a larger quad to hold its light; the hit test keeps the screen itself
361
+ const m = q.glow > .001 ? .75 : 0, hp = pts.slice();
362
+ if (m) for (let k = 0; k < 3; k++) { const c = (pts[k] + pts[3 + k] + pts[6 + k] + pts[9 + k]) / 4; for (let v = 0; v < 4; v++) pts[v * 3 + k] = c + (pts[v * 3 + k] - c) * (1 + 2 * m); }
363
+ const si = stillIndex(i);
364
+ return { i, z: q.z, pts, hp, m, rect: si < 0 ? videoRect : cellRect(si), misc: [q.o, q.sh, si < 0 ? 1 : 0, kb], pan: [-q.ix / 116, -q.iy / 116], lab: [(i % 4) * .25, Math.floor(i / 4) / 32], glow: q.glow };
365
+ }
366
+
367
+ function draw(list) {
368
+ gl.viewport(0, 0, canvas.width, canvas.height); gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT);
369
+ if (!labelsReady && o.labels) makeLabels();
370
+ if (video && video.readyState >= 2 && video.currentTime !== lastVT) { lastVT = video.currentTime; upload(tVideo, video); }
371
+ list.sort((a, b) => a.z - b.z); // back to front
372
+ let k = 0;
373
+ for (const it of list) for (let v = 0; v < 4; v++) {
374
+ const e = 1 + 2 * it.m;
375
+ data.set([it.pts[v * 3], it.pts[v * 3 + 1], it.pts[v * 3 + 2], .5 + (CORNERS[v][2] - .5) * e, .5 + (CORNERS[v][3] - .5) * e,
376
+ ...it.rect, ...it.misc, ...it.pan, ...it.lab, it.glow], k); k += F;
377
+ }
378
+ gl.bufferData(gl.ARRAY_BUFFER, data.subarray(0, k), gl.DYNAMIC_DRAW);
379
+ gl.uniform2f(uHalf, W / 2, H / 2); gl.uniform1f(uP, P); gl.uniform1f(uLab, o.labels ? labOn : 0);
380
+ gl.uniform2f(uView, W / 2, H / 2); gl.uniform1f(uDpr, canvas.width / W);
381
+ gl.uniform4f(uColor, fx, fy, Math.min(W, H) * .3 * (.35 + .65 * on), mono);
382
+ [tAtlas, tVideo, tLab].forEach((tx) => { gl.activeTexture(gl.TEXTURE0 + tx.unit); gl.bindTexture(gl.TEXTURE_2D, tx.t); });
383
+ gl.drawElements(gl.TRIANGLES, list.length * 6, gl.UNSIGNED_SHORT, 0);
384
+ }
385
+
386
+ const project = (it) => {
387
+ const s = [];
388
+ for (let v = 0; v < 4; v++) { const d = P - it.hp[v * 3 + 2]; if (d <= 1) return null; s.push(it.hp[v * 3] * P / d, it.hp[v * 3 + 1] * P / d); }
389
+ return s;
390
+ };
391
+ function hit(list, x, y) { // the front-most screen under the pointer
392
+ for (let j = list.length - 1; j >= 0; j--) {
393
+ const it = list[j]; if (it.misc[0] < .3) continue;
394
+ const s = project(it); if (!s) continue;
395
+ let pos = 0, neg = 0;
396
+ for (let v = 0; v < 4; v++) { const ax = s[v * 2], ay = s[v * 2 + 1], bx = s[(v + 1) % 4 * 2], by = s[(v + 1) % 4 * 2 + 1]; if ((bx - ax) * (y - ay) - (by - ay) * (x - ax) > 0) pos++; else neg++; }
397
+ if (!pos || !neg) return it;
398
+ }
399
+ return null;
400
+ }
401
+ function aimAt(it, dt) {
402
+ let r = null;
403
+ if (it) { const s = project(it); if (s) { const xs = [s[0], s[2], s[4], s[6]].map((v) => v + W / 2), ys = [s[1], s[3], s[5], s[7]].map((v) => v + H / 2); r = { x: Math.min(...xs), y: Math.min(...ys), w: Math.max(...xs) - Math.min(...xs), h: Math.max(...ys) - Math.min(...ys) }; } }
404
+ const i = it && r ? it.i : -1;
405
+ if (i !== AIM.idx) {
406
+ if (i >= 0) {
407
+ const f = filmFor(i);
408
+ tag.innerHTML = `<em>${esc(f.title || '')}</em><small>${o.labels ? `CAM ${String(i + 1).padStart(2, '0')}` : ''}${o.labels && f.year ? ' · ' : ''}${esc(f.year || '')}</small>`;
409
+ tag.classList.remove('is-new'); void tag.offsetWidth; tag.classList.add('is-new');
410
+ if (AIM.o < .05) Object.assign(AIM, { x: r.x - r.w * .1, y: r.y - r.h * .1, w: r.w * 1.2, h: r.h * 1.2 }); // lock on from a little wider
411
+ }
412
+ AIM.idx = i;
413
+ }
414
+ if (r) { AIM.x = damp(AIM.x, r.x, 11, dt); AIM.y = damp(AIM.y, r.y, 11, dt); AIM.w = damp(AIM.w, r.w, 11, dt); AIM.h = damp(AIM.h, r.h, 11, dt); }
415
+ AIM.o = damp(AIM.o, r ? 1 : 0, r ? 9 : 5, dt);
416
+ aim.style.opacity = AIM.o.toFixed(3);
417
+ aim.style.transform = `translate(${AIM.x.toFixed(1)}px,${AIM.y.toFixed(1)}px)`;
418
+ aim.style.width = AIM.w.toFixed(1) + 'px'; aim.style.height = AIM.h.toFixed(1) + 'px';
419
+ }
420
+ function cursorStep(dt) {
421
+ const show = o.cursor && fine && inside;
422
+ cursor.classList.toggle('is-on', show);
423
+ if (!show) { CUR.seen = false; return; }
424
+ if (!CUR.seen) { CUR.x = px; CUR.y = py; CUR.seen = true; }
425
+ CUR.x = damp(CUR.x, px, 15, dt); CUR.y = damp(CUR.y, py, 15, dt);
426
+ const vx = px - CUR.x, vy = py - CUR.y, sp = Math.min(.4, Math.hypot(vx, vy) / 110);
427
+ CUR.s = damp(CUR.s, hover >= 0 ? .42 : 1, 10, dt); CUR.d = damp(CUR.d, hover >= 0 ? 1.35 : 1, 10, dt);
428
+ dot.style.transform = `translate(${px.toFixed(1)}px,${py.toFixed(1)}px) scale(${CUR.d.toFixed(3)})`;
429
+ ring.style.transform = `translate(${CUR.x.toFixed(1)}px,${CUR.y.toFixed(1)}px) rotate(${Math.atan2(vy, vx).toFixed(3)}rad) scale(${(CUR.s * (1 + sp)).toFixed(3)},${(CUR.s * (1 - sp * .45)).toFixed(3)})`;
430
+ }
431
+
432
+ const list = [];
433
+ function frame(nowMs) {
434
+ raf = 0;
435
+ if (!visible) return;
436
+ const now = nowMs / 1000, dt = Math.min(.05, now - (last || now)); last = now;
437
+ if (!started && ready) begin();
438
+ if (!started || !n) { raf = requestAnimationFrame(frame); return; }
439
+ const t = now - t0;
440
+ clock += dt;
441
+ headH = damp(headH, mx * .42 + (reduce ? 0 : Math.sin(clock * .13) * .05), 3.2, dt); headP = damp(headP, my * .26, 3.2, dt);
442
+ const live = inside && settledAt;
443
+ if (live) { fx = damp(fx, px - W / 2, 9, dt); fy = damp(fy, py - H / 2, 9, dt); }
444
+ on = damp(on, live ? 1 : 0, 2.4, dt);
445
+ mono = damp(mono, settledAt ? 1 : 0, 1.4, dt);
446
+ labOn = damp(labOn, settledAt ? 1 : 0, 2.5, dt);
447
+ if (t > HOLD - .05) root.classList.add('is-morph');
448
+ if (t > HOLD + MORPH_V + .4 && !settledAt) { settledAt = now; root.classList.add('is-settled'); }
449
+
450
+ list.length = 0;
451
+ const diag = Math.hypot(W, H);
452
+ let p0 = null, u0 = 1;
453
+ for (let i = 0; i < n; i++) {
454
+ const p = pose(i, dt);
455
+ let q;
456
+ if (i === 0) {
457
+ p0 = p; u0 = ease(clamp((t - HOLD) / MORPH_V));
458
+ q = mix({ x: 0, y: 0, z: 0, rx: 0, ry: 0, w: Math.max(W, H * AR), o: 1, sh: 0, ix: 0, iy: 0, glow: 0 }, p, u0);
459
+ } else {
460
+ // the rest arrive in a wave that starts at the clip's screen
461
+ const u = ease(clamp((t - HOLD - .3 - Math.hypot(p.x - p0.x, p.y - p0.y) / diag * 1.1) / MORPH));
462
+ q = u >= 1 ? p : mix({ ...p, z: p.z - 700, o: 0 }, p, u);
463
+ }
464
+ if (q.o > .002) list.push(item(i, q, i === 0 ? lerp(200 / (Math.max(W, H * AR) / AR), 1, u0) : 1));
465
+ }
466
+ draw(list);
467
+ const it = inside && settledAt ? hit(list, px - W / 2, py - H / 2) : null;
468
+ hover = it ? it.i : -1;
469
+ aimAt(it, dt);
470
+ cursorStep(dt);
471
+ titleEl.style.transform = `translate(-50%, -50%) scale(${lerp(1, .9, ease(clamp((t - HOLD - .15) / 1.7))).toFixed(4)})`;
472
+ raf = requestAnimationFrame(frame);
473
+ }
474
+ const wake = () => { if (!raf && visible) { last = 0; raf = requestAnimationFrame(frame); } };
475
+
476
+ // runs only while on screen; the opening waits for the fonts and for the clip to be playable (or 2.2 s)
477
+ const io = new IntersectionObserver((es) => { visible = es[0].isIntersecting; wake(); });
478
+ io.observe(root);
479
+ const ro = new ResizeObserver(() => { layout(); wake(); });
480
+ ro.observe(root);
481
+ layout();
482
+ Promise.all([document.fonts ? document.fonts.ready : null, video ? new Promise((r) => { video.addEventListener('canplay', r, { once: true }); setTimeout(r, 2200); }) : null])
483
+ .then(() => { ready = true; wake(); });
484
+
485
+ return {
486
+ replay() { if (ready) { begin(); wake(); } },
487
+ destroy() {
488
+ cancelAnimationFrame(raf); raf = 0; visible = false; io.disconnect(); ro.disconnect();
489
+ root.removeEventListener('pointermove', onMove); root.removeEventListener('pointerdown', onMove);
490
+ root.removeEventListener('pointerleave', onLeave); root.removeEventListener('pointercancel', onLeave);
491
+ if (video) { video.pause(); video.removeAttribute('src'); video.load(); }
492
+ const lose = gl.getExtension('WEBGL_lose_context'); if (lose) lose.loseContext();
493
+ root.innerHTML = ''; root.classList.remove('cup', 'cup-cur', 'is-morph', 'is-settled'); root.style.removeProperty('--cup-bar');
494
+ },
495
+ };
496
+ }
package/dist/piggy.cdn.js CHANGED
@@ -1,4 +1,4 @@
1
- // Piggy bank · Craft Library, by Sara Lobo
1
+ // Piggy bank · Sara Lobo Library
2
2
  // A congratulations moment in 3D: a frosted glass piggy bank hops in from the right with a full turn, lands on its hind
3
3
  // feet, nearly tips over and clacks back down, rocking like the rigid glass it is. Coins pop into the air above it and
4
4
  // drop in through the slot; each one knocks the glass, and its eyes close a little more until they curve, happy.
package/dist/piggy.js CHANGED
@@ -1,4 +1,4 @@
1
- // Piggy bank · Craft Library, by Sara Lobo
1
+ // Piggy bank · Sara Lobo Library
2
2
  // A congratulations moment in 3D: a frosted glass piggy bank hops in from the right with a full turn, lands on its hind
3
3
  // feet, nearly tips over and clacks back down, rocking like the rigid glass it is. Coins pop into the air above it and
4
4
  // drop in through the slot; each one knocks the glass, and its eyes close a little more until they curve, happy.
package/dist/thinking.js CHANGED
@@ -1,12 +1,12 @@
1
1
  // Magic sphere and Sweep · Craft Library, by Sara Lobo. thinking.css and thinking.js from the library, unchanged,
2
2
  // around a mount.
3
3
  import { css } from './style.js';
4
- const CSS = "/* Thinking orbs · Craft Library, by Sara Lobo\n A ball of dots and the word beside it, for the wait while an answer is being written. thinking.js draws the ball\n and, for the Sweep, carries the ball's wave on through the word. Made for dark backgrounds: on a light one, set\n --thinking-ink (the dots and the light in the word) and --thinking-dim (the word at rest). */\n\n.thinking {\n --thinking-size: 32px;\n --thinking-ink: #EDEDED;\n --thinking-dim: #8A8A8A;\n position: relative;\n display: inline-flex;\n align-items: center;\n gap: 10px;\n vertical-align: middle;\n}\n/* the ball: drawn by thinking.js at this size. Under 64px it uses more, finer dots, made to sit beside text */\n.thinking-orb {\n display: block;\n flex: none;\n width: var(--thinking-size);\n height: var(--thinking-size);\n}\n/* the word: a light passes through it, left to right, on a loop */\n.thinking-label {\n font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', system-ui, sans-serif;\n font-size: 14px;\n line-height: normal;\n letter-spacing: -.005em;\n white-space: nowrap;\n -webkit-font-smoothing: antialiased;\n text-rendering: optimizeLegibility;\n color: transparent;\n -webkit-background-clip: text;\n background-clip: text;\n background-image: linear-gradient(90deg, var(--thinking-dim) 0 35%, var(--thinking-ink) 50%, var(--thinking-dim) 65% 100%);\n background-size: 250% 100%;\n animation: thinking-shine 2.6s linear infinite;\n}\n@keyframes thinking-shine {\n from {\n background-position: 100% 0;\n }\n to {\n background-position: 0 0;\n }\n}\n/* the Sweep's word is lit by the ball's own wave: thinking.js moves --thinking-x across it */\n.thinking[data-kind=\"sweep\"] .thinking-label {\n --thinking-x: -999px;\n background-image: linear-gradient(90deg, var(--thinking-dim) calc(var(--thinking-x) - 26px), var(--thinking-ink) var(--thinking-x), var(--thinking-dim) calc(var(--thinking-x) + 26px));\n background-size: auto;\n animation: none;\n}\n\n/* less motion: the ball holds still and only breathes; the word stays plain */\n@media (prefers-reduced-motion: reduce) {\n .thinking-label {\n animation: none;\n background: none;\n color: var(--thinking-dim);\n }\n}\n";
4
+ const CSS = "/* Thinking orbs · Sara Lobo Library\n A ball of dots and the word beside it, for the wait while an answer is being written. thinking.js draws the ball\n and, for the Sweep, carries the ball's wave on through the word. Made for dark backgrounds: on a light one, set\n --thinking-ink (the dots and the light in the word) and --thinking-dim (the word at rest). */\n\n.thinking {\n --thinking-size: 32px;\n --thinking-ink: #EDEDED;\n --thinking-dim: #8A8A8A;\n position: relative;\n display: inline-flex;\n align-items: center;\n gap: 10px;\n vertical-align: middle;\n}\n/* the ball: drawn by thinking.js at this size. Under 64px it uses more, finer dots, made to sit beside text */\n.thinking-orb {\n display: block;\n flex: none;\n width: var(--thinking-size);\n height: var(--thinking-size);\n}\n/* the word: a light passes through it, left to right, on a loop */\n.thinking-label {\n font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', system-ui, sans-serif;\n font-size: 14px;\n line-height: normal;\n letter-spacing: -.005em;\n white-space: nowrap;\n -webkit-font-smoothing: antialiased;\n text-rendering: optimizeLegibility;\n color: transparent;\n -webkit-background-clip: text;\n background-clip: text;\n background-image: linear-gradient(90deg, var(--thinking-dim) 0 35%, var(--thinking-ink) 50%, var(--thinking-dim) 65% 100%);\n background-size: 250% 100%;\n animation: thinking-shine 2.6s linear infinite;\n}\n@keyframes thinking-shine {\n from {\n background-position: 100% 0;\n }\n to {\n background-position: 0 0;\n }\n}\n/* the Sweep's word is lit by the ball's own wave: thinking.js moves --thinking-x across it */\n.thinking[data-kind=\"sweep\"] .thinking-label {\n --thinking-x: -999px;\n background-image: linear-gradient(90deg, var(--thinking-dim) calc(var(--thinking-x) - 26px), var(--thinking-ink) var(--thinking-x), var(--thinking-dim) calc(var(--thinking-x) + 26px));\n background-size: auto;\n animation: none;\n}\n\n/* less motion: the ball holds still and only breathes; the word stays plain */\n@media (prefers-reduced-motion: reduce) {\n .thinking-label {\n animation: none;\n background: none;\n color: var(--thinking-dim);\n }\n}\n";
5
5
  let api;
6
6
  function engine() {
7
7
  if (api) return api;
8
8
  const window = {};
9
- // Thinking orbs · Craft Library, by Sara Lobo
9
+ // Thinking orbs · Sara Lobo Library
10
10
  // Two "thinking" indicators made of dots on a canvas, for the wait while an answer is being written:
11
11
  // sphere · Magic sphere: a Rubik's cube on a ball, one grey tone per face. Its slices turn to scramble the pieces,
12
12
  // then retrace their steps until every piece is home, and the ball lights up for a moment.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "craft-library",
3
- "version": "0.4.0",
4
- "description": "Craft Library by Sara Lobo: a liquid glass nav bar, a bursting heart, a magnetic icon, an iridescent 3D card, a glass piggy bank and two thinking indicators (Magic sphere, Sweep), for React or plain HTML",
3
+ "version": "0.5.0",
4
+ "description": "Sara Lobo Library: a liquid glass nav bar, a bursting heart, a magnetic icon, an iridescent 3D card, a glass piggy bank, two thinking indicators (Magic sphere, Sweep) and the Panorama hero, for React or plain HTML",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "module": "./dist/index.js",
@@ -51,11 +51,16 @@
51
51
  "thinking-indicator",
52
52
  "loader",
53
53
  "ai",
54
- "particles"
54
+ "particles",
55
+ "hero",
56
+ "webgl",
57
+ "video",
58
+ "film",
59
+ "landing-page"
55
60
  ],
56
61
  "author": "Sara Lobo",
57
62
  "license": "MIT",
58
- "homepage": "https://claude.ai/artifact/LBBWCfEHJGvfBSynV3wDuR",
63
+ "homepage": "https://saralobolibrary.com",
59
64
  "dependencies": {
60
65
  "three": "0.169.0"
61
66
  }