skills: refine pretext creative demo guidance

Capture the reusable layout and animation lessons from the advanced Pretext demo so the skill teaches measured obstacle fields, morphing geometry, and polished browser examples.
This commit is contained in:
Brooklyn Nicholson 2026-04-29 14:24:15 -05:00
parent c4db1ce08c
commit 165d766891
3 changed files with 1485 additions and 293 deletions

View file

@ -16,7 +16,7 @@ metadata:
[`@chenglou/pretext`](https://github.com/chenglou/pretext) is a 15KB zero-dependency TypeScript library by Cheng Lou (React core, ReasonML, Midjourney) for **DOM-free multiline text measurement and layout**. It does one thing: given `(text, font, width)`, return the line breaks, per-line widths, per-grapheme positions, and total height — all via canvas measurement, no reflow.
That sounds like plumbing. It is not. Because it is fast and geometric, it is a **creative primitive**: you can reflow paragraphs around a moving sprite at 60fps, build games whose level geometry is made of real words, render proportional (not monospaced) ASCII art, animate variable-width Sloane donuts out of prose, shatter text into particles with exact per-grapheme starting positions, or pack shrink-wrapped multiline UI without any `getBoundingClientRect` thrash.
That sounds like plumbing. It is not. Because it is fast and geometric, it is a **creative primitive**: you can reflow paragraphs around a moving sprite at 60fps, build games whose level geometry is made of real words, drive ASCII logos through prose, shatter text into particles with exact per-grapheme starting positions, or pack shrink-wrapped multiline UI without any `getBoundingClientRect` thrash.
This skill exists so Hermes can make **cool demos** with it — the kind people post to X. See `pretext.cool` and `chenglou.me/pretext` for the community demo corpus.
@ -45,7 +45,7 @@ This is visual art rendered in a browser. Pretext returns numbers; **you** draw
- **Don't ship a "hello world" demo.** The `hello-orb-flow.html` template is the *starting* point. Every delivered demo must add intentional color, motion, composition, and one visual detail the user didn't ask for but will appreciate.
- **Dark backgrounds, warm cores, considered palette.** Classic amber-on-black (CRT / terminal) works, but so do cold-white-on-charcoal (editorial) and desaturated pastels (risograph). Pick one and commit.
- **Proportional fonts are the point.** Pretext's whole vibe is "not monospaced" — lean into it. Use Iowan Old Style, Inter, JetBrains Mono, Helvetica Neue, or a variable font. Never default sans.
- **Real prose, not lorem ipsum.** The corpus should mean something. Short manifestos, poetry, the library's own README, a found text — never `lorem ipsum`.
- **Real source/text, not lorem ipsum.** The corpus should mean something. Short manifestos, poetry, real source code, a found text, the library's own README — never `lorem ipsum`.
- **First-paint excellence.** No loading states, no blank frames. The demo must look shippable the instant it opens.
## Stack
@ -139,7 +139,7 @@ The community corpus (see `references/patterns.md`) clusters into a handful of s
| **Reflow around obstacle** | `layoutNextLineRange` + per-row width function | Editorial paragraph that parts around a dragged cursor sprite |
| **Text-as-geometry game** | `layoutWithLines` + per-line collision rects | Breakout where each brick is a measured word |
| **Shatter / particles** | `walkLineRanges` → per-grapheme (x,y) → physics | Sentence that explodes into letters on click |
| **Proportional ASCII** | `layoutWithLines` sampled across (theta,phi) of a 3D surface | Torus / sphere / wave made of prose glyphs (see `donut-orbit.html`) |
| **ASCII obstacle typography** | `layoutNextLineRange` + measured per-row obstacle spans | Bitmap ASCII logo, shape morphs, and draggable wire objects that make text open around their actual geometry |
| **Editorial multi-column** | `layoutNextLineRange` per column + shared cursor | Animated magazine spread with pull quotes |
| **Kinetic type** | `layoutWithLines` + per-line transform over time | Star Wars crawl, wave, bounce, glitch |
| **Multiline shrink-wrap** | `measureLineStats` | Quote card that auto-sizes to its tightest container |
@ -151,7 +151,7 @@ See `templates/donut-orbit.html` and `templates/hello-orb-flow.html` for working
1. **Pick a pattern** from the table above based on the user's brief.
2. **Start from a template**:
- `templates/hello-orb-flow.html` — text reflowing around a moving orb (reflow-around-obstacle pattern)
- `templates/donut-orbit.html`full 3D ASCII torus with orbit controls (proportional-ASCII + interaction)
- `templates/donut-orbit.html`advanced example: measured ASCII logo obstacles, draggable wire sphere/cube, morphing shape fields, selectable DOM text, and dev-only controls
- `write_file` to a new `.html` in `/tmp/` or the user's workspace.
3. **Swap the corpus** for something intentional to the brief. Real prose, 10-100 sentences, no lorem.
4. **Tune the aesthetic** — font, palette, composition, interaction. This is the work; don't skip it.
@ -168,7 +168,9 @@ See `templates/donut-orbit.html` and `templates/hello-orb-flow.html` for working
- `prepare()` / `prepareWithSegments()` is the expensive call. Do it **once** per text+font pair. Cache the handle.
- On resize, only rerun `layout()` / `layoutWithLines()` — never re-prepare.
- For per-frame animations where text doesn't change but geometry does, `layoutNextLineRange` in a tight loop is cheap enough to do every frame at 60fps for normal-length paragraphs.
- When rendering thousands of glyphs per frame (e.g. the donut demo), use a **z-buffer keyed by screen cell** instead of sorting — see `templates/donut-orbit.html` for the pattern.
- When rendering ASCII masks per frame, keep a cell buffer (`Uint8Array`/typed arrays), derive measured per-row obstacle spans from the cells or projected geometry, merge spans, then feed those spans into `layoutNextLineRange` before drawing text.
- Keep visual animation and layout animation coupled. If a sphere morphs into a cube, tween both the rendered cell buffer and the obstacle spans with the same value; otherwise the demo looks painted-on instead of physically reflowed.
- For fades, prefer layer opacity over changing glyph intensity or obstacle scale. Put transient ASCII sprites on their own canvas and fade the canvas with CSS/GSAP opacity so geometry does not appear to shrink.
- Canvas `ctx.font` setting is surprisingly slow; set it **once** per frame if font doesn't vary, not per `fillText` call.
## Common Pitfalls

View file

@ -103,49 +103,93 @@ function tick(dt) {
}
```
## 4. Proportional ASCII surface (donut / sphere / wave)
## 4. ASCII mask as moving obstacle
The "cool demos" money pattern. Sample a parametric 3D surface, use classic luminance → glyph picking, but replace the monospace grid with a **z-buffer keyed by screen cell** and pull glyphs from a real corpus in reading order.
The "cool demos" money pattern: rasterize an ASCII logo, sprite, or bitmap into a cell buffer, then convert the occupied cells into per-row obstacle spans. Pretext lays the paragraphs around those spans, so the text actually opens around the moving ASCII object instead of being visually overpainted.
See `templates/donut-orbit.html` in this skill for the full implementation. Key structure:
See `templates/donut-orbit.html` in this skill for a full implementation. Treat it as an example, not the canonical scene: it shows how to derive spans from an ASCII logo, project a wire shape into obstacle rows, keep text selectable in a DOM layer, and hide tuning controls behind `?dev`. Key structure:
```js
const CELL = 9; // px bucket
const cols = Math.ceil(W / CELL), rows = Math.ceil(H / CELL);
const zbuf = new Float32Array(cols * rows);
const chbuf = new Array(cols * rows);
const CELL_W = 12, CELL_H = 15;
const cols = Math.ceil(W / CELL_W), rows = Math.ceil(H / CELL_H);
const asciiMask = new Uint8Array(cols * rows);
const obstacleRows = Array.from({ length: rows }, () => []);
// Sample the surface
for (let j = 0; j < PHI_STEPS; j++) {
for (let i = 0; i < THETA_STEPS; i++) {
const { sx, sy, ooz, L } = projectSurfacePoint(i, j);
if (L <= 0) continue;
const ci = (sx / CELL) | 0, ri = (sy / CELL) | 0;
const idx = ri * cols + ci;
if (ooz > zbuf[idx]) {
zbuf[idx] = ooz;
chbuf[idx] = GLYPHS[glyphIdx++ % GLYPHS.length];
function rasterizeLogo(time) {
asciiMask.fill(0);
for (const r of obstacleRows) r.length = 0;
for (const block of logoBlocks(time)) {
const r0 = Math.floor(block.y0 / CELL_H);
const r1 = Math.ceil(block.y1 / CELL_H);
for (let r = r0; r <= r1; r++) {
obstacleRows[r]?.push([block.x0 - 18, block.x1 + 22]);
// Fill asciiMask cells here for drawing.
}
}
mergeRowSpans(obstacleRows);
}
function drawParagraphs(prepared) {
let cursor = { segmentIndex: 0, graphemeIndex: 0 };
for (let y = yStart; y < yEnd; y += LINE_H) {
const spans = obstacleRows[Math.floor(y / CELL_H)];
for (const [x0, x1] of freeIntervalsAround(spans)) {
const range = layoutNextLineRange(prepared, cursor, x1 - x0);
if (!range) return;
ctx.fillText(materializeLineRange(prepared, range).text, x0, y);
cursor = range.end;
}
}
}
// Draw once
for (let i = 0; i < chbuf.length; i++) if (chbuf[i]) ctx.fillText(chbuf[i], ...);
```
The `GLYPHS` array comes from pretext:
The important bit is that the ASCII geometry is not decorative only. The same moving spans that draw the logo or draggable object also carve the line intervals passed to `layoutNextLineRange`.
### Measured spans beat magic padding
When a logo or bitmap is rasterized into cells, measure the actual occupied cells per row and then add a small halo. Do not use one giant bounding box. Tight measured spans make the text read as if it is flowing around the letter shapes.
```js
const prepared = prepareWithSegments(CORPUS, FONT);
const { lines } = layoutWithLines(prepared, 260, 16);
const GLYPHS = [];
for (const line of lines) {
const seg = new Intl.Segmenter(undefined, { granularity: "grapheme" });
for (const { segment } of seg.segment(line.text)) GLYPHS.push(segment);
const rowMin = new Float32Array(rows).fill(Infinity);
const rowMax = new Float32Array(rows).fill(-Infinity);
for (const cell of visibleCells) {
rowMin[cell.row] = Math.min(rowMin[cell.row], cell.x);
rowMax[cell.row] = Math.max(rowMax[cell.row], cell.x + CELL_W);
}
for (let row = 0; row < rows; row++) {
if (!Number.isFinite(rowMin[row])) continue;
obstacleRows[row].push([rowMin[row] - halo, rowMax[row] + halo]);
}
```
Why not just `[...CORPUS]`? Because pretext gives you **reading-order graphemes after line-break decisions** — which makes the surface glyphs follow the corpus's natural rhythm, including non-Latin scripts and soft-hyphen-resolved breaks.
For sharp pixel-art letters, smooth adjacent rows before pushing spans. A 1-2 row halo usually prevents code/prose from touching corners without losing the letter silhouette.
### Morphing shapes need morphing obstacles
If the visible object morphs (sphere to cube, logo to particles, etc.), tween the collision field too. A convincing demo uses the same `mix` value for both the rendered buffer and the pretext obstacle rows.
```js
function pushMorphedRows(aRows, bRows, mix) {
for (let row = 0; row < rows; row++) {
const a = aRows[row] ?? [centerX, centerX];
const b = bRows[row] ?? [centerX, centerX];
obstacleRows[row].push([
a[0] + (b[0] - a[0]) * mix,
a[1] + (b[1] - a[1]) * mix,
]);
}
}
```
Without this, the artwork may morph while the text still wraps around the old shape, which breaks the pretext effect.
### Separate visual layers from collision
Use separate canvases when visual treatment should not affect layout. For example, fade an ASCII object with CSS opacity on its own canvas layer, but keep its obstacle rows controlled by explicit shape state. Fading glyph intensity or scaling obstacle spans often looks like the object is shrinking instead of fading.
## 5. Editorial multi-column with shared cursor

File diff suppressed because it is too large Load diff