asciify-engine 4.12.0 → 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 +18 -1
- package/dist/field.cjs +1 -1
- package/dist/field.js +1 -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 +383 -302
- package/skills/asciify-engine/references/kit.md +426 -0
|
@@ -1,460 +1,541 @@
|
|
|
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
|
-
|
|
37
|
+
## command-palette: Command palette
|
|
38
|
+
Category: AI
|
|
39
|
+
|
|
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.”
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { commandPalette } from 'asciify-engine/kit';
|
|
45
|
+
|
|
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
|
|
36
49
|
```
|
|
37
50
|
|
|
38
|
-
|
|
51
|
+
## button-loading: Button loading
|
|
52
|
+
Category: Components
|
|
39
53
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
import type { GlyphField, GlyphFieldOptions } from 'asciify-engine/field';
|
|
54
|
+
Prompt:
|
|
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.”
|
|
43
56
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
57
|
+
```ts
|
|
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
|
+
};
|
|
53
65
|
```
|
|
54
66
|
|
|
55
|
-
##
|
|
67
|
+
## ascii-pill: Badges and pills
|
|
56
68
|
Category: Components
|
|
57
69
|
|
|
58
70
|
Prompt:
|
|
59
|
-
“Make
|
|
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.”
|
|
60
72
|
|
|
61
73
|
```ts
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
67
81
|
```
|
|
68
82
|
|
|
69
|
-
##
|
|
70
|
-
Category:
|
|
83
|
+
## ascii-tabs: Character tabs
|
|
84
|
+
Category: Components
|
|
71
85
|
|
|
72
86
|
Prompt:
|
|
73
|
-
“
|
|
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.”
|
|
74
88
|
|
|
75
89
|
```ts
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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);
|
|
81
95
|
```
|
|
82
96
|
|
|
83
|
-
##
|
|
84
|
-
Category:
|
|
97
|
+
## slide-to-confirm: Slide to confirm
|
|
98
|
+
Category: Components
|
|
85
99
|
|
|
86
100
|
Prompt:
|
|
87
|
-
“
|
|
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]’.”
|
|
88
102
|
|
|
89
103
|
```ts
|
|
90
|
-
import {
|
|
104
|
+
import { slideToConfirm } from 'asciify-engine/kit';
|
|
91
105
|
|
|
92
|
-
|
|
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()) });
|
|
93
108
|
```
|
|
94
109
|
|
|
95
|
-
##
|
|
96
|
-
Category:
|
|
110
|
+
## like-burst: Like burst
|
|
111
|
+
Category: Components
|
|
97
112
|
|
|
98
113
|
Prompt:
|
|
99
|
-
“
|
|
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.”
|
|
100
115
|
|
|
101
116
|
```ts
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
});
|
|
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)) });
|
|
107
121
|
```
|
|
108
122
|
|
|
109
|
-
##
|
|
123
|
+
## loading-states: Status orb
|
|
124
|
+
Category: Components
|
|
125
|
+
|
|
126
|
+
Prompt:
|
|
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.”
|
|
128
|
+
|
|
129
|
+
```ts
|
|
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
|
+
};
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## text-decode: Decode headline
|
|
110
142
|
Category: Text
|
|
111
143
|
|
|
112
144
|
Prompt:
|
|
113
|
-
“
|
|
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.”
|
|
114
146
|
|
|
115
147
|
```ts
|
|
116
|
-
|
|
117
|
-
word.width = 1800; word.height = 480;
|
|
118
|
-
const ctx = word.getContext('2d')!;
|
|
119
|
-
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, word.width, word.height);
|
|
120
|
-
ctx.fillStyle = '#fff'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
121
|
-
ctx.font = '600 400px Georgia, serif';
|
|
122
|
-
ctx.fillText('asciify', word.width / 2, word.height / 2 + 20);
|
|
148
|
+
import { decode } from 'asciify-engine/kit';
|
|
123
149
|
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
hover: 'trail', label: 'asciify, drawn in characters',
|
|
127
|
-
}, '--ascii-ink-strong');
|
|
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();
|
|
128
152
|
```
|
|
129
153
|
|
|
130
|
-
##
|
|
131
|
-
Category:
|
|
154
|
+
## ascii-chart: Block chart
|
|
155
|
+
Category: Components
|
|
132
156
|
|
|
133
157
|
Prompt:
|
|
134
|
-
“
|
|
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.”
|
|
135
159
|
|
|
136
160
|
```ts
|
|
137
|
-
|
|
138
|
-
source: '/media/magnolia.mp4', fit: 'cover', charset: ' .:-=+*#%@', cellSize: 7,
|
|
139
|
-
contrast: 1.2, hover: 'trail', label: 'A magnolia opening, drawn in characters',
|
|
140
|
-
}, '--ascii-ink-strong');
|
|
161
|
+
import { blockChart } from 'asciify-engine/kit';
|
|
141
162
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
if (paused) video?.field.pause(); else video?.field.play();
|
|
163
|
+
const chart = blockChart(plot, {
|
|
164
|
+
values: visits['7d'], labels: days['7d'],
|
|
165
|
+
onHover: (i, v, day) => { readout.textContent = i === null ? '' : `${day} · ${v!.toLocaleString()}`; },
|
|
146
166
|
});
|
|
167
|
+
rangeToggle.onchange = () => chart.set(visits[rangeToggle.value], days[rangeToggle.value]);
|
|
147
168
|
```
|
|
148
169
|
|
|
149
|
-
##
|
|
150
|
-
Category:
|
|
170
|
+
## route-transition: Page transition
|
|
171
|
+
Category: Transitions
|
|
151
172
|
|
|
152
173
|
Prompt:
|
|
153
|
-
“
|
|
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.”
|
|
154
175
|
|
|
155
176
|
```ts
|
|
156
|
-
|
|
157
|
-
mark: 'glyph', scene: 'drift', charset: ' .:+*#', cellSize: 12, hover: 'trail',
|
|
158
|
-
clear: { element: message, padding: 28, feather: 0.3, shape: 'ellipse' },
|
|
159
|
-
});
|
|
177
|
+
import { pageDissolve, blockChart } from 'asciify-engine/kit';
|
|
160
178
|
|
|
161
|
-
|
|
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);
|
|
162
182
|
```
|
|
163
183
|
|
|
164
|
-
##
|
|
184
|
+
## swipe-card: Swipe cards
|
|
165
185
|
Category: Components
|
|
166
186
|
|
|
167
187
|
Prompt:
|
|
168
|
-
“
|
|
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.”
|
|
169
189
|
|
|
170
190
|
```ts
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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);
|
|
176
197
|
```
|
|
177
198
|
|
|
178
|
-
##
|
|
199
|
+
## otp-input: Verification code
|
|
179
200
|
Category: Components
|
|
180
201
|
|
|
181
202
|
Prompt:
|
|
182
|
-
“
|
|
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.”
|
|
183
204
|
|
|
184
205
|
```ts
|
|
185
|
-
|
|
186
|
-
scene: 'flow', charset: ' .:-=+*#', cellSize: 11, hover: 'none',
|
|
187
|
-
clear: { element: emptyMessage, padding: 24, shape: 'rect' },
|
|
188
|
-
});
|
|
206
|
+
import { codeInput } from 'asciify-engine/kit';
|
|
189
207
|
|
|
190
|
-
|
|
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)) });
|
|
191
210
|
```
|
|
192
211
|
|
|
193
|
-
##
|
|
212
|
+
## toast-stack: Toast stack
|
|
194
213
|
Category: Components
|
|
195
214
|
|
|
196
215
|
Prompt:
|
|
197
|
-
“
|
|
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.”
|
|
198
217
|
|
|
199
218
|
```ts
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
|
203
224
|
```
|
|
204
225
|
|
|
205
|
-
##
|
|
226
|
+
## skeleton-loader: Skeleton loader
|
|
206
227
|
Category: Components
|
|
207
228
|
|
|
208
229
|
Prompt:
|
|
209
|
-
“
|
|
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.”
|
|
210
231
|
|
|
211
232
|
```ts
|
|
212
|
-
|
|
233
|
+
import { skeleton } from 'asciify-engine/kit';
|
|
213
234
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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(); };
|
|
218
240
|
```
|
|
219
241
|
|
|
220
|
-
##
|
|
221
|
-
Category:
|
|
242
|
+
## thinking: Thinking state
|
|
243
|
+
Category: AI
|
|
222
244
|
|
|
223
245
|
Prompt:
|
|
224
|
-
“
|
|
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.”
|
|
225
247
|
|
|
226
248
|
```ts
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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)); };
|
|
231
254
|
```
|
|
232
255
|
|
|
233
|
-
##
|
|
234
|
-
Category:
|
|
256
|
+
## stream-text: Streamed reply
|
|
257
|
+
Category: AI
|
|
235
258
|
|
|
236
259
|
Prompt:
|
|
237
|
-
“
|
|
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.”
|
|
238
261
|
|
|
239
262
|
```ts
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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()
|
|
271
|
+
}
|
|
272
|
+
await out.done();
|
|
273
|
+
}
|
|
274
|
+
stopButton.onclick = () => out.stop();
|
|
275
|
+
regenerate.onclick = () => void run();
|
|
276
|
+
void run();
|
|
277
|
+
```
|
|
244
278
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
279
|
+
## image-generation: Image generation
|
|
280
|
+
Category: AI
|
|
281
|
+
|
|
282
|
+
Prompt:
|
|
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.”
|
|
284
|
+
|
|
285
|
+
```ts
|
|
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
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
## ascii-compare: Before and after
|
|
294
|
+
Category: Media
|
|
295
|
+
|
|
296
|
+
Prompt:
|
|
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.”
|
|
298
|
+
|
|
299
|
+
```ts
|
|
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);
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
## image-accordion: Image accordion
|
|
308
|
+
Category: Media
|
|
309
|
+
|
|
310
|
+
Prompt:
|
|
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.”
|
|
312
|
+
|
|
313
|
+
```ts
|
|
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,
|
|
249
320
|
});
|
|
250
321
|
```
|
|
251
322
|
|
|
323
|
+
## ascii-dropzone: Image dropzone
|
|
324
|
+
Category: Media
|
|
325
|
+
|
|
326
|
+
Prompt:
|
|
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.”
|
|
328
|
+
|
|
329
|
+
```ts
|
|
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');
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
## ascii-disintegrate: Delete into characters
|
|
338
|
+
Category: Transitions
|
|
339
|
+
|
|
340
|
+
Prompt:
|
|
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.”
|
|
342
|
+
|
|
343
|
+
```ts
|
|
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);
|
|
351
|
+
}
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
## pull-refresh: Pull to refresh
|
|
355
|
+
Category: Components
|
|
356
|
+
|
|
357
|
+
Prompt:
|
|
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.”
|
|
359
|
+
|
|
360
|
+
```ts
|
|
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();
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
## border-beam: Border beam
|
|
370
|
+
Category: Components
|
|
371
|
+
|
|
372
|
+
Prompt:
|
|
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.”
|
|
374
|
+
|
|
375
|
+
```ts
|
|
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
|
+
};
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
## glyph-odometer: Glyph odometer
|
|
386
|
+
Category: Text
|
|
387
|
+
|
|
388
|
+
Prompt:
|
|
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.”
|
|
390
|
+
|
|
391
|
+
```ts
|
|
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());
|
|
396
|
+
```
|
|
397
|
+
|
|
252
398
|
## sign-in: Sign-in page
|
|
253
399
|
Category: Pages
|
|
254
400
|
|
|
255
401
|
Prompt:
|
|
256
|
-
“Make a split sign-in page with Asciify: [image] in characters on the left
|
|
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.”
|
|
257
403
|
|
|
258
404
|
```ts
|
|
259
|
-
|
|
260
|
-
source: '/images/wing.jpg', fit: 'cover', focus: { x: 0.5, y: 0.4 },
|
|
261
|
-
charset: ' .:-=+*#%@', cellSize: 6, contrast: 1.3,
|
|
262
|
-
hover: 'trail', label: 'A carved wing drawn in characters',
|
|
263
|
-
}, '--ascii-ink-strong');
|
|
405
|
+
import { artPanel } from 'asciify-engine/kit';
|
|
264
406
|
|
|
265
|
-
|
|
407
|
+
const art = artPanel(artSide, { image: '/images/wing.jpg', clear: brandMark, focus: { x: 0.5, y: 0.4 } });
|
|
408
|
+
signInForm.onsubmit = async event => {
|
|
266
409
|
event.preventDefault();
|
|
267
|
-
|
|
268
|
-
art
|
|
269
|
-
|
|
410
|
+
art.pulse(continueButton, 1.8);
|
|
411
|
+
art.shimmer(true);
|
|
412
|
+
await sendLink(emailInput.value).finally(() => art.shimmer(false));
|
|
413
|
+
};
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
```css
|
|
417
|
+
.sign-in { display: grid; grid-template-columns: 41% 1fr; padding: 14px; }
|
|
418
|
+
.sign-in-art { position: relative; border-radius: 18px; overflow: hidden; background: var(--surface); }
|
|
270
419
|
```
|
|
271
420
|
|
|
272
421
|
## pricing: Pricing highlight
|
|
273
422
|
Category: Pages
|
|
274
423
|
|
|
275
424
|
Prompt:
|
|
276
|
-
“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.”
|
|
277
426
|
|
|
278
427
|
```ts
|
|
279
|
-
|
|
280
|
-
mark: 'dot', scene: 'drift', cellSize: 7, dotScale: 0.86, speed: 0.7,
|
|
281
|
-
interactionTarget: featuredCard, hover: 'lantern',
|
|
282
|
-
lantern: { radius: 0.3, strength: 0.75 },
|
|
283
|
-
});
|
|
428
|
+
import { pricingGlow } from 'asciify-engine/kit';
|
|
284
429
|
|
|
285
|
-
|
|
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);
|
|
286
433
|
```
|
|
287
434
|
|
|
288
|
-
##
|
|
289
|
-
Category:
|
|
435
|
+
## not-found: 404 page
|
|
436
|
+
Category: Pages
|
|
290
437
|
|
|
291
438
|
Prompt:
|
|
292
|
-
“Make
|
|
293
|
-
|
|
294
|
-
```ts
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
function decode(duration = 900, hold = 380) {
|
|
301
|
-
if (matchMedia('(prefers-reduced-motion: reduce)').matches || layer.childElementCount) return;
|
|
302
|
-
const stagger = (duration - hold) / Math.max(1, cells.length - 1), start = performance.now();
|
|
303
|
-
const glyphs = cells.map(({ offsetLeft: x, offsetTop: y, offsetWidth: w }) => layer.appendChild(Object.assign(document.createElement('span'), { style: `position:absolute;left:${x}px;top:${y}px;width:${w}px;display:flex;justify-content:center;line-height:normal` })));
|
|
304
|
-
requestAnimationFrame(function frame(now) {
|
|
305
|
-
cells.forEach((cell, i) => {
|
|
306
|
-
const t = (now - start - i * stagger) / hold, busy = t >= 0 && t < 1 && words[i] !== ' ';
|
|
307
|
-
cell.style.color = busy ? 'transparent' : '';
|
|
308
|
-
if (!busy || Math.random() < 0.4) glyphs[i].textContent = busy ? ramp[Math.floor(Math.random() * (t < 0.56 ? 9 : ramp.length))] : '';
|
|
309
|
-
});
|
|
310
|
-
if (now - start < duration) requestAnimationFrame(frame); else layer.replaceChildren();
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
new IntersectionObserver(([entry]) => { if (entry.isIntersecting) decode(); }, { threshold: 0.5 }).observe(headline);
|
|
314
|
-
headline.addEventListener('pointerenter', () => decode());
|
|
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.”
|
|
440
|
+
|
|
441
|
+
```ts
|
|
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 = '/'; };
|
|
315
446
|
```
|
|
316
447
|
|
|
317
|
-
##
|
|
318
|
-
Category:
|
|
448
|
+
## wordmark: Brand wordmark
|
|
449
|
+
Category: Text
|
|
319
450
|
|
|
320
451
|
Prompt:
|
|
321
|
-
“
|
|
322
|
-
|
|
323
|
-
```ts
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
// The photo lies over the canvas, masked by radial-gradient(circle at var(--x) var(--y), #000 calc(var(--r) - 56px), transparent var(--r)),
|
|
330
|
-
// with --r registered as a <length> through @property so it can transition.
|
|
331
|
-
function reveal(open: boolean, x = frame.clientWidth / 2, y = frame.clientHeight / 2) {
|
|
332
|
-
const ms = matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : open ? 520 : 440;
|
|
333
|
-
if (open) photo.style.cssText += `--x:${x}px;--y:${y}px`;
|
|
334
|
-
else art?.field.pulse({ x, y });
|
|
335
|
-
photo.style.transition = `--r ${ms}ms cubic-bezier(.16, 1, .3, 1)`;
|
|
336
|
-
photo.style.setProperty('--r', open ? `${Math.hypot(frame.clientWidth, frame.clientHeight) + 56}px` : '0px');
|
|
337
|
-
}
|
|
338
|
-
frame.addEventListener('pointerenter', event => reveal(true, event.offsetX, event.offsetY));
|
|
339
|
-
frame.addEventListener('pointerleave', event => reveal(false, event.offsetX, event.offsetY));
|
|
340
|
-
frame.addEventListener('focus', () => reveal(true));
|
|
341
|
-
frame.addEventListener('blur', () => reveal(false));
|
|
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.”
|
|
453
|
+
|
|
454
|
+
```ts
|
|
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);
|
|
342
460
|
```
|
|
343
461
|
|
|
344
|
-
##
|
|
345
|
-
Category:
|
|
462
|
+
## video: Video in characters
|
|
463
|
+
Category: Media
|
|
346
464
|
|
|
347
465
|
Prompt:
|
|
348
|
-
“
|
|
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.”
|
|
349
467
|
|
|
350
468
|
```ts
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
});
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
// cover holds the canvas on background: var(--surface), visibility: hidden at rest, masked by radial-gradient(circle at var(--x) var(--y),
|
|
358
|
-
// transparent var(--hole), #000 calc(var(--hole) + 36px), #000 var(--front), transparent calc(var(--front) + 36px)); --hole and --front are @property lengths.
|
|
359
|
-
async function navigate(tab: HTMLElement, swap: () => void) {
|
|
360
|
-
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return swap();
|
|
361
|
-
const box = cover.getBoundingClientRect(), t = tab.getBoundingClientRect(), ease = 'cubic-bezier(.16, 1, .3, 1)';
|
|
362
|
-
const x = t.left + t.width / 2 - box.left, y = t.top + t.height / 2 - box.top, reach = `${Math.hypot(box.width, box.height) + 36}px`;
|
|
363
|
-
cover.style.cssText += `--x:${x}px;--y:${y}px;visibility:visible`;
|
|
364
|
-
veil?.field.play();
|
|
365
|
-
veil?.field.pulse({ x, y });
|
|
366
|
-
await cover.animate({ '--hole': ['-72px', '-72px'], '--front': ['-36px', reach] }, { duration: 180, easing: ease, fill: 'forwards' }).finished;
|
|
367
|
-
swap();
|
|
368
|
-
await cover.animate({ '--hole': ['-72px', reach], '--front': [reach, reach] }, { duration: 320, easing: ease }).finished;
|
|
369
|
-
cover.style.visibility = 'hidden';
|
|
370
|
-
veil?.field.pause();
|
|
371
|
-
}
|
|
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');
|
|
372
474
|
```
|
|
373
475
|
|
|
374
|
-
##
|
|
375
|
-
Category:
|
|
476
|
+
## hero-background: Hero background
|
|
477
|
+
Category: Backgrounds
|
|
376
478
|
|
|
377
479
|
Prompt:
|
|
378
|
-
“
|
|
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.”
|
|
379
481
|
|
|
380
482
|
```ts
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
const settled = Math.floor((now - start - 120) / 28);
|
|
396
|
-
live.textContent = [...text].map((ch, i) => (i < settled || ch === ' ' ? ch : ramp[Math.floor(Math.random() * ramp.length)])).join('');
|
|
397
|
-
if (settled < text.length) requestAnimationFrame(frame);
|
|
398
|
-
});
|
|
483
|
+
import { fieldBackground } from 'asciify-engine/kit';
|
|
484
|
+
|
|
485
|
+
const bg = fieldBackground(heroSection, { preset: 'hero', clear: heroText, ripple: true });
|
|
486
|
+
subscribeButton.onclick = () => bg.pulse(subscribeButton, 1.4);
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
```css
|
|
490
|
+
/* The veil: characters blur and fade toward the words instead of stopping at a box edge. */
|
|
491
|
+
.hero-text { position: relative; isolation: isolate; }
|
|
492
|
+
.hero-text::before {
|
|
493
|
+
content: ""; position: absolute; z-index: -1; inset: -24px -40px; pointer-events: none;
|
|
494
|
+
background: color-mix(in oklab, var(--bg) 58%, transparent);
|
|
495
|
+
backdrop-filter: blur(9px);
|
|
496
|
+
mask-image: radial-gradient(closest-side, #000 52%, rgb(0 0 0 / .6) 74%, transparent);
|
|
399
497
|
}
|
|
400
|
-
button.addEventListener('pointerenter', scramble);
|
|
401
|
-
button.addEventListener('focus', () => { if (button.matches(':focus-visible')) scramble(); });
|
|
402
498
|
```
|
|
403
499
|
|
|
404
|
-
##
|
|
405
|
-
Category: AI
|
|
500
|
+
## Advanced: raw field
|
|
406
501
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
const answer = await ask().finally(() => clearInterval(beat));
|
|
422
|
-
answerText.replaceChildren(...answer.split(' ').map((word, i) => {
|
|
423
|
-
const span = Object.assign(document.createElement('span'), { className: 'word', textContent: `${word} ` });
|
|
424
|
-
span.style.setProperty('--i', String(i));
|
|
425
|
-
return span;
|
|
426
|
-
}));
|
|
427
|
-
reply.dataset.phase = 'done';
|
|
428
|
-
setTimeout(() => dots?.field.pause(), 420);
|
|
429
|
-
play('ready', { emphasis: 'subtle' });
|
|
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(); } };
|
|
430
516
|
}
|
|
431
517
|
```
|
|
432
518
|
|
|
433
|
-
|
|
434
|
-
|
|
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
|
+
```
|
|
435
523
|
|
|
436
|
-
|
|
437
|
-
“While [our image model] generates [prompt], fill the frame with drifting Asciify characters that pulse beside a progress label. When the image arrives, develop it in characters, then fade to the photo from blur to sharp.”
|
|
524
|
+
In React, wrap it once and reuse it:
|
|
438
525
|
|
|
439
|
-
```
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
}, '--ascii-ink-strong');
|
|
443
|
-
const ease = (ms: number, step: (k: number) => void) => new Promise<void>(done => {
|
|
444
|
-
const start = performance.now(), still = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
445
|
-
requestAnimationFrame(function frame(now) { const t = still ? 1 : Math.min(1, (now - start) / ms); step(1 - (1 - t) ** 4); if (t < 1) requestAnimationFrame(frame); else done(); });
|
|
446
|
-
});
|
|
526
|
+
```tsx
|
|
527
|
+
import { useEffect, useRef } from 'react';
|
|
528
|
+
import type { GlyphField, GlyphFieldOptions } from 'asciify-engine/field';
|
|
447
529
|
|
|
448
|
-
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
photo.src = url;
|
|
458
|
-
imageFrame.dataset.done = ''; // CSS fades the photo in from opacity 0 and blur(14px), 760ms on cubic-bezier(.16, 1, .3, 1)
|
|
459
|
-
});
|
|
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
|
+
}
|
|
460
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).
|