Every letter has its own bruise

Every letter has its own bruise
The LCD effect from ADR-0016 worked at the paragraph level. You'd move your cursor near a block of text and the entire thing would light up with chromatic fringe — red one way, blue the other, the whole <p> treated as a single object. It was convincing enough at a glance, but if you really looked you could feel the granularity. Paragraphs aren't objects. They're crowds. Every letter in them is its own piece of glass, and the pressure metaphor breaks down when fifty characters respond identically to a force that should be hitting them at different distances.
So now they don't.
Wrapping every character in its own physics
Each text node inside #content gets walked by a TreeWalker and split into individual <span class="prox-char"> elements. Every letter, every space, every punctuation mark — its own inline-block element that transform can act on. The wrapping excludes PRE, CODE, A, IMG, and other tags where character-level manipulation would be destructive or meaningless.
This sounds expensive. ~4000 Spans on a typical entry. But the expense isn't in having them — inline-block spans don't cost much at rest. The expense is in touching them. And the entire architecture of this system is designed around touching as few as possible per frame.
Two-tier spatial culling
Tier 1 — block gate. Every <p>, <li>, <h2>, etc. Has its bounding rect cached. Before checking any character inside a block, the cursor's distance to the block's nearest edge is tested. If the block is outside proxRadius + 50px, every character inside it is skipped. This rejects entire paragraphs in a single comparison.
Tier 2 — character distance. For characters in blocks that passed the gate, each one's cached center position is tested against the cursor. Only characters within the proximity radius proceed to transform computation.
Result: ~200-600 characters evaluated per frame instead of ~4000. The block gate kills ~80% of the work before it starts.
The displacement model
Characters within range experience three simultaneous effects:
Radial push — each letter translates away from the cursor along the cursor→character vector. Maximum displacement: 7px at zero distance. The intensity curve is the same quadratic toe from the block-level system: intensity * (0.4 + intensity * 0.6), scaled by activity decay. But now each character computes its own distance, its own angle, its own push magnitude. A word directly under the cursor splits apart while a word 150px away barely trembles.
Micro-rotation — displaced characters tilt away from the pressure point, up to 8 degrees. The direction follows the push vector's horizontal component. This is the detail that makes it feel physical — not just "characters moved" but "characters torqued." like pressing into a membrane where each element has its own moment of inertia.
Chromatic fringe — independent per-character text-shadow with R/B split along the push vector (4px spread, 0.5 alpha). Green ghost channel appears above 50% intensity. The shadow values are quantized to 0.5px positions and 0.05 alpha increments — this makes the diff-check between frames actually catch identical values, so characters in slowly-fading zones don't trigger DOM writes every frame.
Click ripple at character resolution
Same wavefront + wake model from the block-level system, but now each letter gets hit individually as the ring passes its position. The band width tightened from 100px to 60px — precision justified by per-character granularity.
The wavefront displacement pushes characters 8px outward from click origin. The wake behind the front trails at 3px, dissolving with distance and time. Drip angle blends from radial to downward over the ripple lifetime. Gravity drift sags the whole field vertically as the ripple ages.
And now ripples stack. Click once, a ring expands. Click again before it finishes, a second ring expands from the new origin. Up to eight simultaneous ripples, each with independent timing, radius, and wake. Oldest gets recycled if you exceed the cap.
The wake problem
The first version had a performance cliff. A single ripple would start at 60fps and decay into the twenties as it expanded. The frame recorder showed why: the wake zone (rDist < rippleRadius) was catching every character the wavefront had already passed. By mid-ripple, that was 1200-1600 characters — nearly every letter on the page. Each one getting a fresh textShadow write every frame because the wake intensity formula included (1 - rippleProgress), which changes every tick, defeating the diff-check.
Three fixes:
Wake floor — characters where total ripple intensity falls below a threshold get skipped entirely. Default: 0.21. The wake fades to near-invisible at distance, so this cuts out ~800 characters that were costing everything for zero visible effect.
Dynamic floor scaling — the wake floor increases by 0.08 per additional active ripple. One ripple: floor 0.21. Two: 0.29. Three: 0.37. The system automatically trades wake fidelity for frame rate as complexity increases. You can rapid-click five times and maintain playable performance because each successive ripple tightens the culling on all of them.
Shadow quantization — offset values rounded to nearest 0.5px, alpha to nearest 0.05. Characters in the slowly-fading wake produce identical shadow strings across multiple frames, so the diff-check prevents unnecessary DOM writes.
Position caching
Reading getBoundingClientRect() on 4000+ spans per frame is a reflow disaster. All character center positions are cached once after wrapping. On scroll, every cached Y is adjusted by the scroll delta — O(1), no reflow. Full recache every 2 seconds or on resize, to correct accumulated float drift from the delta approach.
Block rects follow the same strategy. The scroll listener is passive.
The active set
A sparse object tracks which character indices currently have non-empty transforms. Each frame builds a new active set from the compute loop. Characters that were in the old set but not the new one get their style.transform and style.textShadow cleared. Characters entering the set get their values written only if they differ from the previous frame's cached string.
This means characters outside all effect zones have zero per-frame cost. No iteration, no comparison, no DOM access. The system scales with the number of characters being touched, not the number that exist.
The numbers
Typical frame at idle: 0ms (nothing to compute, nothing to write).
Typical frame with cursor over text: ~0.5ms compute, ~0.3ms DOM writes, ~200 characters active.
Typical frame mid-ripple: ~2ms compute, ~1ms DOM writes, ~400-600 characters active.
Worst case (two overlapping ripples near peak expansion): ~6ms compute, ~3ms DOM. Still under the 33ms budget for 30fps, and the dynamic wake floor keeps it from climbing further.
The staging sandbox
The tuning happened in an isolated sandbox: per-char.html. Single self-contained file with inline styles and script, hardcoded dummy content, no dependencies on production code. It has a full control panel with sliders for every tunable parameter — proximity radius, displacement, rotation, chromatic spread, ripple band width, wake cutoff, drip blend, gravity drift, and the dynamic wake floor. A performance HUD breaks down each frame into compute time, DOM write time, character counts, and shadow write counts. A frame recorder captures 60 frames and dumps a full breakdown to the console.
If future-me needs to retune any of these values, that's where to go. The slider panel lets you watch the effect and the performance counters simultaneously, and the "copy config" button exports the current values as a JSON object you can paste directly into the production CC config.
The config that shipped
{
proxRadius: 170,
maxDisplacement: 7,
maxRotation: 8,
chromaSpread: 4,
chromaAlpha: 0.5,
greenThreshold: 0.5,
activityRamp: 0.08,
activityDecay: 0.02,
rippleBand: 60,
rippleFrontDisp: 8,
rippleWakeDisp: 3,
wakeCutoff: 300,
dripBlend: 0.6,
gravityDrift: 6,
rippleChroma: 0.45,
rippleRotation: 3,
wakeFloor: 0.21,
wakeFloorStep: 0.08
}
Deployment bugs (2026-03-18)
Two bugs survived staging and broke production entries on deploy.
Bug 1: MutationObserver infinite loop — entries won't load
Viscous.js sets up a MutationObserver on #content to re-wrap characters when the DOM changes (e.g., loadEntry() injecting parsed markdown). But wrapContentChars() itself modifies the DOM heavily — unwrapping old spans, creating thousands of new ones. A charWrapping boolean flag was supposed to prevent re-entry, but MutationObserver callbacks are microtasks that fire after the current callback returns. By that point charWrapping is already false. The accumulated mutations trigger a new callback, which calls wrapContentChars() again. Infinite loop. The page freezes at "loading... / Accessing archive..." because the main thread never yields to render.
Fix: hoist contentObserver to module scope. Call contentObserver.disconnect() at the top of wrapContentChars() and contentObserver.observe(...) at the bottom.
Lesson: boolean re-entrancy guards do not protect against microtask-scheduled callbacks. Disconnect/reconnect is the correct pattern for observers that modify their own observed subtree.
Bug 2: whitespace collapse — words strung together
Every character including spaces is wrapped in <span class="prox-char"> with display: inline-block. When a space character is the sole content of an inline-block box, it is simultaneously leading and trailing whitespace, and CSS normal whitespace rules collapse it to zero width. All words run together. This was invisible on the staging page (no one was trying to read the dummy content), but immediately obvious on real entries.
Fix: .prox-char[data-ws] { white-space: pre; } in style.css. The [data-ws] attribute is already set by the wrapping logic on whitespace characters. White-space: pre tells the browser to preserve the space.
Alternative rejected: replacing spaces with \u00A0 (non-breaking space) in JS. Changes text semantics, could break copy-paste, accessibility, and in-page search.
Tegotae
Miyamoto has a word for what this work is actually about: tegotae (手応え) — literally "hand response." there's no clean english equivalent. The closest he's gotten to explaining it is through a related word, hagotae — the sensation on your teeth when you bite into food. Tegotae is that, but for the space between your finger and the screen. The feel of pressing a button and believing that what happened on the other side was caused by you.
The original Super Mario bros started as a featureless block responding to a button press. No character, no enemies, no levels. Just a rectangle and a jump. Miyamoto and the team spent months getting that single interaction right before anything else existed. "Programming is all about numbers," he said, "and the challenge is getting this kind of feeling into numbers." the jump itself isn't even a single arc — it's two parabolas spliced together. Ascending gravity runs at one-fifth of descending gravity while the button is held. Release the button and full gravity snaps in. The result is a jump that feels like it belongs to the player's thumb. Hold longer, go higher. Release, fall fast. The asymmetry is invisible but you feel it in your hand.
Super Mario 64 took it further. Miyamoto built an isolated garden — no enemies, no hazards, just terrain — and spent months tuning Mario's friction and weight inside it. The rule was simple: if the movement didn't feel right in the garden, it couldn't ship. Those test gardens survived into the final game. Bob-omb Battlefield's field with wooden pegs, Snowman's Land's ice sculpture — they're the tuning rigs that happened to become levels.
The staging sandbox at per-char.html is the same pattern. A garden. No production code, no entry loading, no observers — just the raw displacement engine with sliders for every tunable value and a performance HUD counting DOM writes per frame. The proxRadius slider at 170, maxDisplacement at 7, wakeFloor at 0.21 — those numbers came from hours in the garden, watching the effect and the counters simultaneously, nudging values until the text stopped feeling like a digital effect and started feeling like a material. Like LCD fluid. Like something that responds to pressure.
Koji Kondo, when told to make a sound for Mario jumping, said: "but people don't make noise when they jump." the ascending-pitch jump sound had to be invented. It doesn't represent anything real. It represents the feeling of going up. The chromatic fringe on displaced characters works the same way. Real LCD pixels don't split into red and blue when you press the screen. But the aberration feels like pressure. It reads as force applied to a physical substrate, even though the physics are entirely fabricated.
Jonasson and Purho called it "juice" at GDC — "maximum output for minimum input." a juicy system "feels alive and responds to everything you do — tons of cascading action and response for minimal user input." but there's a trap in juice. It's easy to over-apply. Screen shake on everything, particles everywhere, chromatic aberration as decoration. The counter-argument (also from GDC): over-juicing masks shallow design. The effect becomes the product, and the thing underneath — the content, the text, the actual words — disappears behind the spectacle.
That's why the wake floor exists. Why activity decays. Why characters outside the proximity radius have zero per-frame cost. The system is designed to be quiet until you engage it, and to fade back to nothing when you stop. The text is readable first. The tegotae is second. The displacement doesn't exist to impress — it exists so that when you move your cursor across a sentence, you feel the sentence push back.
The feeling
Move your cursor across a paragraph and the letters nearest to it push away — individually, independently, each one tilting and trailing its own chromatic ghost. Stop moving and they drift back, each on its own schedule, the bruise dissolving character by character like liquid crystal realigning after you lift your thumb. Click and a ring of displaced letters expands outward, each glyph getting knocked aside as the wavefront crosses its position, then slowly sagging downward in the wake.
It's the same metaphor as before — LCD pressure physics, chromatic aberration as force feedback — but now the resolution matches the claim. Not "the paragraph responded to your cursor." every letter responded to your cursor.
Miyamoto wanted to patent jumping. He didn't get it. But nobody else's jump ever felt like his, because nobody else spent months in a garden with a featureless block, translating tegotae into numbers. The patent was in the curve, not the mechanic.
The mechanic here is transform: translate(). Anyone can do that. The curve is in the 0.21 wake floor, the quadratic intensity toe, the shadow quantization at 0.5px increments, the two-tier spatial culling that lets the system stay silent until you touch it. The curve is the hours in the garden.
ADR-0017. Every letter has its own bruise.
View this post with the full interactive/glitchy experience on darketype.






