
Pretext
- 246 installs
- 226k repo stars
- Updated August 5, 2026
- nousresearch/hermes-agent
Set consistent agent pretext with role, goals, constraints, and starter context so Hermes runs behave predictably before tools are invoked.
About
Hermes-agent skill for defining and applying pretext framing at run start, standardizing roles, objectives, constraints, and initial context so agents execute with stable instructions before calling tools.
- Reusable run pretext templates
- Role, goal, and constraint framing
- Consistent agent session bootstrap
- Context envelopes before tool calls
- Hermes-agent orchestration hooks
Pretext by the numbers
- 246 all-time installs (skills.sh)
- +12 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #2,568 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/nousresearch/hermes-agent --skill pretextAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 246 |
|---|---|
| repo stars | ★ 226k |
| Last updated | August 5, 2026 |
| Repository | nousresearch/hermes-agent ↗ |
What it does
Set consistent agent pretext with role, goals, constraints, and starter context so Hermes runs behave predictably before tools are invoked.
Files
Pretext Creative Demos
Overview
`@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, 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.
When to Use
Use when the user asks for:
- A "pretext demo" / "cool pretext thing" / "text-as-X"
- Text flowing around a moving shape (hero sections, editorial layouts, animated long-form pages)
- ASCII-art effects using real words or prose, not monospace rasters
- Games where the playfield / obstacles / bricks are made of text (Tetris-from-letters, Breakout-of-prose)
- Kinetic typography with per-glyph physics (shatter, scatter, flock, flow)
- Typographic generative art, especially with non-Latin scripts or mixed scripts
- Multiline "shrink-wrap" UI (smallest container width that still fits the text)
- Anything that would require knowing line breaks before rendering
Don't use for:
- Static SVG/HTML pages where CSS already solves layout — just use CSS
- Rich text editors, general inline formatting engines (pretext is intentionally narrow)
- Image → text (use
ascii-art/ascii-videoskills) - Pure canvas generative art with no text role — use
p5js
Creative Standard
This is visual art rendered in a browser. Pretext returns numbers; you draw the thing.
- Don't ship a "hello world" demo. The
hello-orb-flow.htmltemplate 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 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
Single self-contained HTML file per demo. No build step.
| Layer | Tool | Purpose |
|---|---|---|
| Core | @chenglou/pretext via esm.sh CDN | Text measurement + line layout |
| Render | HTML5 Canvas 2D | Glyph rendering, per-frame composition |
| Segmentation | Intl.Segmenter (built-in) | Grapheme splitting for emoji / CJK / combining marks |
| Interaction | Raw DOM events | Mouse / touch / wheel — no framework |
<script type="module">
import {
prepare, layout, // use-case 1: simple height
prepareWithSegments, layoutWithLines, // use-case 2a: fixed-width lines
layoutNextLineRange, materializeLineRange, // use-case 2b: streaming / variable width
measureLineStats, walkLineRanges, // stats without string allocation
} from "https://esm.sh/@chenglou/pretext@0.0.6";
</script>Pin the version. @0.0.6 at time of writing — check npm for the latest if demo behavior is off.
The Two Use Cases
Almost everything reduces to one of these two shapes. Learn both.
Use-case 1 — measure, then render with CSS/DOM
const prepared = prepare(text, "16px Inter");
const { height, lineCount } = layout(prepared, 320, 20);You still let the browser draw the text. Pretext just tells you how tall the box will be at a given width, without a DOM read. Use for:
- Virtualized lists where rows contain wrapping text
- Masonry with precise card heights
- "Does this label fit?" dev-time checks
- Preventing layout shift when remote text loads
Keep `font` and `letterSpacing` exactly in sync with your CSS. The canvas ctx.font format (e.g. "16px Inter", "500 17px 'JetBrains Mono'") must match the rendered CSS, or measurements drift.
Use-case 2 — measure and render yourself
const prepared = prepareWithSegments(text, FONT);
const { lines } = layoutWithLines(prepared, 320, 26);
for (let i = 0; i < lines.length; i++) {
ctx.fillText(lines[i].text, 0, i * 26);
}This is where the creative work lives. You own the drawing, so you can:
- Render to canvas, SVG, WebGL, or any coordinate system
- Substitute per-glyph transforms (rotation, jitter, scale, opacity)
- Use line metadata (width, grapheme positions) as geometry
For variable-width-per-line flow (text around a shape, text in a donut band, text in a non-rectangular column):
let cursor = { segmentIndex: 0, graphemeIndex: 0 };
let y = 0;
while (true) {
const lineWidth = widthAtY(y); // your function: how wide is the corridor at this y?
const range = layoutNextLineRange(prepared, cursor, lineWidth);
if (!range) break;
const line = materializeLineRange(prepared, range);
ctx.fillText(line.text, leftEdgeAtY(y), y);
cursor = range.end;
y += lineHeight;
}This is the most important pattern in the whole library. It's what unlocks "text flowing around a dragged sprite" — the demo that went viral on X.
Helpers worth knowing
measureLineStats(prepared, maxWidth)→{ lineCount, maxLineWidth }— the widest line, i.e. multiline shrink-wrap width.walkLineRanges(prepared, maxWidth, callback)— iterate lines without allocating strings. Use for stats/physics over graphemes when you don't need the characters.@chenglou/pretext/rich-inline— the same system but for paragraphs mixing fonts / chips / mentions. Import from the subpath.
Demo Recipe Patterns
The community corpus (see references/patterns.md) clusters into a handful of strong patterns. Pick one and riff — don't invent a new category unless asked.
| Pattern | Key API | Example idea |
|---|---|---|
| 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 |
| 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 |
See templates/donut-orbit.html and templates/hello-orb-flow.html for working single-file starters.
Workflow
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— advanced example: measured ASCII logo obstacles, draggable wire sphere/cube, morphing shape fields, selectable DOM text, and dev-only controlswrite_fileto a new.htmlin/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. 5. Verify locally:
cd <dir-with-html> && python3 -m http.server 8765
# then open http://localhost:8765/<file>.html6. Check the console — pretext will throw if prepareWithSegments is called with a bad font string; Intl.Segmenter is available in every modern browser. 7. Show the user the file path, not just the code — they want to open it.
Performance Notes
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,
layoutNextLineRangein a tight loop is cheap enough to do every frame at 60fps for normal-length paragraphs. - 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 intolayoutNextLineRangebefore 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.fontsetting is surprisingly slow; set it once per frame if font doesn't vary, not perfillTextcall.
Common Pitfalls
1. Drifting CSS/canvas font strings. ctx.font = "16px Inter" measured, but CSS says font-family: Inter, sans-serif; font-size: 16px. Fine if Inter loads. If Inter 404s, CSS falls back to sans-serif and measurements drift by 5-20%. Always preload the font or use a web-safe family.
2. Re-preparing inside the animation loop. Only layout* is cheap. Re-calling prepare every frame will tank perf. Keep the prepared handle in module scope.
3. Forgetting `Intl.Segmenter` for grapheme splits. Emoji, combining marks, CJK — "é".split("") gives you two chars. Use new Intl.Segmenter(undefined, { granularity: "grapheme" }) when sampling individual visible glyphs.
4. `break: 'never'` chips without `extraWidth`. In rich-inline, if you use break: 'never' for an atomic chip/mention, you must also supply extraWidth for the pill padding — otherwise chip chrome overflows the container.
5. Using `@chenglou/pretext` from `unpkg` with TypeScript-only entry. Use esm.sh — it compiles the TS exports to browser-ready ESM automatically. unpkg will 404 or serve raw TS.
6. Monospace fallbacks silently erasing the whole point. Users seeing monospace-looking output often have a CSS font-family that fell through to monospace. Verify the actual rendered font via DevTools.
7. Skipping rows vs adjusting width when flowing around a shape. If the corridor on this row is too narrow to fit a line, skip the row (y += lineHeight; continue;) rather than passing a tiny maxWidth to layoutNextLineRange — pretext will return one-grapheme lines that look broken.
8. Shipping a cold demo. The default first-paint looks tutorial-grade. Add: vignette, subtle scanline, idle auto-motion, one carefully chosen interactive response (drag, hover, scroll, click). Without these, "cool pretext demo" lands as "intern repro of the README."
Verification Checklist
- [ ] Demo is a single self-contained
.htmlfile — opens by double-click orpython3 -m http.server - [ ]
@chenglou/pretextimported viaesm.shwith pinned version - [ ] Corpus is real prose, not lorem ipsum, and matches the demo's concept
- [ ] Font string passed to
preparematches the CSS font exactly - [ ]
prepare()/prepareWithSegments()called once, not per frame - [ ] Dark background + considered palette — not the default white canvas
- [ ] At least one interactive response (drag / hover / scroll / click) or idle auto-motion
- [ ] Tested locally with
python3 -m http.serverand confirmed no console errors - [ ] 60fps on a mid-tier laptop (or graceful degradation documented)
- [ ] One "extra mile" detail the user didn't ask for
Reference: Community Demos
Clone these for inspiration / patterns (all MIT-ish, linked from pretext.cool):
- Pretext Breaker — breakout with word-bricks —
github.com/rinesh/pretext-breaker - Tetris × Pretext —
github.com/shinichimochizuki/tetris-pretext - Dragon animation —
github.com/qtakmalay/PreTextExperiments - Somnai editorial engine —
github.com/somnai-dreams/pretext-demos - Bad Apple!! ASCII —
github.com/frmlinn/bad-apple-pretext - Drag-sprite reflow —
github.com/dokobot/pretext-demo - Alarmy editorial clock —
github.com/SmisLee/alarmy-pretext-demo
Official playground: chenglou.me/pretext — accordion, bubbles, dynamic-layout, editorial-engine, justification-comparison, masonry, markdown-chat, rich-note.
Pretext Patterns
Copy-pasteable snippets for the most common pretext demo shapes. Each pattern is self-contained — drop into an HTML <script type="module"> after importing from https://esm.sh/@chenglou/pretext@0.0.6.
1. Flow around an obstacle (variable-width column)
The signature pretext move. Row-by-row ask "how wide is the corridor here?" and let pretext break lines accordingly.
const prepared = prepareWithSegments(TEXT, FONT);
const LINE_H = 24;
function drawFlow(ctx, obstacle /* {x,y,r} */, COL_X, COL_W, H) {
let cursor = { segmentIndex: 0, graphemeIndex: 0 };
let y = 72;
while (y < H - 40) {
const dy = y - obstacle.y;
const inBand = Math.abs(dy) < obstacle.r;
let x = COL_X, w = COL_W;
if (inBand) {
const half = Math.sqrt(obstacle.r ** 2 - dy ** 2);
const leftW = Math.max(0, (obstacle.x - half) - COL_X);
const rightW = Math.max(0, (COL_X + COL_W) - (obstacle.x + half));
if (leftW >= rightW) { x = COL_X; w = leftW - 12; }
else { x = obstacle.x + half + 12; w = rightW - 12; }
if (w < 40) { y += LINE_H; continue; } // skip rather than squeeze
}
const range = layoutNextLineRange(prepared, cursor, w);
if (!range) break;
const line = materializeLineRange(prepared, range);
ctx.fillText(line.text, x, y);
cursor = range.end;
y += LINE_H;
}
}Obstacle variants: circles (above), rectangles (use Math.max(0, …) on the row-segment), multiple obstacles (sort segments and emit the wider remaining lane), animated obstacles (recompute every frame — pretext is fast enough).
2. Text-as-geometry game (word-bricks with collision)
Use layoutWithLines to get stable line rects, then treat each word as an axis-aligned box for physics.
const prepared = prepareWithSegments(WORDS.join(" "), FONT);
const { lines } = layoutWithLines(prepared, FIELD_W, 28);
// Build brick rects: split each line on spaces and measure word-by-word.
const bricks = [];
let y = 50;
for (const line of lines) {
let x = 10;
for (const word of line.text.split(" ")) {
const wPx = ctx.measureText(word).width; // or use walkLineRanges per word
bricks.push({ x, y, w: wPx, h: 24, text: word, hp: 1 });
x += wPx + ctx.measureText(" ").width;
}
y += 28;
}Collision: standard AABB vs the ball. When hp drops to 0, the brick is "eaten." For the aesthetic: fade brick opacity with hp, trail particles from the letters on impact.
3. Shatter / explode typography
Use walkLineRanges + a manual grapheme walk to get (x, y) for every glyph, then spawn particles.
const prepared = prepareWithSegments(TEXT, FONT);
const particles = [];
let y = 100;
walkLineRanges(prepared, COL_W, (line) => {
// materialize so we get per-grapheme positions
const range = materializeLineRange(prepared, line);
const seg = new Intl.Segmenter(undefined, { granularity: "grapheme" });
let x = COL_X;
for (const { segment } of seg.segment(range.text)) {
const w = ctx.measureText(segment).width;
particles.push({ ch: segment, x, y, vx: 0, vy: 0, homeX: x, homeY: y });
x += w;
}
y += LINE_H;
});
// On click, kick particles outward from click point; ease them back to (homeX, homeY).
canvas.addEventListener("click", (e) => {
for (const p of particles) {
const dx = p.x - e.clientX, dy = p.y - e.clientY;
const d = Math.hypot(dx, dy) || 1;
const force = 400 / (d * 0.2 + 1);
p.vx += (dx / d) * force;
p.vy += (dy / d) * force;
}
});
function tick(dt) {
for (const p of particles) {
p.vx *= 0.92; p.vy *= 0.92;
p.vx += (p.homeX - p.x) * 0.06;
p.vy += (p.homeY - p.y) * 0.06;
p.x += p.vx * dt; p.y += p.vy * dt;
}
}4. ASCII mask as moving obstacle
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 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:
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 }, () => []);
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;
}
}
}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.
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]);
}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.
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
Classic magazine layout: three columns, text flows from the end of column 1 into the top of column 2, etc. Pretext makes this trivial because the cursor is portable between layoutNextLineRange calls.
const prepared = prepareWithSegments(ARTICLE, FONT);
let cursor = { segmentIndex: 0, graphemeIndex: 0 };
for (const col of [COL1, COL2, COL3]) {
let y = col.y;
while (y < col.y + col.h) {
const range = layoutNextLineRange(prepared, cursor, col.w);
if (!range) return;
const line = materializeLineRange(prepared, range);
ctx.fillText(line.text, col.x, y);
cursor = range.end;
y += LINE_H;
}
}Add pull quotes by treating them as obstacles in the middle column and using pattern #1 around them.
6. Multiline shrink-wrap (tightest-fitting card)
Given a max width, find the smallest container width that still produces the same line count. Useful for chat bubbles, quote cards, tooltip sizing.
const prepared = prepareWithSegments(text, FONT);
const { lineCount, maxLineWidth } = measureLineStats(prepared, MAX_W);
// card width = maxLineWidth + padding; card height = lineCount * LINE_H + paddingFor a demo that visualizes this, render the card shrinking from MAX_W down to maxLineWidth over a second — the line count stays constant but the right edge pulls in.
7. Kinetic typography
Animate per-line transforms over time. layoutWithLines gives you stable lines; index i drives the timing offset.
const { lines } = layoutWithLines(prepared, W - 80, 40);
function frame(t) {
for (let i = 0; i < lines.length; i++) {
const phase = t * 0.001 - i * 0.15;
const y = 100 + i * 40 + Math.sin(phase) * 12;
const opacity = 0.4 + 0.6 * Math.max(0, Math.sin(phase));
ctx.globalAlpha = opacity;
ctx.fillText(lines[i].text, 40, y);
}
}Variants: Star Wars crawl (perspective skew per line), wave (sine y-offset), bounce (ease-in-out arrival), glitch (per-glyph random offset using Intl.Segmenter).
8. Font stack patterns
| Vibe | Font string | Palette hint |
|---|---|---|
| Editorial / serious | 17px/1.4 "Iowan Old Style", Georgia, serif | bone #e8e6df on charcoal #0c0d10 |
| CRT / terminal | 600 13px "JetBrains Mono", ui-monospace, monospace | amber hsl(38 60% 62%) on #07070a |
| Humanist / modern | 500 17px Inter, ui-sans-serif, system-ui, sans-serif | off-white #f3efe6 on deep-navy #0b1020 |
| Display / poster | 700 64px "Playfair Display", serif | hot-red #ff4130 on cream #f0ebe0 |
| Engineering | 14px "IBM Plex Mono", monospace | neon-green #7cff7c on near-black #0a0a0c |
Always load the web font explicitly (Google Fonts link tag or @font-face) so the canvas measurement matches the CSS render.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no" />
<title>NOUS · pretext</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@400;500&display=block" rel="stylesheet">
<style>
:root { color-scheme: dark; }
:root {
--background: color-mix(in srgb, #041C1C 100%, transparent);
--background-base: #041C1C;
--background-alpha: 1;
--background-blend: difference;
--midground: color-mix(in srgb, #ffe6cb 100%, transparent);
--midground-base: #ffe6cb;
--midground-alpha: 1;
--foreground: color-mix(in srgb, #ffffff 0%, transparent);
--foreground-base: #ffffff;
--foreground-alpha: 0;
--foreground-blend: difference;
}
html, body {
margin: 0;
width: 100%;
min-height: 100vh;
overflow: hidden;
background: #000;
}
/* literally center the composition in 100vh */
body {
display: flex;
align-items: center;
justify-content: center;
}
#stage {
position: relative;
z-index: 2;
width: min(1240px, 100vw);
height: min(720px, 100vh);
cursor: grab;
overflow: visible;
}
#stage:active { cursor: grabbing; }
#c,
#orbCanvas {
display: block;
position: absolute;
left: calc((100vw - min(1240px, 100vw)) / -2);
top: calc((100vh - min(720px, 100vh)) / -2);
width: 100vw;
height: 100vh;
pointer-events: none;
}
#c { z-index: 3; }
#orbCanvas {
z-index: 4;
opacity: 1;
}
#noiseCanvas {
display: block;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
#textLayer {
position: absolute;
inset: 0;
z-index: 2;
color: var(--midground-base);
font: 400 10px/15px "Geist Mono", "JetBrains Mono", "SF Mono", SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
pointer-events: auto;
user-select: text;
-webkit-user-select: text;
}
#textLayer *::selection {
background: var(--selection-bg, var(--midground));
color: var(--background-base);
}
.flow-line {
position: absolute;
height: 20px;
white-space: pre;
overflow: visible;
color: var(--midground-base);
}
#annotationLayer {
position: absolute;
inset: 0;
z-index: 4;
pointer-events: none;
overflow: visible;
}
.annot {
position: absolute;
pointer-events: none;
will-change: opacity, transform;
}
.tok-keyword { color: color-mix(in srgb, var(--midground-base) 54%, #ff174d); }
.tok-string { color: color-mix(in srgb, var(--midground-base) 52%, #ff6b3d); }
.tok-comment { color: color-mix(in srgb, var(--midground-base) 42%, #7f1d1d); }
.tok-number { color: color-mix(in srgb, var(--midground-base) 50%, #ff2a6d); }
.tok-const { color: color-mix(in srgb, var(--midground-base) 48%, #ff003c); }
.layer { position: fixed; inset: 0; pointer-events: none; }
.bg-lens { z-index: 1; background-color: var(--background); mix-blend-mode: var(--background-blend); }
.vignette { z-index: 99; background: radial-gradient(ellipse at 0% 0%, rgba(255,189,56,0) 60%, rgba(255,189,56,0.35) 100%); mix-blend-mode: lighten; opacity: 0.22; }
.fg-lens { z-index: 100; background-color: var(--foreground); mix-blend-mode: var(--foreground-blend); }
.noise {
z-index: 101;
mix-blend-mode: difference;
}
#noiseCanvas {
width: 100%;
height: 100%;
display: block;
}
.terminal-cursor {
position: absolute;
width: 0.6em;
height: 1em;
background: #fff;
z-index: 6;
pointer-events: none;
mix-blend-mode: difference;
animation: term-blink 1s steps(1, end) infinite;
}
@keyframes term-blink {
0%, 60% { opacity: 1; }
60.01%, 100% { opacity: 0; }
}
.cursor-anchor {
display: inline-block;
width: 0;
height: 1em;
vertical-align: text-top;
pointer-events: none;
}
@font-face {
font-family: "Mondwest";
src: url("https://esm.sh/@nous-research/ui@0.4.0/dist/fonts/Mondwest-Regular.woff2") format("woff2");
font-weight: 400; font-style: normal; font-display: block;
}
@font-face {
font-family: "Geist Mono";
src: url("https://cdn.jsdelivr.net/npm/@fontsource/geist-mono@5.2.5/files/geist-mono-latin-400-normal.woff2") format("woff2");
font-weight: 400; font-style: normal; font-display: block;
}
@font-face {
font-family: "JetBrains Mono";
src: url("https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5.2.5/files/jetbrains-mono-latin-400-normal.woff2") format("woff2");
font-weight: 400; font-style: normal; font-display: block;
}
.font-primer { position: fixed; left: -9999px; top: -9999px; font: 400 26px "Mondwest", serif; }
</style>
</head>
<body>
<div id="stage"><div id="textLayer" aria-label="self-learning source stream"></div><div id="terminalCursor" class="terminal-cursor" hidden></div><canvas id="c"></canvas><canvas id="orbCanvas"></canvas></div>
<div class="layer bg-lens"></div>
<div class="layer vignette"></div>
<div class="layer fg-lens"></div>
<div class="layer noise"><canvas id="noiseCanvas"></canvas></div>
<div class="font-primer" aria-hidden="true">Aa Mondwest priming glyphs 0123456789</div>
<script type="module">
import {
prepareWithSegments,
layoutNextLineRange,
materializeLineRange,
} from "https://esm.sh/@chenglou/pretext@0.0.6";
import gsap from "https://esm.sh/gsap@3.12.5";
import GUI from "https://esm.sh/lil-gui@0.19.2";
const SELF_LEARNING_SOURCE = String.raw`
type ToolId=u16; type FeatureId=u32; type W=f32;
struct Obs{tool:ToolId,reward:f32,lat:u32,feat:&'static[FeatureId],tok:u16}
struct Belief{prior:W,conf:W,last:u64,uses:u32}
#[inline] fn sig(x:f32)->f32{1.0/(1.0+(-x).exp())}
#[inline] fn decay(now:u64,last:u64)->f32{1.0-((now-last)as f32/900.0).min(1.0)*0.22}
#[inline] fn clamp8(x:f32)->f32{x.clamp(-8.0,8.0)}
fn score(theta:&[W],b:&[Belief],o:&Obs)->f32{
let sparse=o.feat.iter().fold(0.0,|a,id|a+theta[*id as usize]);
let belief=b[o.tool as usize].prior*b[o.tool as usize].conf.max(0.15);
sparse+belief-(o.lat as f32*0.00072)-(o.tok as f32*0.00009)
}
fn update(theta:&mut[W],b:&mut[Belief],o:&Obs,now:u64,eta:f32){
let p=sig(score(theta,b,o));
let e=(o.reward-p).clamp(-1.0,1.0);
let step=eta*e/(o.feat.len().max(1)as f32).sqrt();
for id in o.feat{
let w=&mut theta[*id as usize];
*w=clamp8(*w+step-0.0003*w.signum());
}
let slot=&mut b[o.tool as usize];
let d=decay(now,slot.last);
let r=(o.reward-o.lat as f32*0.00035).clamp(-1.0,1.0);
slot.prior=slot.prior*d+r*0.14+e*0.06;
slot.conf=(slot.conf*0.93+r.abs()*0.07).min(1.0);
slot.uses=slot.uses.saturating_add(1);
slot.last=now;
}
fn consolidate(trace:&[Obs],theta:&mut[W],b:&mut[Belief],now:u64){
let eta=0.042/(1.0+(trace.len()as f32*0.003));
let mut credit=1.0;
for o in trace.iter().rev(){
update(theta,b,o,now,eta*credit);
credit*=0.985;
}
}
fn choose<'a>(theta:&[W],b:&[Belief],xs:&'a[Obs],temp:f32)->&'a Obs{
xs.iter().max_by(|a,bx|{
let sa=score(theta,b,a)/temp.max(0.05);
let sb=score(theta,b,bx)/temp.max(0.05);
sa.partial_cmp(&sb).unwrap()
}).unwrap()
}
`;
const CODE_CHUNK = SELF_LEARNING_SOURCE
.trim()
.replace(/\t/g, " ")
.replace(/\n{3,}/g, "\n\n");
const STREAM_REPEAT_COUNT = 6;
const streamCorpus = Array.from({ length: STREAM_REPEAT_COUNT }, () => CODE_CHUNK).join("\n\n");
const stage = document.getElementById("stage");
const textLayer = document.getElementById("textLayer");
const canvas = document.getElementById("c");
const orbCanvas = document.getElementById("orbCanvas");
const ctx = canvas.getContext("2d", { alpha: true });
const orbCtx = orbCanvas.getContext("2d", { alpha: true });
let W = 0, H = 0, DPR = 1;
let canvasLeft = 0, canvasTop = 0, canvasW = 0, canvasH = 0;
let BODY_FONT = '400 10px "Geist Mono", "JetBrains Mono", "SF Mono", SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace';
let BODY_LINE_H = 15;
let ASCII_FONT = '8px monospace';
let CELL_W = 4.8;
let CELL_H = 8;
const CHARS = ' ·:;+=░▒▓█';
const EMPTY = 0;
const CHARS_LAST = CHARS.length - 1;
const LOGO = ['N', 'O', 'U', 'S'].map((letter, i) => ({
letter,
rows: [
i === 0
? '██░░░██,██░░░██,███░░██,█░██░██,█░░████,█░░░███,██░░░██,██░░░██,██░░░██'
: i === 1
? '░█████░,██░░░██,██░░░██,██░░░██,██░░░██,██░░░██,██░░░██,██░░░██,░█████░'
: i === 2
? '██░░░██,██░░░██,██░░░██,██░░░██,██░░░██,██░░░██,██░░░██,██░░░██,░█████░'
: '░█████░,██░░░██,██░░░░░,░████░░,░░░░██░,░░░░░██,██░░░██,██░░░██,░█████░'
][0].split(',')
}));
function hash(x, y) {
const n = Math.sin(x * 127.1 + y * 311.7) * 43758.5453;
return n - Math.floor(n);
}
function charAt(t) {
return CHARS[Math.min(CHARS.length - 1, Math.max(0, Math.floor(t * CHARS.length)))];
}
function gridDist(v, step) {
return Math.abs(v / step - Math.round(v / step)) * step;
}
function rot(a, b, angle) {
const c = Math.cos(angle), s = Math.sin(angle);
return [a * c - b * s, a * s + b * c];
}
function hexToRGB(h) {
const v = parseInt(h.slice(1), 16);
return [(v >> 16) & 255, (v >> 8) & 255, v & 255];
}
let fontsReady = false;
let bodyPrepared = null;
function rebuildLayouts() {
bodyPrepared = prepareWithSegments(streamCorpus, BODY_FONT, { whiteSpace: "pre-wrap" });
}
const LENS_0 = { bgBlend: "difference", bgColor: "#041C1C", bgOpacity: 1, mgColor: "#ffe6cb", mgOpacity: 1, fgBlend: "difference", fgColor: "#FFFFFF", fgOpacity: 0 };
const LENS_5I = { bgBlend: "multiply", bgColor: "#ffffff", bgOpacity: 1, mgColor: "#fffff5", mgOpacity: 1, fgBlend: "difference", fgColor: "#041a1f", fgOpacity: 1 };
const LENS = { current: null, mgBase: null, depthColors: null };
function colorMix(color, alpha) { return `color-mix(in srgb, ${color} ${Math.round(alpha * 100)}%, transparent)`; }
function precomputeDepthColors(mgColor) {
const [r, g, b] = hexToRGB(mgColor);
return Array.from({ length: 64 }, (_, i) => {
const depth = i / 63;
const m = 0.4 + depth * 0.6;
return `rgba(${(r * m) | 0},${(g * m) | 0},${(b * m) | 0},${depth})`;
});
}
function applyLens(preset) {
LENS.current = preset;
LENS.mgBase = preset.mgColor;
LENS.depthColors = precomputeDepthColors(preset.mgColor);
const s = document.documentElement.style;
for (const [name, color, alpha] of [["foreground", preset.fgColor, preset.fgOpacity], ["midground", preset.mgColor, preset.mgOpacity], ["background", preset.bgColor, preset.bgOpacity]]) {
s.setProperty(`--${name}`, colorMix(color, alpha));
s.setProperty(`--${name}-base`, color);
s.setProperty(`--${name}-alpha`, `${alpha}`);
}
s.setProperty("--background-blend", preset.bgBlend);
s.setProperty("--foreground-blend", preset.fgBlend);
}
applyLens(LENS_0);
addEventListener("keydown", e => { if (e.key === "x" || e.key === "X") applyLens(LENS.current === LENS_0 ? LENS_5I : LENS_0); });
const SELECTION_COLORS = [
"oklch(85% 0.12 330)",
"oklch(85% 0.12 300)",
"oklch(85% 0.12 270)",
"oklch(85% 0.12 230)",
"oklch(85% 0.12 180)",
"oklch(85% 0.12 150)",
"oklch(85% 0.12 120)",
"oklch(85% 0.12 90)",
"oklch(85% 0.12 60)",
"oklch(85% 0.12 30)",
"oklch(88% 0.10 80)",
];
let selectionIdx = 0;
function cycleSelectionColor() {
requestAnimationFrame(() => {
document.documentElement.style.setProperty("--selection-bg", SELECTION_COLORS[(selectionIdx = (selectionIdx + 1) % SELECTION_COLORS.length)]);
});
}
document.addEventListener("selectstart", cycleSelectionColor);
addEventListener("keydown", e => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a") {
cycleSelectionColor();
e.preventDefault();
const selection = getSelection();
const range = document.createRange();
range.selectNodeContents(textLayer);
selection.removeAllRanges();
selection.addRange(range);
}
});
let asciiMask, asciiChar, asciiGlyph, orbMask, orbChar, orbGlyph, obstacleRows, intervals;
let cols = 0, rows = 0;
let canvasCols = 0, canvasRows = 0;
let lastFrameTime = 0;
const drawnLines = { x: [], y: [], w: [], text: [], count: 0 };
let lineNodes = [];
let terminalAnchorEl = null;
let terminalLineEl = null;
let orbShapeControl = null;
let autoCubeCall = null;
let lastTextSignature = "";
const ORB = {
panX: 0, panY: 0,
orbitX: -1.42, orbitY: 0,
radius: 0.135,
zoom: 3,
cubeScale: 1.8,
autoSpin: 0.18,
lat: 19,
lon: 30,
wire: 0.2,
shape: "sphere",
shapeMix: 0,
drag: null,
positioned: false,
staged: false,
};
const SCENE = {
orbBuildDuration: 1.45,
};
let nousStartTime = null;
const AUTO_CUBE_AFTER_NOUS_RESOLVE = 0.0;
function setOrbShape(shape, { reveal = false, syncControl = true } = {}) {
ORB.shape = shape;
if (syncControl) {
ctrls.orbShape = shape;
orbShapeControl?.updateDisplay();
}
if (reveal) {
gsap.killTweensOf(orbCanvas);
orbCanvas.style.opacity = "1";
}
gsap.killTweensOf(ORB, "shapeMix");
gsap.to(ORB, {
shapeMix: shape === "cube" ? 1 : 0,
duration: Math.max(0.01, ctrls.shapeMorphDuration ?? 0.65),
ease: "power2.inOut",
onUpdate: () => { lastTextSignature = ""; },
});
lastTextSignature = "";
}
function sceneTimeNow() {
return (typeof performance !== "undefined" ? performance.now() : Date.now()) * 0.001 - sceneEpoch;
}
function armNous(atTime = sceneTimeNow()) {
if (nousStartTime !== null) return;
const startAt = Math.max(0, atTime);
nousStartTime = startAt;
const delay = Math.max(0, startAt - sceneTimeNow()) + Math.max(0, ctrls.orbContainNous ?? 1.0);
gsap.killTweensOf(orbCanvas);
gsap.to(orbCanvas, {
opacity: 0,
duration: Math.max(0.05, ctrls.orbFadeDuration ?? 0.7),
delay,
ease: "power2.out",
});
autoCubeCall?.kill();
const nousResolveAt = startAt + (LOGO.length - 1) * 0.4 + 1.0;
const cubeAt = nousResolveAt + AUTO_CUBE_AFTER_NOUS_RESOLVE;
autoCubeCall = gsap.delayedCall(Math.max(0, cubeAt - sceneTimeNow()), () => {
setOrbShape("cube", { reveal: true, syncControl: false });
});
}
function orbBuildStartTime() {
return Math.max(0, ctrls.orbLead ?? 0);
}
function codeTypingStartTime() {
return Math.max(0, orbBuildStartTime() + SCENE.orbBuildDuration * 0.62 - 0.3);
}
function logoCenterPan() {
const aspect = cols / rows / 2;
const h = 0.38;
const w = h * 2.6 * 0.55;
const halfStageX = (w / 2 / aspect) * W;
const centerX = W * 0.5;
const centerY = H * 0.5;
return {
panX: centerX / W - 0.5,
panY: centerY / H - 0.5,
left: centerX - halfStageX,
right: centerX + halfStageX,
};
}
function startSceneTimeline() {
if (ORB.staged || !cols || !rows || !W || !H) return;
ORB.staged = true;
const now = sceneTimeNow();
const startAt = Math.max(0, orbBuildStartTime() + SCENE.orbBuildDuration * 0.72 - now);
gsap.timeline({ delay: startAt })
.to(ORB, {
orbitX: 0.55,
orbitY: `+=${Math.PI * 1.85}`,
duration: 2.4,
ease: "power3.inOut",
onUpdate: () => { lastTextSignature = ""; },
}, "<")
.to(ORB, {
radius: 0.125,
duration: 2.4,
ease: "power3.inOut",
onUpdate: () => { lastTextSignature = ""; },
}, "<")
.to(ORB, {
wire: ctrls.orbWireLow,
duration: ctrls.wireDropDuration,
ease: "power2.inOut",
onUpdate: () => { lastTextSignature = ""; },
onComplete: () => {
armNous(sceneTimeNow() + Math.max(0, ctrls.nousAfterWire));
},
});
}
function resize() {
const r = stage.getBoundingClientRect();
W = Math.max(1, Math.floor(r.width));
H = Math.max(1, Math.floor(r.height));
canvasLeft = r.left;
canvasTop = r.top;
canvasW = Math.max(1, window.innerWidth);
canvasH = Math.max(1, window.innerHeight);
DPR = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.floor(canvasW * DPR);
canvas.height = Math.floor(canvasH * DPR);
orbCanvas.width = Math.floor(canvasW * DPR);
orbCanvas.height = Math.floor(canvasH * DPR);
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
orbCtx.setTransform(DPR, 0, 0, DPR, 0, 0);
ctx.font = ASCII_FONT;
orbCtx.font = ASCII_FONT;
CELL_W = ctx.measureText("M").width || CELL_W;
cols = Math.ceil(W / CELL_W);
rows = Math.ceil(H / CELL_H);
canvasCols = Math.ceil(canvasW / CELL_W);
canvasRows = Math.ceil(canvasH / CELL_H);
asciiMask = new Uint8Array(canvasCols * canvasRows);
asciiChar = new Uint8Array(canvasCols * canvasRows);
asciiGlyph = new Array(canvasCols * canvasRows);
orbMask = new Uint8Array(canvasCols * canvasRows);
orbChar = new Uint8Array(canvasCols * canvasRows);
orbGlyph = new Array(canvasCols * canvasRows);
obstacleRows = Array.from({ length: rows }, () => []);
intervals = new Float32Array(64);
if (!ORB.positioned && W > 100 && H > 100) {
ORB.panX = 0;
ORB.panY = 0;
ORB.positioned = true;
}
startSceneTimeline();
}
addEventListener("resize", () => { lastTextSignature = ""; resize(); });
function orbCenter() {
const rNorm = ORB.radius * ORB.zoom;
return {
x: (0.5 + ORB.panX) * W,
y: (0.5 + ORB.panY) * H,
canvasX: (0.5 + ORB.panX) * W + canvasLeft,
canvasY: (0.5 + ORB.panY) * H + canvasTop,
r: rNorm * H,
rNorm,
};
}
stage.addEventListener("pointerdown", e => {
const rect = stage.getBoundingClientRect();
const center = orbCenter();
const hitScale = ORB.shape === "cube" ? ORB.cubeScale : 1;
const dx = e.clientX - rect.left - center.x;
const dy = e.clientY - rect.top - center.y;
if (Math.hypot(dx, dy) > center.r * 1.35 * hitScale) return;
e.preventDefault();
stage.setPointerCapture(e.pointerId);
ORB.drag = {
id: e.pointerId,
mode: (e.ctrlKey || e.metaKey) ? "rotate" : "move",
dx,
dy,
lastX: e.clientX,
lastY: e.clientY,
};
});
stage.addEventListener("pointermove", e => {
if (!ORB.drag || ORB.drag.id !== e.pointerId) return;
if (ORB.drag.mode === "rotate" || e.ctrlKey || e.metaKey) {
const deltaX = e.clientX - ORB.drag.lastX;
const deltaY = e.clientY - ORB.drag.lastY;
ORB.orbitY += (deltaX / Math.max(1, W)) * Math.PI * 2;
ORB.orbitX = Math.max(-1.5, Math.min(1.5, ORB.orbitX + (deltaY / Math.max(1, H)) * 4));
ORB.drag.mode = "rotate";
ORB.drag.lastX = e.clientX;
ORB.drag.lastY = e.clientY;
return;
}
const rect = stage.getBoundingClientRect();
const cx = e.clientX - rect.left - ORB.drag.dx;
const cy = e.clientY - rect.top - ORB.drag.dy;
ORB.panX = cx / W - 0.5;
ORB.panY = cy / H - 0.5;
lastTextSignature = "";
});
stage.addEventListener("wheel", e => {
const rect = stage.getBoundingClientRect();
const center = orbCenter();
const hitScale = ORB.shape === "cube" ? ORB.cubeScale : 1;
const dx = e.clientX - rect.left - center.x;
const dy = e.clientY - rect.top - center.y;
if (Math.hypot(dx, dy) > center.r * 1.5 * hitScale) return;
e.preventDefault();
ORB.zoom *= Math.exp(-e.deltaY * 0.0012);
ORB.zoom = Math.max(0.25, Math.min(12, ORB.zoom));
lastTextSignature = "";
}, { passive: false });
stage.addEventListener("pointerup", e => {
if (ORB.drag?.id === e.pointerId) ORB.drag = null;
});
stage.addEventListener("pointercancel", e => {
if (ORB.drag?.id === e.pointerId) ORB.drag = null;
});
stage.addEventListener("dblclick", e => {
const rect = stage.getBoundingClientRect();
const center = orbCenter();
const hitScale = ORB.shape === "cube" ? ORB.cubeScale : 1;
const dx = e.clientX - rect.left - center.x;
const dy = e.clientY - rect.top - center.y;
if (Math.hypot(dx, dy) > center.r * 1.45 * hitScale) return;
e.preventDefault();
setOrbShape(ORB.shape === "sphere" ? "cube" : "sphere", { reveal: true });
});
function rasterizeNOUS(time) {
asciiMask.fill(0);
asciiGlyph.fill("");
for (const r of obstacleRows) r.length = 0;
// NOUS uses the same cell-shader/reveal language as bb-ascii's source logo.
if (nousStartTime === null || time < nousStartTime) {
mergeObstacleRows();
return;
}
const t = Math.max(0, time - nousStartTime);
const aspect = cols / rows / 2;
const h = 0.38;
const baseW = h * 2.6 * 0.55;
const letterWidth = 7;
const baseLetterPitch = 7.6;
const letterGap = Math.max(0, ctrls.nousLetterSpacing ?? 0);
const letterAdvance = baseLetterPitch + letterGap;
const baseLogoUnits = LOGO.length * baseLetterPitch - 0.6;
const logoUnits = LOGO.length * baseLetterPitch + (LOGO.length - 1) * letterGap - 0.6;
const w = baseW * (logoUnits / baseLogoUnits);
const nousRowMin = new Float32Array(rows);
const nousRowMax = new Float32Array(rows);
nousRowMin.fill(Infinity);
nousRowMax.fill(-Infinity);
for (let y = 0; y < rows; y++) {
const ny = y / rows - 0.5;
if (Math.abs(ny) > h / 2) continue;
for (let x = 0; x < cols; x++) {
const nx = (x / cols - 0.5) * aspect;
if (Math.abs(nx) > w / 2) continue;
const lx = (nx + w / 2) / w;
const ly = (ny + h / 2) / h;
const tx = lx * logoUnits;
const idx = Math.floor(tx / letterAdvance);
const cx = Math.floor(tx - idx * letterAdvance);
const cy = Math.floor(ly * 9);
if (idx < 0 || idx >= LOGO.length || cx >= letterWidth) continue;
const { letter, rows: glyphRows } = LOGO[idx];
const row = glyphRows[cy];
if (!row || cx >= row.length || row[cx] !== '█') continue;
const localT = Math.max(0, t - idx * 0.4);
const phase1 = Math.min(1, localT);
const phase2 = Math.min(1, Math.max(0, localT - 1));
const reveal = phase1 * 1.5 - hash(cx, cy + idx * 10) * 0.5;
if (phase1 < 1 && reveal <= 0) continue;
const morph = phase2 + hash(cx, cy) * 0.3;
const ch = phase1 < 1
? (reveal >= 1 ? letter : charAt(reveal))
: charAt(morph);
const drawC = Math.floor((canvasLeft + x * CELL_W + CELL_W * 0.5) / CELL_W);
const drawR = Math.floor((canvasTop + y * CELL_H + CELL_H * 0.5) / CELL_H);
let cellIdx = -1;
if (drawR >= 0 && drawR < canvasRows && drawC >= 0 && drawC < canvasCols) {
cellIdx = drawR * canvasCols + drawC;
asciiMask[cellIdx] = 1;
asciiChar[cellIdx] = Math.max(0, CHARS.indexOf(ch));
if (CHARS.indexOf(ch) < 0) asciiGlyph[cellIdx] = ch;
}
const x0 = x * CELL_W;
const x1 = x0 + CELL_W;
if (x0 < nousRowMin[y]) nousRowMin[y] = x0;
if (x1 > nousRowMax[y]) nousRowMax[y] = x1;
// bb-ascii turns late morph cells into solid dark cell backgrounds. In
// this transparent/lensed canvas, a full-block char carries that punch.
if (cellIdx >= 0 && phase1 >= 1 && morph > 0.8) asciiChar[cellIdx] = CHARS.length - 1;
}
}
// Slightly dilate/smooth the measured NOUS silhouette so surrounding text
// does not visually "kiss" sharp letter corners (notably on the S).
const smoothMin = new Float32Array(rows);
const smoothMax = new Float32Array(rows);
smoothMin.fill(Infinity);
smoothMax.fill(-Infinity);
for (let y = 0; y < rows; y++) {
if (!Number.isFinite(nousRowMin[y])) continue;
for (let oy = -1; oy <= 1; oy++) {
const yy = y + oy;
if (yy < 0 || yy >= rows) continue;
if (!Number.isFinite(nousRowMin[yy])) continue;
if (nousRowMin[yy] < smoothMin[y]) smoothMin[y] = nousRowMin[yy];
if (nousRowMax[yy] > smoothMax[y]) smoothMax[y] = nousRowMax[yy];
}
}
const pad = Math.max(4, CELL_W * 1.25);
const bleedRows = 2;
for (let y = 0; y < rows; y++) {
if (!Number.isFinite(smoothMin[y])) continue;
for (let oy = -bleedRows; oy <= bleedRows; oy++) {
const row = y + oy;
if (row < 0 || row >= rows) continue;
const bleedPad = pad + Math.abs(oy) * CELL_W * 0.28;
obstacleRows[row].push([
Math.max(0, smoothMin[y] - bleedPad),
Math.min(W, smoothMax[y] + bleedPad),
]);
}
}
mergeObstacleRows();
}
function mergeObstacleRows() {
for (const spans of obstacleRows) {
if (spans.length < 2) continue;
spans.sort((a, b) => a[0] - b[0]);
let w = 0;
for (let i = 1; i < spans.length; i++) {
const last = spans[w], cur = spans[i];
if (cur[0] <= last[1] + CELL_W * 3.1) last[1] = Math.max(last[1], cur[1]);
else spans[++w] = cur;
}
spans.length = w + 1;
}
}
const CUBE_VERTS = [
[-1, -1, -1], [1, -1, -1], [1, 1, -1], [-1, 1, -1],
[-1, -1, 1], [1, -1, 1], [1, 1, 1], [-1, 1, 1],
];
const CUBE_EDGES = [
[0, 1], [1, 2], [2, 3], [3, 0],
[4, 5], [5, 6], [6, 7], [7, 4],
[0, 4], [1, 5], [2, 6], [3, 7],
];
function projectCubeVerts(center, radiusPx, orbitX, orbitY, scale = 1) {
const camZ = 3.3;
const projScale = radiusPx * 1.34 * scale;
return CUBE_VERTS.map(([vx, vy, vz]) => {
const [rx, ry, rz] = rotateOrbVec(vx, vy, vz, orbitX, orbitY);
const inv = 1 / (camZ + rz);
return {
x: center.canvasX + rx * projScale * inv,
y: center.canvasY + ry * projScale * inv,
d: (rz + 1) * 0.5,
};
});
}
function convexHull(points) {
if (points.length <= 2) return points.slice();
const pts = points
.map(p => ({ x: p.x, y: p.y }))
.sort((a, b) => (a.x - b.x) || (a.y - b.y));
const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
const lower = [];
for (const p of pts) {
while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop();
lower.push(p);
}
const upper = [];
for (let i = pts.length - 1; i >= 0; i--) {
const p = pts[i];
while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) upper.pop();
upper.push(p);
}
lower.pop();
upper.pop();
return lower.concat(upper);
}
function makeEmptySpanRows() {
return Array.from({ length: rows }, () => ({ left: Infinity, right: -Infinity }));
}
function addSpan(spanRows, row, left, right) {
if (row < 0 || row >= rows || right <= left) return;
if (left < spanRows[row].left) spanRows[row].left = left;
if (right > spanRows[row].right) spanRows[row].right = right;
}
function collectCubeObstacleRows(center, radiusPx, orbitX, orbitY, obstacleScale) {
const spanRows = makeEmptySpanRows();
if (obstacleScale <= 0.0001) return spanRows;
const projected = projectCubeVerts(center, radiusPx, orbitX, orbitY, ORB.cubeScale * obstacleScale)
.map(p => ({ x: p.x - canvasLeft, y: p.y - canvasTop }));
const hull = convexHull(projected);
if (hull.length < 3) return spanRows;
let yMin = Infinity;
let yMax = -Infinity;
for (const p of hull) {
if (p.y < yMin) yMin = p.y;
if (p.y > yMax) yMax = p.y;
}
const r0 = Math.max(0, Math.floor((yMin - CELL_H) / CELL_H));
const r1 = Math.min(rows - 1, Math.ceil((yMax + CELL_H) / CELL_H));
const pad = Math.max(4, CELL_W * 1.6);
for (let row = r0; row <= r1; row++) {
const scanY = row * CELL_H + CELL_H * 0.5;
const xs = [];
for (let i = 0; i < hull.length; i++) {
const a = hull[i];
const b = hull[(i + 1) % hull.length];
const y0 = a.y;
const y1 = b.y;
if ((y0 <= scanY && scanY < y1) || (y1 <= scanY && scanY < y0)) {
const t = (scanY - y0) / (y1 - y0);
xs.push(a.x + (b.x - a.x) * t);
}
}
if (xs.length < 2) continue;
xs.sort((a, b) => a - b);
for (let i = 0; i + 1 < xs.length; i += 2) {
const left = Math.max(0, xs[i] - pad);
const right = Math.min(W, xs[i + 1] + pad);
if (right - left > CELL_W) addSpan(spanRows, row, left, right);
}
}
return spanRows;
}
function collectSphereObstacleRows(center, radiusNorm, obstacleScale, rPad) {
const spanRows = makeEmptySpanRows();
const obstacleRadiusNorm = radiusNorm * obstacleScale;
const obstaclePadNorm = (rPad / H) * obstacleScale;
if (obstacleRadiusNorm <= 0.0001) return spanRows;
const radiusPx = radiusNorm * H;
const rMin = Math.floor((center.canvasY - radiusPx - rPad) / CELL_H);
const rMax = Math.ceil((center.canvasY + radiusPx + rPad) / CELL_H);
for (let r = rMin; r <= rMax; r++) {
const y = r * CELL_H + CELL_H * 0.5;
const stageY = y - canvasTop;
const pyForObstacle = stageY / H - 0.5 - ORB.panY;
if (Math.abs(pyForObstacle) > obstacleRadiusNorm + obstaclePadNorm) continue;
const halfNorm = Math.sqrt(Math.max(0, (obstacleRadiusNorm + obstaclePadNorm) ** 2 - pyForObstacle * pyForObstacle));
const halfPx = halfNorm * H;
const stageRow = Math.floor(stageY / CELL_H);
addSpan(spanRows, stageRow, center.x - halfPx, center.x + halfPx);
}
return spanRows;
}
function pushMorphedObstacleRows(sphereRows, cubeRows, mix) {
const centerX = W * (0.5 + ORB.panX);
for (let row = 0; row < rows; row++) {
const sphereValid = Number.isFinite(sphereRows[row].left);
const cubeValid = Number.isFinite(cubeRows[row].left);
if (!sphereValid && !cubeValid) continue;
const sLeft = sphereValid ? sphereRows[row].left : centerX;
const sRight = sphereValid ? sphereRows[row].right : centerX;
const cLeft = cubeValid ? cubeRows[row].left : centerX;
const cRight = cubeValid ? cubeRows[row].right : centerX;
const left = sLeft + (cLeft - sLeft) * mix;
const right = sRight + (cRight - sRight) * mix;
if (right - left > CELL_W) obstacleRows[row].push([Math.max(0, left), Math.min(W, right)]);
}
}
function rotateOrbVec(vx, vy, vz, orbitX, orbitY) {
let a = rot(vy, vz, orbitX); vy = a[0]; vz = a[1];
a = rot(vx, vz, orbitY); vx = a[0]; vz = a[1];
return [vx, vy, vz];
}
function rasterizeCubeWire(center, radiusPx, buildPhase, orbitX, orbitY, weight = 1) {
const unit = Math.max(1, Math.min(CELL_W, CELL_H));
const wirePx = Math.max(1, ORB.wire * radiusPx * 0.17);
const brush = Math.max(0, Math.ceil((wirePx - unit * 0.45) / unit));
const projected = projectCubeVerts(center, radiusPx, orbitX, orbitY, ORB.cubeScale);
for (let ei = 0; ei < CUBE_EDGES.length; ei++) {
const [a, b] = CUBE_EDGES[ei];
const p0 = projected[a];
const p1 = projected[b];
const dx = p1.x - p0.x;
const dy = p1.y - p0.y;
const len = Math.hypot(dx, dy);
const steps = Math.max(2, Math.ceil(len / Math.max(1, unit * 0.45)));
for (let s = 0; s <= steps; s++) {
const t = s / steps;
const px = p0.x + dx * t;
const py = p0.y + dy * t;
const depth = p0.d + (p1.d - p0.d) * t;
const c = Math.floor(px / CELL_W);
const r = Math.floor(py / CELL_H);
for (let oy = -brush; oy <= brush; oy++) {
for (let ox = -brush; ox <= brush; ox++) {
if (brush > 0 && ox * ox + oy * oy > brush * brush + 0.25) continue;
const cc = c + ox;
const rr = r + oy;
if (rr < 0 || rr >= canvasRows || cc < 0 || cc >= canvasCols) continue;
const reveal = buildPhase * 1.45 - hash(cc + ei * 13, rr + ei * 17) * 0.5;
if (buildPhase < 1 && reveal <= 0) continue;
const idx = rr * canvasCols + cc;
const val = buildPhase < 1
? Math.min(CHARS_LAST, Math.floor(reveal * CHARS_LAST))
: Math.min(CHARS_LAST, Math.floor((0.45 + depth * 0.55) * CHARS_LAST));
const weightedVal = Math.min(CHARS_LAST, Math.floor(val * weight));
if (weightedVal <= 0) continue;
orbMask[idx] = 1;
orbGlyph[idx] = "";
if (weightedVal > orbChar[idx]) orbChar[idx] = weightedVal;
}
}
}
}
}
function rasterizeOrb(time) {
orbMask.fill(0);
orbChar.fill(0);
orbGlyph.fill("");
const buildStart = orbBuildStartTime();
if (time < buildStart) return;
const shapeMix = Math.max(0, Math.min(1, ORB.shapeMix ?? (ORB.shape === "cube" ? 1 : 0)));
const sphereWeight = 1 - shapeMix;
const cubeWeight = shapeMix;
const sphereOrbitX = ORB.orbitX;
const sphereOrbitY = ORB.orbitY + time * ORB.autoSpin;
const cubeOrbitX = ORB.orbitX + time * ORB.autoSpin * 0.72;
const cubeOrbitY = ORB.orbitY + time * ORB.autoSpin * 1.35;
const latStep = Math.PI / ORB.lat;
const lonStep = (Math.PI * 2) / ORB.lon;
const rPad = 15.5;
const center = orbCenter();
const radiusPx = center.r;
const radiusNorm = center.rNorm;
const buildPhase = Math.min(1, Math.max(0, (time - buildStart) / SCENE.orbBuildDuration));
const spacePhase = Math.min(1, Math.max(0, (time - buildStart) / Math.max(0.001, ctrls.orbSpaceDuration)));
const spaceGrow = Math.pow(spacePhase, Math.max(0.01, ctrls.orbSpaceEase));
const spaceStart = Math.max(0, Math.min(1, ctrls.orbSpaceStart));
const obstacleScale = spaceStart + (1 - spaceStart) * spaceGrow;
const rMin = Math.floor((center.canvasY - radiusPx - rPad) / CELL_H);
const rMax = Math.ceil((center.canvasY + radiusPx + rPad) / CELL_H);
const sphereObstacleRows = collectSphereObstacleRows(center, radiusNorm, obstacleScale, rPad);
const cubeObstacleRows = collectCubeObstacleRows(center, radiusPx, cubeOrbitX, cubeOrbitY, obstacleScale);
pushMorphedObstacleRows(sphereObstacleRows, cubeObstacleRows, shapeMix);
if (cubeWeight > 0.001) {
rasterizeCubeWire(center, radiusPx, buildPhase, cubeOrbitX, cubeOrbitY, cubeWeight);
}
if (sphereWeight <= 0.001) {
mergeObstacleRows();
return;
}
const cMin = Math.floor((center.canvasX - radiusPx - rPad) / CELL_W);
const cMax = Math.ceil((center.canvasX + radiusPx + rPad) / CELL_W);
for (let r = rMin; r <= rMax; r++) {
const y = r * CELL_H + CELL_H * 0.5;
const stageY = y - canvasTop;
for (let c = cMin; c <= cMax; c++) {
// Direct port of bb-ascii/app/nous/sphere/page.tsx:
// px uses width/height normalization, py is row-normalized, then both
// are offset by pan. This is what makes the sphere circular on screen.
const stageX = c * CELL_W + CELL_W * 0.5 - canvasLeft;
const px = (stageX / W - 0.5 - ORB.panX) * (W / H);
const py = stageY / H - 0.5 - ORB.panY;
const d2 = px * px + py * py;
if (d2 > radiusNorm * radiusNorm) continue;
const pz = Math.sqrt(radiusNorm * radiusNorm - d2);
const depth = (pz / radiusNorm) * 0.5 + 0.5;
let sx = px / radiusNorm, sy = py / radiusNorm, sz = pz / radiusNorm;
let a = rot(sy, sz, sphereOrbitX); sy = a[0]; sz = a[1];
a = rot(sx, sz, sphereOrbitY); sx = a[0]; sz = a[1];
const latD = gridDist(Math.asin(Math.max(-1, Math.min(1, sy))) + Math.PI / 2, latStep);
const lonD = gridDist(Math.atan2(sz, sx) + Math.PI, lonStep);
const wire = ORB.wire * (0.3 + depth * 0.7);
const minD = Math.min(latD, lonD);
if (minD > wire) continue;
const edge = 1 - minD / wire;
if (r >= 0 && r < canvasRows && c >= 0 && c < canvasCols) {
const reveal = buildPhase * 1.5 - hash(c, r) * 0.5;
if (buildPhase < 1 && reveal <= 0) continue;
const idx = r * canvasCols + c;
const val = buildPhase < 1
? Math.min(CHARS_LAST, Math.floor(reveal * CHARS_LAST))
: Math.min(CHARS_LAST, Math.floor(edge * (0.4 + depth * 0.6) * CHARS.length));
const weightedVal = Math.min(CHARS_LAST, Math.floor(val * sphereWeight));
if (weightedVal <= 0) continue;
orbMask[idx] = 1;
orbGlyph[idx] = "";
if (weightedVal > orbChar[idx]) orbChar[idx] = weightedVal;
}
}
}
mergeObstacleRows();
}
let typedChars = 0;
let typedPrepared = null;
let typedDirty = true;
let typedBudget = 0;
function tickTyping(time, dt) {
if (!bodyPrepared) return;
if (time < codeTypingStartTime()) return;
const cps = Math.max(1, ctrls.typeSpeed ?? 42);
typedBudget += dt * cps;
const steps = Math.min(240, Math.floor(typedBudget));
if (steps > 0) {
typedBudget -= steps;
typedChars = Math.min(streamCorpus.length, typedChars + steps);
typedDirty = true;
}
}
function getTypedPrepared() {
if (!typedDirty && typedPrepared) return typedPrepared;
const cap = Math.max(0, Math.floor(typedChars));
const visible = streamCorpus.slice(0, cap);
typedPrepared = prepareWithSegments(visible, BODY_FONT, { whiteSpace: "pre-wrap" });
typedDirty = false;
return typedPrepared;
}
function layoutParagraphs() {
drawnLines.count = 0;
if (typedChars <= 0) return;
const prepared = getTypedPrepared();
ctx.font = BODY_FONT;
const MARGIN_X = Math.max(32, Math.min(72, W * 0.06));
const MIN_LINE_W = 120;
const yStart = Math.max(32, H * 0.08);
const yEnd = H - 32;
let cursor = { segmentIndex: 0, graphemeIndex: 0 };
for (let y = yStart; y < yEnd; y += BODY_LINE_H) {
const row = Math.max(0, Math.min(rows - 1, Math.floor(y / CELL_H)));
const spans = obstacleRows[row] || [];
let n = 0;
let x = MARGIN_X;
for (const [a, b] of spans) {
const left = Math.max(MARGIN_X, a);
const right = Math.min(W - MARGIN_X, b);
if (left - x >= MIN_LINE_W) { intervals[n++] = x; intervals[n++] = left - 10; }
x = Math.max(x, right + 10);
}
if (W - MARGIN_X - x >= MIN_LINE_W) { intervals[n++] = x; intervals[n++] = W - MARGIN_X; }
if (!n) { intervals[n++] = MARGIN_X; intervals[n++] = W - MARGIN_X; }
for (let k = 0; k < n; k += 2) {
const ix0 = intervals[k], ix1 = intervals[k + 1];
const range = layoutNextLineRange(prepared, cursor, ix1 - ix0);
if (!range) return;
const line = materializeLineRange(prepared, range);
const i = drawnLines.count++;
drawnLines.x[i] = ix0;
drawnLines.y[i] = y;
drawnLines.w[i] = ix1 - ix0;
drawnLines.text[i] = line.text;
cursor = range.end;
}
}
}
function escapeHTML(text) {
return text
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">");
}
function highlightSource(text) {
const token = /("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|\/\/[^\n]*|\b(?:as|break|const|else|enum|false|fn|for|if|impl|in|let|match|mut|pub|return|self|static|struct|true|type|use|where|while)\b|\b[A-Z_]{2,}\b|\b\d+(?:\.\d+)?\b)/g;
let out = "";
let last = 0;
for (const match of text.matchAll(token)) {
const value = match[0];
out += escapeHTML(text.slice(last, match.index));
const cls = value.startsWith("#")
? "tok-comment"
: value.startsWith('"') || value.startsWith("'")
? "tok-string"
: /^\d/.test(value)
? "tok-number"
: /^[A-Z_]{2,}$/.test(value)
? "tok-const"
: "tok-keyword";
out += `<span class="${cls}">${escapeHTML(value)}</span>`;
last = match.index + value.length;
}
out += escapeHTML(text.slice(last));
return out;
}
function renderTextLayer() {
const parts = [LENS.mgBase, drawnLines.count];
for (let i = 0; i < drawnLines.count; i++) {
parts.push(drawnLines.x[i] | 0, drawnLines.y[i] | 0, drawnLines.w[i] | 0, drawnLines.text[i]);
}
const signature = parts.join("|");
if (signature === lastTextSignature) return;
lastTextSignature = signature;
ctx.font = BODY_FONT;
textLayer.style.color = LENS.mgBase;
textLayer.style.setProperty("--midground-base", LENS.mgBase);
while (lineNodes.length < drawnLines.count) {
const el = document.createElement("div");
el.className = "flow-line";
textLayer.appendChild(el);
lineNodes.push(el);
}
terminalAnchorEl = null;
terminalLineEl = null;
for (let i = 0; i < lineNodes.length; i++) {
const el = lineNodes[i];
if (i >= drawnLines.count) {
el.hidden = true;
continue;
}
const text = drawnLines.text[i];
const isTail = i === drawnLines.count - 1;
el.hidden = false;
el.innerHTML = isTail
? `${highlightSource(text)}<span class="cursor-anchor" aria-hidden="true"></span>`
: highlightSource(text);
el.style.left = `${drawnLines.x[i]}px`;
el.style.top = `${drawnLines.y[i] - BODY_LINE_H + 4}px`;
el.style.width = `${drawnLines.w[i]}px`;
el.style.textAlign = "left";
el.style.wordSpacing = "0px";
if (isTail) {
terminalAnchorEl = el.querySelector(".cursor-anchor");
terminalLineEl = el;
}
}
}
let sceneEpoch = 0;
function restartScene() {
gsap.killTweensOf(ORB);
gsap.killTweensOf(orbCanvas);
autoCubeCall?.kill();
autoCubeCall = null;
ORB.panX = 0;
ORB.panY = 0;
ORB.zoom = ctrls.orbZoom;
ORB.cubeScale = ctrls.cubeScale;
ORB.shape = ctrls.orbShape;
ORB.shapeMix = ctrls.orbShape === "cube" ? 1 : 0;
ORB.orbitX = -1.42;
ORB.orbitY = 0;
ORB.wire = ctrls.orbWire;
orbCanvas.style.opacity = "1";
ORB.staged = false;
ORB.positioned = true;
typedChars = 0;
typedBudget = 0;
typedPrepared = null;
typedDirty = true;
nousStartTime = null;
sceneEpoch = (typeof performance !== "undefined" ? performance.now() : Date.now()) * 0.001;
lastTextSignature = "";
startSceneTimeline();
}
function applyFontSizes() {
const bodyPx = ctrls.bodyFontSize;
const asciiPx = ctrls.asciiFontSize;
BODY_FONT = `400 ${bodyPx}px "JetBrains Mono", "SF Mono", SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace`;
BODY_LINE_H = Math.round(bodyPx * 1.45);
ASCII_FONT = `${asciiPx}px monospace`;
CELL_H = asciiPx;
textLayer.style.font = `400 ${bodyPx}px/${BODY_LINE_H}px "JetBrains Mono", "SF Mono", SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace`;
rebuildLayouts();
typedPrepared = null;
typedDirty = true;
resize();
lastTextSignature = "";
}
function applyColors() {
if (!LENS.current) return;
LENS.current.bgColor = ctrls.bgColor;
LENS.current.mgColor = ctrls.mgColor;
LENS.current.fgColor = ctrls.fgColor;
applyLens(LENS.current);
lastTextSignature = "";
}
const ctrls = {
bodyFontSize: 10,
asciiFontSize: 8,
autoSpin: 0.18,
orbShape: "sphere",
shapeMorphDuration: 0.65,
cubeScale: 1.8,
orbLead: 0.35,
orbZoom: 3,
orbRadius: 0.135,
orbWire: 0.2,
orbSpaceStart: 0.82,
orbSpaceDuration: 0.7,
orbSpaceEase: 1.1,
orbWireLow: 0.01,
wireDropDuration: 1.2,
orbFadeDuration: 0.85,
orbContainNous: 1.0,
nousAfterWire: 0,
nousLetterSpacing: 2.7,
typeSpeed: 816,
bgColor: "#041C1C",
mgColor: "#ffe6cb",
fgColor: "#FFFFFF",
noiseEnabled: true,
noiseColor: "#eaeaea",
noiseDensity: 0.11,
noiseOpacity: 0.25,
noiseSize: 1,
noiseBlend: "difference",
restart: () => restartScene(),
};
const DEV_MODE = new URLSearchParams(window.location.search).has("dev");
if (DEV_MODE) {
const gui = new GUI({ title: "controls" });
gui.add(ctrls, "bodyFontSize", 10, 22, 1).name("body font").onChange(applyFontSizes);
gui.add(ctrls, "asciiFontSize", 8, 20, 1).name("ascii font").onChange(applyFontSizes);
gui.add(ctrls, "autoSpin", 0, 0.6, 0.01).name("auto-spin").onChange(v => { ORB.autoSpin = v; });
orbShapeControl = gui.add(ctrls, "orbShape", ["sphere", "cube"]).name("orb shape").onChange(v => {
setOrbShape(v);
});
gui.add(ctrls, "shapeMorphDuration", 0.05, 2, 0.01).name("shape morph (s)");
gui.add(ctrls, "orbLead", 0, 6, 0.05).name("orb start (s)").onFinishChange(() => restartScene());
gui.add(ctrls, "orbZoom", 0.25, 12, 0.01).name("orb zoom").onChange(v => { ORB.zoom = v; });
gui.add(ctrls, "cubeScale", 0.8, 3.5, 0.01).name("cube scale").onChange(v => { ORB.cubeScale = v; });
gui.add(ctrls, "orbRadius", 0.08, 0.4, 0.005).name("orb radius").onChange(v => { ORB.radius = v; });
gui.add(ctrls, "orbSpaceStart", 0.4, 1, 0.01).name("space start");
gui.add(ctrls, "orbSpaceDuration", 0.2, 4, 0.05).name("space grow (s)");
gui.add(ctrls, "orbSpaceEase", 0.4, 3, 0.05).name("space ease");
gui.add(ctrls, "orbWire", 0.01, 0.2, 0.005).name("wire start").onChange(v => { ORB.wire = v; });
gui.add(ctrls, "orbWireLow", 0.005, 0.12, 0.001).name("wire low").onFinishChange(() => restartScene());
gui.add(ctrls, "wireDropDuration", 0.4, 6, 0.05).name("wire drop (s)").onFinishChange(() => restartScene());
gui.add(ctrls, "orbContainNous", 0, 3, 0.05).name("orb contain NOUS");
gui.add(ctrls, "orbFadeDuration", 0.1, 3, 0.05).name("orb fade on NOUS");
gui.add(ctrls, "nousAfterWire", 0, 4, 0.05).name("NOUS after wire").onFinishChange(() => restartScene());
gui.add(ctrls, "nousLetterSpacing", 0, 4, 0.05).name("NOUS spacing");
gui.add(ctrls, "typeSpeed", 0, 4000, 10).name("type speed");
gui.addColor(ctrls, "bgColor").name("background").onChange(applyColors);
gui.addColor(ctrls, "mgColor").name("midground").onChange(applyColors);
gui.addColor(ctrls, "fgColor").name("foreground").onChange(applyColors);
const noiseFolder = gui.addFolder("noise");
noiseFolder.add(ctrls, "noiseEnabled").name("on");
noiseFolder.addColor(ctrls, "noiseColor").name("color");
noiseFolder.add(ctrls, "noiseDensity", 0, 1, 0.01).name("density");
noiseFolder.add(ctrls, "noiseOpacity", 0, 1, 0.01).name("opacity");
noiseFolder.add(ctrls, "noiseSize", 0.1, 10, 0.1).name("size");
noiseFolder.add(ctrls, "noiseBlend", [
"normal", "multiply", "screen", "difference", "exclusion",
"color-dodge", "color-burn", "hard-light", "soft-light", "overlay", "lighten",
]).name("blend").onChange(v => { document.querySelector(".noise").style.mixBlendMode = v; });
gui.add(ctrls, "restart").name("restart anim");
}
const terminalCursorEl = document.getElementById("terminalCursor");
function getLastCharRect(lineEl) {
if (!lineEl) return null;
const walker = document.createTreeWalker(lineEl, NodeFilter.SHOW_TEXT);
let lastTextNode = null;
let node = walker.nextNode();
while (node) {
if (node.nodeValue && node.nodeValue.length) lastTextNode = node;
node = walker.nextNode();
}
if (!lastTextNode) return null;
const end = lastTextNode.nodeValue.length;
if (!end) return null;
const range = document.createRange();
range.setStart(lastTextNode, end - 1);
range.setEnd(lastTextNode, end);
const rects = range.getClientRects();
const rect = rects.length ? rects[rects.length - 1] : range.getBoundingClientRect();
return rect && (rect.width || rect.height) ? rect : null;
}
function updateTerminalCursor() {
if (!drawnLines.count || !terminalAnchorEl || !terminalLineEl) {
terminalCursorEl.hidden = true;
return;
}
const stageRect = stage.getBoundingClientRect();
const charRect = getLastCharRect(terminalLineEl);
const anchorRect = terminalAnchorEl.getBoundingClientRect();
if (!anchorRect.width && !anchorRect.height) {
terminalCursorEl.hidden = true;
return;
}
const fontSize = parseInt(BODY_FONT.match(/(\d+)px/)?.[1] || "12", 10);
const fallbackW = Math.max(6, Math.round(fontSize * 0.6));
const leftPx = (charRect?.left ?? (anchorRect.left - fallbackW)) - stageRect.left;
const topPx = (charRect?.top ?? anchorRect.top) - stageRect.top;
const widthPx = Math.max(1, Math.round(charRect?.width || fallbackW));
const heightPx = Math.max(1, Math.round(charRect?.height || (BODY_LINE_H - 4)));
const left = Math.round(leftPx);
const top = Math.round(topPx);
terminalCursorEl.style.left = `${left}px`;
terminalCursorEl.style.top = `${top}px`;
terminalCursorEl.style.height = `${heightPx}px`;
terminalCursorEl.style.width = `${widthPx}px`;
terminalCursorEl.style.fontSize = `${fontSize}px`;
terminalCursorEl.hidden = false;
}
const noiseCanvas = document.getElementById("noiseCanvas");
const noiseGL = noiseCanvas.getContext("webgl", { alpha: true, premultipliedAlpha: true })
|| noiseCanvas.getContext("experimental-webgl", { alpha: true, premultipliedAlpha: true });
let noiseProgram = null;
let noiseLocs = null;
function initNoise() {
if (!noiseGL) return;
const vertSrc = `
attribute vec2 aPos;
varying vec2 vUv;
void main() {
vUv = aPos * 0.5 + 0.5;
gl_Position = vec4(aPos, 0.0, 1.0);
}
`;
const fragSrc = `
precision mediump float;
varying vec2 vUv;
uniform vec2 uRes;
uniform float uDpr;
uniform float uSize;
uniform float uDensity;
uniform float uOpacity;
uniform vec3 uColor;
float hash(vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * 0.1031);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
void main() {
float n = hash(floor(vUv * uRes / (uSize * uDpr)));
gl_FragColor = vec4(uColor, step(1.0 - uDensity, n)) * uOpacity;
}
`;
function compile(type, src) {
const sh = noiseGL.createShader(type);
noiseGL.shaderSource(sh, src);
noiseGL.compileShader(sh);
return sh;
}
const vs = compile(noiseGL.VERTEX_SHADER, vertSrc);
const fs = compile(noiseGL.FRAGMENT_SHADER, fragSrc);
noiseProgram = noiseGL.createProgram();
noiseGL.attachShader(noiseProgram, vs);
noiseGL.attachShader(noiseProgram, fs);
noiseGL.linkProgram(noiseProgram);
noiseGL.useProgram(noiseProgram);
const buf = noiseGL.createBuffer();
noiseGL.bindBuffer(noiseGL.ARRAY_BUFFER, buf);
noiseGL.bufferData(noiseGL.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), noiseGL.STATIC_DRAW);
const aPos = noiseGL.getAttribLocation(noiseProgram, "aPos");
noiseGL.enableVertexAttribArray(aPos);
noiseGL.vertexAttribPointer(aPos, 2, noiseGL.FLOAT, false, 0, 0);
noiseLocs = {
uRes: noiseGL.getUniformLocation(noiseProgram, "uRes"),
uDpr: noiseGL.getUniformLocation(noiseProgram, "uDpr"),
uSize: noiseGL.getUniformLocation(noiseProgram, "uSize"),
uDensity: noiseGL.getUniformLocation(noiseProgram, "uDensity"),
uOpacity: noiseGL.getUniformLocation(noiseProgram, "uOpacity"),
uColor: noiseGL.getUniformLocation(noiseProgram, "uColor"),
};
noiseGL.enable(noiseGL.BLEND);
noiseGL.blendFunc(noiseGL.ONE, noiseGL.ONE_MINUS_SRC_ALPHA);
noiseGL.clearColor(0, 0, 0, 0);
}
function resizeNoise() {
if (!noiseGL) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
noiseCanvas.width = innerWidth * dpr;
noiseCanvas.height = innerHeight * dpr;
noiseGL.viewport(0, 0, noiseCanvas.width, noiseCanvas.height);
if (noiseLocs) {
noiseGL.uniform2f(noiseLocs.uRes, noiseCanvas.width, noiseCanvas.height);
noiseGL.uniform1f(noiseLocs.uDpr, dpr);
}
}
addEventListener("resize", resizeNoise);
initNoise();
resizeNoise();
function drawNoiseFrame() {
if (!noiseGL || !noiseProgram) return;
noiseGL.clear(noiseGL.COLOR_BUFFER_BIT);
if (!ctrls.noiseEnabled) return;
noiseGL.useProgram(noiseProgram);
noiseGL.uniform1f(noiseLocs.uSize, ctrls.noiseSize);
noiseGL.uniform1f(noiseLocs.uDensity, ctrls.noiseDensity);
noiseGL.uniform1f(noiseLocs.uOpacity, ctrls.noiseOpacity);
const [r, g, b] = hexToRGB(ctrls.noiseColor);
noiseGL.uniform3f(noiseLocs.uColor, r / 255, g / 255, b / 255);
noiseGL.drawArrays(noiseGL.TRIANGLE_STRIP, 0, 4);
}
function draw(time) {
if (!fontsReady || !bodyPrepared) { requestAnimationFrame(draw); return; }
const t = time * 0.001 - sceneEpoch;
const dt = lastFrameTime ? Math.min(0.05, (time - lastFrameTime) / 1000) : 0.016;
lastFrameTime = time;
ctx.clearRect(0, 0, canvasW, canvasH);
orbCtx.clearRect(0, 0, canvasW, canvasH);
rasterizeNOUS(t);
rasterizeOrb(t);
tickTyping(t, dt);
layoutParagraphs();
renderTextLayer();
updateTerminalCursor();
drawNoiseFrame();
ctx.font = ASCII_FONT;
ctx.textBaseline = "middle";
ctx.textAlign = "center";
ctx.fillStyle = LENS.mgBase;
orbCtx.font = ASCII_FONT;
orbCtx.textBaseline = "middle";
orbCtx.textAlign = "center";
orbCtx.fillStyle = LENS.mgBase;
for (let r = 0; r < canvasRows; r++) {
const base = r * canvasCols;
for (let c = 0; c < canvasCols; c++) {
const idx = base + c;
if (asciiMask[idx] === EMPTY) continue;
ctx.fillText(asciiGlyph[idx] || CHARS[asciiChar[idx]], c * CELL_W + CELL_W * 0.5, r * CELL_H + CELL_H * 0.5);
}
}
for (let r = 0; r < canvasRows; r++) {
const base = r * canvasCols;
for (let c = 0; c < canvasCols; c++) {
const idx = base + c;
if (orbMask[idx] === EMPTY) continue;
orbCtx.fillText(orbGlyph[idx] || CHARS[orbChar[idx]], c * CELL_W + CELL_W * 0.5, r * CELL_H + CELL_H * 0.5);
}
}
requestAnimationFrame(draw);
}
resize();
const DS_CDN = "https://esm.sh/@nous-research/ui@0.4.0/dist/fonts";
const FACES = [new FontFace("Mondwest", `url(${DS_CDN}/Mondwest-Regular.woff2) format("woff2")`, { weight: "400", display: "block" })];
(async () => {
try {
const loaded = await Promise.all(FACES.map(f => f.load()));
for (const f of loaded) document.fonts.add(f);
await document.fonts.load(BODY_FONT, "Aa");
await document.fonts.load(ASCII_FONT, "Aa");
} catch (err) {
console.warn("DS font load failed, using fallbacks:", err);
}
rebuildLayouts();
fontsReady = true;
requestAnimationFrame(draw);
})();
</script>
</body>
</html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>pretext hello — text flowing around an orb</title>
<style>
html,body { margin:0; padding:0; height:100%; background:#0c0d10; color:#e8e6df; overflow:hidden; }
body { font-family: "Iowan Old Style", Georgia, serif; }
canvas { display:block; width:100vw; height:100vh; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<script type="module">
// Minimal pretext starter: long paragraph flows around a moving orb.
// Uses layoutNextLineRange + variable-width streaming — the "killer app"
// pattern that only pretext can do cheaply in the browser.
import {
prepareWithSegments,
layoutNextLineRange,
materializeLineRange,
} from "https://esm.sh/@chenglou/pretext@0.0.6";
const TEXT = `Pretext measures text without touching the DOM. It returns numbers — widths, line breaks, cursors — and those numbers, arranged with a little imagination, become layouts the browser could never draw on its own. Here, a paragraph flows around a moving orb. Each line is asked for its own width, live. No reflows. No cheats. Just measurement. `.repeat(18);
const FONT = '17px/1.4 "Iowan Old Style", Georgia, serif';
const LINE_H = 24;
const c = document.getElementById("c");
const ctx = c.getContext("2d");
let W, H, DPR;
function resize() {
DPR = Math.min(devicePixelRatio || 1, 2);
W = innerWidth; H = innerHeight;
c.width = W*DPR; c.height = H*DPR;
c.style.width = W+"px"; c.style.height = H+"px";
ctx.setTransform(DPR,0,0,DPR,0,0);
}
addEventListener("resize", resize); resize();
const prepared = prepareWithSegments(TEXT, FONT);
// Orb follows mouse (or bobs idly)
const orb = { x: innerWidth*0.45, y: innerHeight*0.5, r: 140 };
addEventListener("mousemove", e => { orb.x = e.clientX; orb.y = e.clientY; });
function frame(t) {
ctx.fillStyle = "#0c0d10"; ctx.fillRect(0,0,W,H);
// glowing orb
const g = ctx.createRadialGradient(orb.x, orb.y, 0, orb.x, orb.y, orb.r);
g.addColorStop(0, "rgba(255,200,120,0.35)");
g.addColorStop(0.6, "rgba(255,140,80,0.10)");
g.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = g; ctx.fillRect(0,0,W,H);
// flow text as a column, routing around the orb row-by-row
const COL_X = 60, COL_W = W - 120;
let cursor = { segmentIndex: 0, graphemeIndex: 0 };
let y = 72;
ctx.fillStyle = "#e8e6df";
ctx.font = FONT;
ctx.textBaseline = "alphabetic";
while (y < H - 40) {
// does this row intersect the orb band?
const dy = y - orb.y;
const bandY = Math.abs(dy) < orb.r;
// lane = (left, width) skipping over the orb horizontally
let x = COL_X, lineMaxW = COL_W;
if (bandY) {
const half = Math.sqrt(orb.r*orb.r - dy*dy);
const orbLeft = orb.x - half, orbRight = orb.x + half;
// choose the wider side, simple heuristic
const leftWidth = Math.max(0, orbLeft - COL_X);
const rightWidth = Math.max(0, COL_X + COL_W - orbRight);
if (leftWidth >= rightWidth) { x = COL_X; lineMaxW = leftWidth - 12; }
else { x = orbRight + 12; lineMaxW = rightWidth - 12; }
if (lineMaxW < 40) { y += LINE_H; continue; }
}
const range = layoutNextLineRange(prepared, cursor, lineMaxW);
if (!range) break;
const line = materializeLineRange(prepared, range);
ctx.fillText(line.text, x, y);
cursor = range.end;
y += LINE_H;
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>
</body>
</html>