asciify-engine 4.12.1 → 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,152 +1,97 @@
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; } }
36
- ```
37
+ ## command-palette: Command palette
38
+ Category: AI
37
39
 
38
- In React, wrap it once and reuse it:
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.”
39
42
 
40
- ```tsx
41
- import { useEffect, useRef } from 'react';
42
- import type { GlyphField, GlyphFieldOptions } from 'asciify-engine/field';
43
+ ```ts
44
+ import { commandPalette } from 'asciify-engine/kit';
43
45
 
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
- }
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
53
49
  ```
54
50
 
55
- ## voice-field: Voice orb
56
- Category: AI
51
+ ## button-loading: Button loading
52
+ Category: Components
57
53
 
58
54
  Prompt:
59
- “Add a voice input orb to [our assistant]: an Asciify sphere of characters that swells and brightens with loudness, through Listening, Thinking and Done. Demo it with a sample voice; ask for the microphone only when someone clicks Use microphone.”
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.”
60
56
 
61
57
  ```ts
62
- const orb = asciiField(canvas, {
63
- mark: 'glyph', scene: 'orb', charset: ' .:-=+*#%@', cellSize: 6, cellAspect: 0.62,
64
- scale: 0.92, speed: 0.5, hover: 'none', label: 'An orb of characters that follows the voice',
65
- }, '--ascii-ink-strong');
66
- let level = 0;
67
- function follow(read: () => number, ms: number) {
68
- if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
69
- const end = performance.now() + ms;
70
- requestAnimationFrame(function frame(now) {
71
- const raw = read(); level += (raw - level) * (raw > level ? 0.45 : 0.12); // read() is loudness 0–1: fast attack, slow release
72
- orb?.field.update({ scale: 0.92 - 0.16 * level, brightness: -0.14 + 0.46 * level, speed: 0.5 + 2.4 * level });
73
- if (now < end) requestAnimationFrame(frame);
74
- });
75
- }
76
- const sampleVoice = (start = performance.now()) => follow(() => { const t = performance.now() - start; return Math.abs(Math.sin(t / 54)) ** 0.6 * (Math.sin(t / 700) > -0.4 ? 0.9 : 0.05); }, 2800);
77
- micButton.addEventListener('click', async () => {
78
- try {
79
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true }), audio = new AudioContext(), analyser = audio.createAnalyser(), data = new Uint8Array(1024);
80
- audio.createMediaStreamSource(stream).connect(analyser);
81
- follow(() => { analyser.getByteTimeDomainData(data); return Math.sqrt(data.reduce((sum, v) => sum + ((v - 128) / 128) ** 2, 0) / data.length) * 5; }, 8000);
82
- } catch { sampleVoice(); } // denied: say so, and play the sample voice instead
83
- });
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
+ };
84
65
  ```
85
66
 
86
- ## command-palette: Command palette
87
- Category: AI
67
+ ## ascii-pill: Badges and pills
68
+ Category: Components
88
69
 
89
70
  Prompt:
90
- “Build [our ⌘K command palette] with Asciify: it condenses out of characters at the search trigger, results filter as you type, the selected row gets a crisp highlight behind its text with a short column of characters in its left gutter that glides between rows with the arrow keys and brightens under the pointer, and Enter pulses. Characters never sit on the text.”
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.”
91
72
 
92
73
  ```ts
93
- // The canvas fills the palette behind its rows (z-index 0). The selected row's highlight is CSS, translucent
94
- // (color-mix(in oklab, var(--ink) 6%, transparent)) so the gutter marker shows through; rows get 18px of left padding for it.
95
- const fx = asciiField(canvas, { mark: 'glyph', scene: 'none', charset: ' .:-=+*#%@', cellSize: 5, cellAspect: 0.62, levels: 'none', hover: 'none' }, '--ascii-ink-strong');
96
- const art = document.createElement('canvas'), ctx = art.getContext('2d')!;
97
- let selected = 0, markY: number | null = null, glow = 0, hovering = false;
98
- function glide() {
99
- const frame = canvas.getBoundingClientRect(), row = list.children[selected].getBoundingClientRect(), y = row.top - frame.top, x = row.left - frame.left;
100
- const from = markY ?? y, still = matchMedia('(prefers-reduced-motion: reduce)').matches;
101
- markY = from + (y - from) * (still ? 1 : 0.3); glow += ((hovering ? 1 : 0) - glow) * (still ? 1 : 0.25);
102
- ctx.fillStyle = '#000'; ctx.fillRect(0, 0, (art.width = frame.width), (art.height = frame.height));
103
- if (Math.abs(from - markY) > 0.5) { ctx.fillStyle = '#525252'; ctx.fillRect(x + 4, Math.min(from, markY) + 7, 6, Math.abs(from - markY) + row.height - 14); } // the wake
104
- const v = 255 * (0.7 + 0.3 * glow); ctx.fillStyle = `rgb(${[v, v, v]})`;
105
- ctx.beginPath(); ctx.roundRect(x + 4, markY + 7, 6, row.height - 14, 3); ctx.fill(); // the marker: two columns of glyphs, in the gutter only
106
- void fx?.field.setSource(art);
107
- if (Math.abs(y - markY) > 0.5 || Math.abs((hovering ? 1 : 0) - glow) > 0.01) requestAnimationFrame(glide);
108
- }
109
- const select = (i: number) => { selected = (i + list.children.length) % list.children.length; input.setAttribute('aria-activedescendant', list.children[selected].id); glide(); };
110
- input.addEventListener('keydown', e => {
111
- if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); hovering = false; select(selected + (e.key === 'ArrowDown' ? 1 : -1)); }
112
- if (e.key === 'Enter') { fx?.field.pulse({ element: list.children[selected], strength: 0.6 }); runCommand(selected); }
113
- });
114
- list.addEventListener('pointerover', e => { const row = (e.target as Element).closest('[role="option"]'); if (row) { hovering = true; select([...list.children].indexOf(row)); } });
115
- list.addEventListener('pointerleave', () => { hovering = false; glide(); });
116
- input.addEventListener('input', () => { renderResults(input.value); select(0); }); // the matches, then an “Ask AI” row
117
- // To open, animate the palette's clip-path from the trigger's rect while a shot of its rows flies out of the trigger in blocks.
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
118
81
  ```
119
82
 
120
- ## button-loading: Button loading
83
+ ## ascii-tabs: Character tabs
121
84
  Category: Components
122
85
 
123
86
  Prompt:
124
- “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.”
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.”
125
88
 
126
89
  ```ts
127
- // The canvas lies exactly over the label: one square cell per letter (mono label, letter-spaced to one cell).
128
- const word = 'Deploying', fx = asciiField(canvas, { scene: 'none', charset: ' .:-=+*#%@', cellSize: 12, cellAspect: 1, glyphScale: 1.2, levels: 'none', hover: 'none' }, '--btn-text');
129
- const src = Object.assign(document.createElement('canvas'), { width: word.length, height: 1 }), ctx = src.getContext('2d')!; // a texel per letter
130
- let start = 0, raf = 0;
131
- function paint(now: number) { // the crest passes each letter through the ramp and back; black texels draw nothing
132
- [...label.children].forEach((letter, i) => { // label: one aria-hidden <span> per letter
133
- const p = ((now - start) / 1250 - i * 0.07 + 9) % 1, g = p < 0.3 ? Math.round(1 + 8 * Math.sin((Math.PI * p) / 0.3)) : 0, v = Math.round((g / 9) * 255);
134
- (letter as HTMLElement).style.visibility = g ? 'hidden' : 'visible';
135
- ctx.fillStyle = `rgb(${v} ${v} ${v})`; ctx.fillRect(i, 0, 1, 1); // tone g/9 is glyph g of the charset
136
- });
137
- void fx?.field.setSource(src);
138
- if (start) raf = requestAnimationFrame(paint);
139
- }
140
- const show = (text: string) => label.replaceChildren(...[...text].map(c => Object.assign(document.createElement('span'), { textContent: c })));
141
- button.addEventListener('click', async () => { // min-width holds the widest label, so the button never resizes
142
- button.setAttribute('aria-busy', 'true'); show(word); statusLine.textContent = 'Deploying…'; // status: an aria-live region
143
- if (!matchMedia('(prefers-reduced-motion: reduce)').matches) { start = performance.now(); raf = requestAnimationFrame(paint); }
144
- const ok = await deploy().then(() => true, () => false);
145
- start = 0; cancelAnimationFrame(raf); paint(0); button.setAttribute('aria-busy', 'false');
146
- show(ok ? 'Deployed' : 'Retry'); button.classList.toggle('done', ok); // .done shows the check
147
- statusLine.textContent = ok ? 'Deployed' : 'Deploy failed. Retry.';
148
- if (!ok) button.animate([{ translate: '0' }, { translate: '-4px' }, { translate: '4px' }, { translate: '0' }], { duration: 280 });
149
- });
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);
150
95
  ```
151
96
 
152
97
  ## slide-to-confirm: Slide to confirm
@@ -156,81 +101,23 @@ Prompt:
156
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]’.”
157
102
 
158
103
  ```ts
159
- const art = asciiField(canvas, { scene: 'none', levels: 'none', charset: ' .:-=+*#%@', cellSize: 4, hover: 'none' }, '--ascii-ink-strong');
160
- const src = document.createElement('canvas'), ctx = src.getContext('2d')!; // black is no character, white the densest
161
- let x = 0, v = 0, from = 0, done = false; // thumb: role="slider" with touch-action: none, inside the pill track
162
- const max = () => track.clientWidth - thumb.offsetWidth - 8, ease = (t: number) => 1 - (1 - Math.min(1, Math.max(0, t))) ** 3;
163
- function paint(t = 0) { // t: 0→1 over 720ms. The fill condenses into a disc (to 0.5), opens into a ring, the check draws in.
164
- const h = Object.assign(src, { width: track.clientWidth, height: track.clientHeight }).height, head = 4 + x + thumb.offsetWidth / 2;
165
- const c = ease(t * 2), r = 22, cx = mark.offsetLeft + r, cy = h / 2, k = (a: number, b: number) => a + (b - a) * c; // mark: the 44px slot left of 'Paid'
166
- const fill = ctx.createLinearGradient(k(0, cx - r), 0, k(head, cx + r), 0);
167
- fill.addColorStop(0, `rgba(255,255,255,${k(0.14, 1)})`); fill.addColorStop(1, '#fff'); // densest right behind the thumb
168
- ctx.fillStyle = fill; ctx.globalAlpha = 1 - 0.28 * ease((t - 0.36) / 0.3); // the ring settles lighter than the check
169
- ctx.beginPath(); ctx.roundRect(k(0, cx - r), k(0, cy - r), k(head, cx + r) - k(0, cx - r), k(h, cy + r) - k(0, cy - r), k(0, r)); ctx.fill();
170
- ctx.globalAlpha = 1; ctx.fillStyle = '#000'; ctx.beginPath(); ctx.arc(cx, cy, (r - 5) * ease((t - 0.36) / 0.3), 0, 7); ctx.fill();
171
- ctx.strokeStyle = '#fff'; ctx.lineWidth = 4.5; ctx.lineCap = ctx.lineJoin = 'round'; ctx.setLineDash([40 * ease((t - 0.46) / 0.4), 40]);
172
- ctx.beginPath(); ctx.moveTo(cx - 10, cy + 1); ctx.lineTo(cx - 3, cy + 8); ctx.lineTo(cx + 11, cy - 6); ctx.stroke(); ctx.setLineDash([]);
173
- thumb.style.translate = `${x}px 0`; thumb.setAttribute('aria-valuenow', String(Math.round((x / max()) * 100)));
174
- void art?.field.setSource(src);
175
- }
176
- const frames = (step: () => boolean) => requestAnimationFrame(function next() { if (step()) requestAnimationFrame(next); });
177
- function complete() { // the ink eases to the success green while the fill condenses; .paid crossfades the label in
178
- done = true; x = max(); track.classList.add('paid'); const t0 = performance.now(), ink = getComputedStyle(track).getPropertyValue('--success').trim();
179
- frames(() => { const t = Math.min(1, (performance.now() - t0) / 720); if (t > 0.3) art?.field.update({ ink }); paint(t); return t < 1; }); onPay();
180
- }
181
- function release() { // short of the end, a spring carries the thumb home and drains the characters with it
182
- if (x >= max() * 0.9) return complete();
183
- frames(() => { v = v * 0.72 - x * 0.09; x = Math.max(0, x + v); paint(); return x > 0.5 || Math.abs(v) > 0.5; });
184
- }
185
- thumb.addEventListener('pointerdown', e => { thumb.setPointerCapture(e.pointerId); from = e.clientX - x; });
186
- thumb.addEventListener('pointermove', e => { if (done || !thumb.hasPointerCapture(e.pointerId)) return; x = Math.min(max(), Math.max(0, e.clientX - from)); paint(); if (x >= max()) complete(); });
187
- thumb.addEventListener('pointerup', () => { if (!done) release(); });
188
- thumb.addEventListener('keydown', e => { if (!done && (e.key === 'Enter' || e.key === 'End')) { e.preventDefault(); complete(); } });
104
+ import { slideToConfirm } from 'asciify-engine/kit';
105
+
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()) });
189
108
  ```
190
109
 
191
110
  ## like-burst: Like burst
192
111
  Category: Components
193
112
 
194
113
  Prompt:
195
- “Make [our Like button] burst in Asciify characters: on press the heart springs up large around the button, a ring rolls out and glyph sparks fly wide and low, then it condenses back into the icon in 720ms while the count rolls. The burst never covers the post text; at rest the heart is drawn faintly in characters around the icon. Unlike thins out softly; reduced motion just toggles.”
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.”
196
115
 
197
116
  ```ts
198
- // The canvas covers the post and 60px around it, behind the post's text (pointer-events: none). Light in the art becomes glyphs.
199
- const fx = asciiField(canvas, { mark: 'glyph', scene: 'none', charset: ' .:-=+*#%@', cellSize: 5, cellAspect: 0.62, levels: 'none', hover: 'none' }, '--like-ink');
200
- const art = document.createElement('canvas'), ctx = art.getContext('2d')!, heart = new Path2D('M12 20.2s-7.6-4.6-9.2-9.4C1.6 7.2 4 4 7.4 4c2 0 3.6 1.1 4.6 2.6C13 5.1 14.6 4 16.6 4 20 4 22.4 7.2 21.2 10.8 19.6 15.6 12 20.2 12 20.2z');
201
- let likes = 128;
202
- const at = (el: Element) => { const f = canvas.getBoundingClientRect(), b = el.getBoundingClientRect(); return { x: b.left - f.left + b.width / 2, y: b.top - f.top + b.height / 2, bottom: b.bottom - f.top }; };
203
- const drawHeart = (x: number, y: number, s: number, fill: boolean) => { // the 24px icon path scaled to s px
204
- ctx.setTransform(s / 24, 0, 0, s / 24, x - s / 2, y - s / 2); if (fill) ctx.fill(heart); else { ctx.lineWidth = 2.5 * 24 / s; ctx.stroke(heart); } ctx.resetTransform();
205
- };
206
- function paint(now: number, start: number | null) {
207
- const f = canvas.getBoundingClientRect(), { x, y } = at(icon), top = at(body).bottom; // the post's text ends at `top`; the actions row is 14px below
208
- ctx.fillStyle = '#000'; ctx.fillRect(0, 0, (art.width = f.width), (art.height = f.height));
209
- const t = start === null ? 1 : Math.min(1, (now - start) / 720);
210
- if (t >= 1) { ctx.strokeStyle = likeButton.ariaPressed === 'true' ? '#6b6b6b' : '#4d4d4d'; drawHeart(x, y, 33, false); } // rest: a faint heart of characters
211
- else {
212
- const peak = Math.min(58, (y - top) * 2 - 6), grow = t < 0.34 ? 1 - Math.exp(-15 * t) * Math.cos(26 * t) : 1, land = Math.min(1, Math.max(0, (t - 0.52) / 0.4));
213
- const rt = Math.min(1, t / 0.5), out = 1 - (1 - rt) ** 3;
214
- ctx.strokeStyle = `rgb(${[191, 191, 191].map(v => v * (1 - rt))})`; ctx.lineWidth = 5 - 3 * rt;
215
- ctx.beginPath(); ctx.ellipse(x, y, 18 + 62 * out, 14 + 40 * out, 0, 0, 7); ctx.stroke(); // the ring
216
- ctx.fillStyle = '#fff'; ctx.globalAlpha = 1 - t;
217
- for (let i = 0; i < 22; i++) { // sparks: wide sideways, shallow upward, deeper downward
218
- const a = (i / 22) * Math.PI * 2, d = 22 + 70 * out, up = Math.sin(a) < 0;
219
- ctx.beginPath(); ctx.arc(x + Math.cos(a) * d * 1.25, y + Math.sin(a) * d * (up ? 0.5 : 0.75), 4 * (1 - t), 0, 7); ctx.fill();
220
- }
221
- ctx.globalAlpha = 1 - Math.max(0, (t - 0.86) / 0.14); drawHeart(x, y, 10 + (peak - 10) * grow * (1 - land) + 20 * land, true); ctx.globalAlpha = 1;
222
- ctx.fillStyle = '#000'; ctx.fillRect(0, 0, f.width, top); // nothing above the text line (fade the last 12px in practice)
223
- requestAnimationFrame(next => paint(next, start));
224
- }
225
- if (t >= 1) icon.classList.remove('bursting'); // .bursting hides the SVG icon while the characters stand in for it
226
- void fx?.field.setSource(art);
227
- }
228
- likeButton.addEventListener('click', () => {
229
- const on = likeButton.getAttribute('aria-pressed') !== 'true';
230
- likeButton.setAttribute('aria-pressed', String(on)); count.textContent = String((likes += on ? 1 : -1)); // the count rolls in CSS
231
- if (on && !matchMedia('(prefers-reduced-motion: reduce)').matches) { icon.classList.add('bursting'); paint(performance.now(), performance.now()); } else paint(0, null);
232
- });
233
- paint(0, null);
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)) });
234
121
  ```
235
122
 
236
123
  ## loading-states: Status orb
@@ -240,32 +127,15 @@ Prompt:
240
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.”
241
128
 
242
129
  ```ts
243
- const orb = asciiField(canvas, { mark: 'glyph', scene: 'none', fit: 'contain', levels: 'none', cellSize: 6, cellAspect: 0.62, shade: 0.5, hover: 'none' },
244
- '--ascii-ink-strong'); // a source drawn white on black, so white is the densest glyph
245
- const art = Object.assign(document.createElement('canvas'), { width: 240, height: 240 }), pen = art.getContext('2d')!;
246
- const marks = { success: new Path2D('M86 122 110 146 156 96'), warning: new Path2D('M120 80V124M120 152v1'), error: new Path2D('M92 92 148 148M148 92 92 148') };
247
- type State = 'loading' | keyof typeof marks; const still = matchMedia('(prefers-reduced-motion: reduce)').matches; let frame = 0, ink = '';
248
- function paint(state: State, t: number, k: number) { // k closes the ring (or fades it, with Ring off) and strokes the mark
249
- const tail = pen.createConicGradient(t / 167 - 3.4, 120, 120), end = ring.checked ? 0.54 + 0.46 * k : 0.54; // a comet whose tail sweeps round
250
- [[0, ring.checked ? 0.72 * k : 0], [end, ring.checked ? 1 - 0.28 * k : 1 - k], [Math.min(1, end + 0.001), 0]].forEach(([at, a]) => tail.addColorStop(at, `rgb(255 255 255 / ${a})`));
251
- Object.assign(pen, { fillStyle: '#000', strokeStyle: tail, lineCap: 'round', lineJoin: 'round', lineWidth: 14 }).fillRect(0, 0, 240, 240);
252
- pen.setLineDash([]); pen.beginPath(); pen.arc(120, 120, 74 + Math.sin(t / 255) * 5 * (1 - k), 0, Math.PI * 2); pen.stroke();
253
- if (state !== 'loading') { Object.assign(pen, { strokeStyle: '#fff', lineWidth: 22 }).setLineDash([110 * k, 400]); pen.stroke(marks[state]); }
254
- }
255
- function show(state: State, ms = 280, start = performance.now()) { // a fresh source each frame; the ink eases to the state's hex token (--st-success…)
256
- const from = ink, to = (ink = getComputedStyle(canvas).getPropertyValue(state === 'loading' || !colour.checked ? '--ascii-ink-strong' : `--st-${state}`).trim());
257
- const mix = (k: number) => '#' + [1, 3, 5].map(i => Math.round(+`0x${(from || to).slice(i, i + 2)}` * (1 - k) + +`0x${to.slice(i, i + 2)}` * k).toString(16).padStart(2, '0')).join('');
258
- cancelAnimationFrame(frame); frame = requestAnimationFrame(function step(now) {
259
- const k = state === 'loading' ? 0 : still ? 1 : Math.min(1, (now - start) / ms); paint(state, still ? 0 : now, 1 - (1 - k) ** 4);
260
- orb?.field.update({ ink: mix(Math.min(1, (now - start) / (still ? 1 : 200))) }); void orb?.field.setSource(art);
261
- if (state === 'loading' ? !still : k < 1) frame = requestAnimationFrame(step); else orb?.field.pulse({ element: canvas });
262
- });
263
- }
264
- button.addEventListener('click', async () => { // statusLine is role="status"; canvas is aria-hidden; colour and ring are switches
265
- show('loading'); statusLine.textContent = 'Publishing…'; const end: State = await save().then(r => (r.warnings ? 'warning' : 'success'), () => 'error');
266
- show(end); statusLine.textContent = { loading: '', success: 'Published', warning: 'Published with warnings. Review?', error: 'Couldn’t publish. Retry?' }[end];
267
- if (end === 'error') canvas.animate({ translate: ['0', '-5px 0', '4px 0', '-2px 0', '0'] }, { duration: 260, easing: 'cubic-bezier(.16, 1, .3, 1)' });
268
- });
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
+ };
269
139
  ```
270
140
 
271
141
  ## text-decode: Decode headline
@@ -275,62 +145,26 @@ Prompt:
275
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.”
276
146
 
277
147
  ```ts
278
- const head = asciiField(canvas, { mark: 'glyph', scene: 'none', charset: ' .:-=+*#%@', cellSize: 5, cellAspect: 0.62, levels: 'none', fit: 'cover', hover: 'trail', label: '' }, '--ascii-ink-strong');
279
- const w = canvas.clientWidth * 2, h = canvas.clientHeight * 2, make = (cw = w, ch = h) => Object.assign(document.createElement('canvas'), { width: cw, height: ch });
280
- const art = make(), work = make(), cloud = make(Math.ceil(w / 20), Math.ceil(h / 20)), pen = art.getContext('2d')!, out = work.getContext('2d')!;
281
- const size = Math.min(h * 0.4, w * 0.16);
282
- pen.fillRect(0, 0, w, h); // the headline at the canvas's own size, white on black, so nothing is letterboxed
283
- Object.assign(pen, { fillStyle: '#fff', textAlign: 'center', textBaseline: 'middle', font: `600 ${size}px Geist, sans-serif` });
284
- lines.forEach((line, i) => pen.fillText(line, w / 2, h / 2 + (i - 0.5) * size * 1.12));
285
- function drift(t: number) { // a feathered cloud of soft noise, low in tone so it reads as a quiet ground
286
- const c = cloud.getContext('2d')!, img = c.createImageData(cloud.width, cloud.height);
287
- for (let i = 0; i < img.data.length; i += 4) {
288
- const x = (i / 4) % cloud.width, y = Math.floor(i / 4 / cloud.width), edge = Math.max(0, 1 - Math.hypot(x / cloud.width - 0.5, y / cloud.height - 0.5) * 1.6);
289
- img.data[i] = img.data[i + 1] = img.data[i + 2] = Math.max(0, Math.sin(x * 0.7 + t * 2.4) * Math.cos(y * 0.9 - t * 1.6) - 0.2) * 180 * edge; img.data[i + 3] = 255;
290
- }
291
- c.putImageData(img, 0, 0);
292
- }
293
- async function decode(ms = 900) {
294
- if (!head || matchMedia('(prefers-reduced-motion: reduce)').matches) return void head?.field.setSource(art);
295
- for (let start = performance.now(), p = 0; p < 1; ) {
296
- p = Math.min(1, (performance.now() - start) / ms); const edge = (p * 1.3 - 0.15) * w; drift(p); // the first ~150ms is drift alone
297
- out.drawImage(cloud, 0, 0, w, h); if (edge > 0) out.drawImage(art, 0, 0, edge, h, 0, 0, edge, h);
298
- for (let x = Math.max(0, edge); x < edge + w * 0.14; x += 6) for (let y = 0; y < h; y += 10) if (Math.random() > (x - edge) / (w * 0.14)) { out.fillRect(x, y, 6, 10); out.drawImage(art, x, y, 6, 10, x, y, 6, 10); }
299
- await head.field.setSource(await createImageBitmap(work)); await new Promise(requestAnimationFrame);
300
- }
301
- await head.field.setSource(art); // the last frame is the headline itself, so no fragment is left over
302
- }
303
- new IntersectionObserver(([entry]) => { if (entry.isIntersecting) void decode(); }, { threshold: 0.5 }).observe(canvas);
148
+ import { decode } from 'asciify-engine/kit';
149
+
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();
304
152
  ```
305
153
 
306
154
  ## ascii-chart: Block chart
307
155
  Category: Components
308
156
 
309
157
  Prompt:
310
- “Build [our visitors analytics card] with bars drawn in Asciify characters: paint them offscreen and stream each frame to the field, so they grow in left to right, grow again on a 7d/30d toggle, and brighten under a hover readout.”
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.”
311
159
 
312
160
  ```ts
313
- const chart = asciiField(canvas, { mark: 'glyph', scene: 'none', charset: ' .:-=+*#%@', cellSize: 7, cellAspect: 0.6, levels: 'none', fit: 'cover', hover: 'none' }, '--ascii-ink-strong');
314
- const art = document.createElement('canvas'), ctx = art.getContext('2d')!, ease = (t: number) => 1 - (1 - Math.min(1, Math.max(0, t))) ** 4;
315
- let values: number[] = [], active = -1, t0 = 0;
316
-
317
- // Bars are painted white on black, brightest for the hovered column or today, and each frame becomes the field's source.
318
- async function frame(now: number) {
319
- const w = (art.width = plot.clientWidth), h = (art.height = plot.clientHeight), slot = w / values.length, max = Math.max(...values) * 1.04;
320
- ctx.fillStyle = '#000'; ctx.fillRect(0, 0, w, h);
321
- values.forEach((v, i) => {
322
- const k = ease((now - t0 - i * (450 / values.length)) / 520), top = h - (v / max) * h * k, tone = i === (active < 0 ? values.length - 1 : active) ? 255 : 130;
323
- const grad = ctx.createLinearGradient(0, top, 0, h); grad.addColorStop(0, `rgb(${tone} ${tone} ${tone})`); grad.addColorStop(1, `rgb(${tone / 2} ${tone / 2} ${tone / 2})`);
324
- ctx.fillStyle = grad; ctx.fillRect(slot * (i + 0.2), top, slot * 0.6, h - top);
325
- });
326
- await chart?.field.setSource(await createImageBitmap(art));
327
- if (now - t0 < 1000) requestAnimationFrame(frame);
328
- }
329
- const show = (range: string) => { values = series[range]; t0 = matchMedia('(prefers-reduced-motion: reduce)').matches ? -1e4 : performance.now(); requestAnimationFrame(frame); };
330
- rangeButtons.forEach(button => button.addEventListener('click', () => show(button.value)));
331
- plot.addEventListener('pointermove', e => { active = Math.min(values.length - 1, Math.floor((e.offsetX / plot.clientWidth) * values.length)); readout.textContent = `${labels[active]} · ${values[active].toLocaleString()}`; requestAnimationFrame(frame); });
332
- plot.addEventListener('pointerleave', () => { active = -1; readout.textContent = ''; requestAnimationFrame(frame); });
333
- show('7d');
161
+ import { blockChart } from 'asciify-engine/kit';
162
+
163
+ const chart = blockChart(plot, {
164
+ values: visits['7d'], labels: days['7d'],
165
+ onHover: (i, v, day) => { readout.textContent = i === null ? '' : `${day} · ${v!.toLocaleString()}`; },
166
+ });
167
+ rangeToggle.onchange = () => chart.set(visits[rangeToggle.value], days[rangeToggle.value]);
334
168
  ```
335
169
 
336
170
  ## route-transition: Page transition
@@ -340,32 +174,11 @@ Prompt:
340
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.”
341
175
 
342
176
  ```ts
343
- const chars = asciiField(canvas, { mark: 'glyph', scene: 'none', fit: 'cover', levels: 'none', charset: ' .:-=+*#%@',
344
- cellSize: 8, cellAspect: 0.6, shade: 0.55, hover: 'none' }, '--ascii-ink-strong');
345
- // layer: the aria-hidden canvas over the view, opacity 0 at rest. draw(view) paints the view's own elements at layer size, white on black.
346
- const noise = (a: number, b: number) => Math.abs(Math.sin(a * 127.1 + b * 311.7) * 43758.5) % 1;
347
- function blocks(art: HTMLCanvasElement, out: HTMLCanvasElement, p: number, x: number, y: number, pen = out.getContext('2d')!) {
348
- const crest: number[][] = []; pen.fillRect(0, 0, out.width, out.height);
349
- for (let by = 0; by < art.height; by += 8) for (let bx = 0; bx < art.width; bx += 5) { // cell-sized blocks, nearest the tab first
350
- const order = Math.hypot(bx - x, by - y) / Math.hypot(art.width, art.height) * 0.72 + noise(bx, by) * 0.28;
351
- if (order > p) { pen.drawImage(art, bx, by, 5, 8, bx, by, 5, 8); if (order < p + 0.07) crest.push([bx, by]); }
352
- }
353
- pen.globalCompositeOperation = 'lighter'; // the front glows like a pulse running out from the tab
354
- crest.forEach(([bx, by]) => pen.drawImage(art, bx, by, 5, 8, bx, by, 5, 8)); pen.globalCompositeOperation = 'source-over';
355
- }
356
- async function navigate(tab: HTMLElement, swap: () => void) {
357
- const field = chars?.field; if (!field || matchMedia('(prefers-reduced-motion: reduce)').matches) return swap();
358
- const b = layer.getBoundingClientRect(), t = tab.getBoundingClientRect(), x = t.left + t.width / 2 - b.left, y = t.top + t.height / 2 - b.top;
359
- const from = draw(view), out = Object.assign(document.createElement('canvas'), { width: from.width, height: from.height });
360
- const step = async (art: HTMLCanvasElement, p: number) => { blocks(art, out, p, x, y); field.update({ brightness: -0.22 * p });
361
- await field.setSource(await createImageBitmap(out)); await new Promise(requestAnimationFrame); };
362
- layer.style.maskImage = 'none'; layer.style.opacity = '1'; view.style.opacity = '0'; // 80ms opacity transitions, cubic-bezier(.16, 1, .3, 1)
363
- for (let i = 0; i <= 8; i++) await step(from, i / 8 * 1.1); // about 140ms
364
- swap(); const to = draw(view); for (let i = 8; i >= 0; i--) await step(to, i / 8); // about 160ms
365
- view.style.opacity = '1'; rest(); // 120ms
366
- }
367
- const rest = (chart = view.querySelector<HTMLElement>('.chart'), b = layer.getBoundingClientRect()) => { // at rest only the chart stays, in characters, over its hidden HTML bars
368
- layer.style.opacity = chart ? '1' : '0'; if (chart) chart.style.visibility = 'hidden'; layer.style.maskImage = `linear-gradient(to top, #000 ${b.bottom - (chart?.getBoundingClientRect().top ?? b.bottom)}px, transparent 0)`; };
177
+ import { pageDissolve, blockChart } from 'asciify-engine/kit';
178
+
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);
369
182
  ```
370
183
 
371
184
  ## swipe-card: Swipe cards
@@ -375,151 +188,55 @@ Prompt:
375
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.”
376
189
 
377
190
  ```ts
378
- const art = asciiField(canvas, { scene: 'none', levels: 'none', charset: ' .:-=+*#%@', cellSize: 4, contrast: 1.3, hover: 'trail' }, '--ascii-ink-strong'); // canvas covers the stack area
379
- const src = document.createElement('canvas'), ctx = src.getContext('2d')!; // cards: high-contrast photos (one subject on a plain ground) in grey, levels stretched, inverted in light themes, cut to 160×200
380
- let i = 0, dx = 0, from = 0;
381
- function paint(k = 0, dir = 1) {
382
- Object.assign(src, { width: canvas.clientWidth, height: canvas.clientHeight }); const cx = src.width / 2, cy = src.height / 2;
383
- ctx.globalAlpha = 0.45 + 0.55 * k; ctx.drawImage(cards[(i + 1) % cards.length], cx - 80, cy - 100 + 12 * (1 - k)); // the next card resolves
384
- ctx.save(); ctx.translate(cx + dx, cy); ctx.rotate(dx * 0.0015);
385
- for (let y = 0; y < 200; y += 6) { // the top card leaves as rows of characters streaming along the swipe, ragged ones first
386
- const r = Math.abs(Math.sin(y * 91.7)), t = Math.max(0, Math.min(1, k * 1.5 - r * 0.5));
387
- const lead = cx + dx + dir * (t * t * 200 * (0.5 + r) + 100), room = dir > 0 ? src.width - lead : lead; // rows fade out before the field's edge
388
- if (t < 1) { ctx.globalAlpha = (1 - t) * Math.min(1, Math.max(0, room / 56)); ctx.drawImage(cards[i % cards.length], 0, y, 160, 6, -80 + dir * t * t * 200 * (0.5 + r), y - 100, 160, 6); }
389
- }
390
- ctx.restore(); void art?.field.setSource(src);
391
- }
392
- function fling(dir: number) {
393
- let k = 0;
394
- requestAnimationFrame(function step() { k = Math.min(1, k + 0.03); dx += dir * 9; paint(k, dir); if (k < 1) requestAnimationFrame(step); else { i++; dx = 0; paint(); } });
395
- }
396
- card.addEventListener('pointerdown', e => { card.setPointerCapture(e.pointerId); from = e.clientX; }); // card: touch-action: pan-y
397
- card.addEventListener('pointermove', e => { if (card.hasPointerCapture(e.pointerId)) { dx = e.clientX - from; paint(); } });
398
- card.addEventListener('pointerup', () => { if (Math.abs(dx) > 64) fling(Math.sign(dx)); else { dx = 0; paint(); } });
399
- card.addEventListener('keydown', e => { if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') fling(e.key === 'ArrowRight' ? 1 : -1); });
400
- passButton.onclick = () => fling(-1); saveButton.onclick = () => fling(1);
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);
401
197
  ```
402
198
 
403
199
  ## otp-input: Verification code
404
200
  Category: Components
405
201
 
406
202
  Prompt:
407
- “Build [our 6-digit sign-in code] input with Asciify: the cells stay clean, the active one holds a faint caret of characters that breathes slowly, typed or pasted digits resolve in scattered blocks of glyphs, a wrong code shakes and crumbles back to characters, and the right one condenses into a check.”
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.”
408
204
 
409
205
  ```ts
410
- // One real input (inputMode numeric, autocomplete one-time-code, maxLength 6) lies transparent over six cells; the canvas covers the cells.
411
- const fx = asciiField(canvas, { mark: 'glyph', scene: 'none', charset: ' .:-=+*#%@', cellSize: 4, cellAspect: 0.62, levels: 'none', hover: 'none' }, '--ascii-ink-strong');
412
- const art = document.createElement('canvas'), ctx = art.getContext('2d')!, born: number[] = [];
413
- const hash = (a: number, b: number) => { const s = Math.sin(a * 127.1 + b * 311.7) * 43758.5453; return s - Math.floor(s); };
414
- // Empty cells are empty: no static. A new digit arrives in 3×4 blocks over 300ms, then the cell's crisp HTML digit takes over.
415
- function paint(now: number) {
416
- const frame = canvas.getBoundingClientRect(), still = matchMedia('(prefers-reduced-motion: reduce)').matches;
417
- ctx.fillStyle = '#000'; ctx.fillRect(0, 0, (art.width = frame.width), (art.height = frame.height));
418
- let moving = false;
419
- cells.forEach((cell, i) => {
420
- const b = cell.getBoundingClientRect(), x = b.left - frame.left, y = b.top - frame.top, ch = input.value[i], k = ch ? Math.min(1, (now - born[i]) / 300) : 0;
421
- cell.toggleAttribute('data-shown', k >= 0.7);
422
- if (ch && k < 1) { // the digit drawn once, copied in blocks whose turn has come
423
- moving = true; ctx.save(); ctx.beginPath();
424
- for (let by = 0; by < b.height; by += 4) for (let bx = 0; bx < b.width; bx += 3) if (hash(bx + i * 31, by) < k * 1.3 - 0.12) ctx.rect(x + bx, y + by, 3, 4);
425
- ctx.clip(); ctx.fillStyle = '#fff'; ctx.font = `600 ${b.height * 0.6}px Geist`; ctx.textAlign = 'center'; ctx.fillText(ch, x + b.width / 2, y + b.height * 0.7); ctx.restore();
426
- }
427
- if (!ch && i === input.value.length && document.activeElement === input) { // the caret: a 3×22 column, tone 0.2–0.46 over 1.6s
428
- const breath = still ? 0.6 : 0.5 - 0.5 * Math.cos(now / 1600 * Math.PI * 2), v = 255 * (0.2 + 0.26 * breath);
429
- ctx.fillStyle = `rgb(${[v, v, v]})`; ctx.fillRect(x + b.width / 2 - 1.5, y + b.height / 2 - 11, 3, 22); moving = !still;
430
- }
431
- });
432
- void fx?.field.setSource(art);
433
- if (moving) requestAnimationFrame(paint); // idle and unfocused, nothing is drawn and nothing runs
434
- }
435
- input.addEventListener('input', () => {
436
- input.value = input.value.replace(/\D/g, '').slice(0, 6); born[input.value.length - 1] = performance.now(); // a paste fills all six at once
437
- requestAnimationFrame(paint);
438
- if (input.value.length === 6) void verify(input.value).then(ok => (ok ? showCheck() : shakeAndClear())); // a check stroke, or digits back to characters
439
- });
440
- input.addEventListener('focus', () => requestAnimationFrame(paint)); input.addEventListener('blur', () => requestAnimationFrame(paint));
206
+ import { codeInput } from 'asciify-engine/kit';
207
+
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)) });
441
210
  ```
442
211
 
443
212
  ## toast-stack: Toast stack
444
213
  Category: Components
445
214
 
446
215
  Prompt:
447
- “Give [our app's toasts] Asciify entrances: each toast's content gathers out of scattered characters while its surface fades in, the stack sits close under the page with depth and fans out on hover, dismissing or Undo breaks the content back into drifting glyphs, and the newest toast keeps a slow glint of characters running around its edge. Reduced motion shows and hides them plainly.”
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.”
448
217
 
449
218
  ```ts
450
- // The canvas covers the stack at its fanned height, above the toasts (pointer-events: none). Toasts sit at --depth; hover fans them.
451
- const fx = asciiField(canvas, { mark: 'glyph', scene: 'none', charset: ' .:-=+*#%@', cellSize: 5, cellAspect: 0.62, levels: 'none', hover: 'none' }, '--ascii-ink-strong');
452
- const still = () => matchMedia('(prefers-reduced-motion: reduce)').matches;
453
- // shot(): only the toast's content as tone at its place, title bright and meta dim. No outline: a glyph outline left over a crisp
454
- // toast reads as a dashed box. The surface itself fades in CSS (200ms in, 180ms out) while the characters carry the content.
455
- function shot(toast: HTMLElement) {
456
- const art = document.createElement('canvas'), ctx = art.getContext('2d')!, f = canvas.getBoundingClientRect();
457
- ctx.fillRect(0, 0, (art.width = f.width), (art.height = f.height));
458
- for (const [el, tone] of [[toast.querySelector('b')!, '#fff'], [toast.querySelector('small')!, '#888']] as const) {
459
- const r = el.getBoundingClientRect(); ctx.fillStyle = tone; ctx.font = getComputedStyle(el).font; ctx.fillText(el.textContent ?? '', r.left - f.left, r.bottom - f.top - 4);
460
- }
461
- return art;
462
- }
463
- // Blocks of the shot gather from scattered places (in, 340ms) or drift off to the right (out, 440ms); at rest the newest toast's
464
- // border carries a comet of light 4px outside it, once round in 2.4s every 4.2s, on the card around the toast and never over its text.
465
- let glintFrom = performance.now();
466
- function frame(now: number, scatter?: { art: HTMLCanvasElement; into: boolean; start: number; done: () => void }) {
467
- const work = Object.assign(document.createElement('canvas'), { width: canvas.width, height: canvas.height }), ctx = work.getContext('2d')!;
468
- ctx.fillRect(0, 0, work.width, work.height);
469
- const front = stack.lastElementChild as HTMLElement | null, f = canvas.getBoundingClientRect();
470
- if (front && !scatter && !still()) {
471
- const b = front.getBoundingClientRect(), t = ((now - glintFrom) % 4200) / 2400, o = 4, w = b.width + o * 2, h = b.height + o * 2, per = 2 * (w + h);
472
- for (const [len, tone] of [[150, 87], [80, 168], [30, 255]]) if (t < 1) {
473
- ctx.strokeStyle = `rgb(${[tone, tone, tone]})`; ctx.lineWidth = 5; ctx.lineCap = 'round';
474
- ctx.setLineDash([len, per + len]); ctx.lineDashOffset = len - t * (per + 150);
475
- ctx.beginPath(); ctx.roundRect(b.left - f.left - o, b.top - f.top - o, w, h, 17); ctx.stroke();
476
- }
477
- }
478
- if (scatter) {
479
- const t = Math.min(1, (now - scatter.start) / (scatter.into ? 340 : 440)), p = scatter.into ? 1 - (1 - t) ** 3 : 1 - t;
480
- for (let y = 0; y < scatter.art.height; y += 6) for (let x = 0; x < scatter.art.width; x += 4) ctx.drawImage(scatter.art, x, y, 4, 6, x + Math.max(0, Math.abs(Math.sin(x * 12.9 + y * 78.2)) - p) * 60, y, 4, 6);
481
- if (t >= 1) { scatter.done(); scatter = undefined; }
482
- }
483
- void fx?.field.setSource(work);
484
- requestAnimationFrame(next => frame(next, scatter)); // pause this loop while the stack is off screen
485
- }
486
- export const showToast = (toast: HTMLElement) => { stack.append(toast); glintFrom = performance.now() + 700; if (!still()) { toast.dataset.forming = ''; frame(performance.now(), { art: shot(toast), into: true, start: performance.now(), done: () => delete toast.dataset.forming }); } };
487
- export const dismissToast = (toast: HTMLElement) => { if (still()) return toast.remove(); const art = shot(toast); toast.dataset.gone = ''; setTimeout(() => toast.remove(), 150); frame(performance.now(), { art, into: false, start: performance.now(), done: () => {} }); };
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
488
224
  ```
489
225
 
490
226
  ## skeleton-loader: Skeleton loader
491
227
  Category: Components
492
228
 
493
229
  Prompt:
494
- “Make [our post card]’s loading skeleton with Asciify: placeholder blocks drawn in characters with a shimmer sweeping through, then each block resolves into its real content in turn, the photo developing from characters. While the card is in view it loads again a few seconds after landing, silently, so it rests on the skeleton. Reduced motion shows the final card.”
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.”
495
231
 
496
232
  ```ts
497
- // The canvas covers the card (z-index 1, pointer-events: none). Blocks are the card's [data-block] elements, photo last; text stays HTML.
498
- const fx = asciiField(canvas, { mark: 'glyph', scene: 'none', levels: 'none', charset: ' .:-=+*#%@', cellSize: 5, cellAspect: 0.62, hover: 'none' });
499
- const art = document.createElement('canvas'), pen = art.getContext('2d')!, blocks = [...card.querySelectorAll<HTMLElement>('[data-block]')];
500
- const still = matchMedia('(prefers-reduced-motion: reduce)').matches;
501
- function paint(now: number, since: number) { // white is dense; `since` is negative until the data lands, while the shimmer sweeps
502
- const box = card.getBoundingClientRect(), x0 = (still ? 0.3 : (now / 1250) % 1) * box.width * 1.7 - box.width * 0.35;
503
- Object.assign(art, { width: box.width, height: box.height }); pen.fillRect(0, 0, box.width, box.height);
504
- const band = pen.createLinearGradient(x0 - 90, 0, x0 + 90, 60); [0, 0.5, 1].forEach(at => band.addColorStop(at, at === 0.5 ? '#ccc' : '#4c4c4c'));
505
- blocks.forEach((el, i) => { // 60ms apart: text wipes away left to right; images cross-fade, the photo first developing in characters
506
- const r = el.getBoundingClientRect(), x = r.left - box.left, y = r.top - box.top, p = Math.min(1, Math.max(0, (since - 80 - i * 60) / 240));
507
- const text = el.dataset.block === 'text'; el.style.clipPath = text ? `inset(0 ${100 - p * 100}% 0 0)` : '';
508
- el.style.opacity = since < 0 ? '0' : text ? '1' : `${p}`; Object.assign(pen, { fillStyle: band, globalAlpha: text ? 1 : 1 - p }).beginPath();
509
- if (text) pen.roundRect(x + r.width * p, y + r.height * 0.2, r.width * (1 - p), r.height * 0.6, 6); else pen.roundRect(x, y, r.width, r.height, 10);
510
- pen.fill(); if (!(el instanceof HTMLImageElement) || since < 0) return;
511
- const s = Math.min(el.naturalWidth / r.width, el.naturalHeight / r.height); pen.globalAlpha *= Math.min(1, since / 560); // cover crop
512
- pen.drawImage(el, (el.naturalWidth - r.width * s) / 2, (el.naturalHeight - r.height * s) / 2, r.width * s, r.height * s, x, y, r.width, r.height);
513
- }); pen.globalAlpha = 1; void fx?.field.setSource(art);
514
- }
515
- function load() { // Reload calls this too; statusLine is role="status"
516
- let arrived = Infinity; statusLine.textContent = 'Loading post…'; void fetchPost().then(() => { arrived = performance.now() - (still ? 9e3 : 0); });
517
- requestAnimationFrame(function step(now) { // under reduced motion the data lands straight on the final state
518
- paint(now, now - arrived); if (now - arrived < 900) requestAnimationFrame(step); else statusLine.textContent = 'Post loaded'; });
519
- }
520
- reload.addEventListener('click', load); // a looping run plays no sounds and keeps the statusLine region aria-live="off"
521
- // While the card is in view, load again 2.6s after each landing; IntersectionObserver pauses the loop off screen.
522
- new IntersectionObserver(([e]) => { if (e.isIntersecting) load(); }, { threshold: 0.5 }).observe(card);
233
+ import { skeleton } from 'asciify-engine/kit';
234
+
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(); };
523
240
  ```
524
241
 
525
242
  ## thinking: Thinking state
@@ -529,28 +246,11 @@ Prompt:
529
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.”
530
247
 
531
248
  ```ts
532
- import { play } from 'cuelume';
533
-
534
- const dots = asciiField(canvas, { mark: 'dot', scene: 'drift', cellSize: 5, dotScale: 0.86, scale: 3, speed: 2.4, hover: 'none' });
535
-
536
- // The canvas fills the answer area plus a 14px gap below it. While thinking, CSS masks it to line-shaped bands; at rest only its bottom 5px shows: a dotted rule.
537
- async function regenerate(ask: () => Promise<string>) {
538
- play('loading', { emphasis: 'subtle' });
539
- reply.dataset.phase = 'thinking';
540
- dots?.field.update({ brightness: 0 });
541
- dots?.field.play();
542
- const beat = setInterval(() => dots?.field.pulse({ x: Math.random() * canvas.clientWidth, y: canvas.clientHeight / 2, strength: 0.7 }), 640);
543
- const answer = await ask().finally(() => clearInterval(beat));
544
- answerText.replaceChildren(...answer.split(' ').map((word, i) => {
545
- const span = Object.assign(document.createElement('span'), { className: 'word', textContent: `${word} ` });
546
- span.style.setProperty('--i', String(i));
547
- return span;
548
- }));
549
- reply.dataset.phase = 'done';
550
- dots?.field.update({ brightness: 0.3 }); // denser dots for the resting rule, then held still
551
- setTimeout(() => dots?.field.pause(), 420);
552
- play('ready', { emphasis: 'subtle' });
553
- }
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)); };
554
254
  ```
555
255
 
556
256
  ## stream-text: Streamed reply
@@ -560,67 +260,34 @@ Prompt:
560
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.”
561
261
 
562
262
  ```ts
563
- import { play } from 'cuelume';
564
- const head = asciiField(canvas, { mark: 'glyph', scene: 'none', charset: ' :-=+*#%@', cellSize: 12, cellAspect: 7 / 12, levels: 'none', fit: 'cover', hover: 'none' }, '--ascii-ink-strong');
565
- const art = document.createElement('canvas'), pen = art.getContext('2d')!, pending = new Map<HTMLElement, number>(), still = matchMedia('(prefers-reduced-motion: reduce)').matches;
566
- let stopped = false; // canvas covers answerText (line-height 24px) and 6px above and below it: each line's middle row of 12px cells sits on its letters.
567
- function paint(caret: number) {
568
- const box = answerText.getBoundingClientRect(), cell = (r: DOMRect, x: number, w: number, tone: number) => { // one 7×12 cell per character, on its line's middle row
569
- pen.fillStyle = `rgb(${tone * 255} ${tone * 255} ${tone * 255})`; pen.fillRect(Math.round(x / 7) * 7, Math.floor((r.top + r.height / 2 - box.top) / 24) * 24 + 12, Math.max(7, Math.round(w / 7) * 7), 12); };
570
- art.width = canvas.clientWidth * 2; art.height = canvas.clientHeight * 2; pen.scale(2, 2);
571
- pending.forEach((until, ch) => { const r = ch.getBoundingClientRect(); if (performance.now() < until) cell(r, r.left - box.left, r.width, 0.55 + Math.random() * 0.45);
572
- else { pending.delete(ch); ch.style.color = ''; } });
573
- const last = answerText.lastElementChild?.getBoundingClientRect(); if (last) cell(last, last.right - box.left + 5, 7, caret); // the caret, one glyph after the write head
574
- void head?.field.setSource(art);
575
- }
576
- async function streamReply({ tokens, reasoning }: { tokens: AsyncIterable<string>; reasoning: AsyncIterable<string> }) {
577
- reply.dataset.phase = 'reasoning'; answerText.replaceChildren(); thoughts.replaceChildren(); stopped = false;
578
- void (async () => { for await (const line of reasoning) if (!stopped) thoughts.append(Object.assign(document.createElement('li'), { textContent: line })); })();
579
- const tick = setInterval(() => paint(1), 48); // ~20 fps is enough for the flicker
580
- for await (const token of tokens) {
581
- if (stopped) break; reply.dataset.phase = 'streaming'; // a resolving character is transparent in the HTML while the field draws it as a glyph
582
- [...token].forEach((ch, j) => { const span = answerText.appendChild(Object.assign(document.createElement('span'), { textContent: ch }));
583
- if (ch.trim() && !still) { span.style.color = 'transparent'; pending.set(span, performance.now() + 60 + j * 18); } });
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()
584
271
  }
585
- clearInterval(tick); pending.forEach((_, ch) => (ch.style.color = '')); pending.clear(); paint(0.7); // a quiet caret stays at rest
586
- reply.dataset.phase = stopped ? 'stopped' : 'done'; if (!stopped) play('ready', { emphasis: 'subtle' });
272
+ await out.done();
587
273
  }
588
- stopButton.addEventListener('click', () => (stopped = true)); regenerate.addEventListener('click', () => void streamReply(ask()));
274
+ stopButton.onclick = () => out.stop();
275
+ regenerate.onclick = () => void run();
276
+ void run();
589
277
  ```
590
278
 
591
279
  ## image-generation: Image generation
592
280
  Category: AI
593
281
 
594
282
  Prompt:
595
- “While [our image model] generates [prompt], show a drifting Asciify field beside a progress label that develops into the image itself in characters, sharpening from a blur, then crossfade to the photo. Generate again dissolves it back first.”
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.”
596
284
 
597
285
  ```ts
598
- const art = asciiField(canvas, { mark: 'glyph', scene: 'none', charset: ' .:-=+*#%@', cellSize: 7, contrast: 1.4, levels: 'none', fit: 'cover', drift: 0.5 }, '--ascii-ink-strong');
599
- const work = Object.assign(document.createElement('canvas'), { width: 480, height: 270 }), pen = work.getContext('2d')!;
600
- const got: { img?: HTMLImageElement } = {};
601
- // ground(pen, t) paints the drifting noise, as in text-decode. The image is cropped 16:9, like the photo's object-fit.
602
- function frame(img: HTMLImageElement | undefined, k: number, t: number) {
603
- pen.filter = 'none'; pen.globalAlpha = 1; pen.fillStyle = '#000'; pen.fillRect(0, 0, 480, 270);
604
- pen.globalAlpha = 1 - k; ground(pen, t);
605
- if (!img || k <= 0) return;
606
- const sh = img.naturalWidth * 9 / 16;
607
- pen.globalAlpha = Math.min(1, k * 1.25); pen.filter = `brightness(1.3) blur(${(1 - k) * 14}px)`; // the characters sharpen as k reaches 1
608
- pen.drawImage(img, 0, (img.naturalHeight - sh) * 0.45, img.naturalWidth, sh, 0, 0, 480, 270);
609
- }
610
- async function develop(from: number, to: number, ms: number, onProgress = (_: number) => {}) {
611
- const field = art?.field, start = performance.now(), still = matchMedia('(prefers-reduced-motion: reduce)').matches;
612
- for (let t = still ? 1 : 0; field; t = Math.min(1, (performance.now() - start) / ms)) {
613
- frame(got.img, from + (to - from) * t * t * (3 - 2 * t), (performance.now() - start) / 1000); onProgress(t);
614
- await field.setSource(await createImageBitmap(work)); if (t >= 1) break; await new Promise(requestAnimationFrame);
615
- }
616
- }
617
- generate.addEventListener('click', async () => {
618
- if ('done' in imageFrame.dataset) { delete imageFrame.dataset.done; await develop(1, 0, 320); } // the photo fades back to characters, which dissolve
619
- got.img = undefined; void createImage(promptInput.value).then(async url => { const img = new Image(); img.src = url; await img.decode(); got.img = img; photo.src = url; });
620
- while (!got.img) await develop(0, 0, 400); // drift until the image arrives
621
- await develop(0, 1, 1900, t => (progress.textContent = `${Math.round(t * 100)}%`));
622
- imageFrame.dataset.done = ''; // CSS crossfades the photo in: opacity, 600ms, cubic-bezier(.45, 0, .25, 1)
623
- });
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
624
291
  ```
625
292
 
626
293
  ## ascii-compare: Before and after
@@ -630,29 +297,11 @@ Prompt:
630
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.”
631
298
 
632
299
  ```ts
633
- const art = asciiField(canvas, {
634
- source: '/images/kingfisher.jpg', fit: 'cover', charset: ' .:-=+*#%@', cellSize: 6, color: 'source', // in the photo's colors,
635
- saturation: 1.3, contrast: 1.45, gamma: 0.85, brightness: 0.14, fontWeight: 600, glyphScale: 0.96, // crisp on the dark frame
636
- hover: 'trail', label: 'The photo, drawn in characters', // the frame is #0e0e0d in both themes
637
- });
638
- // The divider (2px, #fff with a soft dark shadow) and the round handle (#fff, dark icon) are white in both themes, as in a photo editor;
639
- // a theme surface colour would vanish into the dark frame. The Photo and ASCII chips are dark glass: rgb(14 14 13 / .62) + blur.
640
-
641
- // The photo lies over the canvas, clipped by inset(0 calc(100% - var(--pos)) 0 0); the handle (role="slider") sits at left: var(--pos).
642
- function set(pos: number) {
643
- const value = Math.round(Math.min(100, Math.max(0, pos)));
644
- frame.style.setProperty('--pos', `${value}%`);
645
- handle.setAttribute('aria-valuenow', String(value));
646
- }
647
- const at = (x: number) => { const box = frame.getBoundingClientRect(); return ((x - box.left) / box.width) * 100; };
648
- frame.addEventListener('pointerdown', event => { frame.setPointerCapture(event.pointerId); set(at(event.clientX)); handle.focus(); });
649
- frame.addEventListener('pointermove', event => { if (frame.hasPointerCapture(event.pointerId)) set(at(event.clientX)); });
650
- handle.addEventListener('keydown', event => {
651
- const step = ({ ArrowLeft: -5, ArrowDown: -5, ArrowRight: 5, ArrowUp: 5 } as Record<string, number>)[event.key];
652
- if (step === undefined) return;
653
- event.preventDefault();
654
- set(Number(handle.getAttribute('aria-valuenow')) + step);
655
- });
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);
656
305
  ```
657
306
 
658
307
  ## image-accordion: Image accordion
@@ -662,76 +311,27 @@ Prompt:
662
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.”
663
312
 
664
313
  ```ts
665
- const art = asciiField(canvas, { scene: 'none', levels: 'none', charset: ' .:-=+*#%@', cellSize: 6, hover: 'trail' }, '--ascii-ink-strong'); // canvas behind the panels
666
- const src = document.createElement('canvas'), ctx = src.getContext('2d')!;
667
- // panels: <button aria-expanded> with flex-grow 1, 4.2 when open (transition 280ms); each holds its <img> and caption, shown only when open.
668
- const shown = panels.map(() => 0); // tones: each photo in grey, inverted in light themes
669
- function paint() {
670
- const box = strip.getBoundingClientRect(); Object.assign(src, { width: box.width, height: box.height });
671
- panels.forEach((panel, i) => {
672
- const r = panel.getBoundingClientRect(), img = tones[i], s = Math.max(r.width / img.width, r.height / img.height);
673
- ctx.globalAlpha = 1 - shown[i]; // the open panel's characters give way to its photo
674
- ctx.drawImage(img, (img.width - r.width / s) / 2, (img.height - r.height / s) / 2, r.width / s, r.height / s, r.left - box.left, r.top - box.top, r.width, r.height);
675
- });
676
- void art?.field.setSource(src);
677
- }
678
- function open(index: number) {
679
- panels.forEach((panel, i) => panel.setAttribute('aria-expanded', String(i === index)));
680
- const start = performance.now();
681
- requestAnimationFrame(function step(now) {
682
- const t = Math.min(1, (now - start) / 280);
683
- panels.forEach((_, i) => { shown[i] = i === index ? Math.max(shown[i], t) : Math.min(shown[i], 1 - t); });
684
- paint(); if (t < 1) requestAnimationFrame(step);
685
- });
686
- }
687
- panels.forEach((panel, i) => { panel.addEventListener('pointerenter', () => open(i)); panel.addEventListener('focus', () => open(i)); });
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,
320
+ });
688
321
  ```
689
322
 
690
323
  ## ascii-dropzone: Image dropzone
691
324
  Category: Media
692
325
 
693
326
  Prompt:
694
- “Make an image dropzone for [our upload form]: its border is an even ring of Asciify characters that thickens and breathes while a file is dragged over it, around ‘Drop an image’. A dropped or chosen image develops in characters, then shows its file name, Replace and Download PNG.”
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.”
695
328
 
696
329
  ```ts
697
- // The ring is drawn on the field's own grid: square cells, 2 × 2 source texels each, so the mip levels the field samples stay
698
- // inside one cell and every side gets exactly one even row. The canvas is trimmed to whole cells (and whole CSS pixels) and centred.
699
- const CELL = 6, art = asciiField(canvas, { mark: 'glyph', scene: 'none', charset: ' .:+*#', cellSize: CELL, cellAspect: 1, levels: 'none', contrast: 1, hover: 'none', label: 'Your image in characters' }, '--ascii-ink');
700
- const ring = document.createElement('canvas');
701
- function drawRing(tone: number, inner: number) { // tone 0.56 rests on '+'; a drag lifts it and adds a dimmer row inside
702
- const ratio = Math.min(2, devicePixelRatio), cell = Math.round(CELL * ratio) / ratio, box = zone.getBoundingClientRect();
703
- const cols = Math.floor(box.width / cell), rows = Math.floor(box.height / cell);
704
- Object.assign(canvas.style, { width: `${cols * cell}px`, height: `${rows * cell}px` });
705
- const ctx = Object.assign(ring, { width: cols * 2, height: rows * 2 }).getContext('2d')!, r = 2.6; // corner radius in cells
706
- ctx.fillRect(0, 0, ring.width, ring.height);
707
- for (let y = 0; y < rows; y++) for (let x = 0; x < cols; x++) {
708
- const qx = Math.max(1 + r - x, 0, x - (cols - 2 - r)), qy = Math.max(1 + r - y, 0, y - (rows - 2 - r));
709
- const d = qx && qy ? Math.hypot(qx, qy) - r : -Math.min(x - 1, cols - 2 - x, y - 1, rows - 2 - y); // distance to the ring in cells
710
- const t = Math.abs(d) < 0.5 ? tone : d < -0.5 && d > -1.5 ? tone * 0.55 * inner : 0;
711
- ctx.fillStyle = `rgb(${[t * 255, t * 255, t * 255]})`; ctx.fillRect(x * 2, y * 2, 2, 2);
712
- }
713
- void art?.field.setSource(ring);
714
- }
715
- let over = 0, raf = 0;
716
- const breathe = (now: number) => { const b = 0.5 - 0.5 * Math.cos(now / 1300 * Math.PI * 2); drawRing(0.7 + 0.28 * b, 0.4 + 0.6 * b); if (over) raf = requestAnimationFrame(breathe); };
717
- zone.addEventListener('dragenter', () => { if (!over++) raf = requestAnimationFrame(breathe); });
718
- zone.addEventListener('dragleave', () => { if (!--over) { cancelAnimationFrame(raf); drawRing(0.56, 0); } });
719
- const ease = (ms: number, step: (k: number) => void) => new Promise<void>(done => {
720
- const start = performance.now(), still = matchMedia('(prefers-reduced-motion: reduce)').matches;
721
- 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(); });
722
- });
723
- async function accept(file?: File) {
724
- const field = art?.field; if (!field || !file?.type.startsWith('image/')) return;
725
- await ease(160, k => field.update({ brightness: -k }));
726
- field.update({ charset: ' .:-=+*#%@', cellAspect: 0.8, color: 'source' }); zone.style.background = '#0e0e0d';
727
- await field.setSource(URL.createObjectURL(file)); field.pulse();
728
- await ease(720, k => field.update({ brightness: k - 1 }));
729
- fileName.textContent = file.name; // the bar with the name, Replace and Download PNG appears; Remove draws the ring again
730
- }
731
- zone.addEventListener('dragover', event => event.preventDefault());
732
- zone.addEventListener('drop', event => { event.preventDefault(); over = 0; void accept(event.dataTransfer?.files[0]); });
733
- input.addEventListener('change', () => void accept(input.files?.[0])); // <input type="file" accept="image/*">, opened by Choose file and Replace
734
- drawRing(0.56, 0); // the dashed CSS border stays only as the no-WebGL fallback
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');
735
335
  ```
736
336
 
737
337
  ## ascii-disintegrate: Delete into characters
@@ -741,28 +341,13 @@ Prompt:
741
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.”
742
342
 
743
343
  ```ts
744
- // The list keeps min-height: 3 rows, so a delete only moves the rows below it up: the card never shrinks or re-centres.
745
- const dust = asciiField(canvas, { // over the list, one row tall, visibility: hidden at rest
746
- mark: 'glyph', scene: 'none', charset: ' .:-=+*#%@', cellSize: 5, cellAspect: 0.62, levels: 'none', drift: 5, hover: 'none',
747
- }, '--ascii-ink-strong');
748
- async function remove(row: HTMLElement, name: HTMLElement) {
749
- showUndoToast('Moved to trash', () => list.insertBefore(row, row.nextSibling));
750
- const field = dust?.field, box = row.getBoundingClientRect();
751
- if (!field || matchMedia('(prefers-reduced-motion: reduce)').matches) return row.remove();
752
- const shot = Object.assign(document.createElement('canvas'), { width: box.width * 2, height: box.height * 2 }), ctx = shot.getContext('2d')!;
753
- ctx.scale(2, 2); ctx.fillRect(0, 0, box.width, box.height); ctx.fillStyle = '#fff'; // white text on black: only the text becomes glyphs
754
- ctx.font = getComputedStyle(name).font; ctx.fillText(name.textContent ?? '', name.offsetLeft, name.offsetTop + name.offsetHeight * 0.75);
755
- await field.setSource(shot);
756
- Object.assign(canvas.style, { top: `${row.offsetTop}px`, visibility: 'visible' });
757
- row.style.opacity = '0'; field.pulse();
758
- setTimeout(() => row.animate({ height: [`${box.height}px`, '0px'] }, { duration: 260, easing: 'cubic-bezier(.16, 1, .3, 1)' }).finished.then(() => row.remove()), 300);
759
- const start = performance.now();
760
- requestAnimationFrame(function frame(now) {
761
- const k = Math.min(1, (now - start) / 620);
762
- field.update({ brightness: -0.9 * k });
763
- Object.assign(canvas.style, { translate: `${8 * k}px ${-14 * k}px`, opacity: String(Math.min(1, (1 - k) * 2.2)) });
764
- if (k < 1) requestAnimationFrame(frame); else canvas.style.visibility = 'hidden';
765
- });
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);
766
351
  }
767
352
  ```
768
353
 
@@ -773,140 +358,59 @@ Prompt:
773
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.”
774
359
 
775
360
  ```ts
776
- const art = asciiField(canvas, { scene: 'none', levels: 'none', charset: ' .:-=+*#%@', cellSize: 5, hover: 'none' }, '--ascii-ink-strong'); // over the feed, pointer-events: none
777
- const src = document.createElement('canvas'), ctx = src.getContext('2d')!;
778
- let pull = 0, from = 0, spin = -1;
779
- function paint(check = false) {
780
- Object.assign(src, { width: canvas.clientWidth, height: canvas.clientHeight });
781
- const q = Math.min(1, pull / 88), x = src.width / 2, y = pull / 2; // an 88px pull leaves room for a 56px orb
782
- ctx.strokeStyle = ctx.fillStyle = '#fff'; ctx.lineWidth = 9; ctx.lineCap = 'round'; ctx.beginPath();
783
- if (spin < 0) { ctx.globalAlpha = 0.3 + 0.7 * q; ctx.arc(x, y, 8 + 20 * q, 0, 7); ctx.fill(); } // the orb densifies with the pull
784
- else if (!check) { ctx.arc(x, y, 28, spin, spin + 4.7); ctx.stroke(); } // spinning while it refreshes
785
- else { ctx.moveTo(x - 18, y); ctx.lineTo(x - 6, y + 12); ctx.lineTo(x + 19, y - 13); ctx.stroke(); } // then a check
786
- feed.style.translate = `0 ${pull}px`; void art?.field.setSource(src);
787
- }
788
- async function refresh() {
789
- pull = 88; const start = performance.now();
790
- await new Promise<void>(done => requestAnimationFrame(function step(now) { spin = (now - start) / 140; paint(); if (now - start < 1000) requestAnimationFrame(step); else done(); }));
791
- paint(true); await loadNewPosts(); // new rows arrive as characters: paint their thumbnails and text bars, then fade the HTML in
792
- spin = -1; pull = 0; paint();
793
- }
794
- feed.addEventListener('pointerdown', e => { feed.setPointerCapture(e.pointerId); from = e.clientY; }); // screen: touch-action: none
795
- feed.addEventListener('pointermove', e => { if (feed.hasPointerCapture(e.pointerId)) { pull = 128 * (1 - Math.exp(-Math.max(0, e.clientY - from) / 128)); paint(); } });
796
- feed.addEventListener('pointerup', () => { if (pull >= 88) void refresh(); else { pull = 0; paint(); } });
797
- refreshButton.addEventListener('click', () => void refresh());
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();
798
367
  ```
799
368
 
800
369
  ## border-beam: Border beam
801
370
  Category: Components
802
371
 
803
372
  Prompt:
804
- “Frame [our deploy status card] in an even ring of Asciify characters, with a bright band orbiting it and a fading trail. Hover speeds it up, a press sends a pulse through the ring, and reduced motion holds it still.”
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.”
805
374
 
806
375
  ```ts
807
- // The canvas fills a frame 22px larger than the card on every side (overflow: hidden, pointer-events: none); the card is cleared out of it.
808
- const ring = asciiField(canvas, {
809
- mark: 'glyph', scene: 'none', drift: 0.5, charset: ' .:-=+*#%@', cellSize: 9, cellAspect: 0.62, levels: 'none', fit: 'cover',
810
- hover: 'lantern', lantern: { radius: 0.13, strength: 1, trail: 1.2, follow: 18 }, interactionTarget: canvas, label: 'A ring of characters around the card',
811
- }, '--ascii-ink-strong');
812
- // The ring is painted as one even tone with a fine grain, so every side and corner carries the same density.
813
- const band = Object.assign(document.createElement('canvas'), { width: canvas.clientWidth, height: canvas.clientHeight }), pen = band.getContext('2d')!;
814
- for (let y = 0; y < band.height; y += 4) for (let x = 0; x < band.width; x += 4) { const v = 92 + Math.random() * 76; pen.fillStyle = `rgb(${v} ${v} ${v})`; pen.fillRect(x, y, 4, 4); }
815
- pen.fillStyle = '#000'; pen.beginPath(); pen.roundRect(22, 22, band.width - 44, band.height - 44, 10); pen.fill(); // the card's own box and radius
816
- void ring?.field.setSource(band);
817
- let s = 0, lap = 3800, then = performance.now();
818
-
819
- // The beam is the lantern, walked round the ring's midline 11px in from the frame's edge.
820
- function orbit(now: number) {
821
- const w = canvas.clientWidth - 22, h = canvas.clientHeight - 22, d = ((s += (now - then) / lap) % 1) * 2 * (w + h);
822
- then = now;
823
- const [x, y] = d < w ? [d, 0] : d < w + h ? [w, d - w] : d < 2 * w + h ? [2 * w + h - d, h] : [0, 2 * (w + h) - d];
824
- ring?.field.pointer(x + 11, y + 11);
825
- requestAnimationFrame(orbit);
826
- }
827
- if (!matchMedia('(prefers-reduced-motion: reduce)').matches) requestAnimationFrame(orbit);
828
- card.addEventListener('pointerenter', () => (lap = 1400));
829
- card.addEventListener('pointerleave', () => (lap = 3800));
830
- card.addEventListener('click', e => { const b = canvas.getBoundingClientRect(); ring?.field.pulse({ x: e.clientX - b.left, y: e.clientY - b.top, strength: 1 }); });
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
+ };
831
383
  ```
832
384
 
833
385
  ## glyph-odometer: Glyph odometer
834
386
  Category: Text
835
387
 
836
388
  Prompt:
837
- “Make [our monthly revenue figure] an Asciify odometer: draw the digits in the field, and on [Refresh] roll each changed column through glyphs onto the new digit, leftmost first, in under 600ms. Keep the real value in an aria-live region.”
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.”
838
390
 
839
391
  ```ts
840
- const odo = asciiField(canvas, { mark: 'glyph', scene: 'none', charset: ' .:-=+*#%@', cellSize: 3, cellAspect: 0.66, levels: 'none', fit: 'cover', hover: 'none' }, '--ascii-ink-strong');
841
- const art = document.createElement('canvas'), pen = art.getContext('2d')!, noise = '#%*+=-:';
842
- const money = (n: number) => `$${n.toLocaleString('en-US')}`, ease = (t: number) => 1 - (1 - Math.min(1, Math.max(0, t))) ** 4;
843
- let from = money(48290), to = from, t0 = -1e4;
844
- // canvas covers the figure; figure holds one hidden span per character, so each column is drawn where the text would be.
845
- async function frame(now: number) {
846
- const box = canvas.getBoundingClientRect(), first = [...to].findIndex((ch, i) => ch !== from[i]);
847
- art.width = box.width * 2; art.height = box.height * 2; pen.scale(2, 2); pen.fillRect(0, 0, box.width, box.height);
848
- let moving = false; Object.assign(pen, { font: getComputedStyle(figure).font, textAlign: 'center', textBaseline: 'middle', fillStyle: '#fff' });
849
- [...figure.children].forEach((cell, i) => {
850
- const b = cell.getBoundingClientRect(), x = b.left - box.left + b.width / 2, top = b.top - box.top, order = i - first;
851
- const k = first < 0 || order < 0 ? 1 : ease((now - t0) / (360 + order * 60)), strip = [from[i], ...noise.slice(0, 4 + order), to[i]];
852
- if (k >= 1) return pen.fillText(to[i], x, top + b.height / 2);
853
- moving = true; pen.save(); pen.beginPath(); pen.rect(x - b.width, top, b.width * 2, b.height); pen.clip(); // the column rolls inside its slot
854
- strip.forEach((ch, j) => pen.fillText(ch, x, top + b.height / 2 + (j - k * (strip.length - 1)) * b.height)); pen.restore();
855
- });
856
- await odo?.field.setSource(await createImageBitmap(art));
857
- if (moving) requestAnimationFrame(frame);
858
- }
859
- refresh.addEventListener('click', async () => {
860
- from = to; to = money(await fetchMrr()); live.textContent = to; // live: a visually hidden aria-live="polite" span
861
- figure.replaceChildren(...[...to].map(ch => Object.assign(document.createElement('span'), { textContent: ch })));
862
- t0 = matchMedia('(prefers-reduced-motion: reduce)').matches ? -1e4 : performance.now(); requestAnimationFrame(frame);
863
- });
864
- requestAnimationFrame(frame);
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());
865
396
  ```
866
397
 
867
398
  ## sign-in: Sign-in page
868
399
  Category: Pages
869
400
 
870
401
  Prompt:
871
- “Make a split sign-in page with Asciify: [image] in characters in an inset, rounded panel on the left, trailing under the cursor, and an email field, Continue and a quiet Continue with GitHub on the right. Continue sends a pulse through the art, and while the link sends, a lit band shimmers across the characters.”
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.”
872
403
 
873
404
  ```ts
874
- const art = asciiField(artCanvas, {
875
- source: '/images/wing.jpg', fit: 'cover', focus: { x: 0.5, y: 0.4 },
876
- charset: ' .:-=+*#%@', cellSize: 6, contrast: 1.3,
877
- hover: 'trail', label: 'A carved wing drawn in characters',
878
- }, '--ascii-ink-strong');
879
-
880
- const image = new Image(); image.src = '/images/wing.jpg';
881
- const work = document.createElement('canvas'), pen = work.getContext('2d')!;
882
- let sending = false;
883
-
884
- async function shimmer() {
885
- if (!art || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
886
- await image.decode();
887
- work.width = image.naturalWidth; work.height = image.naturalHeight;
888
- const w = work.width, h = work.height, start = performance.now();
889
- while (sending) {
890
- const x = -0.4 * w + (((performance.now() - start) / 1100) % 1) * 1.8 * w;
891
- pen.globalCompositeOperation = 'source-over'; pen.drawImage(image, 0, 0);
892
- const band = pen.createLinearGradient(x - w * 0.22, x * 0.3, x + w * 0.22, x * 0.3 + h * 0.3);
893
- band.addColorStop(0, 'rgb(0 0 0 / 0)'); band.addColorStop(0.5, 'rgb(255 255 255 / .42)'); band.addColorStop(1, 'rgb(0 0 0 / 0)');
894
- pen.globalCompositeOperation = 'lighter'; pen.fillStyle = band; pen.fillRect(0, 0, w, h);
895
- await art.field.setSource(await createImageBitmap(work));
896
- await new Promise(done => setTimeout(done, 48));
897
- }
898
- await art.field.setSource(image); // same pixels as the rest state, so nothing jumps
899
- }
405
+ import { artPanel } from 'asciify-engine/kit';
900
406
 
901
- 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 => {
902
409
  event.preventDefault();
903
- continueButton.setAttribute('aria-busy', 'true');
904
- // The ring starts at the art's inner edge, level with the button, and runs through the picture.
905
- const box = artCanvas.getBoundingClientRect(), button = continueButton.getBoundingClientRect();
906
- art?.field.pulse({ x: box.width, y: button.top + button.height / 2 - box.top, strength: 1.8 });
907
- sending = true; void shimmer();
908
- sendLink().finally(() => { sending = false; });
909
- });
410
+ art.pulse(continueButton, 1.8);
411
+ art.shimmer(true);
412
+ await sendLink(emailInput.value).finally(() => art.shimmer(false));
413
+ };
910
414
  ```
911
415
 
912
416
  ```css
@@ -918,96 +422,27 @@ signInForm.addEventListener('submit', event => {
918
422
  Category: Pages
919
423
 
920
424
  Prompt:
921
- “Make a two-plan pricing section with a monthly and yearly toggle, using Asciify. The recommended plan, [plan], carries a halftone field: switching billing sends a lit sweep across it and the yearly saving re-forms in dots, the cursor lights it softly, and its button sends a pulse.”
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.”
922
426
 
923
427
  ```ts
924
- // The card's own source: a soft light from the top corner, plus the saving on yearly. Sized to the card, so cover maps 1:1.
925
- function planArt(saving: boolean) {
926
- const w = featuredCard.offsetWidth * 2, h = featuredCard.offsetHeight * 2;
927
- const art = Object.assign(document.createElement('canvas'), { width: w, height: h }), pen = art.getContext('2d')!;
928
- const light = pen.createRadialGradient(w, 0, 0, w, 0, Math.hypot(w, h) * 0.52);
929
- light.addColorStop(0, 'rgb(150 150 150)'); light.addColorStop(0.4, 'rgb(64 64 64)'); light.addColorStop(1, '#000');
930
- pen.fillStyle = light; pen.fillRect(0, 0, w, h);
931
- if (saving) { // in the open space between the plan's copy and its price, with a dark halo so it reads in dots
932
- const size = h * 0.18, x = w * 0.93, y = h * 0.42;
933
- Object.assign(pen, { font: `600 ${size}px Geist, sans-serif`, textAlign: 'right', textBaseline: 'middle', lineJoin: 'round', lineWidth: size * 0.28, strokeStyle: 'rgb(0 0 0 / .85)', fillStyle: '#fff' });
934
- pen.strokeText('−25%', x, y); pen.fillText('−25%', x, y);
935
- }
936
- return art;
937
- }
938
-
939
- let shown = planArt(false);
940
- const featured = asciiField(canvas, {
941
- source: shown, fit: 'cover', mark: 'dot', cellSize: 4, dotScale: 0.92, levels: 'none', drift: 0.5,
942
- interactionTarget: featuredCard, hover: 'lantern', lantern: { radius: 0.26, strength: 0.4 },
943
- });
944
-
945
- // A lit band crosses the card: the old halftone ahead of it, the new one behind it. About 400ms.
946
- async function sweep(to: HTMLCanvasElement) {
947
- const from = shown; shown = to;
948
- if (!featured || matchMedia('(prefers-reduced-motion: reduce)').matches) return void featured?.field.setSource(to);
949
- const work = Object.assign(document.createElement('canvas'), { width: to.width, height: to.height }), pen = work.getContext('2d')!;
950
- const w = work.width, h = work.height, band = w * 0.34;
951
- for (let i = 1; i <= 9 && shown === to; i++) {
952
- const x = -band + (i / 9) * (w + band * 2), edge = Math.max(0, Math.min(w, x));
953
- pen.drawImage(from, 0, 0); if (edge > 0) pen.drawImage(to, 0, 0, edge, h, 0, 0, edge, h);
954
- const light = pen.createLinearGradient(x - band, 0, x + band * 0.4, 0);
955
- light.addColorStop(0, 'rgb(0 0 0 / 0)'); light.addColorStop(0.72, 'rgb(255 255 255 / .5)'); light.addColorStop(1, 'rgb(0 0 0 / 0)');
956
- pen.globalCompositeOperation = 'lighter'; pen.fillStyle = light; pen.fillRect(0, 0, w, h); pen.globalCompositeOperation = 'source-over';
957
- await featured.field.setSource(await createImageBitmap(work));
958
- await new Promise(done => setTimeout(done, 44));
959
- }
960
- if (shown === to) await featured.field.setSource(to);
961
- }
428
+ import { pricingGlow } from 'asciify-engine/kit';
962
429
 
963
- billingToggle.addEventListener('change', () => void sweep(planArt(billingToggle.value === 'yearly')));
964
- trialButton.addEventListener('click', () => featured?.field.pulse({ element: trialButton, strength: 1.6 }));
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);
965
433
  ```
966
434
 
967
435
  ## not-found: 404 page
968
436
  Category: Pages
969
437
 
970
438
  Prompt:
971
- “Make our 404 page with Asciify: draw the number 404 itself large in characters, scattering under the cursor, and when someone presses Go home the characters break apart and re-form. Keep the Page not found line and the button in HTML, readable in light and dark.”
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.”
972
440
 
973
441
  ```ts
974
- // "404" on a canvas sized to the numerals, so `contain` sets it as large as the space allows.
975
- const art = document.createElement('canvas'), pen = art.getContext('2d')!;
976
- pen.font = '600 440px Geist, sans-serif';
977
- art.width = Math.ceil(pen.measureText('404').width) + 160; art.height = 560;
978
- pen.fillRect(0, 0, art.width, art.height);
979
- Object.assign(pen, { fillStyle: '#fff', textAlign: 'center', textBaseline: 'middle', font: '600 440px Geist, sans-serif' });
980
- pen.fillText('404', art.width / 2, art.height / 2 + 16);
981
-
982
- const lost = asciiField(canvas, {
983
- source: art, fit: 'contain', charset: ' .:-=+*#%@', cellSize: 7, contrast: 1.4, drift: 0.8,
984
- hover: 'trail', trail: { strength: 1, radius: 0.42 }, label: '404', interactionTarget: page,
985
- }, '--ascii-ink-strong');
986
-
987
- // Lit 28px tiles of the art, each with its own direction.
988
- const T = 28, data = pen.getImageData(0, 0, art.width, art.height).data, tiles = [];
989
- for (let y = 0; y < art.height; y += T) for (let x = 0; x < art.width; x += T) {
990
- if (data[((y + T / 2) * art.width + x + T / 2) * 4] < 40) continue;
991
- const a = Math.random() * Math.PI * 2, r = 120 + Math.random() * 420;
992
- tiles.push({ x, y, dx: Math.cos(a) * r + (x - art.width / 2) * 0.5, dy: Math.sin(a) * r * 0.6 });
993
- }
994
- const work = Object.assign(document.createElement('canvas'), { width: art.width, height: art.height }), out = work.getContext('2d')!;
995
- const wait = (ms: number) => new Promise(done => setTimeout(done, ms));
996
-
997
- homeLink.addEventListener('click', async () => {
998
- lost?.field.pulse({ element: homeLink, strength: 1.2 });
999
- if (!lost || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
1000
- const out10 = Array.from({ length: 10 }, (_, i) => (i + 1) / 10), back = Array.from({ length: 14 }, (_, i) => 1 - (i + 1) / 14);
1001
- for (const k of [...out10, 1, 1, 1, ...back]) { // scatter, hold a beat, settle back
1002
- const e = k * k * (3 - 2 * k);
1003
- out.globalAlpha = 1; out.fillStyle = '#000'; out.fillRect(0, 0, work.width, work.height);
1004
- out.globalAlpha = 1 - e * 0.72;
1005
- for (const t of tiles) out.drawImage(art, t.x, t.y, T, T, t.x + t.dx * e, t.y + t.dy * e, T, T);
1006
- await lost.field.setSource(await createImageBitmap(work));
1007
- await wait(44);
1008
- }
1009
- await lost.field.setSource(art);
1010
- });
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 = '/'; };
1011
446
  ```
1012
447
 
1013
448
  ## wordmark: Brand wordmark
@@ -1017,19 +452,11 @@ Prompt:
1017
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.”
1018
453
 
1019
454
  ```ts
1020
- const word = document.createElement('canvas');
1021
- word.width = 1800; word.height = 480;
1022
- const ctx = word.getContext('2d')!;
1023
- ctx.fillStyle = '#000'; ctx.fillRect(0, 0, word.width, word.height);
1024
- ctx.fillStyle = '#fff'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
1025
- ctx.font = '600 400px Georgia, serif';
1026
- ctx.fillText('asciify', word.width / 2, word.height / 2 + 20);
1027
-
1028
- const wordmark = asciiField(canvas, {
1029
- source: word, fit: 'contain', charset: ' .:-=+*#%@', cellSize: 9,
1030
- hover: 'trail', trail: { strength: 0.62, radius: 0.22 }, // a short wake that settles cleanly
1031
- label: 'asciify, drawn in characters',
1032
- }, '--ascii-ink-strong');
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);
1033
460
  ```
1034
461
 
1035
462
  ## video: Video in characters
@@ -1039,44 +466,24 @@ Prompt:
1039
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.”
1040
467
 
1041
468
  ```ts
1042
- const video = asciiField(canvas, {
1043
- source: '/media/magnolia.mp4', fit: 'cover', charset: ' .:-=+*#%@', cellSize: 7,
1044
- contrast: 1.2, hover: 'trail', label: 'A magnolia opening, drawn in characters',
1045
- }, '--ascii-ink-strong');
1046
-
1047
- let paused = false;
1048
- playButton.addEventListener('click', () => {
1049
- paused = !paused;
1050
- if (paused) video?.field.pause(); else video?.field.play();
1051
- });
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');
1052
474
  ```
1053
475
 
1054
476
  ## hero-background: Hero background
1055
477
  Category: Backgrounds
1056
478
 
1057
479
  Prompt:
1058
- “Put a calm ASCII background behind our hero with Asciify. Characters drift slowly and reach toward the headline, softening under a frosted veil so the copy stays readable; the cursor ripples the field and the main button sends a strong pulse. Follow our light and dark theme.”
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.”
1059
481
 
1060
482
  ```ts
1061
- const hero = asciiField(canvas, {
1062
- mark: 'glyph', scene: 'drift', charset: ' .:+*#', cellSize: 10, scale: 1.9, brightness: 0.26, contrast: 1.15,
1063
- clear: { element: heroText, padding: -14, feather: 0.22, shape: 'ellipse' }, // small feather: the field reaches the words
1064
- hover: 'trail', interactionTarget: heroSection,
1065
- });
483
+ import { fieldBackground } from 'asciify-engine/kit';
1066
484
 
1067
- // A soft ring every half second while the cursor travels, so the ground answers movement.
1068
- let last = { x: 0, y: 0, at: 0 }, hold = 0;
1069
- heroSection.addEventListener('pointermove', event => {
1070
- const now = performance.now(), box = canvas.getBoundingClientRect();
1071
- const x = event.clientX - box.left, y = event.clientY - box.top;
1072
- if (!hero || now < hold || now - last.at < 520 || Math.hypot(x - last.x, y - last.y) < 36) return;
1073
- last = { x, y, at: now };
1074
- hero.field.pulse({ x, y, strength: 0.65 });
1075
- });
1076
- subscribeButton.addEventListener('click', () => {
1077
- hold = performance.now() + 1400; // keep ripples from replacing the big ring (four rings live at once)
1078
- hero?.field.pulse({ element: subscribeButton, strength: 1.4 });
1079
- });
485
+ const bg = fieldBackground(heroSection, { preset: 'hero', clear: heroText, ripple: true });
486
+ subscribeButton.onclick = () => bg.pulse(subscribeButton, 1.4);
1080
487
  ```
1081
488
 
1082
489
  ```css
@@ -1089,3 +496,46 @@ subscribeButton.addEventListener('click', () => {
1089
496
  mask-image: radial-gradient(closest-side, #000 52%, rgb(0 0 0 / .6) 74%, transparent);
1090
497
  }
1091
498
  ```
499
+
500
+ ## Advanced: raw field
501
+
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(); } };
516
+ }
517
+ ```
518
+
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
+ ```
523
+
524
+ In React, wrap it once and reuse it:
525
+
526
+ ```tsx
527
+ import { useEffect, useRef } from 'react';
528
+ import type { GlyphField, GlyphFieldOptions } from 'asciify-engine/field';
529
+
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
+ }
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).