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