smriti/website/script.js
Himanshu Dongre 15e099b67e
Some checks failed
Deploy website to GitHub Pages / deploy (push) Has been cancelled
Recut the visual design — braided agent strands, refined palette
This pass replaces the v2 visual language with a more handcrafted,
signature-feeling system. Structure and copy are preserved; only the
visual + interaction layer is reworked.

Visual metaphor
---------------
The dominant motif is a braid of two agent strands (claude-code in
ember, codex in indigo) weaving over and under each other through
hexagonal "checkpoint stones." The stones carry short hashes; the
rightmost is "wet" — currently being written — and gets a soft ember
pulse. Braiding has cultural weight (DNA, rope, river confluence) and
reads as "structured intertwining," which is exactly what shared
reasoning state is. The same motif scales down to a taut, stretched
braid in the drift section.

Palette
-------
Refined to feel like a signature, not a template:
- Deeper cool-black `#070710` background (warm accents pop harder
  against blue-black than against grey-black).
- Ember `#ffb663` — warmer and more saturated than v2's `#f5c773`;
  campfire ember, not corporate gold. This is the Smriti color.
- Indigo `#7c8cff` — saturated, fresh; not v2's washed `#8ba0c7`.
- Aurora `#7be3b0` — third pole used sparingly for matches / done
  states, so the page isn't just ember + indigo.
- Warm off-white paper `#e9e7e2` for body text.
- Subtle SVG-noise grain overlay (5% opacity, overlay blend) gives
  the dark surface tooth.
- Three hand-placed ember-glow anchor regions (hero, centerpiece,
  CTA) signal hierarchy — not decorative gradient blobs.

Section redesigns
-----------------
- Hero — braid + 5 hexagonal stones replacing v2's circle-and-line
  timeline. Subtle 14s sway on the strands. The rightmost (wet)
  stone glows and pulses.
- Missing primitive — replaced the rigid 3-column-grid + connector
  gutter with a typographic glossary. Mapped rows get an ember dot;
  unmapped rows get an indigo dot and the "no equivalent" label.
  Reads like a hand-typeset comparison sheet, not a spreadsheet.
- HANDOFF.md vs structured — both panels now have a lined-paper
  background. The HANDOFF panel has a `COLLISION` (red) badge; the
  checkpoint panel has a `RESOLVED` (aurora) badge. The conflict
  line types in, gets struck through, and the resolution types in
  underneath on scroll-in.
- What Smriti is — checkpoint render becomes a parsed structured
  tree with labeled field/value pairs and intent/agent tag pills.
- Multi-agent coordination (centerpiece) — completely rebuilt.
  Three columns: claude-code panel (left) | task pool (center) |
  codex panel (right). Tasks are "packets" that fly from the
  central pool into the side panels via JS-animated transforms,
  with springy easing. Each agent has a working stack that
  populates as packets land. Plays once on scroll-in, replayable.
  Bonus: clicking any unclaimed packet fires it manually to
  whichever agent has fewer claims.
- Drift — repo timeline replaced with a tensile braid stretched
  between the checkpoint stone and the HEAD stone. On scroll-in,
  the HEAD stone nudges +14px right, gets the warning color, and
  the "+2 commits ahead" badge fades in.
- Built with Smriti — terminal panel now has traffic-light dots, a
  blinking caret, and ember underline animation on
  "67 cross-agent continuations" on scroll-in. Numbers are
  semantically colored (ember = main, indigo = codex, aurora =
  success rates).
- Try it — two terminals now sit on a very subtle skew axis (like
  an open laptop spread); hover snaps them straight.
- Brand mark — replaced the dot-grid with a hexagonal stone and two
  woven strand-threads. Soft ember pulse every ~6s.

Motion
------
Tasteful, per-section motion treatments rather than uniform fade-up.
Slow braid sway on hero. Live-typing markdown collision. Springy
packet flight. Drift stone nudge. CLI caret blink. Brand mark pulse.
SVG path draw-in. Honors `prefers-reduced-motion: reduce` — all
animations skip, every element lands in its terminal state, carets
go static.

Implementation
--------------
Still pure HTML + CSS + ~330 lines of vanilla JS. No framework, no
build step, no webfont, no third-party requests. IntersectionObserver
thresholds tuned to 0.18 so animations fire reliably on mobile (tall
stacked sections were below the previous 0.35–0.45 thresholds).
Existing GitHub Pages workflow uploads ./website unchanged.

Mobile responsive at 980 / 720 / 420 breakpoints. Theatre stacks
vertically on mobile, packets still fly to the correct positions
via computed bounding rects. Nav collapses to the GitHub CTA on
small screens.
2026-05-21 13:06:38 +05:30

425 lines
14 KiB
JavaScript

/* =============================================================
Smriti landing page — interactions (v2.1)
No dependencies. Five responsibilities:
1. Reveal-on-scroll for .reveal sections.
2. Drift canvas — toggles .is-drifted on scroll-in (CSS handles the rest).
3. Terminal panel — toggles .is-revealed for the highlight underline.
4. Markdown surface — types the conflict line, strikes it through,
types the resolution. On scroll-in. Replayable.
5. Protocol theatre — 3-column live coordination demo. Tasks fly from
the central pool into the side agent panels as packets, settle into
work-stacks. Replay button. Click-to-fire bonus interaction.
Honors prefers-reduced-motion: skips animations and lands every element
in its terminal state.
============================================================= */
(function () {
"use strict";
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// ── 1. Reveal-on-scroll ────────────────────────────────────────
const revealEls = document.querySelectorAll(".reveal");
if (reduced || !("IntersectionObserver" in window)) {
revealEls.forEach((el) => el.classList.add("in"));
} else {
const revealObs = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add("in");
revealObs.unobserve(entry.target);
}
});
},
{ threshold: 0.1, rootMargin: "0px 0px -8% 0px" }
);
revealEls.forEach((el) => revealObs.observe(el));
}
// ── 2. Drift canvas toggle ────────────────────────────────────
const drift = document.querySelector("[data-drift]");
if (drift) {
if (reduced || !("IntersectionObserver" in window)) {
drift.classList.add("is-drifted");
} else {
const driftObs = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setTimeout(() => drift.classList.add("is-drifted"), 400);
driftObs.unobserve(entry.target);
}
});
},
{ threshold: 0.18 }
);
driftObs.observe(drift);
}
}
// ── 3. Terminal reveal toggle ──────────────────────────────────
const terminal = document.querySelector("[data-terminal]");
if (terminal) {
if (reduced || !("IntersectionObserver" in window)) {
terminal.classList.add("is-revealed");
} else {
const termObs = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
terminal.classList.add("is-revealed");
termObs.unobserve(entry.target);
}
});
},
{ threshold: 0.18 }
);
termObs.observe(terminal);
}
}
// ── 4. Markdown surface — type the collision, strike, resolve ───
const mdSurface = document.querySelector("[data-md-surface]");
if (mdSurface) {
const conflict = mdSurface.querySelector("[data-md-conflict]");
const resolution = mdSurface.querySelector("[data-md-resolution]");
const conflictText = conflict ? conflict.textContent : "";
const resolutionText = resolution
? resolution.textContent.replace(/\s*$/, "")
: "";
function resetMd() {
if (conflict) {
conflict.textContent = conflictText;
conflict.classList.remove("struck");
conflict.style.opacity = "0";
}
if (resolution) {
resolution.innerHTML = '<span class="typing-caret"></span>';
resolution.style.opacity = "1";
}
}
function playMd() {
if (reduced) {
// Land in resolved state directly.
if (conflict) {
conflict.textContent = conflictText;
conflict.classList.add("struck");
conflict.style.opacity = "0.5";
}
if (resolution) {
resolution.innerHTML =
resolutionText + '<span class="typing-caret"></span>';
}
return;
}
resetMd();
// Phase 1: fade conflict in.
setTimeout(() => {
if (conflict) {
conflict.style.transition = "opacity 350ms";
conflict.style.opacity = "1";
}
}, 250);
// Phase 2: strike it.
setTimeout(() => {
if (conflict) conflict.classList.add("struck");
}, 1400);
// Phase 3: type the resolution.
setTimeout(() => {
if (!resolution) return;
let i = 0;
const caretHtml = '<span class="typing-caret"></span>';
const tick = () => {
if (i <= resolutionText.length) {
resolution.innerHTML = resolutionText.slice(0, i) + caretHtml;
i++;
setTimeout(tick, 28);
}
};
tick();
}, 2050);
}
if (reduced || !("IntersectionObserver" in window)) {
playMd();
} else {
const mdObs = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
playMd();
mdObs.unobserve(entry.target);
}
});
},
{ threshold: 0.18 }
);
mdObs.observe(mdSurface);
}
}
// ── 5. Protocol theatre ────────────────────────────────────────
const theatre = document.querySelector("[data-theatre]");
if (!theatre) return;
const pool = theatre.querySelector("[data-pool]");
const claudePanel = theatre.querySelector('[data-agent="claude"]');
const codexPanel = theatre.querySelector('[data-agent="codex"]');
const claudeStack = theatre.querySelector('[data-work-stack="claude"]');
const codexStack = theatre.querySelector('[data-work-stack="codex"]');
const claudeStatus = claudePanel.querySelector("[data-agent-status]");
const codexStatus = codexPanel.querySelector("[data-agent-status]");
const stepText = theatre.querySelector("[data-step-text]");
const stepCounter = theatre.querySelector("[data-step-counter]");
const replayBtn = theatre.querySelector("[data-theatre-replay]");
function getPackets() {
return Array.from(pool.querySelectorAll(".packet"));
}
function setStatus(agent, text) {
const el = agent === "claude" ? claudeStatus : codexStatus;
if (el) el.textContent = text;
}
function clearStacks() {
claudeStack.innerHTML = "";
codexStack.innerHTML = "";
}
function resetTheatre() {
[claudePanel, codexPanel].forEach((p) => p.classList.remove("is-active"));
setStatus("claude", "idle");
setStatus("codex", "idle");
clearStacks();
getPackets().forEach((p) =>
p.classList.remove("claimed-claude", "claimed-codex", "dimmed")
);
if (stepCounter) stepCounter.textContent = "0 / 5";
}
function makeWorkItem(intent, name, pid, agent) {
const li = document.createElement("li");
li.className = "packet claimed-" + agent;
li.innerHTML =
'<span class="intent ' + intent + '">' + intent + "</span>" +
'<span class="name">' + name + "</span>" +
'<span class="pid">' + pid + "</span>";
li.style.opacity = "0";
li.style.transform = "translateY(6px)";
li.style.transition = "opacity 300ms, transform 300ms";
return li;
}
// Animate a packet flying from the pool into an agent's work-stack.
// Returns a Promise that resolves when the packet has settled.
function flyPacket(packet, agent) {
return new Promise((resolve) => {
const targetStack = agent === "claude" ? claudeStack : codexStack;
const intent = packet.dataset.intent;
const name = packet.querySelector(".name").textContent;
const pid = packet.querySelector(".pid").textContent;
// Mark the pool packet as claimed (color tint + still in pool).
const claimClass = "claimed-" + agent;
packet.classList.add(claimClass);
if (reduced) {
const li = makeWorkItem(intent, name, pid, agent);
targetStack.appendChild(li);
requestAnimationFrame(() => {
li.style.opacity = "1";
li.style.transform = "translateY(0)";
});
resolve();
return;
}
const theatreRect = theatre.getBoundingClientRect();
const srcRect = packet.getBoundingClientRect();
// Compute destination: top of the target stack, offset by current
// child count to stack vertically.
const stackRect = targetStack.getBoundingClientRect();
const dstX = stackRect.left - theatreRect.left;
const dstY = stackRect.top - theatreRect.top
+ targetStack.childElementCount * 40;
// Build the flying packet — a clone of the original styled identically.
const fly = document.createElement("div");
fly.className = "packet-flying packet " + claimClass;
fly.style.left = (srcRect.left - theatreRect.left) + "px";
fly.style.top = (srcRect.top - theatreRect.top) + "px";
fly.style.width = srcRect.width + "px";
fly.innerHTML =
'<span class="intent ' + intent + '">' + intent + "</span>" +
'<span class="name">' + name + "</span>" +
'<span class="pid">' + pid + "</span>";
theatre.appendChild(fly);
// Force layout flush, then transition to destination.
// eslint-disable-next-line no-unused-expressions
fly.offsetWidth;
const deltaX = dstX - (srcRect.left - theatreRect.left);
const deltaY = dstY - (srcRect.top - theatreRect.top);
requestAnimationFrame(() => {
fly.style.transform =
"translate(" + deltaX + "px, " + deltaY + "px) scale(0.98)";
});
let resolved = false;
const finalize = () => {
if (resolved) return;
resolved = true;
const li = makeWorkItem(intent, name, pid, agent);
targetStack.appendChild(li);
requestAnimationFrame(() => {
li.style.opacity = "1";
li.style.transform = "translateY(0)";
});
fly.remove();
resolve();
};
fly.addEventListener("transitionend", finalize, { once: true });
// Fallback in case transitionend doesn't fire (e.g. tab hidden).
setTimeout(finalize, 1700);
});
}
function setCaption(html, n) {
if (stepText) stepText.innerHTML = html;
if (stepCounter) stepCounter.textContent = n + " / 5";
}
// ── Scripted 5-step play ───────────────────────────────────────
let playing = false;
let playToken = 0;
function wait(ms) {
return new Promise((r) => setTimeout(r, ms));
}
async function play() {
if (playing) return;
playing = true;
const myToken = ++playToken;
resetTheatre();
const stepDelay = reduced ? 0 : 1400;
const aborted = () => myToken !== playToken;
// Step 1
setCaption('<span class="muted">claude-code reads the pool.</span>', 1);
claudePanel.classList.add("is-active");
setStatus("claude", "reading");
await wait(stepDelay);
if (aborted()) { playing = false; return; }
// Step 2 — fly t-2 to claude
const t2 = pool.querySelector('[data-task="t-2"]');
setCaption(
'claude-code claims <strong>[implement] deletion-safety-guard</strong>.',
2
);
if (t2) await flyPacket(t2, "claude");
setStatus("claude", "working");
await wait(reduced ? 0 : 600);
if (aborted()) { playing = false; return; }
// Step 3
setCaption(
'<span class="muted">codex reads the pool — sees claude-code\'s claim.</span>',
3
);
claudePanel.classList.remove("is-active");
codexPanel.classList.add("is-active");
setStatus("codex", "reading");
await wait(stepDelay);
if (aborted()) { playing = false; return; }
// Step 4 — fly t-1 to codex
const t1 = pool.querySelector('[data-task="t-1"]');
setCaption(
'codex picks complementary work — <strong>[test] deletion-safety-tests</strong>.',
4
);
if (t1) await flyPacket(t1, "codex");
setStatus("codex", "working");
await wait(reduced ? 0 : 600);
if (aborted()) { playing = false; return; }
// Step 5 — settle, dim remaining
setCaption(
'Different agents. Different work. <span class="muted">Coordination through shared state.</span>',
5
);
codexPanel.classList.remove("is-active");
const t3 = pool.querySelector('[data-task="t-3"]');
const t4 = pool.querySelector('[data-task="t-4"]');
if (t3) t3.classList.add("dimmed");
if (t4) t4.classList.add("dimmed");
playing = false;
}
// ── Click-to-fire bonus interaction ────────────────────────────
// Dimmed (passed-over) packets stay interactive — clicking re-activates
// them. Already-claimed packets are inert. Mid-play clicks are ignored.
pool.addEventListener("click", (e) => {
const packet = e.target.closest(".packet");
if (!packet) return;
if (
packet.classList.contains("claimed-claude") ||
packet.classList.contains("claimed-codex")
) {
return;
}
if (playing) return;
// Re-activate if dimmed.
packet.classList.remove("dimmed");
const claudeCount = claudeStack.childElementCount;
const codexCount = codexStack.childElementCount;
const agent = codexCount < claudeCount ? "codex" : "claude";
flyPacket(packet, agent);
});
// ── Replay button ──────────────────────────────────────────────
if (replayBtn) {
replayBtn.addEventListener("click", () => {
playing = false;
playToken++;
requestAnimationFrame(() => play());
});
}
// ── Auto-play once on scroll-into-view ─────────────────────────
if (reduced || !("IntersectionObserver" in window)) {
play();
} else {
let hasPlayed = false;
const playObs = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !hasPlayed) {
hasPlayed = true;
play();
playObs.unobserve(entry.target);
}
});
},
{ threshold: 0.18 }
);
playObs.observe(theatre);
}
})();