An ASCII grid that waits
The ASCII grid behind the home terminal, and how it stays out of the way of the page.
Behind the terminal on the home page there is a grid of dots and small crosses. When you move the mouse over it, a soft glow follows the pointer and turns the dots near it into code characters. When you run a command in the terminal, a ring goes out from the prompt. It is only decoration, so it must never slow down the page.
The late start
The server renders an empty canvas with aria-hidden, so screen readers skip it. CSS lays it over the whole hero from the start, so nothing moves when it gets drawn, and it never catches a click:
.home-hero__ascii {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
}
In the browser the grid does not start right away. The component hands the start to idle, which uses requestIdleCallback to wait until the browser has nothing else to do, or two seconds at the latest:
const cancel = idle(() => {
handle = mount(canvas);
});
Only then does mount size the canvas and draw the grid, so it never competes with the first paint or your first input.
The signal from the terminal
The terminal and the grid are separate islands (parts of the page with their own JavaScript) that do not import each other's components. After every command the terminal sends a losoft:command event on the window with the centre of the prompt line:
const rect = locate();
if (rect) target.dispatchEvent(new CustomEvent(COMMAND_EVENT, { detail: commandPoint(rect) }));
The terminal does not know who listens. The grid listens on the window, turns that point into canvas coordinates and starts a ring there. So the terminal stays the shell from the note “A small shell in the browser”, plus this one event.
Repainting only what changes
Most of the grid never changes, so it is drawn once into a canvas that is not on the page, and each frame copies back only the areas that change. For the glow, that is a square around its position, as big as the gradient, which reaches 1.4 times the glow radius (230 pixels, or 160 on narrow screens):
if (f.glow.on > 0) {
const r = glowRadius(f) * PARAMS.glowSpread;
const { x, y } = f.glow;
out.push(clampRect(f, x - r, y - r, x + r, y + r));
}
Here f is the field, the state of the grid, and f.glow is the glow's position with on, how far it has faded in (0 to 1). A ring grows at 420 pixels per second and lights a band 40 pixels to each side of its edge. Inside the band nothing changes, so the grid repaints four strips around the ring and leaves out the square that fits inside its inner edge. Each frame also repaints the areas of the frame before, so a glow that moved on leaves no trace.
The animation loop stops as soon as nothing moves. After every frame it asks the field whether it needs another one:
export function isIdle(f: Field): boolean {
return !f.target && f.glow.on === 0 && f.waves.length === 0 && !isFading(f);
}
target is the pointer position over the hero (or null), waves is the list of rings still running, and isFading is true while the colours change after a theme switch. So once the pointer has left and the last ring has faded, the grid costs no CPU. The loop also pauses while the grid is off screen, which an IntersectionObserver reports, and while the tab is hidden, which the visibilitychange event reports.
Colours from the theme
The grid has to match the light and the dark theme, so it has no colours of its own and reads the same CSS tokens as the rest of the site:
const style = getComputedStyle(root);
const line = parseColor(style.getPropertyValue("--line-strong"));
const accent = parseColor(style.getPropertyValue("--accent"));
A MutationObserver sees when the header toggle or the theme command changes the data-theme attribute, and a media query sees the system setting change, and the grid reads the tokens again. While it is animating, it fades over to the new colours in 600 milliseconds. Otherwise it switches at once.
Reduced motion
Some people turn off animations in their system settings. With reduced motion there is no glow, no rings and no scroll effect (normally the grid shifts by up to 8 pixels as you scroll). You only see the static grid, in the right colours.
Tests without a browser
Every browser API in this note, from requestAnimationFrame to the observers and media queries, reaches the grid through one interface, FieldEnv. In the browser, browserEnv() fills it with the real APIs, and the tests pass in fakes. So they check without a browser that a quiet hero requests no animation frames, that the loop pauses while the tab is hidden and that reduced motion draws the grid once and stops.