1. The cloth physics model
This is a Verlet-integrated mass-spring grid (position-based dynamics, not force-integration with explicit velocity state). Standard game-physics technique (Thomas Jakobsen, "Advanced Character Physics," 2001 — the classic cloth-sim reference; same lineage as every browser cloth demo since).
Grid: GRID_W=88 × GRID_H=40 particles, spaced CELL_W = AWIDTH/(GRID_W-1), CELL_H = AHEIGHT/(GRID_H-1) apart over an 1080×480 area. Each particle is one character cell.
Particle state — no explicit velocity vector is trusted; velocity is inferred each step from the position delta (this is Verlet's whole trick: implicit velocity = free damping, unconditionally stable, no exploding integrators):
// particle: {pos:{x,y}, oldPos:{x,y}, acc:{x,y}, pinned, char}
function integrate(p, dt) {
if (p.pinned) { p.acc = {x:0,y:0}; return; }
const vx = (p.pos.x - p.oldPos.x) * DAMPING; // DAMPING = 0.99
const vy = (p.pos.y - p.oldPos.y) * DAMPING;
p.oldPos = { x: p.pos.x, y: p.pos.y };
const dd = dt*dt;
const gy = GRAVITY / dd; // GRAVITY = 0.18, dt-normalized so gravity feels frame-rate independent
p.pos.x += vx + p.acc.x*dd;
p.pos.y += vy + (p.acc.y + gy)*dd;
p.acc = {x:0, y:0};
}
Note the GRAVITY/dd and acc*dd pattern — this is deliberately scaling constant acceleration by dt² so the sim is dt-aware despite Verlet not carrying an explicit velocity term. dt is clamped Math.min(33, tNow-lastT) to prevent a tab-switch stall from launching the cloth.
Constraints (distance constraints, solved via Gauss-Seidel relaxation, ITER=10 passes/frame — more iterations = stiffer cloth, this is the classic PBD trade of stiffness for iteration count):
-
Vertical (
downCon): rest lengthCELL_H,min = CELL_H*0.02(COMPRESS),max = CELL_H*1.10(STRETCH) — nearly rigid vertically (this is what keeps letters upright and unsmeared). -
Horizontal: rest length
CELL_W,min = CELL_W*0.60,max = CELL_W*4.0— deliberately loose, letting the cloth billow/pool horizontally while text stays legible top-to-bottom.
function solve(con) {
const dx = con.p2.pos.x - con.p1.pos.x, dy = con.p2.pos.y - con.p1.pos.y;
const d = Math.hypot(dx, dy);
if (d === 0) return;
let target = con.length;
if (d < con.min) target = con.min;
else if (d > con.max) target = con.max;
else return; // inside the slack band → no correction (this is the "cloth" softness)
const pct = (target - d)/d * 0.5;
const ox = dx*pct, oy = dy*pct;
if (!con.p1.pinned) { con.p1.pos.x -= ox; con.p1.pos.y -= oy; }
if (!con.p2.pinned) { con.p2.pos.x += ox; con.p2.pos.y += oy; }
}
Key insight for a builder: this is not a rigid-rod constraint (d !== target always corrects) — it's a slack band. Below min or above max it snaps back; inside the band it's free. That's what gives the drape its "loose cloth" rather than "rigid mesh" character, and it's cheap (early-return skips most pairs most frames).
Top row (j===0) is pinned and its x is force-reset to pinBaseX[i] every frame — this cloth never blows in wind; it hangs dead still until touched.
2. Pointer interaction (radial repulsion field)
const dx = mouseX - p.pos.x, dy = mouseY - p.pos.y;
const ls = dx*dx + dy*dy; // squared dist, avoid sqrt in the gate
if (ls < MOUSE_SIZE && ls > 1) { // MOUSE_SIZE = 6000 (px² radius)
const a = Math.atan2(dy, dx) - Math.PI; // point AWAY from cursor
const str = smoothstep(MOUSE_SIZE, -2000, ls) * MOUSE_STRENGTH / 300; // MOUSE_STRENGTH = 4.2
p.acc.x += Math.cos(a)*str;
p.acc.y += Math.sin(a)*str;
}
smoothstep(6000, -2000, ls) is an inverted falloff curve (edge0 > edge1) — force is near-zero at the boundary, ramps smoothly to max near center, no hard cutoff or discontinuity. There's also a grab: pointerdown within dist²<400 of a particle pins it to the pointer for direct dragging (temporarily overriding pinned), released on pointerup/cancel/blur. This is a textbook example of "the pointer perturbs, never drives" — remove all pointer listeners and the cloth still integrates, settles, and renders correctly; it just never gets touched.
3. Rendering text through the cloth (the atlas trick)
Each glyph is pre-rendered once to an offscreen canvas at build time — never fillText per-frame per-particle (would be catastrophic: 88×40=3520 fillText calls/frame). Instead:
-
Build a
Setof unique characters in the source string. -
For each character, render N discrete energy levels into stamps (this repo uses 3 levels + 1 cyan-core variant): a chromatic-split RGB stamp (red offset -0.5px, cyan offset +0.5px, white center,
globalCompositeOperation='lighter'so the offsets add light rather than occlude) at increasing opacity/blur per level. -
Per particle per frame: pick the level from an "energy" scalar (
p.energy = p.energy*0.92 + speed*0.18, an EMA of local motion speed) — still particles render faint/thin, disturbed particles render bright/bold. This is how "the cloth glows where touched" without any explicit lighting model — it's literally velocity-as-brightness. -
Draw the pre-rendered stamp with a rotation matrix derived from the local
downConedge direction — each glyph tilts to match the cloth's local slope, which is what sells "printed on fabric" rather than "grid of independent sprites":
let cos=1, sin=0;
if (p.downCon) {
const dx = p.downCon.p2.pos.x - p.downCon.p1.pos.x;
const dy = p.downCon.p2.pos.y - p.downCon.p1.pos.y;
const ang = Math.atan2(dy, dx) - Math.PI/2;
cos = Math.cos(ang); sin = Math.sin(ang);
}
ctx.setTransform(cos, sin, -sin, cos, (p.pos.x+ox)*dpr, (p.pos.y+oy)*dpr);
ctx.scale(dpr, dpr);
ctx.drawImage(stamp, -half, -half, size, size);
setTransform (absolute, not save/rotate/restore) avoids a matrix-stack push/pop per glyph — meaningfully cheaper at 3500 draws/frame.
Canvas is cleared with clearRect, never filled — the transparency is what lets the phantom mark and background shader show through the gaps between glyphs. This is fundamental to the two-layer look and trivially portable.
4. The phantom mark — breathe / drift / lean
This lives in HeroComposition.tsx, is CSS only (not canvas), and layers behind the cloth (z-index:1 vs cloth's z-index:2) so it shows through the gaps.
-
Breathe: not shown in the excerpt above but implied by class
.phantom-mark(referenced, defined in the stylesheet — not present in these files, but by convention in this codebase it's a CSS@keyframesopacity/scale pulse). For a portable version, use the canon's ownsin^4(t*1.55)pulse rather than inventing a new curve — it satisfies "no elastic/overshoot" for free:
const pulse = Math.pow(Math.sin(t * 1.55), 4); // sharp systole, long diastole, no bounce
const breatheOpacity = baseOpacity * (0.85 + 0.15*pulse);
const breatheScale = 1 + 0.015*pulse;
-
Drift: driven by scroll fraction within the first viewport,
drift = -36 * scrollFractionpx oftranslateY,phantomOpacity = 0.38*(1 - scroll*0.6)— mark rises and fades as you scroll past the hero. Pure linear function of one scalar input; trivially portable to a scroll listener with rAF-batched reads (already done here viarequestAnimationFrameinside the scroll handler, cancelling the previous rAF each time — cheap and correct). -
Lean: on hover,
split(the chromatic-split pixel offset in the text-shadow) doubles from 2px to 4px —textShadow: -${split}px 0 rgba(255,55,55,.28), ${split}px 0 rgba(60,220,230,.28). That's the entire "lean" — a widening red/cyan fringe, transitioned over0.4s var(--ease-out). No actual geometric skew; the "lean" is chromatic, not spatial. Fully reproducible in Canvas 2D by drawing the glyph twice at ±split with red/cyan fill andlightercomposite, exactly like the atlas technique above. -
Reduced motion:
drift=0,phantomOpacityfixed at0.38,transition:'none'. This is the "freeze at a static correct frame" pattern — underprefers-reduced-motionyou should replicate by fixingt=3.0in the pulse formula (sin(3.0*1.55)^4 ≈ sin(4.65)^4, a specific non-zero, non-arbitrary frame) rather than snapping tot=0.
Portable core (vanilla, no React):
function updatePhantom(mark, t, scrollFrac, hovered, reduced) {
const pulse = reduced ? Math.pow(Math.sin(3.0*1.55), 4) : Math.pow(Math.sin(t*1.55), 4);
const drift = reduced ? 0 : scrollFrac * -36;
const opacity = reduced ? 0.38 : 0.38 * (1 - scrollFrac*0.6) * (0.85+0.15*pulse);
const split = hovered ? 4 : 2;
mark.style.transform = `translateY(${drift}px) scale(${1+0.015*(reduced?pulse:pulse)})`;
mark.style.opacity = opacity;
mark.style.textShadow = `-${split}px 0 rgba(255,55,55,.28), ${split}px 0 rgba(60,220,230,.28)`;
}
5. Steganography module (lib/steganography.ts)
Two independently-portable pure functions, no DOM/React dependency at all — copy verbatim:
-
encodeCyanCells(message, gridW, gridH, margin): deterministically maps each character of a secret string to one grid cell, spaced roughly evenly across the interior (margin-excluded) grid area, in column-major order (idx = col*innerH + row). Step size =max(4, floor(total/n))so characters spread out rather than clustering. This is a real "steganography" only in the loosest sense — it's message-to-coordinate placement, not bit-encoding into pixel LSBs or anything cryptographic. It's fully deterministic (no RNG at all) so it satisfies "same seed, same output" trivially — there's no seed because there's no randomness. -
decodeCyanCells(cells, charAt): reads the character already present at each encoded cell (the cell's character comes from the cloth's own source text — the SOURCE string modulo grid index — not from the message itself) and joins them. So the "hidden message" is spelled out by which existing cloth letters get marked cyan, not by injecting new characters. Clever but subtle: verify in your port thatcharAt(col,row)addresses the same particle indexing scheme asbuild()uses (i*GRID_H+jhere, i.e. column-major storage matches column-major reveal order — not a coincidence). -
sha256: trivialcrypto.subtle.digestwrapper, portable as-is (Web Crypto is available in any modern browser, no polyfill needed).
In ClothManifesto.tsx, the render loop picks level=3 (a distinct "cyan-core" atlas stamp, saturated rgba(0,240,255,0.95), not the red/cyan split of ordinary levels — pure essence-cyan, single color, printed dead-center with no offset) for any particle whose grid cell is in the cyanSet, overriding the energy-driven level entirely. Direct violation risk for the canon: essence #00f0ff is supposed to mark "LIVE elements only" per the laws you're building under — a static hidden-message glyph rendered in pure essence cyan is arguably not "live." If you port this, either (a) reserve essence for elements that are genuinely reactive to state/time, and render the hidden-cell reveal in --sigil-text or witness/signal-at-full instead, or (b) accept it only if the cyan cells are gated behind a live/interactive reveal (as this component does via the hover zones) so the cyan appearance itself is conditional on live user action — that's the more defensible reading and matches how zesign already gates it (Layer C: hover-to-reveal, not always-on).
Three-layer system as documented, in case you replicate the full puzzle:
-
Layer A: prime-directive text embedded in prose, referenced from
lib/voice.ts(not read here — check if porting). - Layer B: the cyan-cell constellation above.
-
Layer C: named hover-reveal zones (
ZONESarray —{key,x,y,w,h,text}fractional rects over the cloth) that reveal a text fragment on hover/focus,role="button" tabIndex={0}with Enter/Space/Escape keyboard parity. Finding all 3 zones triggers asha256(PRIME_DIRECTIVE)final reveal. This is straightforward DOM overlay work, fully portable, and already accessible (keyboard-operable,aria-label).
6. What's portable vs. not, concretely
Directly portable, no changes needed: the Verlet integrator, the constraint solver, the atlas pre-render technique, the pointer repulsion field, encodeCyanCells/decodeCyanCells/sha256, the hover-zone overlay pattern, useActiveRaf's IntersectionObserver+visibilitychange+reduced-motion gating logic (rewrite as a plain function, not a hook — same three conditions: in-view, tab-visible, not-reduced).
Needs adaptation:
-
useActiveRaf/useReducedMotionare React hooks — trivially rewritten as a plain closure-based rAF gate (see below) since the underlying logic (IntersectionObserver +visibilitychange+matchMedialisteners) has zero React dependency. -
Font loading (
document.fonts.load) — keep as-is, it's a Web API, but in a single-file artifact you'll likely@font-facea Departure-family fallback or accept the"Courier New",monospacefallback chain already present, since the doctrine says Departure "does NOT carry" certain glyphs and you can't guarantee the real font file is embeddable within the artifact's asset rules. Decide up front whether you're inlining a font as a data-URI (size cost) or falling back tomonospace— the cloth's chromatic-split rendering reads fine either way since it's about mark density, not font fidelity. -
MiniClothHeader's "settle and stop" pattern (tracksavgMotionoverSETTLE_REQUIRED=24consecutive low-motion frames, then callsdraw()once more and returns without requeuing rAF) is a genuinely good technique for a static decorative cloth strip that shouldn't burn a permanent rAF loop — port this pattern for any secondary/header cloth instance, only run the main hero cloth on a permanent loop since it's pointer-reactive.
Vanilla rAF gate (replacing useActiveRaf):
function activeRaf(cb, el, { rootMargin='200px', reduced=false } = {}) {
if (reduced) { requestAnimationFrame(cb); return () => {}; }
let raf=0, inView=false, running=false;
const tick = t => { if(!running) return; cb(t); raf=requestAnimationFrame(tick); };
const start = () => { if(running) return; running=true; raf=requestAnimationFrame(tick); };
const stop = () => { running=false; cancelAnimationFrame(raf); };
const sync = () => (inView && document.visibilityState==='visible') ? start() : stop();
const io = new IntersectionObserver(([e]) => { inView=e.isIntersecting; sync(); }, {rootMargin});
io.observe(el);
document.addEventListener('visibilitychange', sync);
return () => { stop(); io.disconnect(); document.removeEventListener('visibilitychange', sync); };
}
Not portable / needs a hard decision:
-
Anything React state-driven (hover zone
useState, scrolluseState) is fine to keep as plain JS variables mutated in event listeners — no loss. -
OnebitWarpBg's shader is already a separate concern (the field.BEFORE.glsl you were handed is presumably this or similar) — reuse structurally but note it already violates nothing: single essence-independent chromatic split viavec3(255,55,55)/(60,220,230)-style RGB (which map towarm/coldtokens in the palette, not arbitrary hexes) and a single Bayer8 1-bit threshold, consistent with §09/§07.
7. Chaos-magic sigil construction (Austin Osman Spare's method)
Spare's technique (from his automatic-drawing/"Atavistic Resurgence" writings, early 20th c., popularized in modern chaos magic via Peter Carroll's Liber Null): take a stated intent, strip it to its irreducible letter-set, then compose that letter-set into a single interlocking glyph so the original semantic content is visually destroyed — the sigil is meant to be charged and then forgotten, its meaning inaccessible to the conscious mind.
Algorithm:
- Normalize: uppercase, strip non-letters (or keep a controlled symbol set if you want numerology extensions — skip that here for glyph purity).
- Strip vowels: remove A E I O U (classic Spare method omits vowels to keep the glyph angular and consonant-dense — an aesthetic/practical choice, not universal across all sigil systems, but it's specifically what you were asked for).
- Deduplicate letters: keep first occurrence only, drop repeats — this is the step that actually breaks legibility; without it the sigil often still reads as the phrase.
- Map each remaining letter to a fixed glyph-primitive (stroke set) on a shared coordinate grid — canonically a 3×3 or 5×5 grid of anchor points, each letter of the alphabet pre-assigned a small set of line segments between anchor points (similar in spirit to a seven-segment/font-skeleton scheme, but arbitrary — Spare himself drew freehand; a programmatic version needs a fixed stroke alphabet).
- Superimpose: draw all selected letters' strokes on the same canvas region, sharing the same anchor grid, so strokes overlap and interlock rather than sitting side-by-side. This overlap is what makes it read as one glyph rather than a row of letters.
- Optional: apply a small random-but-deterministic (seeded) rotation/reflection per stroke-set to increase interlocking, or connect endpoints of consecutive letters' strokes with a continuous connecting line (some practitioners draw the letters as one unbroken path, literally lifting the pen only where needed).
Concrete 1-bit implementation (deterministic, no external font, seed = the intent string itself so "same seed, same output" holds trivially):
// 5x5 anchor grid per letter-slot; letters mapped to stroke endpoints on that grid.
// Only need strokes for consonants (post vowel-strip); extend map as needed.
const GRID = 5; // 0..4 in x and y
// Each letter: array of [x0,y0,x1,y1] stroke segments on the 0..4 grid.
// A minimal deterministic "skeleton" font — straight-line strokes only (no curves),
// satisfies the 1-bit / typographic-glyph-only constraint trivially since it's pure vector strokes.
const STROKES = {
B:[[0,0,0,4],[0,0,3,1],[0,2,3,1],[0,2,3,3],[0,4,3,3]],
C:[[3,0,0,0],[0,0,0,4],[0,4,3,4]],
D:[[0,0,0,4],[0,0,2,1],[2,1,2,3],[2,3,0,4]],
// ... one entry per consonant, hand-authored once, reused forever (deterministic)
T:[[0,0,4,0],[2,0,2,4]],
X:[[0,0,4,4],[4,0,0,4]],
// etc — build out A-Z minus vowels; treat missing letters by skipping (never invent curves)
};
function sigilLetters(intent) {
const seen = new Set();
const out = [];
for (const ch of intent.toUpperCase()) {
if (!/[A-Z]/.test(ch)) continue;
if ('AEIOU'.includes(ch)) continue;
if (seen.has(ch)) continue;
seen.add(ch);
out.push(ch);
}
return out; // deterministic given intent string — this IS the seed
}
function drawSigil(ctx, intent, cx, cy, scale, strokeColor) {
const letters = sigilLetters(intent);
ctx.strokeStyle = strokeColor; // e.g. signal at o-1, or sigil-text #cd5860 for a "named" sigil
ctx.lineWidth = 1;
ctx.lineCap = 'square'; // square, not round — matches "corners are square" doctrine
const cell = scale / GRID;
for (const ch of letters) {
const strokes = STROKES[ch];
if (!strokes) continue;
// Deterministic per-letter transform so strokes interlock rather than stack identically:
// rotate by a hash of (ch, position-in-list) — pure function of the intent string, no RNG.
const idx = letters.indexOf(ch);
const angle = ((ch.charCodeAt(0) * 37 + idx * 53) % 360) * Math.PI / 180 * 0.15; // small deterministic jitter
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(angle);
for (const [x0,y0,x1,y1] of strokes) {
ctx.beginPath();
ctx.moveTo((x0-GRID/2)*cell, (y0-GRID/2)*cell);
ctx.lineTo((x1-GRID/2)*cell, (y1-GRID/2)*cell);
ctx.stroke();
}
ctx.restore();
}
}
Design notes for fitting this under the doctrine:
- Pure straight-line strokes on a fixed grid are trivially "1-bit" and typographic-glyph-only — no curves, no icon fonts, no SVG icon sets, satisfies §07 outright.
-
Use
sigil#c2333dfor the stroke color since a sigil is instrumentation-as-line per the laws ("Sigil is instrumentation — lines"), notsigil-text(#cd5860, reserved for actual text). This is the one place the naming coincidence (chaos-magic "sigil" vs. design-system "Sigil" token) is worth being deliberate about — keep the drawn glyph in the line color, never render sigil-red as a fill. -
Determinism: the letter-stripping is already 100% deterministic (no RNG). The optional jitter-rotation above is a deterministic hash of the letter and its position, not
Math.random()— required to satisfy "same seed, same output." -
You must hand-author the
STROKEStable for at least the 21 consonants (Y is debatable — commonly kept as semi-vowel; Spare's own practice varied). This is real, unavoidable work — there's no shortcut that avoids either (a) building a stroke-skeleton alphabet by hand, or (b) rendering actual glyphs from a system font and tracing/thresholding them (more faithful to "handwritten sigil" aesthetics but pulls in font-rendering +getImageDatathresholding complexity, and technically renders literal typeface characters superimposed rather than an abstracted single glyph — arguably truer to Spare's actual practice of overlapping cursive letterforms, but heavier to implement and harder to keep readably "one mark" rather than "stacked letters"). Recommend: for a builder under time pressure, do the font-trace approach — draw each remaining consonant withctx.fillTextat 0 opacity destination-out threshold orglobalCompositeOperation='xor'to punch overlapping strokes into a shared 1-bit mask, then draw the mask's edge pixels as the sigil. This reuses the atlas machinery you already have from the cloth glyphs and avoids hand-authoring a stroke font.
Simpler fallback if hand-authoring 21 stroke-letters is too much work: use the browser's own font to rasterize each surviving consonant into a small offscreen bitmap, XOR-composite all of them into one shared bitmap centered on the same point (so overlapping ink cancels via XOR, producing genuinely interlocked, semantically-scrambled strokes), then trace/draw only the resulting 1-bit edge as your sigil stroke. This is fewer lines of code than a hand-built stroke alphabet and stays fully deterministic (same intent string → same bitmap → same edges), at the cost of relying on whatever monospace fallback the artifact ends up with for its typographic character (acceptable — Departure's excluded-glyph list ⌗ ≡ ☰ ⋮ and astrological symbols doesn't restrict plain A-Z consonants at all, so this is safe under §07).
8. What's hard / likely to break
-
Perf at 3520 particles: the atlas trick is what makes this tractable in Canvas 2D on software GL/CPU rendering (this is not WebGL, it's plain 2D context — good, since headless Playwright here has no GPU). Still,
ctx.setTransform+drawImage3520×/frame plus 10 constraint-solve iterations over ~6700 constraints (88×39 vertical + 87×40 horizontal) is real CPU work — profile early, and consider reducingITERor grid density for a single-file artifact if you're not shipping the exact 1180×560 hero size. -
DPR scaling correctness: every atlas stamp is pre-rendered at
size*dprand the main canvas context isctx.scale(dpr,dpr)'d once — but eachdrawImagecall doesctx.scale(dpr,dpr)again insidesetTransform's already-dpr-baked translate. Read this carefully before porting:setTransform(cos,sin,-sin,cos, x*dpr, y*dpr)sets translate in device pixels, thenctx.scale(dpr,dpr)scales the subsequentdrawImagecall's CSS-pixel-sized draw (-half,-half,size,sizewheresizeis in dpr-multiplied units frombuildAtlas) — this compounds correctly only becausesize = FONT_SIZE*2*dprand the stamp canvas itself was drawn atdprscale too. It's correct as written but fragile to modify; don't "simplify" the dpr handling without re-deriving the whole chain. -
Reduced-motion for the cloth specifically:
useActiveRafwithrunOnceWhenReduced=truepaints exactly one frame from whatever the particle grid's rest state is (flat, since nothing has perturbed it) — that satisfies "static and correct frame," but you must also freeze the phantom-mark pulse at the doctrine'st=3.0, nott=0, and make sure the shader background (if ported) similarly clampsu_timeto a fixed value rather than freezing whatever the last live frame happened to be (non-reproducible, breaks "same seed same output"). - Essence-cyan misuse risk: flagged above — the cyan-core stamp level is applied to static hidden-message cells, not to anything reactively "live." Under the strict reading of the law you're building to, this needs gating behind genuine interactivity (as Layer C's hover zones already do) or recoloring, or it's a violation however faithful to zesign's own original.
-
No
Math.random()anywhere in the ported physics — good, it's all deterministic already (character-to-cell mapping via modulo, not RNG). Preserve this; don't introduce randomness for "organic" variation, or you break reproducibility. Any apparent randomness in cloth motion is deterministic chaos from initial pin positions + pointer input, not RNG.