asciify-engine 4.12.0 → 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.
@@ -52,409 +52,1040 @@ export function AsciiField({ options, onField, className }: { options: GlyphFiel
52
52
  }
53
53
  ```
54
54
 
55
- ## image-card: ASCII image card
56
- Category: Components
55
+ ## voice-field: Voice orb
56
+ Category: AI
57
57
 
58
58
  Prompt:
59
- “Make a card whose image is drawn in characters with Asciify. Use [image]. Characters trail behind the cursor, the title, details and a save button stay as HTML under the image, and the ink follows our light and dark theme.”
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
60
 
61
61
  ```ts
62
- const image = asciiField(canvas, {
63
- source: '/images/statue.jpg', fit: 'cover', focus: { x: 0.5, y: 0.35 },
64
- charset: ' .:-=+*#%@', cellSize: 6, contrast: 1.25,
65
- hover: 'trail', label: 'A marble statue drawn in characters',
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',
66
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
+ });
67
84
  ```
68
85
 
69
- ## hero-background: Hero background
70
- Category: Backgrounds
86
+ ## command-palette: Command palette
87
+ Category: AI
71
88
 
72
89
  Prompt:
73
- “Put a calm ASCII background behind our hero with Asciify. Characters drift slowly, clear around the headline and buttons so they stay readable, and follow our light and dark theme.”
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.”
74
91
 
75
92
  ```ts
76
- const hero = asciiField(canvas, {
77
- mark: 'glyph', scene: 'drift', charset: ' .:+*#', cellSize: 12,
78
- clear: { element: heroText, padding: 24, feather: 0.32, shape: 'ellipse' },
79
- hover: 'trail',
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); }
80
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.
81
118
  ```
82
119
 
83
- ## cli-banner: CLI banner
84
- Category: Text
120
+ ## button-loading: Button loading
121
+ Category: Components
85
122
 
86
123
  Prompt:
87
- “Greet people in ASCII when our CLI starts, using asciifyText from Asciify with [word].”
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.”
88
125
 
89
126
  ```ts
90
- import { asciifyText } from 'asciify-engine';
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]’.”
91
157
 
92
- console.log(asciifyText('Hey', { char: '#' }));
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(); } });
93
189
  ```
94
190
 
95
- ## cursor-field: Cursor field
96
- Category: Backgrounds
191
+ ## like-burst: Like burst
192
+ Category: Components
97
193
 
98
194
  Prompt:
99
- “Add a halftone dot field to [section] with Asciify that drifts, clears around the content, and answers the cursor with [trail, fluid or lantern].”
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.”
100
196
 
101
197
  ```ts
102
- const dots = asciiField(canvas, {
103
- mark: 'dot', scene: 'drift', dotScale: 0.72, cellSize: 8,
104
- clear: { element: content, padding: 40, shape: 'rect' },
105
- hover: 'fluid',
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);
106
232
  });
233
+ paint(0, null);
107
234
  ```
108
235
 
109
- ## wordmark: Brand wordmark
110
- Category: Text
236
+ ## loading-states: Status orb
237
+ Category: Components
111
238
 
112
239
  Prompt:
113
- “Draw our name, [name], in moving characters with Asciify for [the footer or a hero]. The word stays crisp, characters trail under the cursor, and the real name stays in HTML for screen readers.”
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.”
114
241
 
115
242
  ```ts
116
- const word = document.createElement('canvas');
117
- word.width = 1800; word.height = 480;
118
- const ctx = word.getContext('2d')!;
119
- ctx.fillStyle = '#000'; ctx.fillRect(0, 0, word.width, word.height);
120
- ctx.fillStyle = '#fff'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
121
- ctx.font = '600 400px Georgia, serif';
122
- ctx.fillText('asciify', word.width / 2, word.height / 2 + 20);
123
-
124
- const wordmark = asciiField(canvas, {
125
- source: word, fit: 'contain', charset: ' .:-=+*#%@', cellSize: 9,
126
- hover: 'trail', label: 'asciify, drawn in characters',
127
- }, '--ascii-ink-strong');
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
+ });
128
269
  ```
129
270
 
130
- ## video: Video in characters
131
- Category: Media
271
+ ## text-decode: Decode headline
272
+ Category: Text
132
273
 
133
274
  Prompt:
134
- “Play [video] in characters with Asciify as [a section background or a card]. Muted, looping, paused off screen, with a play and pause control, and the poster for reduced motion.”
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.”
135
276
 
136
277
  ```ts
137
- const video = asciiField(canvas, {
138
- source: '/media/magnolia.mp4', fit: 'cover', charset: ' .:-=+*#%@', cellSize: 7,
139
- contrast: 1.2, hover: 'trail', label: 'A magnolia opening, drawn in characters',
140
- }, '--ascii-ink-strong');
141
-
142
- let paused = false;
143
- playButton.addEventListener('click', () => {
144
- paused = !paused;
145
- if (paused) video?.field.pause(); else video?.field.play();
146
- });
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);
147
304
  ```
148
305
 
149
- ## not-found: 404 page
150
- Category: Pages
306
+ ## ascii-chart: Block chart
307
+ Category: Components
151
308
 
152
309
  Prompt:
153
- “Make our 404 page with Asciify: a calm field of characters that clears around the message, trails under the cursor, and pulses when someone presses Go home.”
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.”
154
311
 
155
312
  ```ts
156
- const lost = asciiField(canvas, {
157
- mark: 'glyph', scene: 'drift', charset: ' .:+*#', cellSize: 12, hover: 'trail',
158
- clear: { element: message, padding: 28, feather: 0.3, shape: 'ellipse' },
159
- });
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.”
160
341
 
161
- homeLink.addEventListener('click', () => lost?.field.pulse({ element: homeLink }));
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)`; };
162
369
  ```
163
370
 
164
- ## avatar: ASCII avatar
371
+ ## swipe-card: Swipe cards
165
372
  Category: Components
166
373
 
167
374
  Prompt:
168
- “Draw [person]'s avatar in characters with Asciify, inside our round avatar frame. Characters trail under the cursor, and the name, role and follow button stay as HTML.”
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.”
169
376
 
170
377
  ```ts
171
- const avatar = asciiField(canvas, {
172
- source: '/images/portrait.jpg', fit: 'cover',
173
- charset: ' .:-=+*#%@', cellSize: 5, contrast: 1.1,
174
- hover: 'trail', label: 'Portrait of Elias Varga',
175
- }, '--ascii-ink-strong');
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);
176
401
  ```
177
402
 
178
- ## empty-state: Empty state
403
+ ## otp-input: Verification code
179
404
  Category: Components
180
405
 
181
406
  Prompt:
182
- “Give our empty state a quiet ASCII field with Asciify. Keep the icon, message and primary action readable, and send a pulse through the field from the button when it is pressed.”
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.”
183
408
 
184
409
  ```ts
185
- const empty = asciiField(canvas, {
186
- scene: 'flow', charset: ' .:-=+*#', cellSize: 11, hover: 'none',
187
- clear: { element: emptyMessage, padding: 24, shape: 'rect' },
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
188
439
  });
189
-
190
- primaryButton.addEventListener('click', () => empty?.field.pulse({ element: primaryButton }));
440
+ input.addEventListener('focus', () => requestAnimationFrame(paint)); input.addEventListener('blur', () => requestAnimationFrame(paint));
191
441
  ```
192
442
 
193
- ## loading: Loading state
443
+ ## toast-stack: Toast stack
194
444
  Category: Components
195
445
 
196
446
  Prompt:
197
- “Show [upload or sync] progress with Asciify: a small character field above the file name and progress bar that sends a soft pulse each second while it runs, and stops when it settles.”
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.”
198
448
 
199
449
  ```ts
200
- const loading = asciiField(canvas, { mark: 'glyph', scene: 'drift', charset: ' .:+*#', cellSize: 9, hover: 'none' });
201
- const beat = setInterval(() => loading?.field.pulse({ strength: 0.8 }), 1000);
202
- request.finally(() => clearInterval(beat));
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: () => {} }); };
203
488
  ```
204
489
 
205
- ## copy-pulse: Copy feedback
490
+ ## skeleton-loader: Skeleton loader
206
491
  Category: Components
207
492
 
208
493
  Prompt:
209
- “When someone copies [the coupon code], answer with Asciify: a pulse travels through a halftone field from the copy button.”
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.”
210
495
 
211
496
  ```ts
212
- const ground = asciiField(canvas, { mark: 'dot', scene: 'drift', cellSize: 7, dotScale: 0.8, hover: 'none' });
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
+ ```
213
524
 
214
- copyButton.addEventListener('click', async () => {
215
- await navigator.clipboard.writeText(code);
216
- ground?.field.pulse({ element: copyButton });
217
- });
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
+ }
218
554
  ```
219
555
 
220
- ## brand-field: Brand color field
221
- Category: Backgrounds
556
+ ## stream-text: Streamed reply
557
+ Category: AI
222
558
 
223
559
  Prompt:
224
- “Make an ASCII field in our brand color with Asciify, drifting slower than the default, for [section].”
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.”
225
561
 
226
562
  ```ts
227
- const brand = asciiField(canvas, {
228
- mark: 'glyph', scene: 'drift', charset: ' .:+*#', cellSize: 10,
229
- speed: 0.5, hover: 'none',
230
- }, '--brand');
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()));
231
589
  ```
232
590
 
233
- ## coming-soon: Coming soon page
234
- Category: Pages
591
+ ## image-generation: Image generation
592
+ Category: AI
235
593
 
236
594
  Prompt:
237
- “Make a coming-soon page for [product] with Asciify: our mark and name, one headline, and an email field with a Join waitlist button. A drifting field of characters clears around the form, and pulses from the button when someone joins.”
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.”
238
596
 
239
597
  ```ts
240
- const waitlist = asciiField(canvas, {
241
- mark: 'glyph', scene: 'drift', charset: ' .:+*#', cellSize: 10, hover: 'trail',
242
- clear: { element: form, padding: 14, feather: 0.24, shape: 'ellipse' },
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)
243
623
  });
624
+ ```
244
625
 
245
- form.addEventListener('submit', event => {
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;
246
653
  event.preventDefault();
247
- waitlist?.field.pulse({ element: joinButton });
248
- form.dataset.state = 'joined';
654
+ set(Number(handle.getAttribute('aria-valuenow')) + step);
249
655
  });
250
656
  ```
251
657
 
252
- ## sign-in: Sign-in page
253
- Category: Pages
658
+ ## image-accordion: Image accordion
659
+ Category: Media
254
660
 
255
661
  Prompt:
256
- “Make a split sign-in page with Asciify: [image] in characters on the left, trailing under the cursor, and an email field, Continue and a quiet Continue with GitHub on the right. Continue shows a loading state and pulses the art.”
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.”
257
663
 
258
664
  ```ts
259
- const art = asciiField(artCanvas, {
260
- source: '/images/wing.jpg', fit: 'cover', focus: { x: 0.5, y: 0.4 },
261
- charset: ' .:-=+*#%@', cellSize: 6, contrast: 1.3,
262
- hover: 'trail', label: 'A carved wing drawn in characters',
263
- }, '--ascii-ink-strong');
264
-
265
- signInForm.addEventListener('submit', event => {
266
- event.preventDefault();
267
- continueButton.setAttribute('aria-busy', 'true');
268
- art?.field.pulse({ element: continueButton });
269
- });
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)); });
270
688
  ```
271
689
 
272
- ## pricing: Pricing highlight
273
- Category: Pages
690
+ ## ascii-dropzone: Image dropzone
691
+ Category: Media
274
692
 
275
693
  Prompt:
276
- “Make a two-plan pricing section with a monthly and yearly toggle, using Asciify. The recommended plan, [plan], gets a halftone field that lights up under the cursor and pulses from its button.”
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.”
277
695
 
278
696
  ```ts
279
- const featured = asciiField(canvas, {
280
- mark: 'dot', scene: 'drift', cellSize: 7, dotScale: 0.86, speed: 0.7,
281
- interactionTarget: featuredCard, hover: 'lantern',
282
- lantern: { radius: 0.3, strength: 0.75 },
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(); });
283
722
  });
284
-
285
- trialButton.addEventListener('click', () => featured?.field.pulse({ element: trialButton }));
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
286
735
  ```
287
736
 
288
- ## text-decode: Decode headline
289
- Category: Text
737
+ ## ascii-disintegrate: Delete into characters
738
+ Category: Transitions
290
739
 
291
740
  Prompt:
292
- “Make [our hero headline] decode into place through ASCII characters when it scrolls into view and on hover, left to right in about 900ms, without moving the layout. Screen readers get the real words, and reduced motion shows them at once.”
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.”
293
742
 
294
743
  ```ts
295
- const words = headline.textContent ?? '', ramp = '.:-=+*#%@abcdefghijklmnopqrstuvwxyz', cells = [...words].map(ch => Object.assign(document.createElement('span'), { textContent: ch }));
296
- const layer = Object.assign(document.createElement('span'), { ariaHidden: 'true', style: 'position:absolute;inset:0;pointer-events:none;color:var(--ink-3)' });
297
- headline.setAttribute('aria-label', words);
298
- headline.replaceChildren(layer, ...cells); // the headline is position: relative
299
-
300
- function decode(duration = 900, hold = 380) {
301
- if (matchMedia('(prefers-reduced-motion: reduce)').matches || layer.childElementCount) return;
302
- const stagger = (duration - hold) / Math.max(1, cells.length - 1), start = performance.now();
303
- const glyphs = cells.map(({ offsetLeft: x, offsetTop: y, offsetWidth: w }) => layer.appendChild(Object.assign(document.createElement('span'), { style: `position:absolute;left:${x}px;top:${y}px;width:${w}px;display:flex;justify-content:center;line-height:normal` })));
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();
304
760
  requestAnimationFrame(function frame(now) {
305
- cells.forEach((cell, i) => {
306
- const t = (now - start - i * stagger) / hold, busy = t >= 0 && t < 1 && words[i] !== ' ';
307
- cell.style.color = busy ? 'transparent' : '';
308
- if (!busy || Math.random() < 0.4) glyphs[i].textContent = busy ? ramp[Math.floor(Math.random() * (t < 0.56 ? 9 : ramp.length))] : '';
309
- });
310
- if (now - start < duration) requestAnimationFrame(frame); else layer.replaceChildren();
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';
311
765
  });
312
766
  }
313
- new IntersectionObserver(([entry]) => { if (entry.isIntersecting) decode(); }, { threshold: 0.5 }).observe(headline);
314
- headline.addEventListener('pointerenter', () => decode());
315
767
  ```
316
768
 
317
- ## image-reveal: Photo from characters
318
- Category: Transitions
769
+ ## pull-refresh: Pull to refresh
770
+ Category: Components
319
771
 
320
772
  Prompt:
321
- “Show [photo] drawn in characters with Asciify. On hover or focus the real photo grows out of the characters in a soft circle from the cursor, and on leave it closes back with a pulse where the pointer left.”
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.”
322
774
 
323
775
  ```ts
324
- const art = asciiField(canvas, {
325
- source: '/images/egret.jpg', fit: 'cover', charset: ' .:-=+*#%@', cellSize: 6,
326
- contrast: 1.3, hover: 'trail', label: 'A great egret, drawn in characters',
327
- }, '--ascii-ink-strong');
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
+ ```
328
799
 
329
- // The photo lies over the canvas, masked by radial-gradient(circle at var(--x) var(--y), #000 calc(var(--r) - 56px), transparent var(--r)),
330
- // with --r registered as a <length> through @property so it can transition.
331
- function reveal(open: boolean, x = frame.clientWidth / 2, y = frame.clientHeight / 2) {
332
- const ms = matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : open ? 520 : 440;
333
- if (open) photo.style.cssText += `--x:${x}px;--y:${y}px`;
334
- else art?.field.pulse({ x, y });
335
- photo.style.transition = `--r ${ms}ms cubic-bezier(.16, 1, .3, 1)`;
336
- photo.style.setProperty('--r', open ? `${Math.hypot(frame.clientWidth, frame.clientHeight) + 56}px` : '0px');
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);
337
826
  }
338
- frame.addEventListener('pointerenter', event => reveal(true, event.offsetX, event.offsetY));
339
- frame.addEventListener('pointerleave', event => reveal(false, event.offsetX, event.offsetY));
340
- frame.addEventListener('focus', () => reveal(true));
341
- frame.addEventListener('blur', () => reveal(false));
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 }); });
342
831
  ```
343
832
 
344
- ## route-transition: Page transition
345
- Category: Transitions
833
+ ## glyph-odometer: Glyph odometer
834
+ Category: Text
346
835
 
347
836
  Prompt:
348
- “When someone switches [tabs or pages] in [our app], sweep a dense Asciify character field out from the clicked tab, swap the page underneath, and sweep it away, all in about half a second. Swap instantly under reduced motion.”
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.”
349
838
 
350
839
  ```ts
351
- const veil = asciiField(canvas, {
352
- mark: 'glyph', scene: 'flow', charset: ' .:-=+*#%@', cellSize: 10, scale: 2.4,
353
- speed: 1.6, contrast: 1.4, brightness: 0.34, hover: 'none',
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);
354
863
  });
355
- veil?.field.pause();
864
+ requestAnimationFrame(frame);
865
+ ```
356
866
 
357
- // cover holds the canvas on background: var(--surface), visibility: hidden at rest, masked by radial-gradient(circle at var(--x) var(--y),
358
- // transparent var(--hole), #000 calc(var(--hole) + 36px), #000 var(--front), transparent calc(var(--front) + 36px)); --hole and --front are @property lengths.
359
- async function navigate(tab: HTMLElement, swap: () => void) {
360
- if (matchMedia('(prefers-reduced-motion: reduce)').matches) return swap();
361
- const box = cover.getBoundingClientRect(), t = tab.getBoundingClientRect(), ease = 'cubic-bezier(.16, 1, .3, 1)';
362
- const x = t.left + t.width / 2 - box.left, y = t.top + t.height / 2 - box.top, reach = `${Math.hypot(box.width, box.height) + 36}px`;
363
- cover.style.cssText += `--x:${x}px;--y:${y}px;visibility:visible`;
364
- veil?.field.play();
365
- veil?.field.pulse({ x, y });
366
- await cover.animate({ '--hole': ['-72px', '-72px'], '--front': ['-36px', reach] }, { duration: 180, easing: ease, fill: 'forwards' }).finished;
367
- swap();
368
- await cover.animate({ '--hole': ['-72px', reach], '--front': [reach, reach] }, { duration: 320, easing: ease }).finished;
369
- cover.style.visibility = 'hidden';
370
- veil?.field.pause();
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
371
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
+ });
372
910
  ```
373
911
 
374
- ## scramble-button: Scramble button
375
- Category: Transitions
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
376
919
 
377
920
  Prompt:
378
- “Make [our primary button]'s label scramble through ASCII characters on hover and focus without changing width, with a small Asciify dot halo behind it that follows the cursor and pulses on press.”
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.”
379
922
 
380
923
  ```ts
381
- const halo = asciiField(canvas, {
382
- mark: 'dot', scene: 'drift', cellSize: 6, dotScale: 0.8, scale: 2.2, hover: 'lantern',
383
- clear: { element: button, padding: 4, feather: 0.12, shape: 'rect' },
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 },
384
943
  });
385
- button.addEventListener('click', () => halo?.field.pulse({ element: button }));
386
944
 
387
- const text = label.textContent ?? '', ramp = '.:-=+*#%@abcdefghijklmnopqrstuvwxyz';
388
- const live = Object.assign(document.createElement('span'), { ariaHidden: 'true', textContent: text, style: 'position:absolute;inset:0;white-space:nowrap' });
389
- button.setAttribute('aria-label', text);
390
- label.replaceChildren(Object.assign(document.createElement('span'), { textContent: text, style: 'visibility:hidden;white-space:nowrap' }), live); // label is position: relative; the hidden copy holds the width
391
- function scramble() {
392
- if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
393
- const start = performance.now();
394
- requestAnimationFrame(function frame(now) {
395
- const settled = Math.floor((now - start - 120) / 28);
396
- live.textContent = [...text].map((ch, i) => (i < settled || ch === ' ' ? ch : ramp[Math.floor(Math.random() * ramp.length)])).join('');
397
- if (settled < text.length) requestAnimationFrame(frame);
398
- });
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);
399
961
  }
400
- button.addEventListener('pointerenter', scramble);
401
- button.addEventListener('focus', () => { if (button.matches(':focus-visible')) scramble(); });
962
+
963
+ billingToggle.addEventListener('change', () => void sweep(planArt(billingToggle.value === 'yearly')));
964
+ trialButton.addEventListener('click', () => featured?.field.pulse({ element: trialButton, strength: 1.6 }));
402
965
  ```
403
966
 
404
- ## thinking: Thinking state
405
- Category: AI
967
+ ## not-found: 404 page
968
+ Category: Pages
406
969
 
407
970
  Prompt:
408
- “Show [our assistant]'s thinking state with Asciify: dot lines shimmer where the answer will appear under a Thinking… label, then the answer streams in word by word as the dots fade. Regenerate runs it again.”
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.”
409
972
 
410
973
  ```ts
411
- import { play } from 'cuelume';
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);
412
981
 
413
- const dots = asciiField(canvas, { mark: 'dot', scene: 'drift', cellSize: 5, dotScale: 0.86, scale: 3, speed: 2.4, hover: 'none' });
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');
414
986
 
415
- // The canvas fills the answer area under line-shaped mask bands; CSS fades it out, and each .word in 38ms apart, once data-phase leaves "thinking".
416
- async function regenerate(ask: () => Promise<string>) {
417
- play('loading', { emphasis: 'subtle' });
418
- reply.dataset.phase = 'thinking';
419
- dots?.field.play();
420
- const beat = setInterval(() => dots?.field.pulse({ x: Math.random() * canvas.clientWidth, y: canvas.clientHeight / 2, strength: 0.7 }), 640);
421
- const answer = await ask().finally(() => clearInterval(beat));
422
- answerText.replaceChildren(...answer.split(' ').map((word, i) => {
423
- const span = Object.assign(document.createElement('span'), { className: 'word', textContent: `${word} ` });
424
- span.style.setProperty('--i', String(i));
425
- return span;
426
- }));
427
- reply.dataset.phase = 'done';
428
- setTimeout(() => dots?.field.pause(), 420);
429
- play('ready', { emphasis: 'subtle' });
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 });
430
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
+ });
431
1011
  ```
432
1012
 
433
- ## image-generation: Image generation
434
- Category: AI
1013
+ ## wordmark: Brand wordmark
1014
+ Category: Text
435
1015
 
436
1016
  Prompt:
437
- “While [our image model] generates [prompt], fill the frame with drifting Asciify characters that pulse beside a progress label. When the image arrives, develop it in characters, then fade to the photo from blur to sharp.”
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.”
438
1018
 
439
1019
  ```ts
440
- const art = asciiField(canvas, {
441
- mark: 'glyph', scene: 'flow', charset: ' .:-=+*#%@', cellSize: 7, scale: 2.6, speed: 1.4, contrast: 1.3, hover: 'trail',
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',
442
1032
  }, '--ascii-ink-strong');
443
- const ease = (ms: number, step: (k: number) => void) => new Promise<void>(done => {
444
- const start = performance.now(), still = matchMedia('(prefers-reduced-motion: reduce)').matches;
445
- requestAnimationFrame(function frame(now) { const t = still ? 1 : Math.min(1, (now - start) / ms); step(1 - (1 - t) ** 4); if (t < 1) requestAnimationFrame(frame); else done(); });
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();
446
1051
  });
1052
+ ```
447
1053
 
448
- generate.addEventListener('click', async () => {
449
- const field = art?.field;
450
- if (!field) return;
451
- const beat = setInterval(() => field.pulse({ strength: 0.8 }), 640);
452
- const url = await createImage(promptInput.value).finally(() => clearInterval(beat));
453
- await ease(200, k => field.update({ brightness: -k }));
454
- await field.setSource(url);
455
- field.pulse();
456
- await ease(900, k => field.update({ brightness: k - 1 }));
457
- photo.src = url;
458
- imageFrame.dataset.done = ''; // CSS fades the photo in from opacity 0 and blur(14px), 760ms on cubic-bezier(.16, 1, .3, 1)
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 });
459
1079
  });
460
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
+ ```