asciify-engine 4.12.0 → 4.13.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.
@@ -1,460 +1,541 @@
1
1
  # Asciify components
2
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.
3
+ Ready-made ASCII components for product interfaces. Each one has the prompt a person gives you and the 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
4
 
5
- Build them in the project's own framework, design tokens and lifecycle. Read [design matching](design-matching.md) first. The shared rules:
5
+ Every snippet uses `asciify-engine/kit`, one call per component. Start there by default: [the kit reference](kit.md) lists every option and method. Each kit function:
6
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()`.
7
+ - mounts its own `aria-hidden` field over or inside your element, following its size and border radius, and keeps your text and controls in HTML;
8
+ - inks the field from your theme (`ink` takes a colour or a custom property such as `'--brand'`) and follows light and dark;
9
+ - honours `prefers-reduced-motion` by drawing each state settled, and pauses off screen;
10
+ - falls back without WebGL2 to an inert handle that leaves your HTML working as it was;
11
+ - cleans up on `destroy()`.
13
12
 
14
- ## The shared helper
13
+ Build them in the project's own framework, design tokens and lifecycle; read [design matching](design-matching.md) first. Free names in the snippets, such as `card`, `button` or `deployButton`, are the project's own DOM elements and functions. In React, create the handle in an effect and return its `destroy`:
15
14
 
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.
15
+ ```tsx
16
+ useEffect(() => { const b = beam(ref.current!); return () => b.destroy(); }, []);
17
+ ```
18
+
19
+ For a look the kit doesn't offer, drop down to the raw field: see [Advanced: raw field](#advanced-raw-field) at the end of this file.
20
+
21
+ ## voice-field: Voice orb
22
+ Category: AI
23
+
24
+ Prompt:
25
+ “Add a voice orb to [our assistant]: an Asciify sphere of characters that swells with loudness through Listening, Thinking and Done, in Mono, Aurora or Brand, its gradient shifting as it gets louder. Ask for the microphone only on click.”
17
26
 
18
27
  ```ts
19
- import { isGlyphFieldSupported, mountGlyphField, type GlyphField, type GlyphFieldOptions } from 'asciify-engine/field';
28
+ import { voiceOrb } from 'asciify-engine/kit';
20
29
 
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
- }
30
+ const orb = voiceOrb(orbButton, { palette: 'aurora' }); // or 'mono', 'brand', or your own hex stops
31
+ tintPicker.onchange = () => orb.update({ palette: tintPicker.value as 'mono' | 'aurora' | 'brand' });
32
+ orbButton.onclick = async () => { if (!(await orb.mic())) note.textContent = 'Microphone unavailable'; }; // asks only now
33
+ recognizer.onend = () => orb.think();
34
+ reply.then(() => orb.done());
31
35
  ```
32
36
 
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; } }
37
+ ## command-palette: Command palette
38
+ Category: AI
39
+
40
+ Prompt:
41
+ “Make a ⌘K command palette for [our app] with Asciify: it opens by condensing out of characters at the trigger, filters as you type, and a glyph column glides beside the selected row. Enter runs the action with a pulse.”
42
+
43
+ ```ts
44
+ import { commandPalette } from 'asciify-engine/kit';
45
+
46
+ // ⌘K or the trigger opens it; arrows, Enter and Escape are wired; rows are [role="option"] with an id
47
+ commandPalette(palettePanel, { trigger: paletteTrigger, onRun: (i, row) => commands[row.dataset.id!].run() });
48
+ searchInput.oninput = () => renderRows(searchInput.value); // the kit re-selects the first row
36
49
  ```
37
50
 
38
- In React, wrap it once and reuse it:
51
+ ## button-loading: Button loading
52
+ Category: Components
39
53
 
40
- ```tsx
41
- import { useEffect, useRef } from 'react';
42
- import type { GlyphField, GlyphFieldOptions } from 'asciify-engine/field';
54
+ Prompt:
55
+ “Make [our Deploy button] show loading with Asciify: while the request runs, a wave cycles each letter of ‘Deploying’ through .:-=+*#%@, then resolves to ‘Deployed’ with a check, or ‘Retry’ with a short shake. Its width never changes.”
43
56
 
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
- }
57
+ ```ts
58
+ import { buttonLoading } from 'asciify-engine/kit';
59
+
60
+ const b = buttonLoading(deployButton, { announce: statusLine }); // the width never changes
61
+ deployButton.onclick = async () => {
62
+ b.start('Deploying');
63
+ await deploy().then(() => b.succeed('Deployed'), () => b.fail('Retry')); // a check, or a short shake
64
+ };
53
65
  ```
54
66
 
55
- ## image-card: ASCII image card
67
+ ## ascii-pill: Badges and pills
56
68
  Category: Components
57
69
 
58
70
  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.”
71
+ “Make [our status badges] Asciify pills: tinted pills whose live state is a three-character indicator that breathes, a New pill with a gradient glyph sheen on hover, and a count that rolls through glyphs. Clip every character inside its pill.”
60
72
 
61
73
  ```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');
74
+ import { pill } from 'asciify-engine/kit';
75
+
76
+ // <span class="pill" data-tone="green" data-live><span data-dot></span>Live</span>, data-tone="new", data-count
77
+ const pills = pill(document.querySelectorAll<HTMLElement>('.pill'));
78
+ statusPill.dataset.tone = 'amber';
79
+ pills.refresh(); // tone changes ease over 240ms
80
+ pills.count(14); // only the changed digits roll
67
81
  ```
68
82
 
69
- ## hero-background: Hero background
70
- Category: Backgrounds
83
+ ## ascii-tabs: Character tabs
84
+ Category: Components
71
85
 
72
86
  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.”
87
+ “Make [our segmented tabs] Asciify tabs: the active indicator is a pill of characters that streams across as a glyph trail and condenses under the new tab within 280ms. Panels crossfade; arrow keys, Home and End switch tabs.”
74
88
 
75
89
  ```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
- });
90
+ import { tabs } from 'asciify-engine/kit';
91
+
92
+ // role="tab" buttons with aria-controls; panels crossfade on [data-active] in your CSS
93
+ const t = tabs(tablist, { duration: 280, panels: 'attribute' });
94
+ t.select(2);
81
95
  ```
82
96
 
83
- ## cli-banner: CLI banner
84
- Category: Text
97
+ ## slide-to-confirm: Slide to confirm
98
+ Category: Components
85
99
 
86
100
  Prompt:
87
- “Greet people in ASCII when our CLI starts, using asciifyText from Asciify with [word].”
101
+ “Add a slide to confirm to [our checkout]: ‘Slide to pay [$48]’. Asciify characters fill behind the thumb, drain when it springs back, then condense into a green check in a ring as the label becomes ‘Paid · [$48.00]’.”
88
102
 
89
103
  ```ts
90
- import { asciifyText } from 'asciify-engine';
104
+ import { slideToConfirm } from 'asciify-engine/kit';
91
105
 
92
- console.log(asciifyText('Hey', { char: '#' }));
106
+ // track: a [role="slider"] thumb, [data-label] "Slide to pay $48", and "Paid · $48.00" shown on [data-confirmed]
107
+ const pay = slideToConfirm(payTrack, { mark: doneIcon, onConfirm: () => void charge().catch(() => pay.reset()) });
93
108
  ```
94
109
 
95
- ## cursor-field: Cursor field
96
- Category: Backgrounds
110
+ ## like-burst: Like burst
111
+ Category: Components
97
112
 
98
113
  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].”
114
+ “Make [our Like button] burst in Asciify characters: on press a heart pops to about 1.6× the icon with a few glyph sparks and settles into the icon within 600ms while the count rolls. Unlike crumbles; rest is empty.”
100
115
 
101
116
  ```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
- });
117
+ import { likeBurst, odometer } from 'asciify-engine/kit';
118
+
119
+ const likes = odometer(likeCount);
120
+ likeBurst(likeButton, { kind: 'heart', limit: postBody, onChange: on => likes.set(likes.value + (on ? 1 : -1)) });
107
121
  ```
108
122
 
109
- ## wordmark: Brand wordmark
123
+ ## loading-states: Status orb
124
+ Category: Components
125
+
126
+ Prompt:
127
+ “Give [our Publish button] an Asciify status orb: characters orbit while it runs, then the ring closes around a check, ‘!’ or X, tinting characters and label green, amber or red. Offer bare-mark and neutral options. Keep the label live.”
128
+
129
+ ```ts
130
+ import { statusOrb } from 'asciify-engine/kit';
131
+
132
+ const orb = statusOrb(orbSlot, { ring: true, colourByState: true }); // ring: false for the bare mark, colourByState: false for neutral
133
+ const labels = { success: 'Published', warning: 'Published with warnings. Review?', error: 'Couldn’t publish. Retry?' };
134
+ publishButton.onclick = async () => {
135
+ orb.loading(); statusLine.textContent = 'Publishing…';
136
+ await save().then(r => (r.warnings ? orb.warning() : orb.success()), () => orb.error());
137
+ statusLine.textContent = labels[orb.state as keyof typeof labels];
138
+ };
139
+ ```
140
+
141
+ ## text-decode: Decode headline
110
142
  Category: Text
111
143
 
112
144
  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.”
145
+ “Make [our hero headline] condense out of a drifting Asciify field: a soft cloud of characters drifts, then the words resolve left to right through a flickering band, with nothing left behind. Keep the real headline in the HTML.”
114
146
 
115
147
  ```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);
148
+ import { decode } from 'asciify-engine/kit';
123
149
 
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');
150
+ const d = decode(headline, { duration: 900, settle: 'text' }); // plays when it comes into view; the real text stays in the DOM
151
+ replayButton.onclick = () => d.replay();
128
152
  ```
129
153
 
130
- ## video: Video in characters
131
- Category: Media
154
+ ## ascii-chart: Block chart
155
+ Category: Components
132
156
 
133
157
  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.”
158
+ “Build [our visitors analytics card] with bars drawn in Asciify characters: they grow in left to right, grow again on a 7d/30d toggle, and brighten under a hover readout.”
135
159
 
136
160
  ```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');
161
+ import { blockChart } from 'asciify-engine/kit';
141
162
 
142
- let paused = false;
143
- playButton.addEventListener('click', () => {
144
- paused = !paused;
145
- if (paused) video?.field.pause(); else video?.field.play();
163
+ const chart = blockChart(plot, {
164
+ values: visits['7d'], labels: days['7d'],
165
+ onHover: (i, v, day) => { readout.textContent = i === null ? '' : `${day} · ${v!.toLocaleString()}`; },
146
166
  });
167
+ rangeToggle.onchange = () => chart.set(visits[rangeToggle.value], days[rangeToggle.value]);
147
168
  ```
148
169
 
149
- ## not-found: 404 page
150
- Category: Pages
170
+ ## route-transition: Page transition
171
+ Category: Transitions
151
172
 
152
173
  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.”
174
+ “When someone switches [tabs] in [our app], break the view into its own Asciify characters from the clicked tab, swap it, and condense the next view out of characters in about 400ms. At rest, keep [its chart] in characters.”
154
175
 
155
176
  ```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
- });
177
+ import { pageDissolve, blockChart } from 'asciify-engine/kit';
160
178
 
161
- homeLink.addEventListener('click', () => lost?.field.pulse({ element: homeLink }));
179
+ const page = pageDissolve(view, { dissolve: 180, condense: 220 }); // about 400ms, from the clicked tab
180
+ const chart = blockChart(chartSlot, { values: series.overview }); // at rest the chart stays in characters
181
+ for (const tab of navTabs) tab.onclick = () => page.swap(() => { renderView(tab.dataset.page!); chart.set(series[tab.dataset.page!]); }, tab);
162
182
  ```
163
183
 
164
- ## avatar: ASCII avatar
184
+ ## swipe-card: Swipe cards
165
185
  Category: Components
166
186
 
167
187
  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.”
188
+ “Make a swipeable card stack for [our stays] with Asciify: each [photo] drawn in characters. A swiped card dissolves into characters along the swipe while the next resolves; Pass and Save buttons and arrow keys do the same.”
169
189
 
170
190
  ```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');
191
+ import { swipeDeck } from 'asciify-engine/kit';
192
+
193
+ // stack: [data-card] children, each with an <img>; put captions outside the deck. Arrow keys are wired.
194
+ const deck = swipeDeck(stack, { threshold: 0.4, onSwipe: (dir, i) => (dir > 0 ? saveStay(stays[i]) : passStay(stays[i])) });
195
+ saveButton.onclick = () => deck.next(1);
196
+ passButton.onclick = () => deck.next(-1);
176
197
  ```
177
198
 
178
- ## empty-state: Empty state
199
+ ## otp-input: Verification code
179
200
  Category: Components
180
201
 
181
202
  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.”
203
+ “Make a 6-digit verification code input with Asciify: each digit resolves out of characters as it is typed, a wrong code shakes and crumbles back, and the right one condenses into a check. Support paste and one-time-code autofill.”
183
204
 
184
205
  ```ts
185
- const empty = asciiField(canvas, {
186
- scene: 'flow', charset: ' .:-=+*#', cellSize: 11, hover: 'none',
187
- clear: { element: emptyMessage, padding: 24, shape: 'rect' },
188
- });
206
+ import { codeInput } from 'asciify-engine/kit';
189
207
 
190
- primaryButton.addEventListener('click', () => empty?.field.pulse({ element: primaryButton }));
208
+ // paste and autocomplete="one-time-code" are built in; a wrong code shakes, a right one draws a check
209
+ const code = codeInput(codeRow, { length: 6, onComplete: async c => code.verify(await api.checkCode(c)) });
191
210
  ```
192
211
 
193
- ## loading: Loading state
212
+ ## toast-stack: Toast stack
194
213
  Category: Components
195
214
 
196
215
  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.”
216
+ “Make [our toasts] with Asciify: each one gathers out of characters and stacks with depth, hover fans the stack out, and dismissing breaks a toast back into drifting glyphs. Include Undo.”
198
217
 
199
218
  ```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));
219
+ import { toastStack } from 'asciify-engine/kit';
220
+
221
+ const toasts = toastStack(toastCorner, { max: 3, duration: 6000 }); // a positioned box, such as fixed bottom-right, 360px wide
222
+ toasts.push({ title: 'Thread archived', body: 'Q3 planning · Ana', action: { label: 'Undo', onClick: restoreThread } });
223
+ clearButton.onclick = () => toasts.clear(); // each toast breaks back into drifting glyphs
203
224
  ```
204
225
 
205
- ## copy-pulse: Copy feedback
226
+ ## skeleton-loader: Skeleton loader
206
227
  Category: Components
207
228
 
208
229
  Prompt:
209
- “When someone copies [the coupon code], answer with Asciify: a pulse travels through a halftone field from the copy button.”
230
+ “Make a skeleton loader for [our post card] with Asciify: the placeholder blocks are drawn in characters with a shimmer sweeping across, then each block resolves into its real content and the image develops into the photo.”
210
231
 
211
232
  ```ts
212
- const ground = asciiField(canvas, { mark: 'dot', scene: 'drift', cellSize: 7, dotScale: 0.8, hover: 'none' });
233
+ import { skeleton } from 'asciify-engine/kit';
213
234
 
214
- copyButton.addEventListener('click', async () => {
215
- await navigator.clipboard.writeText(code);
216
- ground?.field.pulse({ element: copyButton });
217
- });
235
+ // blocks: [data-block="text" | "photo" | "avatar"] inside the card
236
+ const s = skeleton(postCard, { shimmer: 1250, stagger: 60 });
237
+ renderPost(await fetchPost());
238
+ await s.resolve(); // text wipes in, the photo develops in characters
239
+ reloadButton.onclick = async () => { s.reset(); renderPost(await fetchPost()); await s.resolve(); };
218
240
  ```
219
241
 
220
- ## brand-field: Brand color field
221
- Category: Backgrounds
242
+ ## thinking: Thinking state
243
+ Category: AI
222
244
 
223
245
  Prompt:
224
- “Make an ASCII field in our brand color with Asciify, drifting slower than the default, for [section].”
246
+ “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 and the dots settle into a dotted rule beneath it.”
225
247
 
226
248
  ```ts
227
- const brand = asciiField(canvas, {
228
- mark: 'glyph', scene: 'drift', charset: ' .:+*#', cellSize: 10,
229
- speed: 0.5, hover: 'none',
230
- }, '--brand');
249
+ import { thinking } from 'asciify-engine/kit';
250
+
251
+ const reply = thinking(answer, { label: statusLabel, lines: 3 }); // "Thinking…", then "Thought for 2s"
252
+ await reply.stream(await ask(question)); // or an async iterable of chunks
253
+ regenerate.onclick = async () => { reply.think(); await reply.stream(await ask(question)); };
231
254
  ```
232
255
 
233
- ## coming-soon: Coming soon page
234
- Category: Pages
256
+ ## stream-text: Streamed reply
257
+ Category: AI
235
258
 
236
259
  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.”
260
+ “Stream [our assistant]’s reply with an Asciify write head: an aria-hidden field over the text draws the caret and each token’s unresolved characters as flickering glyphs that settle into letters. Show reasoning lines, Stop and Regenerate.”
238
261
 
239
262
  ```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
- });
263
+ import { streamText } from 'asciify-engine/kit';
264
+
265
+ const out = streamText(answerText, { caret: true, settle: 60, stagger: 18, reasoningLines: 2 });
266
+ async function run() {
267
+ out.reset();
268
+ for await (const part of llm.stream(question)) {
269
+ if (out.state === 'stopped') break;
270
+ if (part.reasoning) out.reasoning(part.reasoning); else if (part.text) out.write(part.text); // the reasoning tail collapses on done()
271
+ }
272
+ await out.done();
273
+ }
274
+ stopButton.onclick = () => out.stop();
275
+ regenerate.onclick = () => void run();
276
+ void run();
277
+ ```
244
278
 
245
- form.addEventListener('submit', event => {
246
- event.preventDefault();
247
- waitlist?.field.pulse({ element: joinButton });
248
- form.dataset.state = 'joined';
279
+ ## image-generation: Image generation
280
+ Category: AI
281
+
282
+ Prompt:
283
+ “While [our image API] generates [prompt], keep an Asciify field drifting with an elapsed clock, develop each streamed preview into it a step sharper, then the final image, and crossfade to the photo. Failures offer Retry; Regenerate dissolves back first.”
284
+
285
+ ```ts
286
+ import { imageGenerate } from 'asciify-engine/kit';
287
+
288
+ // imageStream(prompt) wraps your provider as an async iterable of { preview?, final? }, each a Blob or URL
289
+ const gen = imageGenerate(imageFrame, { elapsed: clock, onState: s => { generateButton.textContent = s === 'failed' ? 'Retry' : 'Regenerate'; } });
290
+ generateButton.onclick = () => void gen.run(() => imageStream(promptInput.value)); // a finished image dissolves back first
291
+ ```
292
+
293
+ ## ascii-compare: Before and after
294
+ Category: Media
295
+
296
+ Prompt:
297
+ “Add a before and after slider to [our gallery]: [photo] on one side, the same photo drawn in its own colors in Asciify characters on the other. Drag the handle, or use the arrow keys in 5% steps.”
298
+
299
+ ```ts
300
+ import { compare } from 'asciify-engine/kit';
301
+
302
+ // compareFrame: a dark 3:2 box; drag, or arrow keys in 5% steps on the handle
303
+ const c = compare(compareFrame, { image: '/images/kingfisher.jpg', position: 50, label: 'Photo and characters' });
304
+ resetButton.onclick = () => c.set(50, true);
305
+ ```
306
+
307
+ ## image-accordion: Image accordion
308
+ Category: Media
309
+
310
+ Prompt:
311
+ “Build an image accordion for [our prints]: [five photos] as panels drawn in Asciify characters. The hovered or focused panel widens and its characters give way to the full photo in 280ms, with a caption.”
312
+
313
+ ```ts
314
+ import { accordion } from 'asciify-engine/kit';
315
+
316
+ // give the strip a height; panels open on hover, press or focus
317
+ accordion(printStrip, {
318
+ images: [{ src: '/prints/stairwell.webp', title: 'Stairwell', meta: 'Silver print · $120' }, '/prints/shell.webp', '/prints/fern.webp', '/prints/dune.webp', '/prints/pier.webp'],
319
+ duration: 280,
249
320
  });
250
321
  ```
251
322
 
323
+ ## ascii-dropzone: Image dropzone
324
+ Category: Media
325
+
326
+ Prompt:
327
+ “Make an image dropzone with Asciify: a quiet ring of characters frames the drop area and breathes on drag-over. A dropped image develops in characters, with Replace and Download PNG.”
328
+
329
+ ```ts
330
+ import { dropzone } from 'asciify-engine/kit';
331
+
332
+ // buttons inside with data-dropzone="pick" (Replace) and "download" (Download PNG) are wired for you
333
+ const zone = dropzone(dropArea, { onImage: ({ file }) => { if (file) void upload(file); }, onError: message => showError(message) });
334
+ sampleButton.onclick = () => void zone.setImage('/images/sample.webp');
335
+ ```
336
+
337
+ ## ascii-disintegrate: Delete into characters
338
+ Category: Transitions
339
+
340
+ Prompt:
341
+ “When someone deletes a [file or notification] row in [our list], turn it into Asciify characters that thin out and drift up in about 620ms while the rows below close the gap in 260ms. Show an Undo toast.”
342
+
343
+ ```ts
344
+ import { disintegrate, toastStack } from 'asciify-engine/kit';
345
+
346
+ const toasts = toastStack(toastCorner);
347
+ async function removeRow(row: HTMLElement, id: string) {
348
+ toasts.push({ title: 'Moved to trash', action: { label: 'Undo', onClick: () => restoreItem(id) } });
349
+ await disintegrate(row, { duration: 620 }); // the rows below close the gap
350
+ deleteItem(id);
351
+ }
352
+ ```
353
+
354
+ ## pull-refresh: Pull to refresh
355
+ Category: Components
356
+
357
+ Prompt:
358
+ “Add pull to refresh to [our feed]: pulling reveals an Asciify character orb that densifies with distance. Past the threshold it spins while refreshing, condenses into a check, and new [posts] resolve in from characters. Include a Refresh button.”
359
+
360
+ ```ts
361
+ import { pullRefresh } from 'asciify-engine/kit';
362
+
363
+ // feedScreen: overflow auto, content in its first child; the ring spins until onRefresh resolves, then draws a check
364
+ // rows marked data-new resolve in from characters as the content springs back
365
+ const pull = pullRefresh(feedScreen, { threshold: 88, reveal: '[data-new]', onRefresh: () => loadNewPosts() });
366
+ refreshButton.onclick = () => void pull.refresh();
367
+ ```
368
+
369
+ ## border-beam: Border beam
370
+ Category: Components
371
+
372
+ Prompt:
373
+ “Give [our card] an Asciify border beam: rainbow halftone dots inside its rounded border. Rotate or pulse; on deploy, a progress ring that flashes then fades green on success, or cracks red on error. Static under reduced motion.”
374
+
375
+ ```ts
376
+ import { beam } from 'asciify-engine/kit';
377
+
378
+ const b = beam(card, { palette: 'colorful', mode: 'progress', grain: 'dots' }); // or mode 'rotate' or 'pulse'
379
+ deployButton.onclick = async () => {
380
+ b.progress(0);
381
+ await deploy(p => b.progress(p)).then(() => b.success(), () => b.error()); // flash then green, or cracks red
382
+ };
383
+ ```
384
+
385
+ ## glyph-odometer: Glyph odometer
386
+ Category: Text
387
+
388
+ Prompt:
389
+ “Show [our monthly revenue figure] as normal text, an Asciify odometer: on [Refresh] only changed digits hand over to the field, roll through glyphs leftmost first in under 600ms, and land back on the crisp digit. Keep an aria-live value.”
390
+
391
+ ```ts
392
+ import { odometer } from 'asciify-engine/kit';
393
+
394
+ const mrr = odometer(revenueFigure, { value: 48290, format: n => `$${n.toLocaleString('en-US')}` }); // adds an aria-live copy
395
+ refreshButton.onclick = async () => mrr.set(await fetchMrr());
396
+ ```
397
+
252
398
  ## sign-in: Sign-in page
253
399
  Category: Pages
254
400
 
255
401
  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.”
402
+ “Make a split sign-in page with Asciify: [image] in characters on the left that trail under the cursor, and an email field, Continue and Continue with GitHub on the right. Continue pulses the art while the link sends.”
257
403
 
258
404
  ```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');
405
+ import { artPanel } from 'asciify-engine/kit';
264
406
 
265
- signInForm.addEventListener('submit', event => {
407
+ const art = artPanel(artSide, { image: '/images/wing.jpg', clear: brandMark, focus: { x: 0.5, y: 0.4 } });
408
+ signInForm.onsubmit = async event => {
266
409
  event.preventDefault();
267
- continueButton.setAttribute('aria-busy', 'true');
268
- art?.field.pulse({ element: continueButton });
269
- });
410
+ art.pulse(continueButton, 1.8);
411
+ art.shimmer(true);
412
+ await sendLink(emailInput.value).finally(() => art.shimmer(false));
413
+ };
414
+ ```
415
+
416
+ ```css
417
+ .sign-in { display: grid; grid-template-columns: 41% 1fr; padding: 14px; }
418
+ .sign-in-art { position: relative; border-radius: 18px; overflow: hidden; background: var(--surface); }
270
419
  ```
271
420
 
272
421
  ## pricing: Pricing highlight
273
422
  Category: Pages
274
423
 
275
424
  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.”
425
+ “Make a two-plan pricing section with Asciify: the recommended plan, [plan], has a soft halftone light that sweeps when the period toggles, shows the yearly saving in dots, and pulses from its button.”
277
426
 
278
427
  ```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
- });
428
+ import { pricingGlow } from 'asciify-engine/kit';
284
429
 
285
- trialButton.addEventListener('click', () => featured?.field.pulse({ element: trialButton }));
430
+ const glow = pricingGlow(featuredCard, { price: '.price' });
431
+ billingToggle.onchange = () => void glow.saving(billingToggle.value === 'yearly' ? '−25%' : null); // sweeps old out, new in
432
+ trialButton.onclick = () => glow.pulse(trialButton);
286
433
  ```
287
434
 
288
- ## text-decode: Decode headline
289
- Category: Text
435
+ ## not-found: 404 page
436
+ Category: Pages
290
437
 
291
438
  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());
439
+ “Make our 404 page with Asciify: draw 404 large in characters that scatter under the cursor and, on Go home, break apart and re-form. Keep the message and button in HTML.”
440
+
441
+ ```ts
442
+ import { fieldBackground } from 'asciify-engine/kit';
443
+
444
+ const lost = fieldBackground(lostArt, { preset: 'notFound', text: '404' });
445
+ homeLink.onclick = async event => { event.preventDefault(); await lost.scatter(homeLink); location.href = '/'; };
315
446
  ```
316
447
 
317
- ## image-reveal: Photo from characters
318
- Category: Transitions
448
+ ## wordmark: Brand wordmark
449
+ Category: Text
319
450
 
320
451
  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));
452
+ “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.”
453
+
454
+ ```ts
455
+ import { wordmark } from 'asciify-engine/kit';
456
+
457
+ // draws the element's own text, which stays in the DOM for screen readers
458
+ const mark = wordmark(brandName, { font: 'Georgia, "Times New Roman", serif', fontWeight: 600 });
459
+ nameInput.oninput = () => mark.setText(nameInput.value);
342
460
  ```
343
461
 
344
- ## route-transition: Page transition
345
- Category: Transitions
462
+ ## video: Video in characters
463
+ Category: Media
346
464
 
347
465
  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.”
466
+ “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.”
349
467
 
350
468
  ```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
- }
469
+ import { asciiVideo } from 'asciify-engine/kit';
470
+
471
+ // muted and looping, paused off screen; the poster shows first and under reduced motion
472
+ const v = asciiVideo(player, { src: '/media/magnolia.mp4', poster: '/media/magnolia.jpg', label: 'A magnolia opening, drawn in characters' });
473
+ playButton.onclick = () => playButton.setAttribute('aria-label', v.toggle() ? 'Pause' : 'Play');
372
474
  ```
373
475
 
374
- ## scramble-button: Scramble button
375
- Category: Transitions
476
+ ## hero-background: Hero background
477
+ Category: Backgrounds
376
478
 
377
479
  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.”
480
+ “Put a calm ASCII background behind our hero with Asciify. Characters drift toward the headline and soften under a frosted veil, ripple under the cursor, and pulse when someone presses the main button.”
379
481
 
380
482
  ```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
- });
483
+ import { fieldBackground } from 'asciify-engine/kit';
484
+
485
+ const bg = fieldBackground(heroSection, { preset: 'hero', clear: heroText, ripple: true });
486
+ subscribeButton.onclick = () => bg.pulse(subscribeButton, 1.4);
487
+ ```
488
+
489
+ ```css
490
+ /* The veil: characters blur and fade toward the words instead of stopping at a box edge. */
491
+ .hero-text { position: relative; isolation: isolate; }
492
+ .hero-text::before {
493
+ content: ""; position: absolute; z-index: -1; inset: -24px -40px; pointer-events: none;
494
+ background: color-mix(in oklab, var(--bg) 58%, transparent);
495
+ backdrop-filter: blur(9px);
496
+ mask-image: radial-gradient(closest-side, #000 52%, rgb(0 0 0 / .6) 74%, transparent);
399
497
  }
400
- button.addEventListener('pointerenter', scramble);
401
- button.addEventListener('focus', () => { if (button.matches(':focus-visible')) scramble(); });
402
498
  ```
403
499
 
404
- ## thinking: Thinking state
405
- Category: AI
500
+ ## Advanced: raw field
406
501
 
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' });
502
+ The kit is built on `mountGlyphField` from `asciify-engine/field`. When a component needs a look the kit has no option for, mount the field yourself. This helper needs no kit: it mounts a field that takes its ink from a CSS custom property, follows light and dark, and cleans up after itself. Without the kit you own the rest: check `isGlyphFieldSupported()` and keep the static design as the fallback, honour reduced motion, mount off-screen fields lazily and call `destroy()` on unmount. GPU fields need asciify-engine 4.12 or later for `mark`, `clear` and `pulse()`.
503
+
504
+ ```ts
505
+ import { isGlyphFieldSupported, mountGlyphField, type GlyphField, type GlyphFieldOptions } from 'asciify-engine/field';
506
+
507
+ /** A field inked from a CSS variable that follows the theme. Returns null when the GPU field isn't available. */
508
+ export function asciiField(canvas: HTMLCanvasElement, options: GlyphFieldOptions = {}, inkVar = '--ascii-ink') {
509
+ if (!isGlyphFieldSupported()) return null;
510
+ const ink = () => getComputedStyle(canvas).getPropertyValue(inkVar).trim() || '#8f8d86';
511
+ const field: GlyphField = mountGlyphField(canvas, { color: 'ink', background: 'transparent', ...options, ink: ink() });
512
+ const scheme = matchMedia('(prefers-color-scheme: dark)');
513
+ const retint = () => field.update({ ink: ink() });
514
+ scheme.addEventListener('change', retint);
515
+ return { field, destroy() { scheme.removeEventListener('change', retint); field.destroy(); } };
430
516
  }
431
517
  ```
432
518
 
433
- ## image-generation: Image generation
434
- Category: AI
519
+ ```css
520
+ :root { --ascii-ink: #76746e; --ascii-ink-strong: #1d1d1b; }
521
+ @media (prefers-color-scheme: dark) { :root { --ascii-ink: #7a7872; --ascii-ink-strong: #d8d6cf; } }
522
+ ```
435
523
 
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.”
524
+ In React, wrap it once and reuse it:
438
525
 
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
- });
526
+ ```tsx
527
+ import { useEffect, useRef } from 'react';
528
+ import type { GlyphField, GlyphFieldOptions } from 'asciify-engine/field';
447
529
 
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
- });
530
+ export function AsciiField({ options, onField, className }: { options: GlyphFieldOptions; onField?: (field: GlyphField | null) => void; className?: string }) {
531
+ const canvas = useRef<HTMLCanvasElement>(null);
532
+ useEffect(() => {
533
+ const mounted = canvas.current ? asciiField(canvas.current, options) : null;
534
+ onField?.(mounted?.field ?? null);
535
+ return () => { onField?.(null); mounted?.destroy(); };
536
+ }, []); // mount once; call field.update() for later changes
537
+ return <canvas ref={canvas} className={className} aria-hidden="true" />;
538
+ }
460
539
  ```
540
+
541
+ A raw field draws whatever source you give it: paint white on black into an offscreen canvas (white is the densest glyph) and pass it to `field.setSource()` each frame, or use `scene`, `pulse()` and `update()` directly. To build your own component on the kit's plumbing instead (theme ink, reduced motion, fallback and cleanup included), use `mountSurface` from the kit; see [the kit reference](kit.md#your-own-components).