asciify-engine 4.11.1 → 4.12.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "asciify-engine",
3
- "version": "4.11.1",
3
+ "version": "4.12.0",
4
4
  "description": "Framework-agnostic ASCII art engine. Convert images, videos, and GIFs into ASCII art rendered on canvas, or run full-bleed GPU character fields with a fluid cursor wake.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -2,13 +2,13 @@
2
2
  name: asciify-engine
3
3
  description: Build and tune browser ASCII art with asciify-engine so it fits the user's request, preferences and the project's design. Use for photo, video and GIF conversion, interactive heroes, GPU character fields with the user's own words, ambient backgrounds, text banners, art styles and export, hover and motion choices, layered HTML typography, and rendering performance.
4
4
  metadata:
5
- tested-engine: "4.11.1"
5
+ tested-engine: "4.12.0"
6
6
  updated: "2026-10-14"
7
7
  ---
8
8
 
9
9
  # Asciify Engine
10
10
 
11
- Use the published `asciify-engine` npm package for browser canvas rendering. Tested against **4.11.0**. Check the application's installed version and its public types before using newer options; a local checkout can contain unpublished changes.
11
+ Use the published `asciify-engine` npm package for browser canvas rendering. Tested against **4.12.0**. Check the application's installed version and its public types before using newer options; a local checkout can contain unpublished changes.
12
12
 
13
13
  - Agent entry: https://asciify.org/skill (redirects to this Markdown document).
14
14
  - Playground: https://asciify.org/editor
@@ -31,12 +31,15 @@ Most requests are a feeling ("make the hero feel alive", "retro", "subtle behind
31
31
  4. **Decide living or still.** A still photo can be made to feel alive (field `drift`, studio `motion`), but some users want it still. Follow their words; keep art behind text, in grids and in data views still; reduced motion always gets a still. See [living or still](references/design-matching.md#living-or-still).
32
32
  5. **Say what you chose in one line** and offer one alternative. Ask only when the media is missing or motion would be new to a site that has none.
33
33
 
34
+ [references/components.md](references/components.md) has 21 ready-made components with a shared theme-aware helper: interface pieces (image card, avatar, empty state, loading state, copy feedback), backgrounds (hero, cursor field, brand field), text (CLI banner, wordmark, decode headline), transitions (photo from characters, page transition, scramble button), pages (404, coming soon, sign-in, pricing), AI states (thinking, image generation) and video in characters, each with the prompt a person gives and the reference code. Start there when the request matches one.
35
+
34
36
  [references/design-matching.md](references/design-matching.md) maps mood words to settings, explains light themes, contrast and fonts, and gives tested recipes for the common requests. Read it whenever the user describes a look rather than an option.
35
37
 
36
38
  | The user wants | Use |
37
39
  | --- | --- |
38
40
  | Their photo, video or GIF as ASCII on the page, optionally answering the cursor | `asciify` / `asciifyVideo` / `asciifyGif`, plus `mountHover` from `/hover` for the website hovers |
39
41
  | A full-bleed hero or brand field that stays smooth under the cursor, or their own word as the characters | `mountGlyphField` from `/field` ([glyph-field.md](references/glyph-field.md)); `hover: 'trail'` for the classic Asciify Trail on pure characters, `'fluid'` for bold and liquid, `'lantern'` for a subtle light |
42
+ | A calm background behind product content (SaaS hero, pricing, docs), light or dark | `mountGlyphField` with `mark: 'dot'`, `scene: 'drift'`, `clear` around the copy, and `pulse()` on events (4.12) |
40
43
  | Art styles, saved looks, backdrops, masks, effects, PNG/MP4/GIF export | `mountStudio` from `/studio` ([studio-workspace.md](references/studio-workspace.md)) |
41
44
  | Several media and text layers in one artwork | `mountStudioProject` from `/studio` ([layered-projects.md](references/layered-projects.md)) |
42
45
  | An ambient background with no media | A template from asciify.org/backgrounds, or a `/field` generated scene |
@@ -0,0 +1,460 @@
1
+ # Asciify components
2
+
3
+ Ready-made ASCII components for product interfaces. Each one has the prompt a person gives you and the reference code you should arrive at. Every component runs live at https://asciify.org/components, where an agent cursor demonstrates its interaction, so the person can see the result before asking for it.
4
+
5
+ Build them in the project's own framework, design tokens and lifecycle. Read [design matching](design-matching.md) first. The shared rules:
6
+
7
+ - Keep text and controls in HTML. The field is a decorative canvas behind or inside them, with `aria-hidden` on the canvas.
8
+ - Give the field a transparent background and an ink from the project's tokens, for each theme.
9
+ - Check `isGlyphFieldSupported()`, and keep the static design (or a poster image of the field) as the fallback.
10
+ - Honor reduced motion. Fields draw one still frame and skip pulses by default.
11
+ - Call `destroy()` on unmount. Mount fields that start off screen lazily, when they come near the viewport.
12
+ - GPU fields need asciify-engine 4.12 or later for `mark`, `clear` and `pulse()`.
13
+
14
+ ## The shared helper
15
+
16
+ Every component below uses this helper. It mounts a field that takes its ink from a CSS custom property, follows light and dark, and cleans up after itself. Put it in the project once.
17
+
18
+ ```ts
19
+ import { isGlyphFieldSupported, mountGlyphField, type GlyphField, type GlyphFieldOptions } from 'asciify-engine/field';
20
+
21
+ /** A field inked from a CSS variable that follows the theme. Returns null when the GPU field isn't available. */
22
+ export function asciiField(canvas: HTMLCanvasElement, options: GlyphFieldOptions = {}, inkVar = '--ascii-ink') {
23
+ if (!isGlyphFieldSupported()) return null;
24
+ const ink = () => getComputedStyle(canvas).getPropertyValue(inkVar).trim() || '#8f8d86';
25
+ const field: GlyphField = mountGlyphField(canvas, { color: 'ink', background: 'transparent', ...options, ink: ink() });
26
+ const scheme = matchMedia('(prefers-color-scheme: dark)');
27
+ const retint = () => field.update({ ink: ink() });
28
+ scheme.addEventListener('change', retint);
29
+ return { field, destroy() { scheme.removeEventListener('change', retint); field.destroy(); } };
30
+ }
31
+ ```
32
+
33
+ ```css
34
+ :root { --ascii-ink: #76746e; --ascii-ink-strong: #1d1d1b; }
35
+ @media (prefers-color-scheme: dark) { :root { --ascii-ink: #7a7872; --ascii-ink-strong: #d8d6cf; } }
36
+ ```
37
+
38
+ In React, wrap it once and reuse it:
39
+
40
+ ```tsx
41
+ import { useEffect, useRef } from 'react';
42
+ import type { GlyphField, GlyphFieldOptions } from 'asciify-engine/field';
43
+
44
+ export function AsciiField({ options, onField, className }: { options: GlyphFieldOptions; onField?: (field: GlyphField | null) => void; className?: string }) {
45
+ const canvas = useRef<HTMLCanvasElement>(null);
46
+ useEffect(() => {
47
+ const mounted = canvas.current ? asciiField(canvas.current, options) : null;
48
+ onField?.(mounted?.field ?? null);
49
+ return () => { onField?.(null); mounted?.destroy(); };
50
+ }, []); // mount once; call field.update() for later changes
51
+ return <canvas ref={canvas} className={className} aria-hidden="true" />;
52
+ }
53
+ ```
54
+
55
+ ## image-card: ASCII image card
56
+ Category: Components
57
+
58
+ Prompt:
59
+ “Make a card whose image is drawn in characters with Asciify. Use [image]. Characters trail behind the cursor, the title, details and a save button stay as HTML under the image, and the ink follows our light and dark theme.”
60
+
61
+ ```ts
62
+ const image = asciiField(canvas, {
63
+ source: '/images/statue.jpg', fit: 'cover', focus: { x: 0.5, y: 0.35 },
64
+ charset: ' .:-=+*#%@', cellSize: 6, contrast: 1.25,
65
+ hover: 'trail', label: 'A marble statue drawn in characters',
66
+ }, '--ascii-ink-strong');
67
+ ```
68
+
69
+ ## hero-background: Hero background
70
+ Category: Backgrounds
71
+
72
+ Prompt:
73
+ “Put a calm ASCII background behind our hero with Asciify. Characters drift slowly, clear around the headline and buttons so they stay readable, and follow our light and dark theme.”
74
+
75
+ ```ts
76
+ const hero = asciiField(canvas, {
77
+ mark: 'glyph', scene: 'drift', charset: ' .:+*#', cellSize: 12,
78
+ clear: { element: heroText, padding: 24, feather: 0.32, shape: 'ellipse' },
79
+ hover: 'trail',
80
+ });
81
+ ```
82
+
83
+ ## cli-banner: CLI banner
84
+ Category: Text
85
+
86
+ Prompt:
87
+ “Greet people in ASCII when our CLI starts, using asciifyText from Asciify with [word].”
88
+
89
+ ```ts
90
+ import { asciifyText } from 'asciify-engine';
91
+
92
+ console.log(asciifyText('Hey', { char: '#' }));
93
+ ```
94
+
95
+ ## cursor-field: Cursor field
96
+ Category: Backgrounds
97
+
98
+ Prompt:
99
+ “Add a halftone dot field to [section] with Asciify that drifts, clears around the content, and answers the cursor with [trail, fluid or lantern].”
100
+
101
+ ```ts
102
+ const dots = asciiField(canvas, {
103
+ mark: 'dot', scene: 'drift', dotScale: 0.72, cellSize: 8,
104
+ clear: { element: content, padding: 40, shape: 'rect' },
105
+ hover: 'fluid',
106
+ });
107
+ ```
108
+
109
+ ## wordmark: Brand wordmark
110
+ Category: Text
111
+
112
+ Prompt:
113
+ “Draw our name, [name], in moving characters with Asciify for [the footer or a hero]. The word stays crisp, characters trail under the cursor, and the real name stays in HTML for screen readers.”
114
+
115
+ ```ts
116
+ const word = document.createElement('canvas');
117
+ word.width = 1800; word.height = 480;
118
+ const ctx = word.getContext('2d')!;
119
+ ctx.fillStyle = '#000'; ctx.fillRect(0, 0, word.width, word.height);
120
+ ctx.fillStyle = '#fff'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
121
+ ctx.font = '600 400px Georgia, serif';
122
+ ctx.fillText('asciify', word.width / 2, word.height / 2 + 20);
123
+
124
+ const wordmark = asciiField(canvas, {
125
+ source: word, fit: 'contain', charset: ' .:-=+*#%@', cellSize: 9,
126
+ hover: 'trail', label: 'asciify, drawn in characters',
127
+ }, '--ascii-ink-strong');
128
+ ```
129
+
130
+ ## video: Video in characters
131
+ Category: Media
132
+
133
+ Prompt:
134
+ “Play [video] in characters with Asciify as [a section background or a card]. Muted, looping, paused off screen, with a play and pause control, and the poster for reduced motion.”
135
+
136
+ ```ts
137
+ const video = asciiField(canvas, {
138
+ source: '/media/magnolia.mp4', fit: 'cover', charset: ' .:-=+*#%@', cellSize: 7,
139
+ contrast: 1.2, hover: 'trail', label: 'A magnolia opening, drawn in characters',
140
+ }, '--ascii-ink-strong');
141
+
142
+ let paused = false;
143
+ playButton.addEventListener('click', () => {
144
+ paused = !paused;
145
+ if (paused) video?.field.pause(); else video?.field.play();
146
+ });
147
+ ```
148
+
149
+ ## not-found: 404 page
150
+ Category: Pages
151
+
152
+ Prompt:
153
+ “Make our 404 page with Asciify: a calm field of characters that clears around the message, trails under the cursor, and pulses when someone presses Go home.”
154
+
155
+ ```ts
156
+ const lost = asciiField(canvas, {
157
+ mark: 'glyph', scene: 'drift', charset: ' .:+*#', cellSize: 12, hover: 'trail',
158
+ clear: { element: message, padding: 28, feather: 0.3, shape: 'ellipse' },
159
+ });
160
+
161
+ homeLink.addEventListener('click', () => lost?.field.pulse({ element: homeLink }));
162
+ ```
163
+
164
+ ## avatar: ASCII avatar
165
+ Category: Components
166
+
167
+ Prompt:
168
+ “Draw [person]'s avatar in characters with Asciify, inside our round avatar frame. Characters trail under the cursor, and the name, role and follow button stay as HTML.”
169
+
170
+ ```ts
171
+ const avatar = asciiField(canvas, {
172
+ source: '/images/portrait.jpg', fit: 'cover',
173
+ charset: ' .:-=+*#%@', cellSize: 5, contrast: 1.1,
174
+ hover: 'trail', label: 'Portrait of Elias Varga',
175
+ }, '--ascii-ink-strong');
176
+ ```
177
+
178
+ ## empty-state: Empty state
179
+ Category: Components
180
+
181
+ Prompt:
182
+ “Give our empty state a quiet ASCII field with Asciify. Keep the icon, message and primary action readable, and send a pulse through the field from the button when it is pressed.”
183
+
184
+ ```ts
185
+ const empty = asciiField(canvas, {
186
+ scene: 'flow', charset: ' .:-=+*#', cellSize: 11, hover: 'none',
187
+ clear: { element: emptyMessage, padding: 24, shape: 'rect' },
188
+ });
189
+
190
+ primaryButton.addEventListener('click', () => empty?.field.pulse({ element: primaryButton }));
191
+ ```
192
+
193
+ ## loading: Loading state
194
+ Category: Components
195
+
196
+ Prompt:
197
+ “Show [upload or sync] progress with Asciify: a small character field above the file name and progress bar that sends a soft pulse each second while it runs, and stops when it settles.”
198
+
199
+ ```ts
200
+ const loading = asciiField(canvas, { mark: 'glyph', scene: 'drift', charset: ' .:+*#', cellSize: 9, hover: 'none' });
201
+ const beat = setInterval(() => loading?.field.pulse({ strength: 0.8 }), 1000);
202
+ request.finally(() => clearInterval(beat));
203
+ ```
204
+
205
+ ## copy-pulse: Copy feedback
206
+ Category: Components
207
+
208
+ Prompt:
209
+ “When someone copies [the coupon code], answer with Asciify: a pulse travels through a halftone field from the copy button.”
210
+
211
+ ```ts
212
+ const ground = asciiField(canvas, { mark: 'dot', scene: 'drift', cellSize: 7, dotScale: 0.8, hover: 'none' });
213
+
214
+ copyButton.addEventListener('click', async () => {
215
+ await navigator.clipboard.writeText(code);
216
+ ground?.field.pulse({ element: copyButton });
217
+ });
218
+ ```
219
+
220
+ ## brand-field: Brand color field
221
+ Category: Backgrounds
222
+
223
+ Prompt:
224
+ “Make an ASCII field in our brand color with Asciify, drifting slower than the default, for [section].”
225
+
226
+ ```ts
227
+ const brand = asciiField(canvas, {
228
+ mark: 'glyph', scene: 'drift', charset: ' .:+*#', cellSize: 10,
229
+ speed: 0.5, hover: 'none',
230
+ }, '--brand');
231
+ ```
232
+
233
+ ## coming-soon: Coming soon page
234
+ Category: Pages
235
+
236
+ Prompt:
237
+ “Make a coming-soon page for [product] with Asciify: our mark and name, one headline, and an email field with a Join waitlist button. A drifting field of characters clears around the form, and pulses from the button when someone joins.”
238
+
239
+ ```ts
240
+ const waitlist = asciiField(canvas, {
241
+ mark: 'glyph', scene: 'drift', charset: ' .:+*#', cellSize: 10, hover: 'trail',
242
+ clear: { element: form, padding: 14, feather: 0.24, shape: 'ellipse' },
243
+ });
244
+
245
+ form.addEventListener('submit', event => {
246
+ event.preventDefault();
247
+ waitlist?.field.pulse({ element: joinButton });
248
+ form.dataset.state = 'joined';
249
+ });
250
+ ```
251
+
252
+ ## sign-in: Sign-in page
253
+ Category: Pages
254
+
255
+ Prompt:
256
+ “Make a split sign-in page with Asciify: [image] in characters on the left, trailing under the cursor, and an email field, Continue and a quiet Continue with GitHub on the right. Continue shows a loading state and pulses the art.”
257
+
258
+ ```ts
259
+ const art = asciiField(artCanvas, {
260
+ source: '/images/wing.jpg', fit: 'cover', focus: { x: 0.5, y: 0.4 },
261
+ charset: ' .:-=+*#%@', cellSize: 6, contrast: 1.3,
262
+ hover: 'trail', label: 'A carved wing drawn in characters',
263
+ }, '--ascii-ink-strong');
264
+
265
+ signInForm.addEventListener('submit', event => {
266
+ event.preventDefault();
267
+ continueButton.setAttribute('aria-busy', 'true');
268
+ art?.field.pulse({ element: continueButton });
269
+ });
270
+ ```
271
+
272
+ ## pricing: Pricing highlight
273
+ Category: Pages
274
+
275
+ Prompt:
276
+ “Make a two-plan pricing section with a monthly and yearly toggle, using Asciify. The recommended plan, [plan], gets a halftone field that lights up under the cursor and pulses from its button.”
277
+
278
+ ```ts
279
+ const featured = asciiField(canvas, {
280
+ mark: 'dot', scene: 'drift', cellSize: 7, dotScale: 0.86, speed: 0.7,
281
+ interactionTarget: featuredCard, hover: 'lantern',
282
+ lantern: { radius: 0.3, strength: 0.75 },
283
+ });
284
+
285
+ trialButton.addEventListener('click', () => featured?.field.pulse({ element: trialButton }));
286
+ ```
287
+
288
+ ## text-decode: Decode headline
289
+ Category: Text
290
+
291
+ Prompt:
292
+ “Make [our hero headline] decode into place through ASCII characters when it scrolls into view and on hover, left to right in about 900ms, without moving the layout. Screen readers get the real words, and reduced motion shows them at once.”
293
+
294
+ ```ts
295
+ const words = headline.textContent ?? '', ramp = '.:-=+*#%@abcdefghijklmnopqrstuvwxyz', cells = [...words].map(ch => Object.assign(document.createElement('span'), { textContent: ch }));
296
+ const layer = Object.assign(document.createElement('span'), { ariaHidden: 'true', style: 'position:absolute;inset:0;pointer-events:none;color:var(--ink-3)' });
297
+ headline.setAttribute('aria-label', words);
298
+ headline.replaceChildren(layer, ...cells); // the headline is position: relative
299
+
300
+ function decode(duration = 900, hold = 380) {
301
+ if (matchMedia('(prefers-reduced-motion: reduce)').matches || layer.childElementCount) return;
302
+ const stagger = (duration - hold) / Math.max(1, cells.length - 1), start = performance.now();
303
+ const glyphs = cells.map(({ offsetLeft: x, offsetTop: y, offsetWidth: w }) => layer.appendChild(Object.assign(document.createElement('span'), { style: `position:absolute;left:${x}px;top:${y}px;width:${w}px;display:flex;justify-content:center;line-height:normal` })));
304
+ requestAnimationFrame(function frame(now) {
305
+ cells.forEach((cell, i) => {
306
+ const t = (now - start - i * stagger) / hold, busy = t >= 0 && t < 1 && words[i] !== ' ';
307
+ cell.style.color = busy ? 'transparent' : '';
308
+ if (!busy || Math.random() < 0.4) glyphs[i].textContent = busy ? ramp[Math.floor(Math.random() * (t < 0.56 ? 9 : ramp.length))] : '';
309
+ });
310
+ if (now - start < duration) requestAnimationFrame(frame); else layer.replaceChildren();
311
+ });
312
+ }
313
+ new IntersectionObserver(([entry]) => { if (entry.isIntersecting) decode(); }, { threshold: 0.5 }).observe(headline);
314
+ headline.addEventListener('pointerenter', () => decode());
315
+ ```
316
+
317
+ ## image-reveal: Photo from characters
318
+ Category: Transitions
319
+
320
+ Prompt:
321
+ “Show [photo] drawn in characters with Asciify. On hover or focus the real photo grows out of the characters in a soft circle from the cursor, and on leave it closes back with a pulse where the pointer left.”
322
+
323
+ ```ts
324
+ const art = asciiField(canvas, {
325
+ source: '/images/egret.jpg', fit: 'cover', charset: ' .:-=+*#%@', cellSize: 6,
326
+ contrast: 1.3, hover: 'trail', label: 'A great egret, drawn in characters',
327
+ }, '--ascii-ink-strong');
328
+
329
+ // The photo lies over the canvas, masked by radial-gradient(circle at var(--x) var(--y), #000 calc(var(--r) - 56px), transparent var(--r)),
330
+ // with --r registered as a <length> through @property so it can transition.
331
+ function reveal(open: boolean, x = frame.clientWidth / 2, y = frame.clientHeight / 2) {
332
+ const ms = matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : open ? 520 : 440;
333
+ if (open) photo.style.cssText += `--x:${x}px;--y:${y}px`;
334
+ else art?.field.pulse({ x, y });
335
+ photo.style.transition = `--r ${ms}ms cubic-bezier(.16, 1, .3, 1)`;
336
+ photo.style.setProperty('--r', open ? `${Math.hypot(frame.clientWidth, frame.clientHeight) + 56}px` : '0px');
337
+ }
338
+ frame.addEventListener('pointerenter', event => reveal(true, event.offsetX, event.offsetY));
339
+ frame.addEventListener('pointerleave', event => reveal(false, event.offsetX, event.offsetY));
340
+ frame.addEventListener('focus', () => reveal(true));
341
+ frame.addEventListener('blur', () => reveal(false));
342
+ ```
343
+
344
+ ## route-transition: Page transition
345
+ Category: Transitions
346
+
347
+ Prompt:
348
+ “When someone switches [tabs or pages] in [our app], sweep a dense Asciify character field out from the clicked tab, swap the page underneath, and sweep it away, all in about half a second. Swap instantly under reduced motion.”
349
+
350
+ ```ts
351
+ const veil = asciiField(canvas, {
352
+ mark: 'glyph', scene: 'flow', charset: ' .:-=+*#%@', cellSize: 10, scale: 2.4,
353
+ speed: 1.6, contrast: 1.4, brightness: 0.34, hover: 'none',
354
+ });
355
+ veil?.field.pause();
356
+
357
+ // cover holds the canvas on background: var(--surface), visibility: hidden at rest, masked by radial-gradient(circle at var(--x) var(--y),
358
+ // transparent var(--hole), #000 calc(var(--hole) + 36px), #000 var(--front), transparent calc(var(--front) + 36px)); --hole and --front are @property lengths.
359
+ async function navigate(tab: HTMLElement, swap: () => void) {
360
+ if (matchMedia('(prefers-reduced-motion: reduce)').matches) return swap();
361
+ const box = cover.getBoundingClientRect(), t = tab.getBoundingClientRect(), ease = 'cubic-bezier(.16, 1, .3, 1)';
362
+ const x = t.left + t.width / 2 - box.left, y = t.top + t.height / 2 - box.top, reach = `${Math.hypot(box.width, box.height) + 36}px`;
363
+ cover.style.cssText += `--x:${x}px;--y:${y}px;visibility:visible`;
364
+ veil?.field.play();
365
+ veil?.field.pulse({ x, y });
366
+ await cover.animate({ '--hole': ['-72px', '-72px'], '--front': ['-36px', reach] }, { duration: 180, easing: ease, fill: 'forwards' }).finished;
367
+ swap();
368
+ await cover.animate({ '--hole': ['-72px', reach], '--front': [reach, reach] }, { duration: 320, easing: ease }).finished;
369
+ cover.style.visibility = 'hidden';
370
+ veil?.field.pause();
371
+ }
372
+ ```
373
+
374
+ ## scramble-button: Scramble button
375
+ Category: Transitions
376
+
377
+ Prompt:
378
+ “Make [our primary button]'s label scramble through ASCII characters on hover and focus without changing width, with a small Asciify dot halo behind it that follows the cursor and pulses on press.”
379
+
380
+ ```ts
381
+ const halo = asciiField(canvas, {
382
+ mark: 'dot', scene: 'drift', cellSize: 6, dotScale: 0.8, scale: 2.2, hover: 'lantern',
383
+ clear: { element: button, padding: 4, feather: 0.12, shape: 'rect' },
384
+ });
385
+ button.addEventListener('click', () => halo?.field.pulse({ element: button }));
386
+
387
+ const text = label.textContent ?? '', ramp = '.:-=+*#%@abcdefghijklmnopqrstuvwxyz';
388
+ const live = Object.assign(document.createElement('span'), { ariaHidden: 'true', textContent: text, style: 'position:absolute;inset:0;white-space:nowrap' });
389
+ button.setAttribute('aria-label', text);
390
+ label.replaceChildren(Object.assign(document.createElement('span'), { textContent: text, style: 'visibility:hidden;white-space:nowrap' }), live); // label is position: relative; the hidden copy holds the width
391
+ function scramble() {
392
+ if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
393
+ const start = performance.now();
394
+ requestAnimationFrame(function frame(now) {
395
+ const settled = Math.floor((now - start - 120) / 28);
396
+ live.textContent = [...text].map((ch, i) => (i < settled || ch === ' ' ? ch : ramp[Math.floor(Math.random() * ramp.length)])).join('');
397
+ if (settled < text.length) requestAnimationFrame(frame);
398
+ });
399
+ }
400
+ button.addEventListener('pointerenter', scramble);
401
+ button.addEventListener('focus', () => { if (button.matches(':focus-visible')) scramble(); });
402
+ ```
403
+
404
+ ## thinking: Thinking state
405
+ Category: AI
406
+
407
+ Prompt:
408
+ “Show [our assistant]'s thinking state with Asciify: dot lines shimmer where the answer will appear under a Thinking… label, then the answer streams in word by word as the dots fade. Regenerate runs it again.”
409
+
410
+ ```ts
411
+ import { play } from 'cuelume';
412
+
413
+ const dots = asciiField(canvas, { mark: 'dot', scene: 'drift', cellSize: 5, dotScale: 0.86, scale: 3, speed: 2.4, hover: 'none' });
414
+
415
+ // The canvas fills the answer area under line-shaped mask bands; CSS fades it out, and each .word in 38ms apart, once data-phase leaves "thinking".
416
+ async function regenerate(ask: () => Promise<string>) {
417
+ play('loading', { emphasis: 'subtle' });
418
+ reply.dataset.phase = 'thinking';
419
+ dots?.field.play();
420
+ const beat = setInterval(() => dots?.field.pulse({ x: Math.random() * canvas.clientWidth, y: canvas.clientHeight / 2, strength: 0.7 }), 640);
421
+ const answer = await ask().finally(() => clearInterval(beat));
422
+ answerText.replaceChildren(...answer.split(' ').map((word, i) => {
423
+ const span = Object.assign(document.createElement('span'), { className: 'word', textContent: `${word} ` });
424
+ span.style.setProperty('--i', String(i));
425
+ return span;
426
+ }));
427
+ reply.dataset.phase = 'done';
428
+ setTimeout(() => dots?.field.pause(), 420);
429
+ play('ready', { emphasis: 'subtle' });
430
+ }
431
+ ```
432
+
433
+ ## image-generation: Image generation
434
+ Category: AI
435
+
436
+ Prompt:
437
+ “While [our image model] generates [prompt], fill the frame with drifting Asciify characters that pulse beside a progress label. When the image arrives, develop it in characters, then fade to the photo from blur to sharp.”
438
+
439
+ ```ts
440
+ const art = asciiField(canvas, {
441
+ mark: 'glyph', scene: 'flow', charset: ' .:-=+*#%@', cellSize: 7, scale: 2.6, speed: 1.4, contrast: 1.3, hover: 'trail',
442
+ }, '--ascii-ink-strong');
443
+ const ease = (ms: number, step: (k: number) => void) => new Promise<void>(done => {
444
+ const start = performance.now(), still = matchMedia('(prefers-reduced-motion: reduce)').matches;
445
+ requestAnimationFrame(function frame(now) { const t = still ? 1 : Math.min(1, (now - start) / ms); step(1 - (1 - t) ** 4); if (t < 1) requestAnimationFrame(frame); else done(); });
446
+ });
447
+
448
+ generate.addEventListener('click', async () => {
449
+ const field = art?.field;
450
+ if (!field) return;
451
+ const beat = setInterval(() => field.pulse({ strength: 0.8 }), 640);
452
+ const url = await createImage(promptInput.value).finally(() => clearInterval(beat));
453
+ await ease(200, k => field.update({ brightness: -k }));
454
+ await field.setSource(url);
455
+ field.pulse();
456
+ await ease(900, k => field.update({ brightness: k - 1 }));
457
+ photo.src = url;
458
+ imageFrame.dataset.done = ''; // CSS fades the photo in from opacity 0 and blur(14px), 760ms on cubic-bezier(.16, 1, .3, 1)
459
+ });
460
+ ```
@@ -37,6 +37,7 @@ Never introduce a new brand color, a black canvas on a light site, or a second a
37
37
  | --- | --- | --- |
38
38
  | A photo, video or GIF converted on their page, with optional hover | `asciify`, `asciifyVideo`, `asciifyGif` (+ `mountHover` from `/hover`) | Lightest path. Disable core hover and motion when `/hover` owns them. |
39
39
  | A full-bleed hero or brand field that must feel smooth under the cursor, their name or word as the characters, an Unseen-style look | `mountGlyphField` from `/field` | WebGL2 on a GPU, 120 fps class. Glyph ramps only. Keep a poster and a fallback. See [glyph-field.md](glyph-field.md). |
40
+ | A calm background for a product page, docs, pricing or SaaS hero, in light or dark | `mountGlyphField` with `mark: 'dot'`, `scene: 'drift'` and `clear` around the copy | The halftone. Muted ink, transparent background, `pulse()` on real events. See [glyph-field.md](glyph-field.md#halftone-for-interfaces-412). |
40
41
  | Art styles (halftone, dither, braille, print, mosaic, LED…), saved looks, backdrops, masks, effects, PNG/MP4/GIF export | `mountStudio` from `/studio` | Studio owns motion, hover and finish. Start from `studioLook(id)` when one of the 12 looks matches the mood. |
41
42
  | Several media and text layers in one artwork | `mountStudioProject` from `/studio` | One loop and budget for up to eight layers. See [layered-projects.md](layered-projects.md). |
42
43
  | An ambient animated background with no media (rain, stars, waves, aurora…) | A background template from asciify.org/backgrounds | Downloaded files, not npm exports. Or a `/field` generated scene (`flow`, `orb`) for a GPU field. |
@@ -54,6 +55,7 @@ Never introduce a new brand color, a black canvas on a light site, or a second a
54
55
  | Techy, data, futuristic | Braille, blocks, dots, LED, hex | Source color with saturation 1.1–1.3, or a cool brand ink | 5–9px | Silk, Contour, Lens; field with `orb` or `flow` |
55
56
  | Playful | Emoji or custom glyphs, mosaic, bricks, voxels | Source color | 9–14px | Elastic, Scatter, Ripple rings |
56
57
  | Brand, identity, "our name in the art" | The brand word after a quiet ramp: `' .,:-+' + WORD` | One ink (`color: 'ink'`, `shade: 1`) so density and brightness carry the image | 10–14px; `cellAspect` 0.75–0.85 for display fonts | Field `hover: 'fluid'` (Unseen-like) or `'lantern'` (quieter); video or generated scene underneath |
58
+ | Product UI, SaaS, docs | Halftone dots (`mark: 'dot'`) | Muted text token, never the accent; light and dark both | 12–16px dot spacing, `dotScale` ~0.6, `clear` around the copy | Lantern or none; `pulse()` on saves and messages |
57
59
  | Behind body text | Light ramp, no dense glyphs | Ink at low contrast against the background; opacity 0.15–0.35 | 10–14px | Ambient only, or none; never strong refraction behind reading text |
58
60
 
59
61
  Hover choice by intent:
@@ -32,7 +32,10 @@ Size the canvas with CSS (for example `position:absolute; inset:0; width:100%; h
32
32
  | Option | Default | Notes |
33
33
  | --- | --- | --- |
34
34
  | `source` | none | URL (`.mp4`, `.webm`, `.mov` load as muted, looping, inline video), `HTMLImageElement`, `HTMLVideoElement`, `HTMLCanvasElement` or `ImageBitmap`. Cross-origin media needs CORS. |
35
- | `scene` | `'flow'` | Generated motion when there is no source: `'flow'` (domain-warped noise), `'orb'` (lit sphere) or `'none'` (blank, wake only). |
35
+ | `scene` | `'flow'` | Generated motion when there is no source: `'flow'` (domain-warped noise), `'drift'` (a few broad lobes, made for halftone, 4.12), `'orb'` (lit sphere) or `'none'` (blank, wake only). |
36
+ | `mark`, `dotScale` | `'glyph'`, `0.9` | `'dot'` draws a halftone: one circle per cell whose area follows tone, antialiased at any pixel ratio. `dotScale` is the largest dot as a fraction of the cell; about 0.6 keeps a calm, open grid. (4.12) |
37
+ | `scale` | `1` | Feature size of generated scenes; higher is finer. Use about 1.8 on phones, where the field is tall. (4.12) |
38
+ | `clear` | `false` | Keeps the field quiet around content so text stays readable: an element, `{ element, padding, feather, shape }` (followed through layout changes), or a zone in field fractions. (4.12) |
36
39
  | `charset` | `' .,:-=+*#%@'` | Ordered from empty to dense. Graphemes, so emoji, braille and block shading each take one cell. Up to 256. |
37
40
  | `cellSize`, `cellAspect` | `12`, `1` | Cell height in CSS px; width ÷ height. Monospaced ramps look best near `0.6`. Cells are whole device pixels, and glyphs are sampled texel for texel. |
38
41
  | `font`, `fontWeight`, `glyphScale` | monospace, `500`, `0.8` | Any loaded CSS font. The atlas is redrawn when the font finishes loading. |
@@ -56,6 +59,26 @@ Size the canvas with CSS (for example `position:absolute; inset:0; width:100%; h
56
59
 
57
60
  `update(options)` merges and rebuilds only what changed (the atlas for type changes, the simulation for resolution changes). `setSource(source)` swaps media. `pointer(x, y)` moves the wake in CSS pixels relative to the canvas (`pointer(null)` lifts it), which is useful for an intro stroke. `redraw()` draws immediately, so you can copy the canvas with `drawImage` in the same task, for example to cross-fade between looks.
58
61
 
62
+ ## Halftone for interfaces (4.12)
63
+
64
+ Product pages, docs, pricing and SaaS heroes want a background that is calm, works in light and dark themes, and never fights the copy. Use dots, the drift scene, a clear zone around the content, and `pulse()` for interface events:
65
+
66
+ ```ts
67
+ const field = mountGlyphField(heroCanvas, {
68
+ mark: 'dot', scene: 'drift', cellSize: 14, dotScale: 0.64,
69
+ color: 'ink', ink: tokens.textMuted, background: 'transparent',
70
+ clear: { element: heroCopy, padding: 40, feather: 0.18 },
71
+ hover: 'lantern',
72
+ });
73
+ saveButton.addEventListener('click', () => field.pulse({ element: saveButton }));
74
+ ```
75
+
76
+ - Ink: the muted text token, not the accent. On light grounds a mid grey such as `#9a988f`; on dark about `#5f5e58`.
77
+ - Clear zone: always set one when text sits over the field. Measure contrast at the busiest frame anyway.
78
+ - Pulse: one ring per meaningful event (saved, sent, connected), not on every click. It is skipped under reduced motion.
79
+ - `pulse({ x, y })` takes CSS pixels relative to the canvas; `pulse({ element })` starts at the element's centre; no argument starts at the centre.
80
+
81
+
59
82
  ## Living or still
60
83
 
61
84
  A photo with `drift: 0`, no video and a pointer that has left draws once and stops. Add `drift` (0.15–0.35) to let it breathe, use a video, or use a generated `scene` for continuous motion. Choose by the user's words and the context; [design-matching.md](design-matching.md#living-or-still) has the rules. Reduced motion stops drift and generated scenes; Lantern follows instantly and stops flickering.