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