asciify-engine 4.11.1 → 4.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1091 @@
1
+ # Asciify components
2
+
3
+ Ready-made ASCII components for product interfaces. Each one has the prompt a person gives you and the reference code you should arrive at. Every component runs live at https://asciify.org/components, where an agent cursor demonstrates its interaction, so the person can see the result before asking for it.
4
+
5
+ Build them in the project's own framework, design tokens and lifecycle. Read [design matching](design-matching.md) first. The shared rules:
6
+
7
+ - Keep text and controls in HTML. The field is a decorative canvas behind or inside them, with `aria-hidden` on the canvas.
8
+ - Give the field a transparent background and an ink from the project's tokens, for each theme.
9
+ - Check `isGlyphFieldSupported()`, and keep the static design (or a poster image of the field) as the fallback.
10
+ - Honor reduced motion. Fields draw one still frame and skip pulses by default.
11
+ - Call `destroy()` on unmount. Mount fields that start off screen lazily, when they come near the viewport.
12
+ - GPU fields need asciify-engine 4.12 or later for `mark`, `clear` and `pulse()`.
13
+
14
+ ## The shared helper
15
+
16
+ Every component below uses this helper. It mounts a field that takes its ink from a CSS custom property, follows light and dark, and cleans up after itself. Put it in the project once.
17
+
18
+ ```ts
19
+ import { isGlyphFieldSupported, mountGlyphField, type GlyphField, type GlyphFieldOptions } from 'asciify-engine/field';
20
+
21
+ /** A field inked from a CSS variable that follows the theme. Returns null when the GPU field isn't available. */
22
+ export function asciiField(canvas: HTMLCanvasElement, options: GlyphFieldOptions = {}, inkVar = '--ascii-ink') {
23
+ if (!isGlyphFieldSupported()) return null;
24
+ const ink = () => getComputedStyle(canvas).getPropertyValue(inkVar).trim() || '#8f8d86';
25
+ const field: GlyphField = mountGlyphField(canvas, { color: 'ink', background: 'transparent', ...options, ink: ink() });
26
+ const scheme = matchMedia('(prefers-color-scheme: dark)');
27
+ const retint = () => field.update({ ink: ink() });
28
+ scheme.addEventListener('change', retint);
29
+ return { field, destroy() { scheme.removeEventListener('change', retint); field.destroy(); } };
30
+ }
31
+ ```
32
+
33
+ ```css
34
+ :root { --ascii-ink: #76746e; --ascii-ink-strong: #1d1d1b; }
35
+ @media (prefers-color-scheme: dark) { :root { --ascii-ink: #7a7872; --ascii-ink-strong: #d8d6cf; } }
36
+ ```
37
+
38
+ In React, wrap it once and reuse it:
39
+
40
+ ```tsx
41
+ import { useEffect, useRef } from 'react';
42
+ import type { GlyphField, GlyphFieldOptions } from 'asciify-engine/field';
43
+
44
+ export function AsciiField({ options, onField, className }: { options: GlyphFieldOptions; onField?: (field: GlyphField | null) => void; className?: string }) {
45
+ const canvas = useRef<HTMLCanvasElement>(null);
46
+ useEffect(() => {
47
+ const mounted = canvas.current ? asciiField(canvas.current, options) : null;
48
+ onField?.(mounted?.field ?? null);
49
+ return () => { onField?.(null); mounted?.destroy(); };
50
+ }, []); // mount once; call field.update() for later changes
51
+ return <canvas ref={canvas} className={className} aria-hidden="true" />;
52
+ }
53
+ ```
54
+
55
+ ## voice-field: Voice orb
56
+ Category: AI
57
+
58
+ 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.”
60
+
61
+ ```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
+ });
84
+ ```
85
+
86
+ ## command-palette: Command palette
87
+ Category: AI
88
+
89
+ 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.”
91
+
92
+ ```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.
118
+ ```
119
+
120
+ ## button-loading: Button loading
121
+ Category: Components
122
+
123
+ 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.”
125
+
126
+ ```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
+ });
150
+ ```
151
+
152
+ ## slide-to-confirm: Slide to confirm
153
+ Category: Components
154
+
155
+ Prompt:
156
+ “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
+
158
+ ```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(); } });
189
+ ```
190
+
191
+ ## like-burst: Like burst
192
+ Category: Components
193
+
194
+ 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.”
196
+
197
+ ```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);
234
+ ```
235
+
236
+ ## loading-states: Status orb
237
+ Category: Components
238
+
239
+ Prompt:
240
+ “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
+
242
+ ```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
+ });
269
+ ```
270
+
271
+ ## text-decode: Decode headline
272
+ Category: Text
273
+
274
+ Prompt:
275
+ “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
+
277
+ ```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);
304
+ ```
305
+
306
+ ## ascii-chart: Block chart
307
+ Category: Components
308
+
309
+ 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.”
311
+
312
+ ```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');
334
+ ```
335
+
336
+ ## route-transition: Page transition
337
+ Category: Transitions
338
+
339
+ Prompt:
340
+ “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
+
342
+ ```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)`; };
369
+ ```
370
+
371
+ ## swipe-card: Swipe cards
372
+ Category: Components
373
+
374
+ Prompt:
375
+ “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
+
377
+ ```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);
401
+ ```
402
+
403
+ ## otp-input: Verification code
404
+ Category: Components
405
+
406
+ 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.”
408
+
409
+ ```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));
441
+ ```
442
+
443
+ ## toast-stack: Toast stack
444
+ Category: Components
445
+
446
+ 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.”
448
+
449
+ ```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: () => {} }); };
488
+ ```
489
+
490
+ ## skeleton-loader: Skeleton loader
491
+ Category: Components
492
+
493
+ 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.”
495
+
496
+ ```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);
523
+ ```
524
+
525
+ ## thinking: Thinking state
526
+ Category: AI
527
+
528
+ Prompt:
529
+ “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
+
531
+ ```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
+ }
554
+ ```
555
+
556
+ ## stream-text: Streamed reply
557
+ Category: AI
558
+
559
+ Prompt:
560
+ “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
+
562
+ ```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); } });
584
+ }
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' });
587
+ }
588
+ stopButton.addEventListener('click', () => (stopped = true)); regenerate.addEventListener('click', () => void streamReply(ask()));
589
+ ```
590
+
591
+ ## image-generation: Image generation
592
+ Category: AI
593
+
594
+ 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.”
596
+
597
+ ```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
+ });
624
+ ```
625
+
626
+ ## ascii-compare: Before and after
627
+ Category: Media
628
+
629
+ Prompt:
630
+ “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
+
632
+ ```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
+ });
656
+ ```
657
+
658
+ ## image-accordion: Image accordion
659
+ Category: Media
660
+
661
+ Prompt:
662
+ “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
+
664
+ ```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)); });
688
+ ```
689
+
690
+ ## ascii-dropzone: Image dropzone
691
+ Category: Media
692
+
693
+ 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.”
695
+
696
+ ```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
735
+ ```
736
+
737
+ ## ascii-disintegrate: Delete into characters
738
+ Category: Transitions
739
+
740
+ Prompt:
741
+ “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
+
743
+ ```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
+ });
766
+ }
767
+ ```
768
+
769
+ ## pull-refresh: Pull to refresh
770
+ Category: Components
771
+
772
+ Prompt:
773
+ “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
+
775
+ ```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());
798
+ ```
799
+
800
+ ## border-beam: Border beam
801
+ Category: Components
802
+
803
+ 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.”
805
+
806
+ ```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 }); });
831
+ ```
832
+
833
+ ## glyph-odometer: Glyph odometer
834
+ Category: Text
835
+
836
+ 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.”
838
+
839
+ ```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);
865
+ ```
866
+
867
+ ## sign-in: Sign-in page
868
+ Category: Pages
869
+
870
+ 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.”
872
+
873
+ ```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
+ }
900
+
901
+ signInForm.addEventListener('submit', event => {
902
+ 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
+ });
910
+ ```
911
+
912
+ ```css
913
+ .sign-in { display: grid; grid-template-columns: 41% 1fr; padding: 14px; }
914
+ .sign-in-art { position: relative; border-radius: 18px; overflow: hidden; background: var(--surface); }
915
+ ```
916
+
917
+ ## pricing: Pricing highlight
918
+ Category: Pages
919
+
920
+ 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.”
922
+
923
+ ```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
+ }
962
+
963
+ billingToggle.addEventListener('change', () => void sweep(planArt(billingToggle.value === 'yearly')));
964
+ trialButton.addEventListener('click', () => featured?.field.pulse({ element: trialButton, strength: 1.6 }));
965
+ ```
966
+
967
+ ## not-found: 404 page
968
+ Category: Pages
969
+
970
+ 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.”
972
+
973
+ ```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
+ });
1011
+ ```
1012
+
1013
+ ## wordmark: Brand wordmark
1014
+ Category: Text
1015
+
1016
+ Prompt:
1017
+ “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
+
1019
+ ```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');
1033
+ ```
1034
+
1035
+ ## video: Video in characters
1036
+ Category: Media
1037
+
1038
+ Prompt:
1039
+ “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
+
1041
+ ```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
+ });
1052
+ ```
1053
+
1054
+ ## hero-background: Hero background
1055
+ Category: Backgrounds
1056
+
1057
+ 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.”
1059
+
1060
+ ```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
+ });
1066
+
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
+ });
1080
+ ```
1081
+
1082
+ ```css
1083
+ /* The veil: characters blur and fade toward the words instead of stopping at a box edge. */
1084
+ .hero-text { position: relative; isolation: isolate; }
1085
+ .hero-text::before {
1086
+ content: ""; position: absolute; z-index: -1; inset: -24px -40px; pointer-events: none;
1087
+ background: color-mix(in oklab, var(--bg) 58%, transparent);
1088
+ backdrop-filter: blur(9px);
1089
+ mask-image: radial-gradient(closest-side, #000 52%, rgb(0 0 0 / .6) 74%, transparent);
1090
+ }
1091
+ ```