asciify-engine 4.13.0 → 4.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@
6
6
  import { beam, decode, odometer, buttonLoading, statusOrb, skeleton, pill, tabs, imageGenerate } from 'asciify-engine/kit';
7
7
  ```
8
8
 
9
- Imports are tree-shaken: one component costs about 4–5.5 KB gzip on top of `asciify-engine/field`, which the kit shares rather than bundling again.
9
+ Imports are tree-shaken: one component costs about 6–8.5 KB gzip on top of `asciify-engine/field`, which the kit shares rather than bundling again.
10
10
 
11
11
  ## Shared options and handle
12
12
 
@@ -17,8 +17,11 @@ Every function takes these options:
17
17
  | `ink` | per component | A CSS colour, a custom property (`'--brand'`) or `'var(--brand, #888)'`. Re-read when the theme changes (media query, or `class`/`data-theme` on `<html>` or `<body>`). Defaults pick light or dark ink from the element's own opaque background, else from its text colour. |
18
18
  | `look` | none | Raw glyph-field settings layered over the component's look: `charset`, `cellSize`, `font` and so on. |
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
+ | `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
+ | `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. |
20
23
 
21
- Every handle has `supported`, `field` (the underlying `GlyphField`, or `null`), `update(options)`, `play()`, `pause()` and `destroy()`. Components add their own methods, listed below.
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.
22
25
 
23
26
  In React, create the handle in an effect and return `destroy`:
24
27
 
@@ -37,18 +40,18 @@ b.success(); // the ring closes, a flash sweeps round, a green fade settles
37
40
  b.error(); // stops dead, flickers red twice, cracks into dim red fragments until reset() or progress()
38
41
  ```
39
42
 
40
- Options: `palette` (`'colorful' | 'mono' | 'ocean' | 'sunset'` or your own hex stops, default `colorful`), `mode` (`'rotate'` two turning arcs, `'pulse'` a breathing border, `'progress'` all but dark at rest and a progress ring while running; default `rotate`; modes crossfade when changed), `grain` (`'dots' | 'glyphs'`), `speed` (lap multiplier, default 1), `hover` (faster and brighter on hover and visible focus, default true), `depth` (glow depth in px, default 18; it also stops 2px short of the padding). Methods: `progress(0..1)` (in `rotate` and `pulse` the beam speeds up as it fills), `success()`, `error()`, `reset()`. Reduced motion draws one settled frame per state. There is no bloom outside the border.
43
+ Options: `palette` (`'colorful' | 'mono' | 'ocean' | 'sunset'` or your own hex stops, default `colorful`), `mode` (`'rotate'` two turning arcs, `'pulse'` a breathing border, `'progress'` all but dark at rest and a progress ring while running; default `rotate`; modes crossfade when changed), `grain` (`'dots' | 'glyphs'`), `speed` (lap multiplier, default 1), `hover` (faster and brighter on hover and visible focus, default true; pointer moves count too, so a pointer already over the element or a scripted one heats it), `depth` (glow depth in px, default 18; it also stops 2px short of the padding). Methods: `progress(0..1)` (in `rotate` and `pulse` the beam speeds up as it fills), `success()`, `error()`, `reset()`. Reduced motion draws one settled frame per state. There is no bloom outside the border.
41
44
 
42
45
  ## decode
43
46
 
44
47
  A headline that condenses out of a drifting field, left to right. By default it draws the element's own text exactly where the browser laid it out, and keeps the real text in the DOM for selection and screen readers.
45
48
 
46
49
  ```ts
47
- const d = decode(document.querySelector('h1')!, { duration: 900 });
50
+ const d = decode(document.querySelector('h1')!, { duration: 800 });
48
51
  d.replay();
49
52
  ```
50
53
 
51
- Options: `text` (draw this instead, centred and as large as fits; `\n` breaks lines), `duration` (ms, default 900), `trigger` (`'visible' | 'manual'`), `replayOnHover` (default true), `settle` (`'glyphs'` keeps the headline in characters, `'text'` hands back to the crisp HTML). Ink defaults to the element's text colour. Method: `replay()`.
54
+ Options: `text` (draw this instead, centred and as large as fits, in the element's own font weight, family and letter spacing; `\n` breaks lines), `duration` (ms, default 900), `trigger` (`'visible' | 'manual'`), `replayOnHover` (default true; entry is read from pointer moves too, so a scripted cursor replays it), `settle` (`'glyphs'` keeps the headline in characters, `'text'` hands back to the crisp HTML). Ink defaults to the element's text colour. Method: `replay()`.
52
55
 
53
56
  ## odometer
54
57
 
@@ -59,7 +62,7 @@ const o = odometer(figure, { value: 48290, format: n => `$${n.toLocaleString('en
59
62
  o.set(49530);
60
63
  ```
61
64
 
62
- Options: `value` (default: parsed from the element's text), `format` (default `toLocaleString()`), `duration` (ms the first column rolls, default 360), `stagger` (ms longer per column, default 60), `glyphs` (default `#%*+=-:`). Methods: `set(n)`; getter `value`.
65
+ Options: `value` (default: parsed from the element's text), `format` (default `toLocaleString()`), `duration` (ms the first column rolls, default 360), `stagger` (ms longer per column, default 60), `glyphs` (default `#%*+=-:`), `face` (`'text'`: crisp text at rest, characters only while a column rolls; `'glyph'`: the whole figure drawn in characters, at rest too; default `text`, and `update({ face })` switches it live). Methods: `set(n)`; getter `value`.
63
66
 
64
67
  ## buttonLoading
65
68
 
@@ -71,7 +74,7 @@ b.start('Deploying');
71
74
  await deploy().then(() => b.succeed('Deployed'), () => b.fail('Retry'));
72
75
  ```
73
76
 
74
- Options: `text` (idle label, default the label's text), `label` (the element holding the text, when the button also has an icon), `check` (a check before the success label, default true), `shake` (on failure, default true), `announce` (a live region to write each state to). Methods: `start(label?)`, `succeed(label?)`, `fail(label?)`, `reset(label?)`; getter `state`. Sets `aria-busy` and `data-state` on the button. Without WebGL the methods still change the label text.
77
+ Options: `text` (idle label, default the label's text), `label` (the element holding the text, when the button also has an icon), `check` (a crisp SVG check before the success label, default true), `icon` (an icon-only button's icon, default its first `svg` when it has no text: one cell breathes through the glyphs over it while loading, and a check takes its place on success; the button's `aria-label` follows each state), `shake` (on failure, default true), `announce` (a live region to write each state to). Methods: `start(label?)`, `succeed(label?)`, `fail(label?)`, `reset(label?)`; getter `state`. Sets `aria-busy` and `data-state` on the button. Without WebGL the methods still change the label text.
75
78
 
76
79
  ## statusOrb
77
80
 
@@ -95,7 +98,7 @@ await fetchPost();
95
98
  await s.resolve();
96
99
  ```
97
100
 
98
- Options: `blocks` (selector, default `[data-block]`), `shimmer` (ms per sweep, default 1250), `resolve` (ms per block, default 240), `stagger` (ms between blocks unless `data-delay` is set, default 60), `develop` (ms an image develops, default 560). Methods: `resolve()` (a promise for the last block), `reset()`; getter `loading`. Sets `aria-busy` on the element while loading.
101
+ Options: `blocks` (selector, default `[data-block]`; `reset()` fades loaded content out under the returning skeleton), `shimmer` (ms per sweep, default 1250), `resolve` (ms per block, default 240), `stagger` (ms between blocks unless `data-delay` is set, default 60), `develop` (ms an image develops, default 560). Methods: `resolve()` (a promise for the last block), `reset()`; getter `loading`. Sets `aria-busy` on the element while loading.
99
102
 
100
103
  ## pill
101
104
 
@@ -114,7 +117,7 @@ pills.refresh(); // tone changes ease over 240ms
114
117
  pills.count(14); // only the changed digits roll
115
118
  ```
116
119
 
117
- 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` (on hover for `new` pills, 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 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.
118
121
 
119
122
  ## tabs
120
123
 
@@ -148,6 +151,36 @@ const s = mountSurface({ host: el, look: () => ({ mark: 'glyph', cellSize: 6 }),
148
151
  paint: ({ pen, w, h, now, still }) => { pen.fillStyle = '#fff'; pen.fillRect(0, 0, (w * (now % 1000)) / 1000, h); return !still; } });
149
152
  ```
150
153
 
154
+ ## Performance and integration
155
+
156
+ A page can hold every kit component at once. The shared core keeps the GPU cost to what is on screen:
157
+
158
+ - **Lazy mounting.** A field mounts when its element comes within half a viewport of the screen and is released, freeing its WebGL context, beyond one viewport. Mounts that cost more than about 4 ms in a frame wait for the next one, nearest the screen first. The handle survives release and remount: its methods keep working, `update()` calls made while released apply on the next mount, `pause()` holds, and the component paints its current state when it mounts again. `handle.field` stays the same object throughout. A remount swaps a copy of the canvas, with its attributes and inline styles, into the same place. `lazy: false` mounts at once, anywhere on the page, and is never released by distance.
159
+ - **Context budget.** At most 12 kit fields hold a WebGL context at once, across all components and page helpers (browsers drop the oldest past about 16). Past the budget the live field farthest off screen is released first, and a waiting field mounts as soon as it is nearer than the farthest live one. Change it with `setFieldBudget(n)`; `getFieldBudget()` reads it.
160
+ - **Posters.** `poster: '/stills/hero.webp'` puts an `<img>` under the canvas (`update({ poster })` points it at another still, such as the other theme's), placed exactly where the field draws. It fades out (280 ms, none under reduced motion) when the first GPU frame lands, shows again while the field is released, and stays as the fallback without WebGL or after the field fails, until `destroy()`. `update({ poster })` swaps the still in place, for a theme change, on every component including the page helpers and `asciiVideo`. `asciiVideo` keeps its own poster.
161
+ - **Pausing.** As before, a field draws nothing while the page is hidden or its element is off screen.
162
+
163
+ ```ts
164
+ import { beam, statusOrb, setFieldBudget, setGlobalLook, subscribeGlobalLook } from 'asciify-engine/kit';
165
+
166
+ setFieldBudget(10);
167
+ const orb = statusOrb(el, { poster: '/stills/orb-dark.webp' });
168
+
169
+ // A header "Characters" menu: re-skins every glyph field at once.
170
+ setGlobalLook({ charset: ' .:-=+YCIFSA#@' });
171
+ setGlobalLook(null); // each field gets its own characters back
172
+ const off = subscribeGlobalLook(look => menu.value = look?.charset ?? '');
173
+
174
+ // Per-instance tuning from a customise panel.
175
+ orb.update({ ink: '--brand', look: { cellSize: 6 } });
176
+ ```
177
+
178
+ `setGlobalLook({ charset })` 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
+
180
+ 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
+
182
+ For your own `mountSurface` or `mountPageField` component, pass the options box as `kit` (`kit: state`) and the same box to `handle()`, so `lazy`, `poster` and `globalLook` apply and the poster is removed on `destroy()`.
183
+
151
184
  ## Phase 2c: page and field helpers
152
185
 
153
186
  Whole-element fields that draw their own scene or source: page backgrounds, a wordmark, a video, the sign-in art panel and the pricing card's glow. Each lays one canvas over or under the element (inset 0, following its border radius), inks it from the theme, honours reduced motion and restores the element on `destroy()`. Without WebGL2 they return inert handles and leave the HTML alone.
@@ -168,7 +201,7 @@ const lost = fieldBackground(art, { preset: 'notFound' });
168
201
  home.onclick = async () => { await lost.scatter(home); location.href = '/'; };
169
202
  ```
170
203
 
171
- Options: `preset` (`'hero' | 'brand' | 'notFound'`, default `hero`), `clear` (the element to keep clear; `hero` and `brand`), `palette` (one ink or `{ light, dark }`, winning over `ink`; `brand` defaults to `var(--brand)`), `text` (`notFound`, default `404`), `ripple` (the pointer drops a soft ring every 520ms or so, default true), `label` (`notFound` defaults to its text; the grounds are hidden as decoration). Methods: `pulse(from?, strength?)` (an element, a viewport point or the centre; default 1.4), `scatter(from?)` (a promise for the re-formed numerals; other presets just pulse). The field sits under the content (`isolation: isolate`, `z-index: -1`), so give the hero its own background.
204
+ Options: `preset` (`'hero' | 'brand' | 'notFound'`, default `hero`), `clear` (the element to keep clear; `hero` and `brand`), `palette` (one ink or `{ light, dark }`, winning over `ink`; `brand` defaults to `var(--brand)`), `text` (`notFound`, default `404`), `ripple` (the pointer drops a soft ring every 520ms or so, default true), `label` (`notFound` defaults to its text; the grounds are hidden as decoration). Methods: `pulse(from?, strength?)` (an element, a viewport point or the centre; default 1.4), `scatter(from?)` (a promise for the re-formed numerals; other presets just pulse). The field sits under the content (`isolation: isolate`, `z-index: -1`), so give the hero its own background. Without WebGL2 its poster lies in the same place and the kit isolates the element for it too, so the page's backgrounds never cover it.
172
205
 
173
206
  ### wordmark
174
207
 
@@ -179,7 +212,9 @@ const mark = wordmark(box, { text: 'asciify' });
179
212
  input.oninput = () => mark.setText(input.value);
180
213
  ```
181
214
 
182
- Options: `text`, `font` (default `Georgia, "Times New Roman", serif`), `fontWeight` (default 600). Methods: `setText(text)`; getter `text`.
215
+ Options: `text`, `font` (default `Georgia, "Times New Roman", serif`), `fontWeight` (default 600), `artHeight` (height of the 1800 px wide canvas the word is set on, default 520; a shorter one sets the word larger in a wide element), `label` (the canvas's accessible name when the word is not the element's own text; default the word). Methods: `setText(text)`; getter `text`.
216
+
217
+ The landing's closing name is `wordmark(box, { text: 'asciify', artHeight: 480, label: 'asciify, drawn in characters', look: { charset: ' .:-=+*#%@', font: '"Geist Mono", ui-monospace, monospace', fontWeight: 400, cellSize: 9, cellAspect: 0.62, glyphScale: 0.92, contrast: 1, shade: 0.5, trail: { strength: 0.9, radius: 0.3 } } })`, and its hero is `fieldBackground(section, { preset: 'hero', ripple: false, palette: { light: '#7d7b75', dark: '#77756f' }, look: { scale: 1.25, cellSize: 12, brightness: 0.24, contrast: 1, charset: ' .:+*#', cellAspect: 0.62, glyphScale: 0.86, clear: { element: copy, padding: -24, feather: 0.6, shape: 'ellipse' } } })` (`scale` 1.6 under 760px).
183
218
 
184
219
  ### asciiVideo
185
220
 
@@ -255,7 +290,7 @@ search.oninput = () => render(filter(search.value)); // the kit re-selects row 0
255
290
  cmd.select(2);
256
291
  ```
257
292
 
258
- 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. 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.
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.
259
294
 
260
295
  ### slideToConfirm
261
296
 
@@ -266,7 +301,7 @@ const pay = slideToConfirm(track, { onConfirm: () => charge(), mark: track.query
266
301
  charge().catch(() => pay.reset());
267
302
  ```
268
303
 
269
- The track holds a `[role="slider"]` thumb (or any first child) and an optional `[data-label]`. Options: `onConfirm`, `thumb`, `label`, `mark` (where the check lands, default the track's middle), `threshold` (default 0.9), `resetAfter` (ms, default 0: stays confirmed), `color` (default `--ascii-success`). Drag, fling, a press on the track (a nudge), arrow keys, Home, End, Enter and Space all work. Methods: `confirm()`, `reset()`; getter `confirmed`. Sets `data-state` (`idle`, `dragging`, `confirmed`) and `data-confirmed` once the check is in, for your done text.
304
+ The track holds a `[role="slider"]` thumb (or any first child) and an optional `[data-label]`. Options: `onConfirm`, `thumb`, `label`, `mark` (where the check lands, default the track's middle), `threshold` (default 0.9), `resetAfter` (ms, default 0: stays confirmed), `color` (default `--ascii-success`). Drag, fling, a press on the track (a nudge), arrow keys, Home, End, Enter and Space all work; only the pointer that took the thumb drags it, and a pointer that cannot be captured (a scripted one) still drags. Methods: `confirm()`, `reset()`; getter `confirmed`. Sets `data-state` (`idle`, `dragging`, `confirmed`, `resetting` while the fill fades on reset) and `data-confirmed` once the check is in, for your done text.
270
305
 
271
306
  ### likeBurst
272
307
 
@@ -277,7 +312,7 @@ const like = likeBurst(button, { limit: postBody, onChange: on => api.like(post.
277
312
  const save = likeBurst(saveButton, { kind: 'save' });
278
313
  ```
279
314
 
280
- Options: `kind` (`'heart' | 'save'`), `icon` (default the first `svg`), `manage` (click toggles `aria-pressed`, default true), `limit` (nothing is drawn above this element's bottom), `outset` (default 40px), `onChange(pressed)`. Ink defaults to `--ascii-like` (`#e5484d`, `#ff6b70` on dark) for hearts. Methods: `burst()`, `undo()`, `toggle(pressed?)`; getter `pressed`.
315
+ Options: `kind` (`'heart' | 'save'`), `icon` (default the first `svg`), `manage` (click toggles `aria-pressed`, default true), `limit` (nothing is drawn above this element's bottom), `outset` (default 40px), `layer` (`'under'`, the default, draws between the button's background and its content so the count and label stay crisp over the sparks; `'over'` draws above), `onChange(pressed)`. Point `icon` at a wrapper when the button swaps a line and a filled icon: the whole wrapper hides while the characters pop and fades up as they land. Ink defaults to `--ascii-like` (`#e5484d`, `#ff6b70` on dark) for hearts. Methods: `burst()`, `undo()`, `toggle(pressed?)`; getter `pressed`. For the count, pair it with `odometer(countEl)` and call `set()` from `onChange`.
281
316
 
282
317
  ### blockChart
283
318
 
@@ -288,7 +323,7 @@ const chart = blockChart(plot, { values: visits, labels: days, onHover: (i, v, d
288
323
  range.onchange = () => chart.set(nextVisits, nextDays);
289
324
  ```
290
325
 
291
- 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), `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.
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.
292
327
 
293
328
  ### pageDissolve
294
329
 
@@ -299,7 +334,7 @@ const page = pageDissolve(body);
299
334
  tab.onclick = () => page.swap(() => render(tab.dataset.page), tab);
300
335
  ```
301
336
 
302
- 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. Sets `data-state` (`out`, `in`). Under reduced motion or without WebGL2, `render` runs at once.
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.
303
338
 
304
339
  ### swipeDeck
305
340
 
@@ -311,7 +346,7 @@ keep.onclick = () => deck.next(1);
311
346
  pass.onclick = () => deck.next(-1);
312
347
  ```
313
348
 
314
- Cards are `[data-card]` children stacked in the deck (absolutely, same size), each with an `<img>`. Options: `cards`, `loop` (default true), `threshold` (share of the width, default 0.4), `radius` (default the deck's border radius), `outset` (default 56px), `onSwipe(dir, index, card)`. Methods: `next(dir?)` (false at the end of a deck that does not loop); getter `index`. With the field the cards are drawn rather than shown, so put captions outside the deck; images must be same-origin or CORS-enabled to be drawn. Sets `--ascii-lean` (-1 to 1) for your buttons. Without WebGL2 one HTML card shows at a time and follows the drag.
349
+ Cards are `[data-card]` children stacked in the deck (absolutely, same size), each with an `<img>`. Options: `cards`, `loop` (default true), `threshold` (share of the width, default 0.4), `radius` (default the deck's border radius), `outset` (default 56px; give it the distance to your stage's edge so a fling streams across it, rows thinning before the field's edge), `leanTarget` (the element that receives `--ascii-lean`, such as the row holding your buttons; default the deck), `onSwipe(dir, index, card)`. Methods: `next(dir?)` (false at the end of a deck that does not loop); getter `index`. With the field the cards are drawn rather than shown, so put captions outside the deck; images must be same-origin or CORS-enabled to be drawn. Sets `--ascii-lean` (-1 to 1) for your buttons. Only the pointer that took the card drags it. Without WebGL2 one HTML card shows at a time and follows the drag.
315
350
 
316
351
  ### codeInput
317
352
 
@@ -321,7 +356,7 @@ A one-time-code field: each digit resolves out of scattered characters, the care
321
356
  const code = codeInput(row, { length: 6, onComplete: async c => code.verify(await api.check(c)) });
322
357
  ```
323
358
 
324
- The kit uses the row's `input` (or adds a transparent one, `autocomplete="one-time-code"`) and its `[data-cell]` children (or adds them), writing each digit into a `[data-digit]` span. Options: `length` (default 6), `accept` (`'digits' | 'alphanumeric'`), `cells`, `onComplete(code)`. Methods: `verify(ok)`, `clear()`; getters `value`, `state`. Sets `data-state` (`idle`, `error`, `success`) on the row, `data-active` on the caret's cell, `data-shown` once a digit has landed, and `aria-invalid` after a wrong code. Style the cells; the field reaches 16px past the row.
359
+ The kit uses the row's `input` (or adds a transparent one, `autocomplete="one-time-code"`) and its `[data-cell]` children (or adds them), writing each digit into a `[data-digit]` span. Options: `length` (default 6), `accept` (`'digits' | 'alphanumeric'`), `cells`, `onComplete(code)`. Methods: `verify(ok)`, `clear()`, `fill(code)` (enters a whole code as a paste or autofill would, the characters resolving one after another, for a “From Mail” button); getters `value`, `state`. Sets `data-state` (`idle`, `error`, `success`) on the row, `data-active` on the caret's cell, `data-shown` once a digit has landed, and `aria-invalid` after a wrong code. Style the cells; the field reaches 16px past the row.
325
360
 
326
361
  ## Phase 2b: feedback, streaming and media
327
362
 
@@ -341,7 +376,7 @@ const id = toasts.push({ title: 'Thread archived', body: 'Q3 planning · Ana', a
341
376
  toasts.dismiss(id);
342
377
  ```
343
378
 
344
- 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`). 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.
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.
345
380
 
346
381
  ### thinking
347
382
 
@@ -353,7 +388,7 @@ await reply.stream(await ask(question)); // or an async iterable of c
353
388
  regenerate.onclick = () => reply.think();
354
389
  ```
355
390
 
356
- Options: `lines` (bands while thinking, default 3; the element is held at that many lines high), `label` (an element for the status), `labels` (`{ thinking, done(seconds) }`), `start` (default true; false waits for `think()`), `stagger` (ms between words, default 38), `rule` (default true). Methods: `think()`, `stream(text | chunks)` (resolves once the last word has faded in; a partial word is held until it is whole); getter `state` (`thinking`, `streaming`, `done`). Sets `aria-busy` while thinking. Ink defaults to `--ascii-ink`; the dots sit under the text.
391
+ Options: `lines` (bands while thinking, default 3; the element is held at that many lines high), `label` (an element for the status), `labels` (`{ thinking, done(seconds) }`), `start` (default true; false waits for `think()`), `stagger` (ms between words, default 38), `rule` (default true), `onState(state, seconds)` (for a status you render yourself). Methods: `think()`, `stream(text | chunks)` (resolves once the last word has faded in; a partial word is held until it is whole); getter `state` (`thinking`, `streaming`, `done`). Sets `aria-busy` while thinking; with border-box sizing the held height adds the element's padding, so bottom padding makes room for the rule. Ink defaults to `--ascii-ink`, else a quiet graphite; the dots sit under the text.
357
392
 
358
393
  ### streamText
359
394
 
@@ -367,7 +402,7 @@ await out.done();
367
402
  stopButton.onclick = () => out.stop();
368
403
  ```
369
404
 
370
- Options: `caret` (default true: bright while writing, dim at rest), `settle` (ms the first character of a token flickers, default 60), `stagger` (ms more per character, default 18), `reasoningLines` (reasoning lines kept in view, default 2), `onState(state)`. Methods: `write(token)`, `reasoning(line)` (adds a line to a tail the kit puts just before the element, `.ascii-reasoning`: the newest lines stay in view, older ones scroll up under a fade, and it collapses once the reply is done or stopped), `done()` (resolves once the last characters land), `stop()` (lands what was written at once), `reset()` (clears the text and the reasoning); getters `state` (`idle`, `streaming`, `done`, `stopped`) and `text`. Sets `aria-busy` while streaming. The field covers the content box with one row of cells per line, so give the element a fixed `line-height`; ink defaults to the text colour.
405
+ Options: `caret` (default true: bright while writing, dim at rest), `settle` (ms the first character of a token flickers, default 60), `stagger` (ms more per character, default 18), `reasoningLines` (reasoning lines kept in view, default 2), `onState(state)`. Methods: `write(token)`, `reasoning(line)` (adds a line to a tail the kit puts just before the element, `.ascii-reasoning`: the newest lines stay in view, older ones scroll up under a fade, and it collapses once the reply is done or stopped), `done()` (resolves once the last characters land), `stop()` (lands what was written at once; stopped while only reasoning, it folds the tail away), `reset()` (clears the text and the reasoning); getters `state` (`idle`, `streaming`, `done`, `stopped`) and `text`. Sets `aria-busy` while streaming. The field covers the content box from a quarter line above to a quarter line below, in cells half a line tall and about a letter wide, so each glyph sits on its line's lowercase letters like a typed character; give the element a fixed `line-height`. New reasoning lines slide in together with the old ones. Ink defaults to the text colour.
371
406
 
372
407
  ### compare
373
408
 
@@ -378,7 +413,7 @@ const c = compare(frame, { image: '/kingfisher.webp', position: 58 }); // frame:
378
413
  c.set(30, true);
379
414
  ```
380
415
 
381
- Options: `image` (a URL, or an `<img>`; one already inside the frame is used in place), `position` (0–100 photo share, default 50), `label` (the slider's name), `onChange(position)`. Method: `set(position, glide?)`; getter `position`. The kit adds the photo, a divider and a `role="slider"` handle, and sets `--ascii-pos` on the frame. Images from another origin need CORS. Without WebGL2 the frame just shows the photo, with no slider.
416
+ Options: `image` (a URL, or an `<img>`; one already inside the frame is used in place), `position` (0–100 photo share, default 50), `label` (the slider's name), `onChange(position)`. Method: `set(position, glide?)`; getter `position`. The kit adds the photo, a divider and a `role="slider"` handle, or uses your own: an `<img>`, a `[role="slider"]` and a `[data-compare-line]` inside the frame are taken in place, keeping their classes and `aria-label`. It sets `--ascii-pos` on the frame, `aria-valuenow` and `aria-valuetext` on the handle, and `data-dragging` on the frame while a pointer drags. Images from another origin need CORS. Without WebGL2 the frame just shows the photo, with no slider.
382
417
 
383
418
  ### accordion
384
419
 
@@ -400,18 +435,18 @@ zone.setImage('/sample.webp');
400
435
  saveButton.onclick = () => zone.download();
401
436
  ```
402
437
 
403
- Options: `onImage({ file, src, width, height })`, `onError(message, file)`, `maxBytes` (default 10 MB), `develop` (ms, default 720), `filename` (for `download()`, default `<name>-ascii.png`). 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. 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.
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.
404
439
 
405
440
  ### disintegrate
406
441
 
407
- Deletes a row into characters: its text and icons become glyphs that thin out from the right and drift up and away while the row closes up. Not a handle: it returns a promise for the end.
442
+ Deletes a row into characters: its text and icons become shimmering glyphs that thin out from the right and drift up and away while the row closes up. Not a handle: it returns a promise for the end.
408
443
 
409
444
  ```ts
410
445
  await disintegrate(row); // then remove the row from your data
411
446
  await disintegrate(row, { remove: true });
412
447
  ```
413
448
 
414
- Options: `duration` (ms, default 620), `collapse` (close the row's height, default true), `remove` (take the row out of the DOM at the end; default false, which leaves it hidden and collapsed for your framework to remove), `host` (the element the field is drawn over, default the row's parent, so the characters can drift past the row's box). Calling it again on the same row returns the same promise. Without WebGL2, or under reduced motion, the row closes at once.
449
+ Options: `duration` (ms, default 620), `collapse` (close the row's height, default true), `remove` (take the row out of the DOM at the end; default false, which leaves it hidden and collapsed for your framework to remove), `host` (the element the field is drawn over, default the row's parent, so the characters can drift past the row's box). Calling it again on the same row returns the same promise. The row stays as it is until the field can draw its first frame, so a slow mount never leaves a gap. Without WebGL2, or under reduced motion, the row closes at once.
415
450
 
416
451
  ### pullRefresh
417
452
 
@@ -423,4 +458,55 @@ refreshButton.onclick = () => pull.refresh();
423
458
  pull.revealRows(feed.querySelectorAll('.just-arrived')); // or reveal any rows yourself
424
459
  ```
425
460
 
426
- 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 and media, 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.
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.
462
+
463
+ ## Backgrounds
464
+
465
+ Every background on asciify.org/backgrounds as one call, drawn the same way as the site's stage: the same renderers, clock, pointer smoothing, pixel ratio and graphite inks, so a preset with the same settings matches it in light and dark. The canvas sits under the element's content (`isolation: isolate`, `z-index: -1`), follows the theme, pauses off screen and in hidden tabs, and holds one still frame under reduced motion.
466
+
467
+ ```ts
468
+ import { background, caustics, aurora } from 'asciify-engine/kit';
469
+
470
+ const bg = background(hero, { preset: caustics, clear: headline, speed: 0.8 });
471
+ bg.update({ palette: { light: '#5b4bd1', dark: '#8f80ff' }, cellSize: 10 });
472
+ bg.setPreset(aurora); // cross-fades, keeping your options
473
+ ```
474
+
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.
476
+
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.
478
+
479
+ `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
+
481
+ The canvas templates draw in the page's fonts: load Geist Mono (and JetBrains Mono for `fluid`) as the site does, and note that the page's `-webkit-font-smoothing` changes their weight on macOS just as it does on the site.
482
+
483
+ | Preset | Kind | Looks like | Size, speed, cursor |
484
+ | --- | --- | --- | --- |
485
+ | `drift` | GPU | Characters that drift slowly in broad soft lobes and clear round your content | 11, 1, trail |
486
+ | `halftone` | GPU | The same drifting ground as dots | 14, 1, lantern |
487
+ | `wordField` | GPU | Your word (`text`) as a flowing field of letters | 13, 1, trail |
488
+ | `fluid` | GPU | The original Asciify hero: flowing folds with a wake behind the cursor | 7, 1, wake |
489
+ | `caustics` | canvas | Fine pools of light drifting like water across stone | 12, 1, no |
490
+ | `contour` | canvas | Fine topographic lines with a slow, rolling drift | 12, 1, no |
491
+ | `facet` | canvas | Folded planes catching light along their edges | 12, 1, no |
492
+ | `etch` | canvas | Cut-grain lines following a sculpted current | 12, 1, no |
493
+ | `moire` | canvas | Two fine line fields overlapping into broad bands | 12, 1, no |
494
+ | `weave` | canvas | Interlaced threads with a subtle change in tension | 12, 1, no |
495
+ | `veil` | canvas | Tall folds of light across a quiet ground | 12, 1, no |
496
+ | `dune` | canvas | Layered sand ridges with slow light and fine grain | 12, 1, no |
497
+ | `wave` | canvas | An interference field with a soft vortex at the cursor | 13, 0.6, yes |
498
+ | `aurora` | canvas | Soft bands of light that ripple under the cursor | 13, 0.6, yes |
499
+ | `noise` | canvas | A slow fractal noise field that warps round the cursor | 13, 0.5, yes |
500
+ | `silk` | canvas | Long ribbons following a slow flow field | 13, 1, no |
501
+ | `rain` | canvas | Columns of falling characters with bright heads | 13, 1, no |
502
+ | `stars` | canvas | A sparse starfield that twinkles and leans to the cursor | 14, 1, yes |
503
+ | `pulse` | canvas | Rings of characters rippling out from the cursor | 14, 1, yes |
504
+ | `grid` | canvas | Scan bands rolling down a grid, glitching near the cursor | 12, 1, yes |
505
+ | `voidWell` | canvas | A gravity well spiralling characters round the cursor (`void` is reserved in JS) | 13, 1, yes |
506
+ | `morph` | canvas | Every cell shimmering at its own frequencies | 14, 1, no |
507
+ | `fire` | canvas | A cellular flame rising from the bottom edge | 13, 1, no |
508
+ | `dna` | canvas | Double helices scrolling past with base-pair letters | 13, 1, no |
509
+ | `terrain` | canvas | A side-scrolling landscape in density characters | 13, 1, no |
510
+ | `circuit` | canvas | Circuit traces with signals running along them | 13, 1, no |
511
+
512
+ Your own preset is an object with `id`, `name`, `description`, `cellSize`, `speed`, `cursor` and `mount(context)`; `mountCanvas(context, draw)` runs a 2D template with the same clock and pointer as the built-ins.
@@ -98,7 +98,7 @@ For GSAP, pass your installed `gsap` and `ScrollTrigger` instances through `scro
98
98
  ## Procedural backgrounds
99
99
 
100
100
  ```js
101
- import { asciiBackground } from './wave.js'; // Download from asciify.org/backgrounds
101
+ import { asciiBackground } from './wave.js'; // 4.0–4.13: a template downloaded from asciify.org/backgrounds
102
102
  const background = asciiBackground(host, {
103
103
  type: 'aurora', fontSize: 14, speed: 0.8, density: 0.55,
104
104
  });
@@ -106,4 +106,4 @@ const background = asciiBackground(host, {
106
106
  background.destroy();
107
107
  ```
108
108
 
109
- In 4.0, procedural backgrounds are copyable/downloadable templates at asciify.org/backgrounds, outside npm. Import the downloaded template locally. Fluid also remains application code. No procedural background type is a package option in 4.0.
109
+ From 4.0 to 4.13, procedural backgrounds were downloadable templates at asciify.org/backgrounds, outside npm, and existing imports keep working. Newer kits ship them all as `background(host, { preset })` from `asciify-engine/kit`, Fluid included; use that for new work ([kit.md](kit.md#backgrounds), [applying-asciify.md](applying-asciify.md#9-backgrounds)).
@@ -11,7 +11,7 @@ A downloaded PNG is one output; it is not a substitute for a live component.
11
11
  | Application-owned render loop | `/studio` → `createStudioRenderer` | Caller passes time, pointer input, invalidation and cleanup. |
12
12
  | ASCII text data | Core frame/text conversion | No animation loop needed unless requested. |
13
13
  | Full-bleed hero or brand field that must stay smooth under the cursor, or the user's word as the characters | `/field` → `mountGlyphField` (4.11+) | The field owns its wake and drift. Keep a poster and a `/studio` or core fallback for browsers without a GPU; see [glyph-field.md](glyph-field.md). |
14
- | Ambient background with no media | Downloaded template from asciify.org/backgrounds, or a `/field` generated scene | Templates own their loop; call `destroy()` on unmount. |
14
+ | Interface component, or a background with no media | `/kit`: one call per component, `background()` with a preset | The kit owns its loop, theme ink, posters and fallback; call `destroy()` on unmount. See [applying-asciify.md](applying-asciify.md). |
15
15
 
16
16
  Choose colors, fonts and sizes from the project's design tokens before writing settings; [design-matching.md](design-matching.md) maps the user's mood words to values.
17
17
 
@@ -28,7 +28,7 @@ Preserve the existing framework, design and chosen media. Use one renderer owner
28
28
 
29
29
  ## 6. Ambient background
30
30
 
31
- “Build [pattern] behind [content] using a template from asciify.org/backgrounds. Templates are local downloads, not npm exports. For any Studio style, use the downloaded studio-background.js helper and its STUDIO.md instructions. Keep HTML readable, preserve the chosen look, pause offscreen and destroy on unmount.”
31
+ “Build [pattern] behind [content] with `background()` from asciify-engine/kit and the [pattern] preset, imported by name. Keep the copy clear with `clear` and a frosted veil, ink from our theme tokens, pause offscreen and destroy on unmount. For a Studio render style on a background, use the downloaded studio-background.js helper and its STUDIO.md instructions.”
32
32
 
33
33
  The website's Studio background helper renders a continuous procedural source through /studio; it supports the site's 24 styles and 12 looks. Its original template mode uses its own character/dither controls. Never imply every control applies to every renderer.
34
34
 
@@ -46,7 +46,7 @@ Use one motion owner: `animationStyle` for core, `motion` for `/hover`, or `moti
46
46
 
47
47
  ## 4.0 background migration
48
48
 
49
- Remove imports from `asciify-engine/backgrounds` and generator imports from the root package. Download the chosen `.js` template from https://asciify.org/backgrounds and change the import to `./wave.js` (or your selected file). The template exports `asciiBackground`; keep calling `destroy()` on unmount. It handles resizing, hidden/offscreen suspension, reduced motion, and cleanup. A selected template contains only that generator; backgrounds.js is the optional full collection. Application-owned source templates are separate from npm updates. Existing installations can stay on 3.x while migrating. Text and media rendering remain supported, including text backgrounds.
49
+ Remove imports from `asciify-engine/backgrounds` and generator imports from the root package. Download the chosen `.js` template from https://asciify.org/backgrounds and change the import to `./wave.js` (or your selected file). The template exports `asciiBackground`; keep calling `destroy()` on unmount. It handles resizing, hidden/offscreen suspension, reduced motion, and cleanup. A selected template contains only that generator; backgrounds.js is the optional full collection. Application-owned source templates are separate from npm updates. Existing installations can stay on 3.x while migrating. Since 4.14 the kit exports every background as `background(host, { preset })` from `asciify-engine/kit`; prefer that to a downloaded template when the installed kit has it. Text and media rendering remain supported, including text backgrounds.
50
50
 
51
51
  Use `CHARACTER_SETS` from root or `/core` for the same eight choices as the editor's `STUDIO_CHARACTER_SETS`. This data-only import does not load Studio. Keep custom characters available separately; do not import the removed `CHARSETS` object.
52
52