asciify-engine 4.12.1 → 4.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -1
- package/dist/characters-BgG9AZQl.d.cts +72 -0
- package/dist/characters-BgG9AZQl.d.ts +72 -0
- package/dist/core.d.cts +2 -1
- package/dist/core.d.ts +2 -1
- package/dist/field.cjs +1 -1
- package/dist/field.d.cts +6 -0
- package/dist/field.d.ts +6 -0
- package/dist/field.js +1 -1
- package/dist/index.d.cts +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/kit.cjs +1 -0
- package/dist/kit.d.cts +1170 -0
- package/dist/kit.d.ts +1170 -0
- package/dist/kit.js +1 -0
- package/dist/{model-B0jK31nS.d.cts → model-9SbRVqqI.d.cts} +1 -72
- package/dist/{model-BARiWJks.d.ts → model-DXHhQhxP.d.ts} +1 -72
- package/dist/studio.d.cts +3 -2
- package/dist/studio.d.ts +3 -2
- package/package.json +11 -1
- package/skills/asciify-engine/SKILL.md +74 -47
- package/skills/asciify-engine/references/applying-asciify.md +234 -0
- package/skills/asciify-engine/references/components.md +263 -813
- package/skills/asciify-engine/references/design-matching.md +10 -7
- package/skills/asciify-engine/references/glyph-field.md +3 -0
- package/skills/asciify-engine/references/kit.md +512 -0
- package/skills/asciify-engine/references/media-api.md +2 -2
- package/skills/asciify-engine/references/project-integration.md +1 -1
- package/skills/asciify-engine/references/scenario-prompts.md +1 -1
- package/skills/asciify-engine/references/upgrading.md +1 -1
|
@@ -33,14 +33,16 @@ Never introduce a new brand color, a black canvas on a light site, or a second a
|
|
|
33
33
|
|
|
34
34
|
## 3. Pick the tool
|
|
35
35
|
|
|
36
|
+
For an interface component or a background, use `asciify-engine/kit` and follow [applying-asciify.md](applying-asciify.md); the rows below are for media looks and the lower-level APIs.
|
|
37
|
+
|
|
36
38
|
| The user wants | Use | Notes |
|
|
37
39
|
| --- | --- | --- |
|
|
38
40
|
| A photo, video or GIF converted on their page, with optional hover | `asciify`, `asciifyVideo`, `asciifyGif` (+ `mountHover` from `/hover`) | Lightest path. Disable core hover and motion when `/hover` owns them. |
|
|
39
41
|
| A full-bleed hero or brand field that must feel smooth under the cursor, their name or word as the characters, an Unseen-style look | `mountGlyphField` from `/field` | WebGL2 on a GPU, 120 fps class. Glyph ramps only. Keep a poster and a fallback. See [glyph-field.md](glyph-field.md). |
|
|
40
|
-
| A calm background for a product page, docs, pricing or SaaS hero, in light or dark | `
|
|
42
|
+
| A calm background for a product page, docs, pricing or SaaS hero, in light or dark | Kit `background(el, { preset: halftone, clear })` or `fieldBackground(el, { preset: 'hero', clear })` | Graphite ink, clear zone plus veil. See [applying-asciify.md](applying-asciify.md#9-backgrounds). Raw `mountGlyphField` with `mark: 'dot'` only for a look the kit lacks ([glyph-field.md](glyph-field.md#halftone-for-interfaces-412)). |
|
|
41
43
|
| Art styles (halftone, dither, braille, print, mosaic, LED…), saved looks, backdrops, masks, effects, PNG/MP4/GIF export | `mountStudio` from `/studio` | Studio owns motion, hover and finish. Start from `studioLook(id)` when one of the 12 looks matches the mood. |
|
|
42
44
|
| Several media and text layers in one artwork | `mountStudioProject` from `/studio` | One loop and budget for up to eight layers. See [layered-projects.md](layered-projects.md). |
|
|
43
|
-
| An ambient animated background with no media (rain, stars, waves, aurora…) |
|
|
45
|
+
| An ambient animated background with no media (rain, stars, waves, aurora…) | Kit `background(el, { preset })` with one of the 26 presets | Import presets by name from `asciify-engine/kit`. See [applying-asciify.md](applying-asciify.md#9-backgrounds) for which suits which surface. |
|
|
44
46
|
| A text banner or lettering | `asciifyText` / `renderTextToCanvas` | Plain text output works anywhere; canvas for color. |
|
|
45
47
|
| Headline text visible through the art | Layered HTML, see [layered-hero.md](layered-hero.md) | Real text stays in the DOM. |
|
|
46
48
|
|
|
@@ -86,7 +88,7 @@ Autonomous motion that lasts longer than five seconds needs a visible pause cont
|
|
|
86
88
|
|
|
87
89
|
## 5. Derive concrete values from the tokens
|
|
88
90
|
|
|
89
|
-
- **Ink.**
|
|
91
|
+
- **Ink.** Interface fields and grounds use a quiet graphite mapped to the muted text token, never the accent; colour is for outcomes only ([applying-asciify.md](applying-asciify.md#2-ink-quiet-by-default-colour-only-for-meaning)). A one-ink brand or media artwork the user asked for may use the accent. Photo looks keep source color unless the brand is strictly monochrome.
|
|
90
92
|
- **Ground.** Use the page background token exactly, or a transparent canvas over it. On light themes draw dark ink on the light ground: core `invert: true` or `'auto'`, field `background: <page color>` with a dark `ink` and `invert: true`, studio dark `ink` with a light `backdrop.color`.
|
|
91
93
|
- **Backdrop.** `backdrop` puts a blurred copy of the source under the characters. It enriches a photo but reads as a pale photographic wash on light subjects. Leave it at 0 when the user wants pure ASCII, and never use it behind text.
|
|
92
94
|
- **Wake color.** Keep the field's default `highlight: 'auto'`, which brightens each character's own color. Only set a hex highlight when the brand asks for a second color; never default to white on a colored artwork.
|
|
@@ -134,11 +136,12 @@ const field = mountGlyphField(canvas, {
|
|
|
134
136
|
|
|
135
137
|
For a quieter product page, add `hover: 'lantern'` (a soft light, no distortion) and keep `drift: 0` so the photo stays still until touched. Use `/studio` instead when they also want halftone, dither or an export button.
|
|
136
138
|
|
|
137
|
-
**"A subtle animated background behind our pricing section."**
|
|
139
|
+
**"A subtle animated background behind our pricing section."** Use a quiet kit preset with the page's muted ink, the cursor off and the copy kept clear:
|
|
138
140
|
|
|
139
|
-
```
|
|
140
|
-
import {
|
|
141
|
-
const
|
|
141
|
+
```ts
|
|
142
|
+
import { background, silk } from 'asciify-engine/kit';
|
|
143
|
+
const bg = background(pricing, { preset: silk, ink: '--ascii-ink', opacity: 0.6, cursor: false, clear: pricingCopy });
|
|
144
|
+
// On unmount: bg.destroy();
|
|
142
145
|
```
|
|
143
146
|
|
|
144
147
|
**"Retro terminal look for our launch video."**
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# GPU glyph fields (4.11)
|
|
2
2
|
|
|
3
|
+
For interface components and backgrounds, use `asciify-engine/kit` first ([applying-asciify.md](applying-asciify.md), [kit.md](kit.md)); it is built on this field and adds theme ink, posters, lazy mounting, the context budget and fallbacks. Mount the field directly only for a look the kit has no option for.
|
|
4
|
+
|
|
3
5
|
`asciify-engine/field` draws an image, a video or generated motion as characters entirely on the GPU, with a fluid wake under the cursor. Use it for full-bleed heroes, section backgrounds and brand fields where the frame must stay smooth while people move over it. It is a separate, optional entry; importing the root, `/core`, `/hover` or `/studio` never loads it.
|
|
4
6
|
|
|
5
7
|
Use `/studio` instead when you need the 24 render styles, dither, print styles, masks, effects or exports. The field draws glyph ramps only: characters, braille, block shading, dots, or your own letters.
|
|
@@ -38,6 +40,7 @@ Size the canvas with CSS (for example `position:absolute; inset:0; width:100%; h
|
|
|
38
40
|
| `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) |
|
|
39
41
|
| `charset` | `' .,:-=+*#%@'` | Ordered from empty to dense. Graphemes, so emoji, braille and block shading each take one cell. Up to 256. |
|
|
40
42
|
| `cellSize`, `cellAspect` | `12`, `1` | Cell height in CSS px; width ÷ height. Monospaced ramps look best near `0.6`. Cells are whole device pixels, and glyphs are sampled texel for texel. |
|
|
43
|
+
| `cellFit` | `'pixel'` | `'pixel'` snaps each cell to whole device pixels, so a fractional `cellSize` rounds and rows drift from a renderer that does not. `'fill'` fits a whole number of cells of at least `cellSize` × `cellAspect` and stretches them to fill the canvas exactly, as the core renderer lays out a frame; each glyph stays texel for texel, centred in its cell. |
|
|
41
44
|
| `font`, `fontWeight`, `glyphScale` | monospace, `500`, `0.8` | Any loaded CSS font. The atlas is redrawn when the font finishes loading. |
|
|
42
45
|
| `color` | `'ink'` | `'ink'` uses `ink`; `'source'` takes each cell's hue from the media. |
|
|
43
46
|
| `ink`, `highlight` | `#f3ba32`, `'auto'` | Hex colors. The wake lifts characters toward `highlight`; `'auto'` brightens each character's own color so the wake keeps the artwork's hue. |
|
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
# Asciify kit
|
|
2
|
+
|
|
3
|
+
`asciify-engine/kit` turns the interface components into one call each. Every function mounts a GPU glyph field over or inside your element, follows its size and border radius, inks it from your theme, pauses off screen, honours reduced motion, and cleans up on `destroy()`. Without WebGL2 it returns an inert handle and leaves your HTML as it was.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { beam, decode, odometer, buttonLoading, statusOrb, skeleton, pill, tabs, imageGenerate } from 'asciify-engine/kit';
|
|
7
|
+
```
|
|
8
|
+
|
|
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
|
+
|
|
11
|
+
## Shared options and handle
|
|
12
|
+
|
|
13
|
+
Every function takes these options:
|
|
14
|
+
|
|
15
|
+
| Option | Default | Meaning |
|
|
16
|
+
| --- | --- | --- |
|
|
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
|
+
| `look` | none | Raw glyph-field settings layered over the component's look: `charset`, `cellSize`, `font` and so on. |
|
|
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. |
|
|
23
|
+
|
|
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
|
+
|
|
26
|
+
In React, create the handle in an effect and return `destroy`:
|
|
27
|
+
|
|
28
|
+
```tsx
|
|
29
|
+
useEffect(() => { const b = beam(ref.current!); return () => b.destroy(); }, []);
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## beam
|
|
33
|
+
|
|
34
|
+
A band of light round an element's border, as fine halftone dots or small characters inside the edge. The field covers the element exactly and is clipped to its computed corner radii (each corner its own), and dots where a corner curves through a cell are held small enough to sit inside the curve, so nothing lands past the corners.
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
const b = beam(card, { palette: 'colorful', mode: 'progress' });
|
|
38
|
+
b.progress(0.4); // progress mode: a ring grows clockwise from the top to 40%
|
|
39
|
+
b.success(); // the ring closes, a flash sweeps round, a green fade settles, then back to rest
|
|
40
|
+
b.error(); // stops dead, flickers red twice, cracks into dim red fragments until reset() or progress()
|
|
41
|
+
```
|
|
42
|
+
|
|
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.
|
|
44
|
+
|
|
45
|
+
## decode
|
|
46
|
+
|
|
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.
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
const d = decode(document.querySelector('h1')!, { duration: 800 });
|
|
51
|
+
d.replay();
|
|
52
|
+
```
|
|
53
|
+
|
|
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()`.
|
|
55
|
+
|
|
56
|
+
## odometer
|
|
57
|
+
|
|
58
|
+
A figure in the page's own font. Only changed digits roll through glyphs, leftmost first, then land back on the crisp digit. A visually hidden `aria-live` copy announces each value.
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
const o = odometer(figure, { value: 48290, format: n => `$${n.toLocaleString('en-US')}` });
|
|
62
|
+
o.set(49530);
|
|
63
|
+
```
|
|
64
|
+
|
|
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`.
|
|
66
|
+
|
|
67
|
+
## buttonLoading
|
|
68
|
+
|
|
69
|
+
The label cycles through characters while the request runs, then decodes into the result. The button never shrinks between states. Use a monospaced label font for the even letter grid.
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
const b = buttonLoading(button, { announce: statusLine });
|
|
73
|
+
b.start('Deploying');
|
|
74
|
+
await deploy().then(() => b.succeed('Deployed'), () => b.fail('Retry'));
|
|
75
|
+
```
|
|
76
|
+
|
|
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.
|
|
78
|
+
|
|
79
|
+
## statusOrb
|
|
80
|
+
|
|
81
|
+
An orb of characters that orbits while work runs, then closes into a ring around a check, `!` or X, tinted by the outcome.
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
const orb = statusOrb(span, { colourByState: true, ring: true });
|
|
85
|
+
orb.loading();
|
|
86
|
+
await save().then(r => (r.warnings ? orb.warning() : orb.success()), () => orb.error());
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Options: `colourByState` (default true), `ring` (close into a ring, or leave the bare mark; default true), `colors` (`{ success, warning, error }`; defaults read `--ascii-success`, `--ascii-warning`, `--ascii-error`). Ink defaults to `--ascii-ink-strong`. Methods: `idle()`, `loading()`, `success()`, `warning()`, `error()`; getter `state`. Give the element a size, such as 104px square.
|
|
90
|
+
|
|
91
|
+
## skeleton
|
|
92
|
+
|
|
93
|
+
Placeholder blocks drawn in characters with a shimmer; on `resolve()` text wipes in left to right and images develop in characters, then fade to the photo.
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
const s = skeleton(card); // blocks: [data-block="text" | "photo" | "avatar"]; images default to photo
|
|
97
|
+
await fetchPost();
|
|
98
|
+
await s.resolve();
|
|
99
|
+
```
|
|
100
|
+
|
|
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.
|
|
102
|
+
|
|
103
|
+
## pill
|
|
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).
|
|
106
|
+
|
|
107
|
+
```html
|
|
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>
|
|
110
|
+
<span class="pill" data-count>12</span>
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
const pills = pill(document.querySelectorAll('.pill'));
|
|
115
|
+
statusPill.dataset.tone = 'red'; delete statusPill.dataset.live;
|
|
116
|
+
pills.refresh(); // tone changes ease over 240ms
|
|
117
|
+
pills.count(14); // only the changed digits roll
|
|
118
|
+
```
|
|
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.
|
|
121
|
+
|
|
122
|
+
## tabs
|
|
123
|
+
|
|
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`.
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
const t = tabs(tablist, { onChange: i => track('tab', i) });
|
|
128
|
+
t.select(2);
|
|
129
|
+
```
|
|
130
|
+
|
|
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.
|
|
132
|
+
|
|
133
|
+
## imageGenerate
|
|
134
|
+
|
|
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.
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
const gen = imageGenerate(frame, { elapsed: clock }); // frame holds the <img> that shows the result
|
|
139
|
+
const state = await gen.run(() => imageStream(prompt)); // 'done' or 'failed'
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Options: `stream` (starts at once; a function is called again by each `run()`), `image` (default the first `<img>` inside), `elapsed` (an element for the clock), `onState(state, error?)`. Methods: `run(stream?)`, `reset()`; getter `state`. Sets `data-state` (`generating`, `done`, `failed`) and `aria-busy` on the element. Give the frame `overflow: hidden` and a background; the field sits above that background and below the photo. Without WebGL2 each preview goes straight into the `<img>`, which stays hidden until it has a source.
|
|
143
|
+
|
|
144
|
+
## Your own components
|
|
145
|
+
|
|
146
|
+
`mountSurface` is the plumbing the kit is built on: give it an element, a look, an ink and a `paint(frame)` that draws white-on-black into `frame.pen` and returns true while it needs more frames.
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
import { mountSurface } from 'asciify-engine/kit';
|
|
150
|
+
const s = mountSurface({ host: el, look: () => ({ mark: 'glyph', cellSize: 6 }), ink: () => '--ascii-ink-strong', reducedMotion: () => undefined,
|
|
151
|
+
paint: ({ pen, w, h, now, still }) => { pen.fillStyle = '#fff'; pen.fillRect(0, 0, (w * (now % 1000)) / 1000, h); return !still; } });
|
|
152
|
+
```
|
|
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
|
+
|
|
184
|
+
## Phase 2c: page and field helpers
|
|
185
|
+
|
|
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.
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
import { fieldBackground, wordmark, asciiVideo, artPanel, pricingGlow } from 'asciify-engine/kit';
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### fieldBackground
|
|
193
|
+
|
|
194
|
+
The hero's drifting ground of characters under the content, with a soft elliptical clear zone round the headline so a frosted veil over it reads clean; the same ground in the brand colour; or a 404 set large in characters that scatters and re-forms.
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
const bg = fieldBackground(hero, { preset: 'hero', clear: headline });
|
|
198
|
+
subscribe.onclick = () => bg.pulse(subscribe); // a ring from the button; cursor ripples hold off for a beat
|
|
199
|
+
|
|
200
|
+
const lost = fieldBackground(art, { preset: 'notFound' });
|
|
201
|
+
home.onclick = async () => { await lost.scatter(home); location.href = '/'; };
|
|
202
|
+
```
|
|
203
|
+
|
|
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.
|
|
205
|
+
|
|
206
|
+
### wordmark
|
|
207
|
+
|
|
208
|
+
A name or a word set in characters with a trail under the pointer. By default it draws the element's own text, which stays in the DOM, transparent, for selection and screen readers; pass `text` to label the canvas instead.
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
const mark = wordmark(box, { text: 'asciify' });
|
|
212
|
+
input.oninput = () => mark.setText(input.value);
|
|
213
|
+
```
|
|
214
|
+
|
|
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).
|
|
218
|
+
|
|
219
|
+
### asciiVideo
|
|
220
|
+
|
|
221
|
+
A video drawn in characters, muted and looping. `play()` and `pause()` stop the video and the field together.
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
const v = asciiVideo(player, { src: '/magnolia.mp4', poster: '/magnolia-ascii.webp' });
|
|
225
|
+
button.onclick = () => { const on = v.toggle(); button.setAttribute('aria-label', on ? 'Pause' : 'Play'); };
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Options: `src` (a URL or a `<video>`; default the first `<video>` inside, hidden while the field draws it), `autoplay` (default true, false under reduced motion, which shows the first frame), `poster` (an image URL shown, cover-fit, until the first frame is drawn, and under reduced motion until play), `label` (default `Video, drawn in characters`). Methods: `play()`, `pause()`, `toggle()`; getter `playing`. Give the element a size and a play control. Without WebGL2 the video itself plays in the element, muted and looping, with the poster, and the methods control it.
|
|
229
|
+
|
|
230
|
+
### artPanel
|
|
231
|
+
|
|
232
|
+
The sign-in page's art: an image in characters with a trail, a ring for form events and a lit band that crosses again and again while a request runs.
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
const art = artPanel(panel, { image: '/wing.webp', clear: brandMark });
|
|
236
|
+
form.onsubmit = async () => {
|
|
237
|
+
art.pulse(submitButton, 1.8);
|
|
238
|
+
art.shimmer(true);
|
|
239
|
+
await sendLink().finally(() => art.shimmer(false));
|
|
240
|
+
art.pulse(undefined, 0.6);
|
|
241
|
+
};
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Options: `image` (a URL or an `<img>`; default the first `<img>` inside, hidden while drawn), `clear` (a brand mark over the art, kept clear in a soft rectangle), `focus` (cover crop, default `{ x: 0.5, y: 0.4 }`), `label` (default the image's `alt`). Methods: `pulse(from?, strength?)` (default 1), `shimmer(on)`. Give the panel `overflow: hidden` and a background; the field sits under its content.
|
|
245
|
+
|
|
246
|
+
### pricingGlow
|
|
247
|
+
|
|
248
|
+
The recommended plan's halftone: light from the top corner, a lantern under the pointer, and the yearly saving set large in dots in the open space between the plan's copy and its price.
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
const glow = pricingGlow(card, { price: '.price' });
|
|
252
|
+
period.onchange = () => glow.saving(yearly ? '−25%' : null); // the band sweeps the old halftone out, the new one in
|
|
253
|
+
cta.onclick = () => glow.pulse(cta);
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Options: `saving` (text, default none), `price` (the element or selector the open space ends at, default `[data-price]`; the space starts below the last of the card's children before it, and must be at least 30px tall), `font` (default `"Geist", Arial, sans-serif`). Methods: `sweep()` (one band over the current halftone), `saving(text | null)`, `pulse(from?, strength?)` (default 1.6). Ink defaults read the card's own ground: lighter dots on a dark card, darker on a light one. The field sits under the card's content; the art is rebuilt when the card resizes.
|
|
257
|
+
|
|
258
|
+
Route transitions are covered by `pageDissolve`.
|
|
259
|
+
|
|
260
|
+
## Phase 2a: voice, commands, gestures and data
|
|
261
|
+
|
|
262
|
+
Interactive components that keep their text and controls in HTML and paint the moment in characters. Each one wires its own pointer, keyboard and ARIA, and keeps working as plain HTML without WebGL2: the slider still confirms, the palette still opens, the code input still takes a code.
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
import { voiceOrb, commandPalette, slideToConfirm, likeBurst, blockChart, pageDissolve, swipeDeck, codeInput } from 'asciify-engine/kit';
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
### voiceOrb
|
|
269
|
+
|
|
270
|
+
A sphere of characters that swells with the voice, hums while the reply is worked out and settles when done. The microphone is asked for only when `mic()` is called.
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
const orb = voiceOrb(button, { palette: 'aurora' });
|
|
274
|
+
orb.listen();
|
|
275
|
+
recognizer.onvolume = v => orb.level(v); // 0–1; fast attack, slower release
|
|
276
|
+
recognizer.onend = () => orb.think();
|
|
277
|
+
reply.then(() => orb.done());
|
|
278
|
+
micButton.onclick = async () => { if (!(await orb.mic())) note.textContent = 'Microphone unavailable'; };
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Options: `palette` (`'mono' | 'aurora' | 'brand'` or your own hex stops, default `aurora`), `level` (start, default 0), `swell` (scale the element up to 10% with the level, default true; `--ascii-level` is set either way). Methods: `level(v)`, `listen()`, `think()`, `done()`, `idle()`, `mic()` (a promise for whether it got the microphone; `think`, `done`, `idle` and `destroy` stop it); getters `state`, `current`. Sets `data-state`. Give the element a size and a round border radius; the field reaches 8% past it.
|
|
282
|
+
|
|
283
|
+
### commandPalette
|
|
284
|
+
|
|
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.
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
const cmd = commandPalette(panel, { trigger, onRun: (i, row) => commands[row.dataset.id!].run() });
|
|
289
|
+
search.oninput = () => render(filter(search.value)); // the kit re-selects row 0 on input
|
|
290
|
+
cmd.select(2);
|
|
291
|
+
```
|
|
292
|
+
|
|
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.
|
|
294
|
+
|
|
295
|
+
### slideToConfirm
|
|
296
|
+
|
|
297
|
+
Characters fill the track behind the thumb as it travels; on completion the fill condenses into a disc, opens into a ring and a check draws inside it while the ink eases to the success colour.
|
|
298
|
+
|
|
299
|
+
```ts
|
|
300
|
+
const pay = slideToConfirm(track, { onConfirm: () => charge(), mark: track.querySelector('.done-icon') });
|
|
301
|
+
charge().catch(() => pay.reset());
|
|
302
|
+
```
|
|
303
|
+
|
|
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.
|
|
305
|
+
|
|
306
|
+
### likeBurst
|
|
307
|
+
|
|
308
|
+
The mark springs out of the icon to about 1.5x in characters, an outline rolls out, a few sparks fly, and it lands back in the crisp icon; undo loosens and thins it out.
|
|
309
|
+
|
|
310
|
+
```ts
|
|
311
|
+
const like = likeBurst(button, { limit: postBody, onChange: on => api.like(post.id, on) });
|
|
312
|
+
const save = likeBurst(saveButton, { kind: 'save' });
|
|
313
|
+
```
|
|
314
|
+
|
|
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`.
|
|
316
|
+
|
|
317
|
+
### blockChart
|
|
318
|
+
|
|
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.
|
|
320
|
+
|
|
321
|
+
```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);
|
|
324
|
+
```
|
|
325
|
+
|
|
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.
|
|
327
|
+
|
|
328
|
+
### pageDissolve
|
|
329
|
+
|
|
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.
|
|
331
|
+
|
|
332
|
+
```ts
|
|
333
|
+
const page = pageDissolve(body);
|
|
334
|
+
tab.onclick = () => page.swap(() => render(tab.dataset.page), tab);
|
|
335
|
+
```
|
|
336
|
+
|
|
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.
|
|
338
|
+
|
|
339
|
+
### swipeDeck
|
|
340
|
+
|
|
341
|
+
A deck of photo cards drawn in characters: drag or press the arrow keys to fling the top card, which breaks into rows of characters along the swipe while the next card rises.
|
|
342
|
+
|
|
343
|
+
```ts
|
|
344
|
+
const deck = swipeDeck(slot, { onSwipe: (dir, i) => (dir > 0 ? save(stays[i]) : pass(stays[i])) });
|
|
345
|
+
keep.onclick = () => deck.next(1);
|
|
346
|
+
pass.onclick = () => deck.next(-1);
|
|
347
|
+
```
|
|
348
|
+
|
|
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.
|
|
350
|
+
|
|
351
|
+
### codeInput
|
|
352
|
+
|
|
353
|
+
A one-time-code field: each digit resolves out of scattered characters, the caret is a column of characters that breathes, a wrong code breaks up and shakes, and a right one gathers into a check in a ring.
|
|
354
|
+
|
|
355
|
+
```ts
|
|
356
|
+
const code = codeInput(row, { length: 6, onComplete: async c => code.verify(await api.check(c)) });
|
|
357
|
+
```
|
|
358
|
+
|
|
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.
|
|
360
|
+
|
|
361
|
+
## Phase 2b: feedback, streaming and media
|
|
362
|
+
|
|
363
|
+
Toasts, assistant replies and image components. Where a component has meaning without the field it keeps working as plain HTML without WebGL2: toasts still stack, `thinking` and `streamText` still write their text, the accordion and dropzone still show photos, `disintegrate` still closes the row and `pullRefresh` still refreshes. Components that build markup add their fallback styles once, as a `<style>` whose rules all sit in `:where()`, so any class of yours overrides them.
|
|
364
|
+
|
|
365
|
+
```ts
|
|
366
|
+
import { toastStack, thinking, streamText, compare, accordion, dropzone, disintegrate, pullRefresh } from 'asciify-engine/kit';
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
### toastStack
|
|
370
|
+
|
|
371
|
+
Toasts that gather out of scattered characters, newest in front and the rest tucked behind, fanning out while pointed at or focused. Dismissed toasts break into characters that drift up and away while the stack closes up.
|
|
372
|
+
|
|
373
|
+
```ts
|
|
374
|
+
const toasts = toastStack(corner); // corner: a positioned box with a width, such as fixed bottom-right, 360px wide
|
|
375
|
+
const id = toasts.push({ title: 'Thread archived', body: 'Q3 planning · Ana', action: { label: 'Undo', onClick: restore } });
|
|
376
|
+
toasts.dismiss(id);
|
|
377
|
+
```
|
|
378
|
+
|
|
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.
|
|
380
|
+
|
|
381
|
+
### thinking
|
|
382
|
+
|
|
383
|
+
An assistant reply's thinking state: line-shaped bands of drifting dots while the model thinks, then the answer fades in word by word as the dots give way, leaving a quiet dotted rule under it.
|
|
384
|
+
|
|
385
|
+
```ts
|
|
386
|
+
const reply = thinking(answer, { label: status }); // status reads "Thinking…", then "Thought for 2s"
|
|
387
|
+
await reply.stream(await ask(question)); // or an async iterable of chunks
|
|
388
|
+
regenerate.onclick = () => reply.think();
|
|
389
|
+
```
|
|
390
|
+
|
|
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.
|
|
392
|
+
|
|
393
|
+
### streamText
|
|
394
|
+
|
|
395
|
+
Streamed text with a glyph write head: each token lands as flickering character cells that resolve into letters left to right, with a block caret after the last one. The real text is in the DOM all along, for selection and screen readers.
|
|
396
|
+
|
|
397
|
+
```ts
|
|
398
|
+
const out = streamText(paragraph);
|
|
399
|
+
for await (const step of llm.reasoning(prompt)) out.reasoning(step); // optional: a short tail of reasoning above the reply
|
|
400
|
+
for await (const token of llm.stream(prompt)) out.write(token);
|
|
401
|
+
await out.done();
|
|
402
|
+
stopButton.onclick = () => out.stop();
|
|
403
|
+
```
|
|
404
|
+
|
|
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.
|
|
406
|
+
|
|
407
|
+
### compare
|
|
408
|
+
|
|
409
|
+
A before and after slider: the photo left of the split and the same photo in characters, in its own colours, on the right. Drag anywhere in the frame, or use the arrow keys, Page Up and Down, Home and End on the handle.
|
|
410
|
+
|
|
411
|
+
```ts
|
|
412
|
+
const c = compare(frame, { image: '/kingfisher.webp', position: 58 }); // frame: a dark, 3:2 box
|
|
413
|
+
c.set(30, true);
|
|
414
|
+
```
|
|
415
|
+
|
|
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.
|
|
417
|
+
|
|
418
|
+
### accordion
|
|
419
|
+
|
|
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.
|
|
421
|
+
|
|
422
|
+
```ts
|
|
423
|
+
const a = accordion(strip, { images: [{ src: '/stairwell.webp', title: 'Stairwell', meta: 'Silver print · $120' }, '/shell.webp'], open: 1 });
|
|
424
|
+
```
|
|
425
|
+
|
|
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.
|
|
427
|
+
|
|
428
|
+
### dropzone
|
|
429
|
+
|
|
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.
|
|
431
|
+
|
|
432
|
+
```ts
|
|
433
|
+
const zone = dropzone(box, { onImage: ({ file, width, height }) => upload(file), onError: message => toast(message) });
|
|
434
|
+
zone.setImage('/sample.webp');
|
|
435
|
+
saveButton.onclick = () => zone.download();
|
|
436
|
+
```
|
|
437
|
+
|
|
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.
|
|
439
|
+
|
|
440
|
+
### disintegrate
|
|
441
|
+
|
|
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.
|
|
443
|
+
|
|
444
|
+
```ts
|
|
445
|
+
await disintegrate(row); // then remove the row from your data
|
|
446
|
+
await disintegrate(row, { remove: true });
|
|
447
|
+
```
|
|
448
|
+
|
|
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.
|
|
450
|
+
|
|
451
|
+
### pullRefresh
|
|
452
|
+
|
|
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.
|
|
454
|
+
|
|
455
|
+
```ts
|
|
456
|
+
const pull = pullRefresh(screen, { onRefresh: () => loadNewPosts(), reveal: '[data-new]' });
|
|
457
|
+
refreshButton.onclick = () => pull.refresh();
|
|
458
|
+
pull.revealRows(feed.querySelectorAll('.just-arrived')); // or reveal any rows yourself
|
|
459
|
+
```
|
|
460
|
+
|
|
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'; //
|
|
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
|
-
|
|
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)).
|