asciify-engine 4.17.0 → 4.18.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "asciify-engine",
3
- "version": "4.17.0",
3
+ "version": "4.18.0",
4
4
  "description": "Framework-agnostic ASCII art engine. Convert images, videos, and GIFs into ASCII art rendered on canvas, or run full-bleed GPU character fields with a fluid cursor wake.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -1,17 +1,17 @@
1
1
  ---
2
2
  name: asciify-engine
3
- description: Apply Asciify the way asciify.org does - ASCII components and backgrounds that belong in a real interface, inked from the theme, clear around text, inside the page's radii and motion. Use for UI components in characters (loading, status, toasts, tabs, AI states, charts, transitions, pages), animated ASCII backgrounds, and also photo, video and GIF conversion, GPU character fields, art styles and export, hover and motion, layered typography and rendering performance.
3
+ description: Apply Asciify the way asciify.org does - ASCII components and backgrounds that belong in a real interface, inked from the theme, clear around text, inside the page's radii and motion. Use for UI components in characters (loading, status, toasts, tabs, AI states, charts, transitions, pages), animated ASCII backgrounds, and also photo, video and GIF conversion, GPU character fields, artwork looks for wallpapers, posters and prints, art styles and export, hover and motion, layered typography and rendering performance.
4
4
  metadata:
5
- tested-engine: "4.17.0"
5
+ tested-engine: "4.18.0"
6
6
  updated: "2026-10-17"
7
7
  ---
8
8
 
9
9
  # Asciify Engine
10
10
 
11
- Use the published `asciify-engine` npm package. Tested against **4.17.0**. Check the application's installed version and its public types before using newer options; a local checkout can contain unpublished changes.
11
+ Use the published `asciify-engine` npm package. Tested against **4.18.0**. Check the application's installed version and its public types before using newer options; a local checkout can contain unpublished changes.
12
12
 
13
13
  - Agent entry: https://asciify.org/skill (redirects to this Markdown document).
14
- - Components: https://asciify.org/components · Backgrounds: https://asciify.org/backgrounds
14
+ - Components: https://asciify.org/components · Looks: https://asciify.org/looks · Backgrounds: https://asciify.org/backgrounds
15
15
  - Playground: https://asciify.org/editor · API documentation: https://asciify.org/docs/api
16
16
  - Repository: https://github.com/ayangabryl/asciify-engine
17
17
 
@@ -41,6 +41,7 @@ Most requests are a feeling ("make the loading state feel alive", "a calm backgr
41
41
  | Their video in characters on the page | Kit `asciiVideo` |
42
42
  | Their photo, video or GIF converted, optionally answering the cursor | Advanced: `asciify` / `asciifyVideo` / `asciifyGif`, plus `mountHover` from `/hover` |
43
43
  | A full-bleed field with a look the kit has no option for | Advanced: `mountGlyphField` from `/field` ([glyph-field.md](references/glyph-field.md)) |
44
+ | Their photo or video as finished artwork in a named look: a wallpaper, poster, cover, print or hero artwork ("newsprint", "riso", "phosphor", "engraving", "Lego") | `mountStudio` / `exportStudio` from `/studio` with a look from [looks.md](references/looks.md) |
44
45
  | Art styles, saved looks, masks, effects, PNG/MP4/GIF export | Advanced: `mountStudio` from `/studio` ([studio-workspace.md](references/studio-workspace.md)) |
45
46
  | Several media and text layers in one artwork | Advanced: `mountStudioProject` from `/studio` ([layered-projects.md](references/layered-projects.md)) |
46
47
  | A text banner or lettering | Advanced: `asciifyText` / `renderTextToCanvas` |
@@ -69,7 +70,7 @@ const bg = background(pricing, { preset: halftone, clear: pricingCopy });
69
70
 
70
71
  The 26 presets, as export → `id` (name):
71
72
 
72
- - GPU glyph field (WebGL2, shares the context budget): `drift` → `drift` (Drift), `halftone` → `halftone` (Halftone), `wordField` → `field` (Field), `fluid` → `fluid` (Fluid).
73
+ - GPU glyph field (WebGL2, shares the context budget): `drift` → `drift` (Drift), `halftone` → `halftone` (Halftone), `swell` → `swell` (Swell), `wordField` → `field` (Field), `fluid` → `fluid` (Fluid).
73
74
  - 2D canvas (no WebGL): `caustics` → `caustics` (Caustics), `contour` → `contour` (Contour), `facet` → `facet` (Facet), `etch` → `etch` (Etch), `moire` → `moire` (Moiré), `weave` → `weave` (Weave), `veil` → `veil` (Veil), `dune` → `dune` (Dune), `wave` → `wave` (Wave), `aurora` → `aurora` (Aurora), `noise` → `noise` (Noise), `silk` → `silk` (Silk), `rain` → `rain` (Rain), `stars` → `stars` (Stars), `pulse` → `pulse` (Pulse), `grid` → `grid` (Grid), `voidWell` → `void` (Void), `morph` → `morph` (Morph), `fire` → `fire` (Fire), `dna` → `dna` (DNA), `terrain` → `terrain` (Terrain), `circuit` → `circuit` (Circuit).
74
75
 
75
76
  `voidWell` is the export for the `void` preset, because `void` is reserved in JavaScript. `wordField`'s id is `field`, the name the site uses. `fieldBackground` is separate: its `hero`, `brand` and `notFound` presets are the asciify.org hero ground, the same ground in the brand colour, and the 404.
@@ -191,7 +192,7 @@ Keep these as sibling layers. A transformed or isolated content wrapper can trap
191
192
 
192
193
  ## Studio composition and export
193
194
 
194
- For optional wallpaper/backdrops, alternate renderers, palettes, masks, saved looks, text fonts, or deterministic MP4/GIF export, read [references/studio-workspace.md](references/studio-workspace.md). `/studio` is separate from `/hover` and `AsciiOptions`; do not replace an existing hero pipeline just to add a backdrop.
195
+ For a finished artwork in a ready look (wallpaper, poster, cover, print), start from [references/looks.md](references/looks.md): 22 looks, each with the prompt, exact settings, and wallpaper/poster/loop export sizes. For optional wallpaper/backdrops, alternate renderers, palettes, masks, saved looks, text fonts, or deterministic MP4/GIF export, read [references/studio-workspace.md](references/studio-workspace.md). `/studio` is separate from `/hover` and `AsciiOptions`; do not replace an existing hero pipeline just to add a backdrop.
195
196
 
196
197
  ## GPU glyph fields
197
198
 
@@ -93,7 +93,7 @@ Text never sits on raw characters, and never in a box. Two layers do the work:
93
93
 
94
94
  | Surface | Cell size | Characters | Site reference |
95
95
  | --- | --- | --- | --- |
96
- | Page hero ground | 10–12px, broad shapes | Sparse: `' .:+*#'` in the mono face | `fieldBackground` `hero` (10px), `drift` (11px), `halftone` (14px dot spacing) |
96
+ | Page hero ground | 10–12px, broad shapes | Sparse: `' .:+*#'` in the mono face | `fieldBackground` `hero` (10px), `drift` (11px), `halftone` (14px dot spacing), `swell` (10px dot spacing) |
97
97
  | Section band or card stage | 7–10px | Fuller ramp for images (`' .:-=+*#%@'`), quiet ramp for grounds | images 7px, character fields 10px, halftone 9px |
98
98
  | Control: button, pill, tabs, code input, odometer | Leave it to the kit | The kit fits cells to the element's text | Use a monospaced label for `buttonLoading` |
99
99
 
@@ -191,7 +191,7 @@ publishButton.addEventListener('click', async () => {
191
191
 
192
192
  | Surface | Presets | Notes |
193
193
  | --- | --- | --- |
194
- | Product hero, pricing, docs landing, behind copy | `drift`, `halftone`, or `fieldBackground` `hero` | GPU grounds that clear around `clear`. `halftone` is the calmest; `fieldBackground` `hero` is asciify.org's own hero, with ripples and `pulse()`. |
194
+ | Product hero, pricing, docs landing, behind copy | `drift`, `halftone`, `swell`, or `fieldBackground` `hero` | GPU grounds that clear around `clear`. `halftone` is the calmest; `swell` is the finest, a dot grid in slow waves that stays quiet in the middle even without `clear`; `fieldBackground` `hero` is asciify.org's own hero, with ripples and `pulse()`. |
195
195
  | A brand or name moment | `wordField` (id `field`), `fieldBackground` `brand` | Your word in the field; keep copy off its centre. |
196
196
  | Art-first hero with little copy | `fluid` | The original Asciify hero, 7px cells at 0.7 opacity. Not behind reading text. |
197
197
  | Quiet texture: section bands, footers, empty states, side panels | `caustics`, `contour`, `facet`, `etch`, `moire`, `weave`, `veil`, `dune`, `silk` | Canvas, no cursor, slow. The safest behind text after `halftone`. |
@@ -38,14 +38,14 @@ reply.then(() => orb.done());
38
38
  Category: AI
39
39
 
40
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.”
41
+ “Make a ⌘K command palette for [our app] with Asciify: it opens by condensing out of characters at the trigger, a soft highlight glides between rows, rows a search brings in resolve out of characters, and the AI row shimmers as a crest turns its letters to characters and back. Enter runs the action with a pulse.”
42
42
 
43
43
  ```ts
44
44
  import { commandPalette } from 'asciify-engine/kit';
45
45
 
46
- // ⌘K or the trigger opens it; arrows, Enter and Escape are wired; rows are [role="option"] with an id
46
+ // ⌘K or the trigger opens it; arrows, Enter and Escape are wired; rows are [role="option"] with an id, AI rows carry data-ai
47
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
48
+ searchInput.oninput = () => renderRows(searchInput.value); // the kit re-selects the first row; new rows resolve out of characters
49
49
  ```
50
50
 
51
51
  ## button-loading: Button loading
@@ -68,15 +68,16 @@ deployButton.onclick = async () => {
68
68
  Category: Components
69
69
 
70
70
  Prompt:
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.”
71
+ “Make [our status badges] Asciify pills: soft tinted pills with one glyph before the label, a dot in the status tone that breathes while live, turns as a half-filled spinner while syncing and blinks when a job fails; a solid ink New pill whose sparkle twinkles on hover; and a count that rolls through glyphs. Keep every character inside its pill and off the text.”
72
72
 
73
73
  ```ts
74
74
  import { pill } from 'asciify-engine/kit';
75
75
 
76
- // <span class="pill" data-tone="green" data-live><span data-dot></span>Live</span>, data-tone="new", data-count
76
+ // <span class="pill" data-tone="green" data-live><span data-dot></span><span>Live</span></span>
77
+ // <span class="pill" data-tone="new"><span data-dot></span><span>New</span></span>, <span class="pill" data-count>12</span>
77
78
  const pills = pill(document.querySelectorAll<HTMLElement>('.pill'));
78
- statusPill.dataset.tone = 'amber';
79
- pills.refresh(); // tone changes ease over 240ms
79
+ statusPill.dataset.tone = 'red';
80
+ pills.refresh(); // tone changes ease over 240ms; red blinks the dot
80
81
  pills.count(14); // only the changed digits roll
81
82
  ```
82
83
 
@@ -84,13 +85,14 @@ pills.count(14); // only the changed digits roll
84
85
  Category: Components
85
86
 
86
87
  Prompt:
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.”
88
+ “Make [our segmented tabs] Asciify tabs: a crisp pill glides to the active tab, its leading end first, with a row of characters along its foot that streaks toward the new tab and settles into a tapered line. The panel's figure rolls like an odometer and its text slides in from the direction of travel; arrow keys, Home and End switch tabs.”
88
89
 
89
90
  ```ts
90
- import { tabs } from 'asciify-engine/kit';
91
+ import { odometer, tabs } from 'asciify-engine/kit';
91
92
 
92
- // role="tab" buttons with aria-controls; panels crossfade on [data-active] in your CSS
93
- const t = tabs(tablist, { duration: 280, panels: 'attribute' });
93
+ // <span class="thumb"> in the tablist, placed from --tabs-x / --tabs-y / --tabs-w / --tabs-h; role="tab" buttons
94
+ const figure = odometer(valueEl, { value: 1_280_000, format: compact });
95
+ const t = tabs(tablist, { panels: 'none', onChange: i => figure.update({ value: VALUES[i], format: FORMATS[i] }) });
94
96
  t.select(2);
95
97
  ```
96
98
 
@@ -155,16 +157,19 @@ replayButton.onclick = () => d.replay();
155
157
  Category: Components
156
158
 
157
159
  Prompt:
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.”
160
+ “Build [our visitors analytics card] with bars drawn in Asciify characters: crisp glyph columns with a denser cap, today lit, growing in left to right. Hover or touch scrubs the bars and rolls the headline to that day; the 7d/30d toggle glides the shared days into place while the rest grow in.”
159
161
 
160
162
  ```ts
161
- import { blockChart } from 'asciify-engine/kit';
163
+ import { blockChart, odometer } from 'asciify-engine/kit';
162
164
 
165
+ let range = '7d';
166
+ const headline = odometer(totalEl, { value: total[range] });
167
+ // plot: touch-action: pan-y; draw the axis labels in HTML below it.
163
168
  const chart = blockChart(plot, {
164
- values: visits['7d'], labels: days['7d'],
165
- onHover: (i, v, day) => { readout.textContent = i === null ? '' : `${day} · ${v!.toLocaleString()}`; },
169
+ values: visits['7d'], labels: days['7d'], look: { cellSize: 4.5 }, // small cells keep a gap between 30 bars
170
+ onHover: (i, v, day) => { label.textContent = day ?? 'Visitors'; headline.set(i === null ? total[range] : v!); },
166
171
  });
167
- rangeToggle.onchange = () => chart.set(visits[rangeToggle.value], days[rangeToggle.value]);
172
+ rangeToggle.onchange = () => { range = rangeToggle.value; chart.set(visits[range], days[range]); headline.set(total[range]); };
168
173
  ```
169
174
 
170
175
  ## route-transition: Page transition
@@ -176,8 +181,8 @@ Prompt:
176
181
  ```ts
177
182
  import { pageDissolve, blockChart } from 'asciify-engine/kit';
178
183
 
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
184
+ const page = pageDissolve(view); // one wave from the clicked tab, about 430ms
185
+ const chart = blockChart(chartSlot, { values: series.overview, lazy: false }); // at rest the chart stays in characters
181
186
  for (const tab of navTabs) tab.onclick = () => page.swap(() => { renderView(tab.dataset.page!); chart.set(series[tab.dataset.page!]); }, tab);
182
187
  ```
183
188
 
@@ -213,7 +218,7 @@ const code = codeInput(codeRow, { length: 6, onComplete: async c => code.verify(
213
218
  Category: Components
214
219
 
215
220
  Prompt:
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.”
221
+ “Make [our toasts] with Asciify: each one rises in and gathers out of characters, the stack layers by tone and soft shadow with no outlines, hover fans it out, and dismissing or swiping a toast away breaks it into glyphs that drift the way it went. Include Undo.”
217
222
 
218
223
  ```ts
219
224
  import { toastStack } from 'asciify-engine/kit';
@@ -308,15 +313,15 @@ resetButton.onclick = () => c.set(50, true);
308
313
  Category: Media
309
314
 
310
315
  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.”
316
+ “Build an image accordion for [our prints]: [five photos] as panels drawn in Asciify characters. The hovered, tapped or focused panel widens on one smooth spring while its characters cross over to the full photo, with a caption; a quick sweep across the strip doesn't open every panel on the way.”
312
317
 
313
318
  ```ts
314
319
  import { accordion } from 'asciify-engine/kit';
315
320
 
316
- // give the strip a height; panels open on hover, press or focus
321
+ // give the strip a height; panels open on hover (with intent), tap, press or focus
317
322
  accordion(printStrip, {
318
323
  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,
324
+ duration: 420,
320
325
  });
321
326
  ```
322
327
 
@@ -324,7 +329,7 @@ accordion(printStrip, {
324
329
  Category: Media
325
330
 
326
331
  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.”
332
+ “Make an image dropzone with Asciify: a quiet ring of the lightest characters frames the drop area. On drag-over, packets of denser characters chase round it and a halo leans in toward the file. A dropped image opens out from the drop point in characters, with Replace and Download PNG.”
328
333
 
329
334
  ```ts
330
335
  import { dropzone } from 'asciify-engine/kit';
@@ -355,14 +360,14 @@ async function removeRow(row: HTMLElement, id: string) {
355
360
  Category: Components
356
361
 
357
362
  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.”
363
+ “Add pull to refresh to [our feed]: pulling draws a small, iOS-sized ring of Asciify characters round in the gap, closing with a small pop at the threshold. Let go and it opens into a spinning arc while refreshing, draws a check, and new [posts] resolve in from characters. Include a Refresh button.”
359
364
 
360
365
  ```ts
361
366
  import { pullRefresh } from 'asciify-engine/kit';
362
367
 
363
- // feedScreen: overflow auto, content in its first child; the ring spins until onRefresh resolves, then draws a check
368
+ // feedScreen: overflow auto, content in its first child; the 26px ring spins until onRefresh resolves, then draws a check
364
369
  // 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() });
370
+ const pull = pullRefresh(feedScreen, { threshold: 64, reveal: '[data-new]', onRefresh: () => loadNewPosts() });
366
371
  refreshButton.onclick = () => void pull.refresh();
367
372
  ```
368
373
 
@@ -399,7 +404,7 @@ refreshButton.onclick = async () => mrr.set(await fetchMrr());
399
404
  Category: Pages
400
405
 
401
406
  Prompt:
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.”
407
+ “Make a split sign-in page with Asciify: [image] in characters on the left that trail under the cursor, and on the right a labelled email field (a filled pill with a mail icon, a focus ring on :focus-visible, the error in the label row so nothing reflows), Continue and Continue with GitHub. Continue pulses the art and shows a loading glyph while the link sends.”
403
408
 
404
409
  ```ts
405
410
  import { artPanel } from 'asciify-engine/kit';
@@ -473,29 +478,20 @@ const v = asciiVideo(player, { src: '/media/magnolia.mp4', poster: '/media/magno
473
478
  playButton.onclick = () => playButton.setAttribute('aria-label', v.toggle() ? 'Pause' : 'Play');
474
479
  ```
475
480
 
476
- ## hero-background: Hero background
481
+ ## hero-background: Background
477
482
  Category: Backgrounds
478
483
 
479
484
  Prompt:
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.”
485
+ “Put the Asciify Swell background behind our hero: a fine dot grid that swells in slow rolling waves, quiet in the middle. Keep it clear around the headline and let it answer the cursor.”
481
486
 
482
487
  ```ts
483
- import { fieldBackground } from 'asciify-engine/kit';
488
+ import { background, swell } from 'asciify-engine/kit';
484
489
 
485
- const bg = fieldBackground(heroSection, { preset: 'hero', clear: heroText, ripple: true });
486
- subscribeButton.onclick = () => bg.pulse(subscribeButton, 1.4);
490
+ // One of 27 presets. Import only the one you use; bg.setPreset(other) cross-fades to another.
491
+ const bg = background(heroSection, { preset: swell, clear: heroText });
487
492
  ```
488
493
 
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);
497
- }
498
- ```
494
+ Any preset from [the kit reference](kit.md#backgrounds) fits the same call: `swell`, `drift`, `halftone`, `wordField` and `fluid` run on the GPU glyph field; the rest are 2D canvases. For asciify.org's own hero ground, with the frosted veil and `pulse()`, use `fieldBackground(heroSection, { preset: 'hero', clear: heroText })`.
499
495
 
500
496
  ## Advanced: raw field
501
497
 
@@ -34,8 +34,9 @@ Size the canvas with CSS (for example `position:absolute; inset:0; width:100%; h
34
34
  | Option | Default | Notes |
35
35
  | --- | --- | --- |
36
36
  | `source` | none | URL (`.mp4`, `.webm`, `.mov` load as muted, looping, inline video), `HTMLImageElement`, `HTMLVideoElement`, `HTMLCanvasElement` or `ImageBitmap`. Cross-origin media needs CORS. |
37
- | `scene` | `'flow'` | Generated motion when there is no source: `'flow'` (domain-warped noise), `'drift'` (a few broad lobes, made for halftone, 4.12), `'orb'` (lit sphere) or `'none'` (blank, wake only). |
37
+ | `scene` | `'flow'` | Generated motion when there is no source: `'flow'` (domain-warped noise), `'drift'` (a few broad lobes, made for halftone, 4.12), `'swell'` (three crossing sine swells rolling diagonally, made for a fine dot grid), `'orb'` (lit sphere) or `'none'` (blank, wake only). |
38
38
  | `mark`, `dotScale` | `'glyph'`, `0.9` | `'dot'` draws a halftone: one circle per cell whose area follows tone, antialiased at any pixel ratio. `dotScale` is the largest dot as a fraction of the cell; about 0.6 keeps a calm, open grid. (4.12) |
39
+ | `mark` (Studio styles) | | `FIELD_MARKS` also holds the Studio's tile and print styles, drawn per cell in the composite pass from the cell's tone and colour (one shader branch each, no extra pass or texture). Tiles, which fade with tone in `color: 'ink'` and stay solid in `'source'`: `'pixel'` (filled cell), `'mosaic'` (gapped tile), `'lego'` (gapped brick lit from above, with a ringed stud), `'voxel'` (isometric cube, faces at 1, 0.72 and 0.42), `'disco'` (gapped tile, lit upper facet, shaded lower), `'hex'` (flat-topped hexagon), `'led'` (round lamp, soft halo, glint). `'dither'` lights a 4x4 Bayer screen of about five dots a cell. `'cmyk'` separates the cell's hue into offset cyan, magenta and yellow dots, with the neutral share as a key dot in the ink's own lightness. Print marks, whose size carries the tone: `'stipple'` (four jittered dots a cell), `'engraving'` (parallel cuts at -35°, running on across cells), `'crosshatch'` (two more directions join in darker tones), `'woodblock'` (broad carved bands in five steps, edges wavering), `'risograph'` (main ink and a coral second ink, offset), `'pointillism'` (four small strokes, their shade varied). Every mark but `'dot'` leaves blank the cells the `charset` leaves blank (its first glyph), so keep a ramp that starts with a space. Cells near square suit them best. |
39
40
  | `scale` | `1` | Feature size of generated scenes; higher is finer. Use about 1.8 on phones, where the field is tall. (4.12) |
40
41
  | `clear` | `false` | Keeps the field quiet around content so text stays readable: an element, `{ element, padding, feather, shape }` (followed through layout changes), or a zone in field fractions. (4.12) |
41
42
  | `charset` | `' .,:-=+*#%@'` | Ordered from empty to dense. Graphemes, so emoji, braille and block shading each take one cell. Up to 256. |
@@ -19,7 +19,7 @@ Every function takes these options:
19
19
  | `reducedMotion` | the visitor's setting | `true` or `false` to override `prefers-reduced-motion`. Reduced motion draws each state settled, in one frame. |
20
20
  | `lazy` | `true` | Mount the field only near the viewport and release it far from it. `false` mounts at once, wherever the element is. See Performance and integration. |
21
21
  | `poster` | none | A still image under the field: shown until the first GPU frame, while the field is released, and in its place without WebGL. |
22
- | `globalLook` | `true` | `false` keeps this field's own characters when `setGlobalLook` re-skins the page. |
22
+ | `globalLook` | `true` | `false` keeps this field's own characters and mark when `setGlobalLook` re-skins the page. |
23
23
 
24
24
  Every handle has `supported`, `field` (the underlying `GlyphField`, or `null` without WebGL), `update(options)`, `play()`, `pause()` and `destroy()`. `update` merges, and `look` merges key by key, so `update({ look: { cellSize: 6 } })` keeps an earlier `charset`; a look key set to `undefined` is removed, so the component's own value comes back. `update({ poster })` points the poster at a new still, such as the other theme's, without remounting. Components add their own methods, listed below.
25
25
 
@@ -102,37 +102,51 @@ Options: `blocks` (selector, default `[data-block]`; `reset()` fades loaded cont
102
102
 
103
103
  ## pill
104
104
 
105
- Pills and badges with one strip of characters each: the cell row nearest the pill's centre, clipped to whole cells that clear its rounded ends, so no character reaches the edge. The pill's own CSS draws its fill; the field adds a breathing three-character live indicator on status pills, a sweep across live states, a sheen on `new` pills and rolling digits on count pills. Pass one pill to put the field under its text, or a list to share one field (one WebGL context) over their common container; then each pill's labels are lifted above the field (`position: relative; z-index: 2`, bare text wrapped in a span).
105
+ Pills and badges whose characters stay inside them. The pill's own CSS draws its fill and label; the field draws one glyph before the label and is clipped to a stadium one cell row tall inside each pill, so nothing reaches an edge or crosses the text. Status pills get a dot in their tone: it breathes while live, turns as a half-filled spinner (`◐◓◑◒`) while an amber pill is live (syncing), blinks open twice when a pill turns red, and is an open ring for `neutral`. A `new` pill gets a sparkle (`✦`) that twinkles when the pointer arrives. Count pills roll their changed digits through the dot glyphs. Pass one pill to put the field under its text, or a list to share one field (one WebGL context) over their common container; then each pill's labels are lifted above the field (`position: relative; z-index: 2`, bare text wrapped in a span).
106
106
 
107
107
  ```html
108
108
  <span class="pill" data-tone="green" data-live><span data-dot></span><span>Live</span></span>
109
- <span class="pill" data-tone="new">New</span>
109
+ <span class="pill" data-tone="new"><span data-dot></span><span>New</span></span>
110
110
  <span class="pill" data-count>12</span>
111
111
  ```
112
112
 
113
113
  ```ts
114
114
  const pills = pill(document.querySelectorAll('.pill'));
115
115
  statusPill.dataset.tone = 'red'; delete statusPill.dataset.live;
116
- pills.refresh(); // tone changes ease over 240ms
116
+ pills.refresh(); // tone changes ease over 240ms; red blinks the dot
117
117
  pills.count(14); // only the changed digits roll
118
118
  ```
119
119
 
120
- Kinds: `data-count` makes a count pill, `data-tone="new"` a sheen pill, anything else a status pill. Options: `tone` (fallback for `data-tone`: `green | amber | red | neutral | new`, default `neutral`), `live` (fallback for `data-live`: the indicator breathes from `.#.` to `*#*` and a sweep crosses every few seconds; default false), `indicator` (draw the indicator over the pill's `[data-dot]`, adding a 15px one when missing; default true), `sheen` (when the pointer moves onto a `new` pill, default true), `tones` (`{ light, dark }` maps of `[r, g, b]` glyph hues). Methods: `sheen(pill?)`, `count(value, pill?)`, `refresh()`. Count pills keep a visually hidden copy of the number for screen readers.
120
+ Kinds: `data-count` makes a count pill, `data-tone="new"` a sparkle pill, anything else a status pill. Options: `tone` (fallback for `data-tone`: `green | amber | red | neutral | new`, default `neutral`), `live` (fallback for `data-live`; default false), `indicator` (draw the glyph over the pill's `[data-dot]`, adding a 12px one when missing; default true), `sheen` (twinkle a `new` pill's sparkle when the pointer moves onto it, default true), `tones` (`{ light, dark }` maps of `[r, g, b]` glyph hues; the defaults are `PILL_TONES`, which also suit the pills' CSS text and fill). Methods: `sheen(pill?)` (twinkle), `count(value, pill?)`, `refresh()`. Count pills keep a visually hidden copy of the number for screen readers.
121
+
122
+ The field uses square 12px cells sampled exactly, so a glyph never leaves a ghost in its neighbours. It moves its grid under the first status dot and nudges any other pill by under half a cell (inline `translate`), so every glyph sits centred; lay pills out in a column (same width, same edge) and rows on multiples of 12px and nothing moves at all. Keep pills at least one cell from the shared container's right and bottom edge. The charset is the kit's own glyph table, so `globalLook` does not apply and `look.charset` should be left alone.
121
123
 
122
124
  ## tabs
123
125
 
124
- Segmented tabs whose indicator is a pill of characters that streams across and condenses under the new tab. The kit wires clicks, arrow keys, Home and End, `aria-selected`, roving `tabindex` and the panels named by `aria-controls`.
126
+ Segmented tabs with a gliding indicator. The pill is yours, so its outline stays crisp: style any element from `--tabs-x`, `--tabs-y`, `--tabs-w` and `--tabs-h`, which the kit sets on the tablist every frame (and once at rest without WebGL). The leading end goes first and the trailing end catches up, so it stretches and settles; a new choice mid-glide leaves from where it is. The field draws a row of characters along the pill's foot that glides with it (the grid moves with the indicator, so characters never step from cell to cell), runs dense toward the leading end in flight and settles into an even, tapered line. The kit wires clicks, arrow keys, Home and End, `aria-selected`, roving `tabindex` and the panels named by `aria-controls`.
127
+
128
+ ```html
129
+ <div role="tablist" class="tabs">
130
+ <span class="thumb"></span>
131
+ <button role="tab" aria-controls="p1">Overview</button> …
132
+ </div>
133
+ <style>
134
+ .tabs { position: relative; }
135
+ .thumb { position: absolute; z-index: -2; left: var(--tabs-x); top: var(--tabs-y); width: var(--tabs-w); height: var(--tabs-h); border-radius: 999px; background: var(--surface-2); }
136
+ [role="tab"] { position: relative; z-index: 2; padding-bottom: 6px; } /* room for the row */
137
+ </style>
138
+ ```
125
139
 
126
140
  ```ts
127
141
  const t = tabs(tablist, { onChange: i => track('tab', i) });
128
142
  t.select(2);
129
143
  ```
130
144
 
131
- Options: `tabs` (selector, default `[role="tab"]`), `active` (default: the tab with `aria-selected="true"`), `duration` (ms, default 280), `panels` (`'hidden'` toggles the attribute, `'attribute'` toggles `data-active`, `'none'`), `manage` (default true; false leaves events and ARIA to you), `onChange(index, tab)`. Methods: `select(i, focus?)`; getter `active`. The tablist needs a transparent background where the indicator shows; the field sits above the tablist's own background and below the tab labels.
145
+ Options: `tabs` (selector, default `[role="tab"]`), `active` (default: the tab with `aria-selected="true"`), `duration` (ms, default 440), `panels` (`'hidden'` toggles the attribute, `'attribute'` toggles `data-active`, `'none'`), `manage` (default true; false leaves events and ARIA to you), `onChange(index, tab)`. Methods: `select(i, focus?)`; getter `active`. The field sits above the tablist's own background (`z-index: -1` in its stacking context) and below the tab labels, so put the pill element at `z-index: -2`. Ink defaults to `--ascii-ink`. Under reduced motion the indicator moves at once. For a figure in the panel that rolls on change, pair it with `odometer` and `update({ value, format })`.
132
146
 
133
147
  ## imageGenerate
134
148
 
135
- An image being generated: the field drifts with an elapsed clock, each streamed preview develops in a step sharper, then the final image, and the photo crossfades in. Wrap any provider (OpenAI partial images, Replicate or fal streams, a poll loop) as an async iterable of `{ preview?, final? }`, each a Blob or URL.
149
+ An image being generated: the field drifts with an elapsed clock, each streamed preview develops in a step sharper, then the final image, and the photo crossfades in. Every change (a preview, the final, a reset) develops over the same 680ms sine curve whenever it arrives, an incoming picture lays over the outgoing one so the total never jumps, a preview keeps sharpening and the ground keeps drifting faintly over it until the next update, and the photo starts in (560ms) while the characters settle on the final. A new `run()` fades the photo back out over its characters. Wrap any provider (OpenAI partial images, Replicate or fal streams, a poll loop) as an async iterable of `{ preview?, final? }`, each a Blob or URL.
136
150
 
137
151
  ```ts
138
152
  const gen = imageGenerate(frame, { elapsed: clock }); // frame holds the <img> that shows the result
@@ -168,6 +182,7 @@ const orb = statusOrb(el, { poster: '/stills/orb-dark.webp' });
168
182
 
169
183
  // A header "Characters" menu: re-skins every glyph field at once.
170
184
  setGlobalLook({ charset: ' .:-=+YCIFSA#@' });
185
+ setGlobalLook({ style: 'lego' }); // any of the Studio's 24 styles, KIT_STYLES
171
186
  setGlobalLook(null); // each field gets its own characters back
172
187
  const off = subscribeGlobalLook(look => menu.value = look?.charset ?? '');
173
188
 
@@ -175,7 +190,9 @@ const off = subscribeGlobalLook(look => menu.value = look?.charset ?? '');
175
190
  orb.update({ ink: '--brand', look: { cellSize: 6 } });
176
191
  ```
177
192
 
178
- `setGlobalLook({ charset })` balances the set's weight against classic ASCII: block and solid sets draw smaller glyphs so they keep detail, sets without light glyphs (binary) ease off, and faint sets (minimal, braille, code) draw larger, bolder glyphs; tones are untouched. It applies to every glyph field, live or released, except dot marks, fields whose own `charset` holds letters (a word or a brand, such as `wordmark`), and fields created with `globalLook: false` (use it for a locked region). An empty `charset` keeps each field's own. `getGlobalLook()` reads the current look, so `subscribeGlobalLook` pairs with React's `useSyncExternalStore`.
193
+ `setGlobalLook({ style })` takes one of the Studio's 24 render styles (`KIT_STYLES`, typed `KitStyle`, in `STUDIO_STYLES` order). Glyph-like styles (`ascii`, `blocks`, `braille`, `lines`, `cross`, `diagonal`, `diamond`, `mixed`) bring their ramp, which a `charset` beside them overrides; `dots` is the halftone mark; the rest (`pixel`, `mosaic`, `lego`, `voxel`, `disco`, `dither`, `hex`, `led`, `cmyk`, `stipple`, `engraving`, `crosshatch`, `woodblock`, `risograph`, `pointillism`) switch the field's `mark` and keep each field's own charset, which decides the cells left blank. Unknown styles and empty charsets are dropped. `setGlobalLook(null)` gives each field its own mark and characters back.
194
+
195
+ `setGlobalLook({ charset })` balances the set's weight against classic ASCII: block and solid sets draw smaller glyphs so they keep detail, sets without light glyphs (binary) ease off, and faint sets (minimal, braille, code) draw larger, bolder glyphs; tones are untouched. A look applies to every glyph field, live or released, except dot marks, fields whose own `charset` holds letters (a word or a brand, such as `wordmark`), and fields created with `globalLook: false` (use it for a locked region). An empty `charset` keeps each field's own. `getGlobalLook()` reads the current look, so `subscribeGlobalLook` pairs with React's `useSyncExternalStore`.
179
196
 
180
197
  Each change sweeps: a field that has drawn its first frame holds that frame over itself and wipes it away left to right in 200ms (after a 40ms beat, `cubic-bezier(.23,1,.32,1)`), led by a 2px scan line, as the new characters land under it. The line is `--ascii-sweep` (default amber `#f3ba32`, at 35% with a soft glow); the held frame and the line are an `aria-hidden` canvas and span marked `data-ascii-sweep`, placed exactly over the field (style them alongside the field, for example with the same mask) and removed when the sweep ends. Under reduced motion (the visitor's, or `reducedMotion: true`), and for a field still on its poster, the characters change at once.
181
198
 
@@ -282,7 +299,7 @@ Options: `palette` (`'mono' | 'aurora' | 'brand'` or your own hex stops, default
282
299
 
283
300
  ### commandPalette
284
301
 
285
- The palette condenses out of its trigger in blocks of characters and dissolves back into it, with a glyph marker in the selected row's gutter that glides between rows. The rows' own highlight stays crisp HTML.
302
+ The palette condenses out of its trigger in blocks of characters and dissolves back into it. A crisp HTML highlight glides between rows; rows a search brings in (or moves) resolve out of characters left to right; and a selected AI row shimmers every few seconds, a crest crossing it that turns its letters to characters and back while the row's text is masked under the crest.
286
303
 
287
304
  ```ts
288
305
  const cmd = commandPalette(panel, { trigger, onRun: (i, row) => commands[row.dataset.id!].run() });
@@ -290,7 +307,7 @@ search.oninput = () => render(filter(search.value)); // the kit re-selects row 0
290
307
  cmd.select(2);
291
308
  ```
292
309
 
293
- Options: `trigger`, `input` (default `input`), `rows` (default `[role="option"]`), `open` (default: open unless the panel has `hidden`), `hotkey` (⌘K / Ctrl+K, default `'k'`, `false` for none), `manage` (arrow keys, Home, End, Enter, Escape, hover, outside clicks, `aria-selected` and `aria-activedescendant`; default true), `closeOnRun` (default true), `onRun(index, row)`, `onOpenChange(open)`. Methods: `open(focus?)`, `close()`, `toggle()`, `select(i)`, `run(i?)`; getters `isOpen`, `selected`. Closed is the `hidden` attribute; `data-state` is `opening` or `closing` in flight, and closing fades the panel's surface as its clip lands so the trigger shows through. Give rows an `id` and a little left padding for the marker; the field lies over the panel (it ignores the pointer) and draws only in that gutter, so the marker shows on top of the row's own highlight.
310
+ Options: `trigger`, `input` (default `input`), `rows` (default `[role="option"]`), `open` (default: open unless the panel has `hidden`), `hotkey` (⌘K / Ctrl+K, default `'k'`, `false` for none), `manage` (arrow keys, Home, End, Enter, Escape, hover, outside clicks, `aria-selected` and `aria-activedescendant`; default true), `closeOnRun` (default true), `onRun(index, row)`, `onOpenChange(open)`, `highlight` (default true; `false` if your rows style `aria-selected` themselves), `ai` (rows that shimmer, default `[data-ai]`). Methods: `open(focus?)`, `close()`, `toggle()`, `select(i)`, `run(i?)`; getters `isOpen`, `selected`. Closed is the `hidden` attribute; `data-state` is `opening` or `closing` in flight, and closing fades the panel's surface as its clip lands so the trigger shows through. Give rows an `id`. The kit adds a `[data-ascii-cmd-highlight]` element as the panel's first child, sized to the selected row with its radius; colour it with `--ascii-cmd-highlight` (default 7% of the text colour). The panel becomes its own stacking context (`isolation: isolate`) and the field lies under the content and over the panel's fill (z-index -1, ignoring the pointer), so the characters never cover text. Rows are watched (a MutationObserver), so a framework re-rendering them after the input event is fine. Without WebGL2 the highlight, keys and the open and close clip still work.
294
311
 
295
312
  ### slideToConfirm
296
313
 
@@ -316,25 +333,25 @@ Options: `kind` (`'heart' | 'save'`), `icon` (default the first `svg`), `manage`
316
333
 
317
334
  ### blockChart
318
335
 
319
- A bar chart in columns of characters: the bars grow in staggered when it first comes into view, the last is emphasised, and hover or the arrow keys light one bar and dim the rest.
336
+ A bar chart in columns of characters on a whole-cell grid: every bar is a crisp block of glyphs with one denser cap row, sitting on a dotted baseline. The bars grow in staggered when the chart first comes into view, the last ("today") is lit, and hover, touch or the arrow keys light one bar and fade the rest. `set()` morphs: bars whose label survives glide and resize into place, new ones grow in, dropped ones sink away.
320
337
 
321
338
  ```ts
322
- const chart = blockChart(plot, { values: visits, labels: days, onHover: (i, v, day) => readout(i === null ? total : v, day) });
323
- range.onchange = () => chart.set(nextVisits, nextDays);
339
+ const chart = blockChart(plot, { values: visits, labels: days, onHover: (i, v, day) => headline.set(i === null ? total : v!) });
340
+ range.onchange = () => chart.set(nextVisits, nextDays); // shared days glide, e.g. the last 7 of 30
324
341
  ```
325
342
 
326
- Options: `values`, `labels`, `max` (default the largest plus 4%), `duration` (ms, default 520), `stagger` (default 45ms up to 7 bars, else 9), `format` (for the screen-reader table), `intro` (grow the bars in when first seen, default true; `false` draws them in place, as in a view that is swapped in), `onHover(index, value, label)`. Methods: `set(values, labels?)`, `highlight(i | null)`; getters `active`, `values`. Adds a visually hidden table of the values and makes the plot focusable. Draw axes and the readout in HTML. Without WebGL2 the bars are plain blocks in the text colour, with the same emphasis.
343
+ Options: `values`, `labels` (also how `set()` matches bars; without labels they match from the right), `max` (default the largest plus 4%), `duration` (ms for growing, gliding and resizing, default 560), `stagger` (default 45ms up to 7 bars, else 12), `format` (for the screen-reader table), `intro` (grow the bars in when first seen, default true; `false` draws them in place, as in a view that is swapped in), `onHover(index, value, label)`. Methods: `set(values, labels?)`, `highlight(i | null)`; getters `active`, `values`. Cells are square (`look.cellSize`, default 7px) and the canvas is trimmed to whole cells, centred on the plot's bottom edge, so every glyph lands exactly; bars take whole cells, with gaps of about half a bar, and drop the gaps only when there are more bars than fit (about `width / 2` cells): for 30 bars in a 300px plot pass `look: { cellSize: 4.5 }`. Adds a visually hidden table of the values and makes the plot focusable; set `touch-action: pan-y` on the plot so a finger can scrub. Draw the axis labels and the readout in HTML, and roll the readout with `odometer`. Without WebGL2 the bars are plain blocks in the text colour, with the same emphasis.
327
344
 
328
345
  ### pageDissolve
329
346
 
330
- The view breaks into its own characters from where the user clicked, the content swaps underneath, and the next view condenses out of characters: about 300ms end to end.
347
+ One wave of characters from where the user clicked: the crisp view crossfades into its own characters and breaks up outward, the content swaps underneath once the old HTML has faded, the next view condenses in behind the same wave, and it crossfades back to crisp HTML. About 430ms end to end, never a frame with neither view on screen.
331
348
 
332
349
  ```ts
333
350
  const page = pageDissolve(body);
334
351
  tab.onclick = () => page.swap(() => render(tab.dataset.page), tab);
335
352
  ```
336
353
 
337
- Options: `origin` (an element or `{ x, y }`, default top centre), `dissolve` (ms, default 140), `condense` (ms, default 160). Methods: `swap(render, origin?)` (a promise; swaps queue, and `render` may be async); getter `busy`. The kit draws the view from its DOM (text in its own font, filled and bordered boxes, media as blocks), never from image pixels; another kit component inside the view, such as a `blockChart`, is drawn as its settled frame, and kit posters and screen-reader-only text are left out. Sets `data-state` (`out`, `in`). Under reduced motion or without WebGL2, `render` runs at once.
354
+ Options: `origin` (an element or `{ x, y }`, default top centre), `dissolve` (ms the old view takes to break up, default 300), `condense` (ms the new view takes to land, default 300; it starts about 110ms in, once the old HTML has faded). Methods: `swap(render, origin?)` (a promise; swaps queue, and `render` may be async); getter `busy`. The kit draws the view from its DOM (text in its own font, filled and bordered boxes, media as blocks), never from image pixels; another kit component inside the view, such as a `blockChart`, is drawn as its settled frame, and kit posters and screen-reader-only text are left out. Sets `data-state` (`out`, `in`). Under reduced motion, without WebGL2, or while the field waits for a WebGL context, `render` runs at once. It shows no poster: at rest it is a blank layer over the live view. Mount a `blockChart` inside the view with `lazy: false`, so it has its context as the view lands.
338
355
 
339
356
  ### swipeDeck
340
357
 
@@ -376,7 +393,7 @@ const id = toasts.push({ title: 'Thread archived', body: 'Q3 planning · Ana', a
376
393
  toasts.dismiss(id);
377
394
  ```
378
395
 
379
- A toast is `{ title, body?, icon?, action?, duration? }`; `icon` is a node (such as an inline SVG) or a short text. Options: `max` (default 3; the oldest dissolves when another arrives), `duration` (ms each toast stays, default 6000, 0 keeps them; pointing at or focusing the stack holds them), `render(toast, id)` (your own `<li>`; mark parts with `data-paint` = `strong`, `text`, `meta`, `tile`, `icon` or `none` to set how they gather), `label` (the list's name, default `Notifications`), `glint` (default true: at rest a comet of characters runs round the newest toast's edge every 4.2s, outside its text; between passes the field draws nothing). Methods: `push(toast)` (returns an id), `dismiss(id)`, `clear()`; getter `toasts`. The kit adds an `<ol class="ascii-toasts">` and a `role="status"` region that announces each toast. Classes: `ascii-toast`, `-icon`, `-text`, `-title`, `-body`, `-action`, `-close`; `--ascii-toast-bg` and `--ascii-toast-ink` set the surface. The field reaches 24px past the container and lies above every toast, so the glint and gathering characters are never hidden behind one tucked away. A toast pushed before the field is drawing (still mounting, or released off screen) simply shows. The gathering characters are inked like the page, so a toast whose fill is too close to that ink to read them on (contrast under 3:1, such as an inverted dark pill on a light page) keeps its fill and shadow clear, inline and without a transition, until its text lands, then fills in with its own transition; no CSS is needed for it.
396
+ A toast is `{ title, body?, icon?, action?, duration? }`; `icon` is a node (such as an inline SVG) or a short text. Options: `max` (default 3; the oldest dissolves when another arrives), `duration` (ms each toast stays, default 6000, 0 keeps them; pointing at or focusing the stack holds them), `render(toast, id)` (your own `<li>`; mark parts with `data-paint` = `strong`, `text`, `meta`, `tile`, `icon` or `none` to set how they gather), `label` (the list's name, default `Notifications`), `swipe` (default true: drag a toast sideways and it follows, fading; let go past 64px or on a flick and its characters carry on the way it was thrown). `glint` is deprecated and ignored: nothing draws round a toast at rest. Methods: `push(toast)` (returns an id), `dismiss(id)`, `clear()`; getter `toasts`. The kit adds an `<ol class="ascii-toasts">` and a `role="status"` region that announces each toast. Classes: `ascii-toast`, `-icon`, `-text`, `-title`, `-body`, `-action`, `-close`; `--ascii-toast-bg`, `--ascii-toast-ink` and `--ascii-toast-shadow` set the surface; there is no outline, and tucked-behind toasts dim a step each so the stack reads in layers. New toasts rise 14px as they gather; a dismissed toast's neighbours close up at once while its characters drift. Each toast bridges the 8px gap below it, so pointing between fanned toasts keeps the stack open. The field reaches 24px past the container and lies above every toast, so gathering and breaking characters are never hidden behind one tucked away. A toast pushed before the field is drawing (still mounting, or released off screen) simply shows. The gathering characters are inked like the page, so a toast whose fill is too close to that ink to read them on (contrast under 3:1, such as an inverted dark pill on a light page) keeps its fill and shadow clear, inline and without a transition, until its text lands, then fills in with its own transition; no CSS is needed for it.
380
397
 
381
398
  ### thinking
382
399
 
@@ -417,17 +434,17 @@ Options: `image` (a URL, or an `<img>`; one already inside the frame is used in
417
434
 
418
435
  ### accordion
419
436
 
420
- A strip of image panels: the open one shows its photo, the closed ones the same photos in characters. Pointing at, pressing or focusing a panel opens it, and as it opens its characters flare denser for a beat, then give way to the photo.
437
+ A strip of image panels: the open one shows its photo, the closed ones the same photos in characters. Pointing at, tapping or focusing a panel opens it. Each panel's width, photo and caption follow one critically damped spring, so the characters cross over to the photo with no gap or flash, and changing panels mid-move carries the motion on. A mouse sweeping fast across the strip waits about 90ms on a panel before opening it; a slow mouse, a tap or a key opens at once.
421
438
 
422
439
  ```ts
423
440
  const a = accordion(strip, { images: [{ src: '/stairwell.webp', title: 'Stairwell', meta: 'Silver print · $120' }, '/shell.webp'], open: 1 });
424
441
  ```
425
442
 
426
- Each image is a URL or `{ src, alt?, title?, meta?, focus? }`. Options: `images`, `open` (default 0), `duration` (ms to open, default 280), `label` (the group's name, default `Images`), `onChange(index)`. Method: `select(index, focus?)`; getter `open`. The kit adds a `<button class="ascii-acc-panel">` per image with `aria-expanded`, a roving `tabindex` and arrow, Home and End keys. Give the strip a height. Closed panels draw the photo as tone (inverted on light grounds); without WebGL2 they show the photo dimmed.
443
+ Each image is a URL or `{ src, alt?, title?, meta?, focus? }`. Options: `images`, `open` (default 0), `duration` (ms the spring takes to settle, default 420), `label` (the group's name, default `Images`), `onChange(index)`. Method: `select(index, focus?)`; getter `open`. The kit adds a `<button class="ascii-acc-panel">` per image with `aria-expanded`, a roving `tabindex` and arrow, Home and End keys, and sets `--ascii-acc-open`, `--ascii-acc-photo` and `--ascii-acc-caption` (0–1) on each panel every frame for your own CSS. Give the strip a height. Closed panels draw the photo as tone (inverted on light grounds); without WebGL2 they show the photo dimmed.
427
444
 
428
445
  ### dropzone
429
446
 
430
- An image dropzone whose border is a quiet ring of characters that thickens and breathes while a file is dragged over it. A dropped image develops in characters, in its own colours, on a dark ground, and can be saved as a PNG.
447
+ An image dropzone whose border is a quiet ring of the lightest characters. While a file is dragged over it, the ring lifts, packets of denser characters chase round it and a halo a few cells deep leans in on the side nearest the file. A dropped image opens out from the drop point in characters, in its own colours, on a dark ground, and can be saved as a PNG.
431
448
 
432
449
  ```ts
433
450
  const zone = dropzone(box, { onImage: ({ file, width, height }) => upload(file), onError: message => toast(message) });
@@ -435,7 +452,7 @@ zone.setImage('/sample.webp');
435
452
  saveButton.onclick = () => zone.download();
436
453
  ```
437
454
 
438
- Options: `onImage({ file, src, width, height })`, `onError(message, file)`, `maxBytes` (default 10 MB), `develop` (ms, default 720), `filename` (for `download()`, default `<name>-ascii.png`), `onState(state, name)`. Methods: `setImage(url | Blob)`, `clear()`, `pick()` (opens the file picker), `download(filename?)` (resolves with the PNG Blob, or null); getter `state` (`empty`, `developing`, `done`). Elements inside with `data-dropzone="pick"`, `"clear"` or `"download"` are wired to those actions. An empty element gets a title, hint and Choose file button (`.ascii-drop-empty`, hidden once an image shows); with your own content, style it from `data-state` and `data-over` on the zone. The ring is one even row of characters on every side: the canvas is trimmed to whole cells, centred, and the ring is drawn one block per cell, so no side doubles or thins at any size. Its ink defaults to `--ascii-ink`, else mid graphite. The image develops out of the dark after its levels are set, so it reads at full contrast. Give the zone a size and a background. Characters in the photo's own colours need a dark ground, so on a light zone the image is drawn on `#0b0b0a` (pass `look: { background }` for another); `download()` fills with the zone's background, or that ground.
455
+ Options: `onImage({ file, src, width, height })`, `onError(message, file)`, `maxBytes` (default 10 MB), `develop` (ms, default 900), `filename` (for `download()`, default `<name>-ascii.png`), `onState(state, name)`. Methods: `setImage(url | Blob)`, `clear()`, `pick()` (opens the file picker), `download(filename?)` (resolves with the PNG Blob, or null); getter `state` (`empty`, `developing`, `done`). Elements inside with `data-dropzone="pick"`, `"clear"` or `"download"` are wired to those actions. An empty element gets a title, hint and Choose file button (`.ascii-drop-empty`, hidden once an image shows); with your own content, style it from `data-state` and `data-over` on the zone. The ring is one even row of characters on every side: the canvas is trimmed to whole cells, centred, and the ring is drawn one block per cell, so no side doubles or thins at any size. Its ink defaults to `--ascii-ink`, else mid graphite. The image develops in a soft circle from where it was dropped (the centre for `setImage()` and the picker), its wavefront lit; under reduced motion it shows at once and the drag look holds still. Give the zone a size and a background. Characters in the photo's own colours need a dark ground, so on a light zone the image is drawn on `#0b0b0a` (pass `look: { background }` for another); `download()` fills with the zone's background, or that ground.
439
456
 
440
457
  ### disintegrate
441
458
 
@@ -450,7 +467,7 @@ Options: `duration` (ms, default 620), `collapse` (close the row's height, defau
450
467
 
451
468
  ### pullRefresh
452
469
 
453
- Pull to refresh: dragging the content down at the top gathers an orb of characters in the gap, which tightens into a ring at the threshold. Let go past it and the ring spins while `onRefresh` runs, then draws a check and springs back.
470
+ Pull to refresh: dragging the content down at the top draws a small ring of characters round in the gap (26px, the size of iOS's indicator), closing it with a small pop at the threshold. Let go past it and the content rests 48px down while the ring opens into a spinning arc as `onRefresh` runs; then it draws a check and springs back, the ring shrinking away with the gap.
454
471
 
455
472
  ```ts
456
473
  const pull = pullRefresh(screen, { onRefresh: () => loadNewPosts(), reveal: '[data-new]' });
@@ -458,7 +475,7 @@ refreshButton.onclick = () => pull.refresh();
458
475
  pull.revealRows(feed.querySelectorAll('.just-arrived')); // or reveal any rows yourself
459
476
  ```
460
477
 
461
- Options: `onRefresh` (may return a promise; the ring spins at least 600ms), `content` (the element that moves, default the first child), `threshold` (px, default 88), `messages` (`{ refreshing, done, failed }` for the live region), `reveal` (a selector for the rows `onRefresh` added: a frame after it resolves they are hidden behind the check, then resolve in from characters as the content springs back). Methods: `refresh()`, `revealRows(rows)` (each row appears as glyphs of its own text, with its photos drawn as tone rather than flat tiles, following a row that is still opening, then hands over to the HTML over about 760ms; a row is revealed once, and under reduced motion or without WebGL2 rows just show); getter `state` (`idle`, `pulling`, `refreshing`, `done`). Sets `aria-busy` while refreshing. It engages only when the element is scrolled to the top; give it `overflow: hidden` or `auto` and keep a Refresh button for keyboard users.
478
+ Options: `onRefresh` (may return a promise; the ring spins at least 600ms), `content` (the element that moves, default the first child), `threshold` (px, default 64), `messages` (`{ refreshing, done, failed }` for the live region), `reveal` (a selector for the rows `onRefresh` added: a frame after it resolves they are hidden behind the check, then resolve in from characters as the content springs back). Methods: `refresh()`, `revealRows(rows)` (each row appears as glyphs of its own text, with its photos drawn as tone rather than flat tiles, following a row that is still opening, then hands over to the HTML over about 760ms; a row is revealed once, and under reduced motion or without WebGL2 rows just show); getter `state` (`idle`, `pulling`, `refreshing`, `done`). Sets `aria-busy` while refreshing. It engages only when the element is scrolled to the top; give it `overflow: hidden` or `auto` and keep a Refresh button for keyboard users.
462
479
 
463
480
  ## Backgrounds
464
481
 
@@ -472,9 +489,9 @@ bg.update({ palette: { light: '#5b4bd1', dark: '#8f80ff' }, cellSize: 10 });
472
489
  bg.setPreset(aurora); // cross-fades, keeping your options
473
490
  ```
474
491
 
475
- Presets are separate exports, so a page pays only for the ones it imports: `background()` with one canvas preset is about 6.2 KB gzip (6.8 KB for `wave`), and each further canvas preset adds 0.2–0.9 KB (all 22 together: 13.9 KB). `drift`, `halftone`, `wordField` and `fluid` drive the GPU glyph field, so they cost about 7 KB (`fluid` 9.7 KB) plus the shared `asciify-engine/field` build, 17 KB gzip, that every field component uses (and take `lazy`, `poster`, `globalLook` and the context budget like every field component); the rest are 2D canvases and need no WebGL. To offer a menu of several, import those you list.
492
+ Presets are separate exports, so a page pays only for the ones it imports: `background()` with one canvas preset is about 6.2 KB gzip (6.8 KB for `wave`), and each further canvas preset adds 0.2–0.9 KB (all 22 together: 13.9 KB). `drift`, `halftone`, `swell`, `wordField` and `fluid` drive the GPU glyph field, so they cost about 7 KB (`fluid` 9.7 KB) plus the shared `asciify-engine/field` build, 17 KB gzip, that every field component uses (and take `lazy`, `poster`, `globalLook` and the context budget like every field component); the rest are 2D canvases and need no WebGL. To offer a menu of several, import those you list.
476
493
 
477
- Options: `preset` (required), `palette` (one ink or `{ light, dark }`, winning over `color` and `ink`; default the preset's graphite for the ground), `color` (one ink for both grounds), `accent` (heads, peaks and signals for the presets that draw them; default the preset's own, or one mixed from the ink), `speed` (0–5, default the preset's tuned pace, the Speed control on the site), `cellSize` (character size in CSS px, dot spacing for `halftone`; default the preset's), `charset` (characters from empty to dense, or a named set: `standard`, `asciify`, `minimal`, `detailed`, `letters`, `technical`, `blocks`, `braille`, `dots`), `cursor` (pointer response, default on for the presets that answer it; the GPU presets also take `'trail' | 'fluid' | 'lantern'`), `clear` (an element to keep clear: the field fades out round it), `opacity` (default 1, `fluid` 0.7), `dither` (ordered dither 0–1 for the tonal presets), `text` (`wordField`'s word, default `ASCIIFY`), plus `ink`, `look` (glyph-field settings, GPU presets only) and `reducedMotion`. Methods: `setPreset(preset)`; getter `preset`. `field` is the `GlyphField` for the GPU presets and `null` for canvas ones.
494
+ Options: `preset` (required), `palette` (one ink or `{ light, dark }`, winning over `color` and `ink`; default the preset's graphite for the ground), `color` (one ink for both grounds), `accent` (heads, peaks and signals for the presets that draw them; default the preset's own, or one mixed from the ink), `speed` (0–5, default the preset's tuned pace, the Speed control on the site), `cellSize` (character size in CSS px, dot spacing for `halftone` and `swell`; default the preset's), `charset` (characters from empty to dense, or a named set: `standard`, `asciify`, `minimal`, `detailed`, `letters`, `technical`, `blocks`, `braille`, `dots`), `cursor` (pointer response, default on for the presets that answer it; the GPU presets also take `'trail' | 'fluid' | 'lantern'`), `clear` (an element to keep clear: the field fades out round it), `opacity` (default 1, `fluid` 0.7, `swell` 0.6), `dither` (ordered dither 0–1 for the tonal presets), `text` (`wordField`'s word, default `ASCIIFY`), plus `ink`, `look` (glyph-field settings, GPU presets only) and `reducedMotion`. Methods: `setPreset(preset)`; getter `preset`. `field` is the `GlyphField` for the GPU presets and `null` for canvas ones.
478
495
 
479
496
  `fluid` lays its cells out as the site's hero does: a whole number of 7 × 12.07 px cells (`cellSize` / 0.58 tall) stretched to fill the stage exactly, so rows keep their place down a tall page, drawn at up to 1.5 device pixels per CSS pixel. It does this through the field's `cellFit: 'fill'` (see glyph-field.md).
480
497
 
@@ -484,6 +501,7 @@ The canvas templates draw in the page's fonts: load Geist Mono (and JetBrains Mo
484
501
  | --- | --- | --- | --- |
485
502
  | `drift` | GPU | Characters that drift slowly in broad soft lobes and clear round your content | 11, 1, trail |
486
503
  | `halftone` | GPU | The same drifting ground as dots | 14, 1, lantern |
504
+ | `swell` | GPU | A fine dot grid whose dots swell in slow rolling waves, quiet in the middle and clear round your content; dots rise under the pointer and settle behind it | 10, 1, lantern |
487
505
  | `wordField` | GPU | Your word (`text`) as a flowing field of letters | 13, 1, trail |
488
506
  | `fluid` | GPU | The original Asciify hero: flowing folds with a wake behind the cursor | 7, 1, wake |
489
507
  | `caustics` | canvas | Fine pools of light drifting like water across stone | 12, 1, no |