diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..e29b329 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,24 @@ +name: Site quality + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + static: + name: Static integrity + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - name: Check tracked text + run: python3 scripts/check_text.py + - name: Check local links and semantics + run: python3 scripts/check_site.py + - name: Check JavaScript syntax + run: | + node --check field.js + node --check motion.js diff --git a/assets/architecture.svg b/assets/architecture.svg deleted file mode 100644 index 62589e6..0000000 --- a/assets/architecture.svg +++ /dev/null @@ -1,31 +0,0 @@ - - GraphABI architecture as semantic flow - Framework adapters emit versioned traces, contracts check edge observations, impact analysis traces affected nodes, and reports explain a witness and repair location. - - - - FRAMEWORK-INDEPENDENT SEMANTIC COMPATIBILITY PIPELINE - - - FLOWAdaptersLangGraph today - RECORDTrace modelSQLite · JSONL - CHECKContractsEvaluator registry - TRACEImpactNetworkX paths - EXPLAINReportsJSON · HTML - - - - core boundary - TraceBundle 0.1 - - CompatibilityReport 0.1 - - deterministic evidence - - Framework types stop at adapters. Report presentation never decides compatibility. - diff --git a/assets/logo-dark.svg b/assets/logo-dark.svg deleted file mode 100644 index 1deb7f0..0000000 --- a/assets/logo-dark.svg +++ /dev/null @@ -1,10 +0,0 @@ - - GraphABI for dark backgrounds - Light GraphABI wordmark with a broken semantic edge. - - - - - - GraphABI - diff --git a/assets/report-preview.svg b/assets/report-preview.svg deleted file mode 100644 index 8a26e40..0000000 --- a/assets/report-preview.svg +++ /dev/null @@ -1,33 +0,0 @@ - - GraphABI trace-backed compatibility report - The report shows structural compatibility passing, semantic compatibility breaking, the first broken edge, its witness, downstream path, and repair location. - - - - GraphABI report - RESEARCH_DEMO · CANDIDATE-003 · REPORT 0.1 - The schema passed. Meaning broke at the first edge. - STRUCTURAL✓ PASS - SEMANTIC× BREAKING - FIRST BREAKING EDGEresearcher → verifier3 BREAKING - - - - researcherproducer - verifierdirect impact - decision_makertransitive - publisherterminal · side effect - - - - - - TRACE-BACKED WITNESS · VERIFIED_REQUIRES_OPENED_SUPPORTING_SOURCE - Candidate asserted verification after source access failed. - EXPECTATIONopened_sources_count > 0 - OBSERVED · CANDIDATE-003opened_sources_count = 0 - - NEAREST REPAIRBefore verifier consumes the researcher result. - AFFECTED TERMINAL PATHverifier → decision_maker → publisher - - diff --git a/field.js b/field.js new file mode 100644 index 0000000..ce2346f --- /dev/null +++ b/field.js @@ -0,0 +1,633 @@ +/* ============================================================================= + GraphABI: the ambient field + ----------------------------------------------------------------------------- + A live force-directed graph: nodes drift under repulsion and edge springs, + the topology rewires itself, and neutral signals travel the edges. The field + is possibility, not evidence. It never renders a pass, break, blast radius, + witness, or other product conclusion. + + Canvas 2D, no dependencies. Three.js would buy nothing here and cost ~600 KB + against a documented performance budget; the interesting work is the + simulation, not the rasteriser. + ========================================================================== */ + +(() => { + "use strict"; + + const canvas = document.querySelector("[data-field]"); + if (!canvas || !canvas.getContext) return; + + const ctx = canvas.getContext("2d", { alpha: true }); + const reduced = window.matchMedia("(prefers-reduced-motion: reduce)"); + const dark = window.matchMedia("(prefers-color-scheme: dark)"); + const finePointer = window.matchMedia("(hover: hover) and (pointer: fine)"); + const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection; + const constrained = Boolean( + (navigator.hardwareConcurrency && navigator.hardwareConcurrency <= 4) || + (navigator.deviceMemory && navigator.deviceMemory <= 4) || + (connection && connection.saveData) + ); + + /* ---------------------------------------------------------- palette --- */ + + const C = {}; + const readPalette = () => { + const s = getComputedStyle(document.documentElement); + const rgb = (name, fallback) => { + const v = s.getPropertyValue(name).trim() || fallback; + const h = v.replace("#", ""); + const n = h.length === 3 ? h.split("").map((c) => c + c).join("") : h; + const i = parseInt(n, 16); + return [(i >> 16) & 255, (i >> 8) & 255, i & 255]; + }; + C.edge = rgb("--edge", "#556070"); + C.pulse = rgb("--pulse", "#A78BFA"); + C.isDark = dark.matches; + // A trace of violet in the mesh: inactive structure still belongs to the + // same system as the meaning flowing over it. + C.mesh = C.edge.map((v, i) => Math.round(v * 0.78 + C.pulse[i] * 0.22)); + }; + const rgba = (c, a) => `rgba(${c[0]},${c[1]},${c[2]},${a})`; + + /* ------------------------------------------------------------ state --- */ + + let W = 0, H = 0, dpr = 1; + let nodes = []; + let edges = []; + let pulses = []; + let obstacles = []; + let scrollY = window.scrollY || 0; + let pointerX = -9999, pointerY = -9999; + let pointerVX = 0, pointerVY = 0, pointerSpeed = 0, pointerEnergy = 0; + let pointerLastX = 0, pointerLastY = 0, pointerLastAt = 0; + let pointerFine = 0, pointerTouch = 0, touchScroll = 0; + let touchStartX = 0, touchStartY = 0; + let touchReleaseTimer = 0; + let raf = 0; + let running = false; + let last = 0; + let lastFrameAt = 0; + let mutateAt = 0; + let spawnAt = 0; + let constellationAt = 0; + let splitAt = 0; + let topologyMode = 0; + + const rand = (a, b) => a + Math.random() * (b - a); + const pick = (arr) => arr[(Math.random() * arr.length) | 0]; + + /* ------------------------------------------------------ build graph --- */ + + const REST = 132; + + const connected = (a, b) => + edges.some((e) => (e.a === a && e.b === b) || (e.a === b && e.b === a)); + + const degree = (i) => edges.reduce((n, e) => n + (e.a === i || e.b === i ? 1 : 0), 0); + + const addEdge = (a, b, life, expiresAt) => { + edges.push({ + a, b, + // A fixed per-edge bow, so curvature is stable while the nodes move. + bow: rand(-0.3, 0.3), + life: life === undefined ? 1 : life, + target: 1, + expiresAt: expiresAt || 0, + }); + }; + + const build = () => { + const area = W * H; + const budget = constrained ? 0.62 : 1; + const count = Math.max(16, Math.min(constrained ? 38 : 64, Math.round(area / 23000 * budget))); + nodes = []; + edges = []; + pulses = []; + + // Relaxed scatter: seed on a jittered grid so the field starts even + // rather than clumped, then let the simulation take over. + const cols = Math.ceil(Math.sqrt(count * (W / H))); + const rows = Math.ceil(count / cols); + for (let i = 0; i < count; i++) { + const cx = ((i % cols) + 0.5) / cols; + const cy = (((i / cols) | 0) + 0.5) / rows; + nodes.push({ + x: cx * W + rand(-W / cols / 2.4, W / cols / 2.4), + y: cy * H + rand(-H / rows / 2.4, H / rows / 2.4), + vx: 0, vy: 0, + // Independent slow wander keeps the field breathing once forces settle. + px: rand(0, Math.PI * 2), py: rand(0, Math.PI * 2), + ps: rand(0.12, 0.3), + // Depth. Near nodes are larger, brighter and drift further; far ones + // sit back. It reads as a volume rather than a flat wireframe. + z: Math.random(), + r: 0, + }); + const n = nodes[nodes.length - 1]; + n.r = 1.1 + n.z * 1.7; + } + + // k-nearest wiring, degree-capped so no node becomes a hub. + for (let i = 0; i < nodes.length; i++) { + const near = nodes + .map((n, j) => ({ j, d: Math.hypot(n.x - nodes[i].x, n.y - nodes[i].y) })) + .filter((o) => o.j !== i) + .sort((p, q) => p.d - q.d) + .slice(0, 2); + for (const o of near) { + if (degree(i) >= 3 || degree(o.j) >= 3) continue; + if (!connected(i, o.j) && Math.random() < 0.86) addEdge(i, o.j); + } + } + }; + + // Edges are handed out by reference, never by index: topology mutation + // splices the array while pulses are still travelling across it. + const neighbours = (i) => { + const out = []; + for (const e of edges) { + if (e.life < 0.6 || e.target === 0) continue; + if (e.a === i) out.push({ edge: e, to: e.b }); + else if (e.b === i) out.push({ edge: e, to: e.a }); + } + return out; + }; + + /* ------------------------------------------------------- obstacles --- */ + + // Cached in document coordinates, so the per-frame cost is one subtraction + // and never a forced layout. + const OBSTACLE_SELECTOR = "h1, .hero-lede, .hero-actions, .install-line, .section-heading, .pulse-scene"; + + const measure = () => { + const top = window.scrollY; + obstacles = [...document.querySelectorAll(OBSTACLE_SELECTOR)].map((el) => { + const r = el.getBoundingClientRect(); + return { x: r.left - 28, y: r.top + top - 28, w: r.width + 56, h: r.height + 56 }; + }); + }; + + /* ---------------------------------------------------------- physics --- */ + + const step = (dt, t) => { + const n = nodes.length; + pointerEnergy += ((pointerFine || pointerTouch ? 1 : 0) - pointerEnergy) * Math.min(1, dt * 0.09); + pointerSpeed *= Math.pow(0.89, dt); + + // Node-node repulsion. O(n^2) is genuinely cheaper than a spatial index + // at this scale, and the constant factor is what matters at 60 fps. + for (let i = 0; i < n; i++) { + const a = nodes[i]; + for (let j = i + 1; j < n; j++) { + const b = nodes[j]; + let dx = b.x - a.x, dy = b.y - a.y; + let d2 = dx * dx + dy * dy; + if (d2 > 26000 || d2 < 0.01) continue; + const d = Math.sqrt(d2); + const f = (1 - d / 162) * 38 / d; + dx *= f; dy *= f; + a.vx -= dx; a.vy -= dy; + b.vx += dx; b.vy += dy; + } + } + + // Edge springs toward a rest length. + for (const e of edges) { + if (e.life < 0.02) continue; + const a = nodes[e.a], b = nodes[e.b]; + const dx = b.x - a.x, dy = b.y - a.y; + const d = Math.hypot(dx, dy) || 1; + const f = (d - REST) * 0.0016 * e.life; + const ux = dx / d * f, uy = dy / d * f; + a.vx += ux; a.vy += uy; + b.vx -= ux; b.vy -= uy; + } + + for (let i = 0; i < n; i++) { + const p = nodes[i]; + + // Content repulsion: the type carves space out of the graph. + for (const o of obstacles) { + const oy = o.y - scrollY; + if (oy > H || oy + o.h < 0) continue; + if (p.x < o.x || p.x > o.x + o.w || p.y < oy || p.y > oy + o.h) continue; + const left = p.x - o.x, right = o.x + o.w - p.x; + const up = p.y - oy, down = oy + o.h - p.y; + const m = Math.min(left, right, up, down); + const push = 0.5; + if (m === left) p.vx -= push * (1 - left / o.w); + else if (m === right) p.vx += push * (1 - right / o.w); + else if (m === up) p.vy -= push * (1 - up / o.h); + else p.vy += push * (1 - down / o.h); + } + + // A slow pointer gathers a local constellation around a small ring. A + // fast pointer opens a corridor through the topology. Both forces fade + // after release so the graph returns to its spring equilibrium. + if (pointerEnergy > 0.01) { + const dx = p.x - pointerX, dy = p.y - pointerY; + const d2 = dx * dx + dy * dy; + if (d2 < 52900 && d2 > 1) { + const d = Math.sqrt(d2); + const near = 1 - d / 230; + const fast = Math.min(1, Math.max(0, (pointerSpeed - 0.38) / 0.92)); + const slow = 1 - fast; + const ring = 34 + p.z * 30; + const radial = (d - ring) * 0.0048 * near * slow * pointerEnergy; + p.vx -= dx / d * radial; + p.vy -= dy / d * radial; + + const orbit = Math.sin(p.px + p.py) * 0.045 * near * slow * pointerEnergy; + p.vx += -dy / d * orbit; + p.vy += dx / d * orbit; + + if (fast > 0) { + const speed = Math.hypot(pointerVX, pointerVY) || 1; + const ux = pointerVX / speed, uy = pointerVY / speed; + const side = Math.sign(ux * dy - uy * dx) || (i % 2 ? 1 : -1); + const split = near * fast * pointerEnergy * (0.72 + p.z * 0.7); + p.vx += -uy * side * split; + p.vy += ux * side * split; + } + } + } + + // Section traversal changes equilibrium without turning the field into + // a scroll animation. Modes alternate between open space, loose + // subgraphs, and threaded layers. + if (topologyMode === 1) { + const anchor = i % 3; + const ax = W * (0.22 + anchor * 0.28); + const ay = H * (anchor === 1 ? 0.6 : 0.34); + p.vx += (ax - p.x) * 0.000055; + p.vy += (ay - p.y) * 0.000055; + } else if (topologyMode === 2) { + const lane = H * (0.24 + (i % 4) * 0.17); + p.vy += (lane - p.y) * 0.00007; + } + + // Slow independent wander, so a settled graph still breathes. + const w = 0.008 + p.z * 0.013; + p.vx += Math.cos(t * p.ps + p.px) * w; + p.vy += Math.sin(t * p.ps + p.py) * w; + + p.vx *= 0.90; p.vy *= 0.90; + + const sp = Math.hypot(p.vx, p.vy); + if (sp > 1.6) { p.vx = p.vx / sp * 1.6; p.vy = p.vy / sp * 1.6; } + + p.x += p.vx * dt; p.y += p.vy * dt; + + // Soft walls, not wrapping: wrapping makes edges jump across the screen. + const m = 40; + if (p.x < m) p.vx += (m - p.x) * 0.02; + if (p.x > W - m) p.vx -= (p.x - (W - m)) * 0.02; + if (p.y < m) p.vy += (m - p.y) * 0.02; + if (p.y > H - m) p.vy -= (p.y - (H - m)) * 0.02; + } + + // Slow exploration creates short-lived local edges. Fast traversal may + // retire one nearby edge, producing a brief corridor that settles calmly. + if (pointerEnergy > 0.35 && pointerSpeed < 0.38 && t > constellationAt) { + constellationAt = t + 0.46; + const near = nodes + .map((node, i) => ({ i, d: Math.hypot(node.x - pointerX, node.y - pointerY) })) + .filter((item) => item.d < 170) + .sort((a, b) => a.d - b.d) + .slice(0, 6); + for (let i = 0; i < near.length - 1; i++) { + const a = near[i].i, b = near[i + 1].i; + if (!connected(a, b) && degree(a) < 5 && degree(b) < 5) { + addEdge(a, b, 0, t + 1.6); + break; + } + } + } + + if (pointerEnergy > 0.4 && pointerSpeed > 0.9 && t > splitAt) { + splitAt = t + 0.34; + let closest = null, closestD = 150; + for (const edge of edges) { + if (edge.target === 0 || edge.expiresAt) continue; + const a = nodes[edge.a], b = nodes[edge.b]; + const d = Math.hypot((a.x + b.x) * 0.5 - pointerX, (a.y + b.y) * 0.5 - pointerY); + if (d < closestD) { closest = edge; closestD = d; } + } + if (closest) closest.target = 0; + } + + // Topology mutation: retire an overstretched edge, grow a plausible one. + if (t > mutateAt) { + mutateAt = t + rand(2.4, 4.6); + let worst = -1, worstD = 0; + for (let k = 0; k < edges.length; k++) { + const e = edges[k]; + if (e.target === 0) continue; + const d = Math.hypot(nodes[e.b].x - nodes[e.a].x, nodes[e.b].y - nodes[e.a].y); + if (d > worstD) { worstD = d; worst = k; } + } + if (worst >= 0 && worstD > REST * 1.5) edges[worst].target = 0; + + const a = (Math.random() * nodes.length) | 0; + let best = -1, bestD = Infinity; + for (let j = 0; j < nodes.length; j++) { + if (j === a || connected(a, j) || degree(j) >= 4) continue; + const d = Math.hypot(nodes[j].x - nodes[a].x, nodes[j].y - nodes[a].y); + if (d < bestD) { bestD = d; best = j; } + } + if (best >= 0 && bestD < REST * 1.7 && degree(a) < 4) addEdge(a, best, 0); + } + + for (let k = edges.length - 1; k >= 0; k--) { + const e = edges[k]; + if (e.expiresAt && t > e.expiresAt) e.target = 0; + e.life += (e.target - e.life) * Math.min(1, dt * 0.9); + if (e.target === 0 && e.life < 0.01) edges.splice(k, 1); + } + }; + + /* ---------------------------------------------------------- pulses --- */ + + const spawn = () => { + if (!edges.length) return; + const start = (Math.random() * nodes.length) | 0; + const path = []; + let cur = start, prev = -1; + const hops = 2 + ((Math.random() * 3) | 0); + for (let i = 0; i < hops; i++) { + const opts = neighbours(cur).filter((o) => o.to !== prev); + if (!opts.length) break; + const nx = pick(opts); + path.push({ edge: nx.edge, from: cur, to: nx.to }); + prev = cur; + cur = nx.to; + } + if (path.length < 2) return; + + pulses.push({ + path, i: 0, t: 0, + speed: rand(0.55, 0.85), + done: 0, + }); + }; + + const stepPulses = (dt, t) => { + if (t > spawnAt && pulses.length < (constrained ? 2 : 4)) { + spawnAt = t + rand(1.1, 2.4); + spawn(); + } + for (let k = pulses.length - 1; k >= 0; k--) { + const p = pulses[k]; + + if (p.done > 0) { + p.done += dt; + if (p.done > 0.9) pulses.splice(k, 1); + continue; + } + + p.t += p.speed * dt; + + if (p.t >= 1) { + p.t = 0; + p.i++; + if (p.i >= p.path.length) { p.i = p.path.length - 1; p.t = 1; p.done = 0.001; } + } + } + }; + + /* ----------------------------------------------------------- paint --- */ + + // Quadratic control point for an edge, bowed perpendicular to its span. + const control = (a, b, bow) => { + const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2; + const dx = b.x - a.x, dy = b.y - a.y; + return { x: mx - dy * bow, y: my + dx * bow }; + }; + + const at = (a, c, b, t) => { + const u = 1 - t; + return { + x: u * u * a.x + 2 * u * t * c.x + t * t * b.x, + y: u * u * a.y + 2 * u * t * c.y + t * t * b.y, + }; + }; + + const draw = () => { + ctx.clearRect(0, 0, W, H); + + const edgeA = C.isDark ? 0.42 : 0.34; + const nodeA = C.isDark ? 0.62 : 0.50; + + ctx.lineCap = "round"; + + for (const e of edges) { + if (e.life < 0.02) continue; + const a = nodes[e.a], b = nodes[e.b]; + const c = control(a, b, e.bow); + + ctx.beginPath(); + ctx.moveTo(a.x, a.y); + ctx.quadraticCurveTo(c.x, c.y, b.x, b.y); + + const az = (a.z + b.z) / 2; + ctx.strokeStyle = rgba(C.mesh, edgeA * e.life * (0.45 + az * 0.75)); + ctx.lineWidth = 0.6 + az * 0.8; + ctx.stroke(); + } + + for (const p of nodes) { + ctx.beginPath(); + ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); + ctx.fillStyle = rgba(C.mesh, nodeA * (0.4 + p.z * 0.8)); + ctx.fill(); + } + + for (const p of pulses) { + const hop = p.path[p.i]; + const a = nodes[hop.from], b = nodes[hop.to]; + if (!a || !b) continue; + const c = control(a, b, hop.edge.bow); + const head = at(a, c, b, p.t); + + const col = C.pulse; + const fade = p.done > 0 ? Math.max(0, 1 - p.done / 0.9) : 1; + + // Trail: a short sampled span of the same curve behind the head. + const t0 = Math.max(0, p.t - 0.34); + ctx.beginPath(); + for (let s = 0; s <= 8; s++) { + const q = at(a, c, b, t0 + (p.t - t0) * (s / 8)); + s === 0 ? ctx.moveTo(q.x, q.y) : ctx.lineTo(q.x, q.y); + } + ctx.strokeStyle = rgba(col, 0.34 * fade); + ctx.lineWidth = 1.6; + ctx.stroke(); + + ctx.beginPath(); + ctx.arc(head.x, head.y, 2.4, 0, Math.PI * 2); + ctx.fillStyle = rgba(col, 0.9 * fade); + ctx.fill(); + ctx.beginPath(); + ctx.arc(head.x, head.y, 6, 0, Math.PI * 2); + ctx.fillStyle = rgba(col, 0.12 * fade); + ctx.fill(); + } + }; + + /* ------------------------------------------------------------ loop --- */ + + const frame = (now) => { + if (!running) return; + raf = requestAnimationFrame(frame); + if (constrained && now - lastFrameAt < 30) return; + lastFrameAt = now; + if (!last) last = now; + // Clamp dt so a backgrounded tab never resumes with an exploded step. + const dt = Math.min(0.05, (now - last) / 1000); + last = now; + const t = now / 1000; + // Forces are tuned per 60 fps frame, so dt is expressed in frame units. + step(dt * 60, t); + stepPulses(dt, t); + draw(); + }; + + const start = () => { + if (running || reduced.matches) return; + running = true; + last = 0; + lastFrameAt = 0; + raf = requestAnimationFrame(frame); + }; + + const stop = () => { + running = false; + cancelAnimationFrame(raf); + }; + + /* ------------------------------------------------------------ size --- */ + + const resize = () => { + dpr = Math.min(constrained ? 1.25 : 2, window.devicePixelRatio || 1); + W = window.innerWidth; + H = window.innerHeight; + canvas.width = Math.round(W * dpr); + canvas.height = Math.round(H * dpr); + canvas.style.width = W + "px"; + canvas.style.height = H + "px"; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + build(); + measure(); + if (reduced.matches) settle(); + }; + + // Reduced motion: run the simulation to rest off-screen, paint one frame, + // and never start a loop. The field becomes a still topology, not nothing. + const settle = () => { + stop(); + for (let i = 0; i < 220; i++) step(1, 0); + pulses = []; + draw(); + }; + + /* --------------------------------------------------------- observe --- */ + + readPalette(); + resize(); + + if (reduced.matches) settle(); else start(); + + let resizeTimer = 0; + let lastW = window.innerWidth; + window.addEventListener("resize", () => { + // Mobile browsers fire resize on URL-bar collapse; only a width change + // is worth rebuilding the graph for. + const w = window.innerWidth; + clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => { + if (Math.abs(w - lastW) < 2 && canvas.width === Math.round(w * dpr)) { measure(); return; } + lastW = w; + resize(); + }, 180); + }, { passive: true }); + + window.addEventListener("scroll", () => { scrollY = window.scrollY; }, { passive: true }); + + const updatePointer = (event, isFine) => { + const now = performance.now(); + const elapsed = Math.max(8, now - pointerLastAt); + const vx = (event.clientX - pointerLastX) / elapsed; + const vy = (event.clientY - pointerLastY) / elapsed; + pointerVX += (vx - pointerVX) * 0.45; + pointerVY += (vy - pointerVY) * 0.45; + pointerSpeed += (Math.hypot(vx, vy) - pointerSpeed) * 0.35; + pointerX = event.clientX; + pointerY = event.clientY; + pointerLastX = event.clientX; + pointerLastY = event.clientY; + pointerLastAt = now; + if (isFine) pointerFine = 1; + }; + + if (finePointer.matches) { + window.addEventListener("pointermove", (event) => { + updatePointer(event, true); + }, { passive: true }); + window.addEventListener("pointerleave", () => { pointerFine = 0; }, { passive: true }); + document.addEventListener("mouseleave", () => { pointerFine = 0; }, { passive: true }); + } + + // Touch influence is short and passive. It never prevents default, never + // starts over a control, and gives way as soon as a vertical scroll begins. + window.addEventListener("pointerdown", (event) => { + if (event.pointerType !== "touch" || event.target.closest("a,button,input,select,textarea,summary,[role='button']")) return; + touchStartX = event.clientX; + touchStartY = event.clientY; + clearTimeout(touchReleaseTimer); + touchScroll = 0; + pointerTouch = 1; + pointerSpeed = 0; + updatePointer(event, false); + }, { passive: true }); + window.addEventListener("pointermove", (event) => { + if (!pointerTouch || event.pointerType !== "touch") return; + const dx = event.clientX - touchStartX; + const dy = event.clientY - touchStartY; + if (Math.abs(dy) > 14 && Math.abs(dy) > Math.abs(dx)) { + touchScroll = 1; + pointerTouch = 0; + return; + } + if (!touchScroll && Math.hypot(dx, dy) < 84) updatePointer(event, false); + }, { passive: true }); + const releaseTouch = () => { + if (touchScroll) { pointerTouch = 0; touchScroll = 0; return; } + touchReleaseTimer = setTimeout(() => { pointerTouch = 0; }, 420); + }; + window.addEventListener("pointerup", releaseTouch, { passive: true }); + window.addEventListener("pointercancel", releaseTouch, { passive: true }); + + if ("IntersectionObserver" in window) { + const sections = [...document.querySelectorAll("main > section")]; + const sectionObserver = new IntersectionObserver((entries) => { + const visible = entries.filter((entry) => entry.isIntersecting).sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0]; + if (visible) topologyMode = Math.abs(sections.indexOf(visible.target)) % 3; + }, { rootMargin: "-28% 0px -28% 0px", threshold: [0.05, 0.35, 0.7] }); + sections.forEach((section) => sectionObserver.observe(section)); + } + window.addEventListener("load", measure); + // Text reflowing after a late font swap moves every obstacle the field + // routes around, so the cached document-space rects have to be retaken. + if (document.fonts && document.fonts.ready) document.fonts.ready.then(measure); + + document.addEventListener("visibilitychange", () => { + document.hidden ? stop() : start(); + }); + + dark.addEventListener("change", () => { readPalette(); if (reduced.matches) draw(); }); + + reduced.addEventListener("change", () => { + if (reduced.matches) settle(); + else { build(); start(); } + }); +})(); diff --git a/index.html b/index.html index 041e710..d6d2f5e 100644 --- a/index.html +++ b/index.html @@ -2,9 +2,10 @@ - + - + + @@ -19,24 +20,42 @@ GraphABI: Semantic Compatibility Infrastructure + + + + + + + + @@ -44,145 +63,205 @@
-

Semantic Compatibility Infrastructure

-

Your schema passed.
Your agent still broke.

+

Semantic Compatibility Infrastructure

+

Your schema passed.Your agent still broke.

GraphABI finds the first edge where meaning changed, shows the exact recorded witness, and traces every downstream node that may be affected.

-
- uvx --from git+https://github.com/graphabi/graphabi graphabi demo --allow-breaking +
+ + uvx --from git+https://github.com/graphabi/graphabi graphabi demo --allow-breaking +
-
Recorded graphBreak found · researcher → verifier
- +
+ Recorded graph + Break found · researcher → verifier +
+
-
SCHEMA✓ PASSsame ResearchResult
-
SEMANTICS× BREAKINGtrace-backed
+
Schema✓ PASSsame ResearchResult
+
Semantics× BREAKINGtrace-backed
-
TRACE-BACKED WITNESS · CANDIDATE-003verified=true arrived without an opened supporting source.
-
CONTRACT EXPECTEDopened_sources_count > 0
TRACE OBSERVEDopened_sources_count = 0
-

Repair before verifier · affected path reaches publisher

+
+ Trace-backed witness · candidate-003 + verified=true arrived without an opened supporting source. +
+
+
Contract expectedopened_sources_count > 0
+
Trace observedopened_sources_count = 0
+
+

Repair before verifier · affected path reaches publisher

-
-
-

Graph Playground

Run the edge check.

-

Choose the recorded producer output, then watch the semantic pulse travel toward the verifier. The candidate keeps its schema and breaks its meaning.

-
-
-
- - - +
+
+
+

Graph playground

Run the edge check.

+

Choose the recorded producer output, then watch the semantic pulse travel toward the verifier. The candidate keeps its schema and breaks its meaning.

-
-
-
-

The bug normal tests miss

Same shape.
Different contract.

-

Pydantic can validate a boolean. It cannot prove the boolean still means what the next node assumes.

-
-
-
-
BASELINE OUTPUT✓ VALID
-
{
-  "verified": true,
-  "confidence": 0.92,
-  "sources": ["source-1"]
+    
+
+
+

The bug normal tests miss

Same shape.
Different contract.

+

Pydantic can validate a boolean. It cannot prove the boolean still means what the next node assumes.

+
+
+
+
Baseline output✓ Valid
+
{
+  "verified": true,
+  "confidence": 0.92,
+  "sources": ["source-1"]
 }
-

Source opened and shown to support the claim.

-
- -
-
CANDIDATE OUTPUT✓ VALID
-
{
-  "verified": true,
-  "confidence": 0.92,
-  "sources": ["source-1"]
+            

Source opened and shown to support the claim.

+
+ +
+
Candidate output✓ Valid
+
{
+  "verified": true,
+  "confidence": 0.92,
+  "sources": ["source-1"]
 }
-

Source access failed; the claim merely sounded plausible.

+

Source access failed; the claim merely sounded plausible.

+
+

The JSON is identical. The meaning at researcher → verifier is not.

-

The JSON is identical. The meaning at researcher → verifier is not.

-
-
-

The graph is the explanation

The pulse stops.
The evidence begins.

-

Failure does not propagate as a successful execution. The graph freezes at the incompatible edge, then marks the downstream blast radius.

+
+
+

How GraphABI works

One causal sequence.

Every interface follows the same reasoning path. Nothing animates without explaining a step.

+
    +
  1. 01Flow

    Record what crosses each graph edge.

  2. +
  3. 02Check

    Evaluate what the consumer relies on.

  4. +
  5. 03Break

    Stop at the first incompatible meaning.

  6. +
  7. 04Trace

    Calculate downstream terminal paths.

  8. +
  9. 05Explain

    Show the exact run and conflicting value.

  10. +
  11. 06Fix

    Name the nearest repair location.

  12. +
-
-
producerconsumer
-
semantic value in flightexplicit consumer contractrecorded witness
-
-
- -
-

How GraphABI works

One causal sequence.

Every interface follows the same reasoning path. Nothing animates without explaining a step.

-
    -
  1. 01Flow

    Record what crosses each graph edge.

  2. -
  3. 02Check

    Evaluate what the consumer relies on.

  4. -
  5. 03Break

    Stop at the first incompatible meaning.

  6. -
  7. 04Trace

    Calculate downstream terminal paths.

  8. -
  9. 05Explain

    Show the exact run and conflicting value.

  10. -
  11. 06Fix

    Name the nearest repair location.

  12. -
-
-

Consumer-driven edge contracts

Write the assumption down.

The verifier defines what it needs from the researcher. Evaluators stay deterministic and independently extensible.

-
-
version: "0.1"
+    
+
+

Consumer-driven edge contracts

Write the assumption down.

The verifier defines what it needs from the researcher. Evaluators stay deterministic and independently extensible.

+
+
+
graphabi.contracts.yaml
+
version: "0.1"
 graph: research_demo
 edges:
   - id: researcher_to_verifier
     producer: researcher
     consumer: verifier
     invariants:
-      - id: verified_requires_opened_source
-        evaluator: implication
-        severity: breaking
-        when:
-          path: output.verified
-          equals: true
-        require:
-          path: metadata.opened_sources_count
-          greater_than: 0
-

SHIPPED EVALUATORS

  • 01 Implication
  • 02 Provenance
  • 03 Preservation
  • 04 Unit consistency
  • 05 Authority
  • 06 Freshness
Add an evaluator without changing core →
+ - id: verified_requires_opened_supporting_source + evaluator: provenance + severity: breaking + rule: opened_supporting_source
+
+
+

Contract anatomy

+
    +
  • 01
    Producerresearcher
  • +
  • 02
    Consumerverifier
  • +
  • 03
    Explicit invariantverified requires source access
  • +
  • 04
    Severitybreaking
  • +
  • 05
    Observed witnesscandidate-003
  • +
+ Read the contract format +
+
-
-

Run the proof locally

The failure is real output.

The demo executes baseline and candidate LangGraphs, records both in SQLite, and generates offline JSON and HTML.

-
-
graphabi demo --allow-breaking
-
GraphABI semantic compatibility report
+    
+
+

Run the proof locally

The failure is real output.

The demo executes baseline and candidate LangGraphs, records both in SQLite, and generates offline JSON and HTML.

+
+
graphabi demo --allow-breaking
+
GraphABI semantic compatibility report
 Structural compatibility: PASS
 Semantic compatibility: FAIL
 First breaking edge: researcher → verifier
@@ -193,62 +272,202 @@ 

Your schema passed.
Your agent still broke.< Reports: .graphabi/reports/latest/report.json .graphabi/reports/latest/index.html

+
-
-

Framework-independent core

Adapters observe.
Core decides.

LangGraph integration ends at a versioned trace boundary. Contracts, comparison, impact, and reporting do not import framework types.

- Adapters emit versioned traces, contracts check edge observations, impact analysis traces affected paths, and reports explain witnesses. +
+
+

Framework-independent core

Adapters observe.
Core decides.

LangGraph integration ends at a versioned trace boundary. Contracts, comparison, impact, and reporting do not import framework types.

+
+

Framework-independent semantic compatibility pipeline

+
    +
  1. + Flow + Adapters + LangGraph today +
  2. + +
  3. + Record + Trace model + SQLite · JSONL +
  4. + +
  5. + Check + Contracts + Evaluator registry +
  6. + +
  7. + Trace + Impact + NetworkX paths +
  8. + +
  9. + Explain + Reports + JSON · HTML +
  10. + + +
+

Framework types stop at the adapter boundary. Report presentation never decides compatibility.

+
+
-
-

A report that explains causality

Not a score.
A repairable witness.

Machine-readable JSON and a self-contained HTML report come from one redacted report model. The report works offline.

-
GraphABI report with structural pass, semantic break, first broken edge, witness, downstream path, and repair location.
Exact demo state: candidate-003 · researcher → verifier · publisher affected
+
+
+

A report that explains causality

Not a score.
A repairable witness.

Machine-readable JSON and a self-contained HTML report come from one redacted report model. The report works offline.

+
+
+
report.jsonmachine-readable
+
{
+  "structural": "pass",
+  "semantic": "breaking",
+  "first_breaking_edge": {
+    "producer": "researcher",
+    "consumer": "verifier",
+    "contract": "verified_requires_opened_supporting_source"
+  },
+  "witness": {
+    "run": "candidate-003",
+    "expected": "opened_sources_count > 0",
+    "observed": 0
+  },
+  "affected": ["verifier", "decision_maker", "publisher"],
+  "repair_before": "verifier"
+}
+
+
+
index.htmloffline, self-contained
+
+
+
Structural✓ PASS
+
Semantic× BREAKING
+
+
+ First breaking edge +

researcherverifier

+
+
+ Witness · candidate-003 +

opened_sources_count > 0 expected, 0 observed.

+
+
+ Downstream +

verifierdecision_makerpublisher

+
+
+ Repair location +

Before verifier

+
+
+
+
+

One redacted report model. Both renderings are generated from it, and neither can disagree with the other.

+
+
+ +
+
+

Measured technical proof

Small surface.
Serious checks.

Current branch measurements, linked to the source that enforces them.

+
+
Tests
112passing locally
+
Coverage
92.94%85% CI floor
+
Python CI
3.12 · 3.13both required
+
Evaluators
7 typesdeterministic registry
+
Runtime
Local-firstno required API key
+
Adapter
LangGraphone maintained adapter
+
+

Benchmarks cover synthetic graphs at 10, 100, and 1,000 nodes. They do not establish production scale. Inspect the methodology.

+
+
+ +
+
+
Proof boundary

What GraphABI does not prove.

Honest uncertainty is part of the interface.

+
    +
  • 01

    GraphABI evaluates explicit contracts. It does not understand arbitrary meaning.

  • +
  • 02

    A passing observed edge does not prove unobserved branches or inputs are compatible.

  • +
  • 03

    Trace schema 0.1 does not yet pair repeated loop or retry occurrences.

  • +
  • 04

    LangGraph is the only maintained adapter. OpenTelemetry and OpenInference ingestion are planned.

  • +
+ Read every current limitation +
-
-

Public alpha roadmap

The next edges are explicit.

Planned means planned, not hidden behind a maturity claim.

-
- 01Causal pairing for loopsPlanned - 02Contract coveragePlanned - 03OpenTelemetry ingestionPlanned - 04Second framework adapterPlanned - 05Model-migration examplePlanned +
+
-
- -

Semantic Compatibility Infrastructure

-

Find where meaning broke.

-

Run GraphABI locally, inspect the witness, and help define the edge-level contracts agent graphs need.

-
Contribute
+
+
+
+ +

Semantic Compatibility Infrastructure

+

Catch the first broken edge.

+

Run the local demo, read the contract, and inspect the trace-backed witness.

+ +
+
diff --git a/motion.js b/motion.js index 3d9beaf..e543d0f 100644 --- a/motion.js +++ b/motion.js @@ -1,137 +1,383 @@ +/* ============================================================================= + GraphABI: product motion + ----------------------------------------------------------------------------- + Everything here is causal. Motion exists to show where meaning flowed, what + checked it, where it stopped, and what that reached. Nothing loops, and no + state is expressed by motion alone: the final frame always carries the + whole result in text and colour. + ========================================================================== */ + (() => { "use strict"; - const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); - const timersByScene = new WeakMap(); + const reduced = window.matchMedia("(prefers-reduced-motion: reduce)"); + const $ = (sel, root) => (root || document).querySelector(sel); + const $$ = (sel, root) => [...(root || document).querySelectorAll(sel)]; + + /* ------------------------------------------------- edge measurement --- + Pulses travel with transform, which needs the edge's pixel length. + One ResizeObserver keeps the custom property honest across breakpoints. */ + + const measured = $$(".graph-edge, .playground-edge"); + if (measured.length && "ResizeObserver" in window) { + const ro = new ResizeObserver((entries) => { + for (const entry of entries) { + const el = entry.target; + el.style.setProperty("--edge-w", el.offsetWidth + "px"); + el.style.setProperty("--edge-h", el.offsetHeight + "px"); + } + }); + measured.forEach((el) => ro.observe(el)); + } + + /* --------------------------------------------------- canonical replay --- */ + + const timers = new WeakMap(); - const clearScene = (scene) => { - (timersByScene.get(scene) || []).forEach(window.clearTimeout); - timersByScene.set(scene, []); + const clear = (scene) => { + (timers.get(scene) || []).forEach(clearTimeout); + timers.set(scene, []); }; - const schedule = (scene, delay, action) => { - const timers = timersByScene.get(scene) || []; - timers.push(window.setTimeout(action, delay)); - timersByScene.set(scene, timers); + const after = (scene, delay, fn) => { + const list = timers.get(scene) || []; + list.push(setTimeout(fn, delay)); + timers.set(scene, list); }; const announce = (scene, text) => { - const phase = scene.querySelector("[data-scene-phase]"); - const live = scene.querySelector("[data-scene-live]"); + const phase = $("[data-scene-phase]", scene); + const live = $("[data-scene-live]", scene); if (phase) phase.textContent = text; if (live) live.textContent = text; }; const finish = (scene) => { + clear(scene); scene.className = "pulse-scene is-complete"; - announce(scene, "Break found: researcher to verifier"); - const button = scene.querySelector("[data-replay]"); + announce(scene, "Break found · researcher → verifier"); + const button = $("[data-replay]", scene); if (button) button.disabled = false; }; const play = (scene) => { - clearScene(scene); - if (reducedMotion.matches) { - finish(scene); - return; - } - const button = scene.querySelector("[data-replay]"); + clear(scene); + if (reduced.matches) return finish(scene); + + const button = $("[data-replay]", scene); if (button) button.disabled = true; scene.className = "pulse-scene is-playing"; - announce(scene, "Run begins: loading recorded nodes"); - schedule(scene, 180, () => { scene.classList.add("nodes-active"); announce(scene, "Flow: nodes activated"); }); - schedule(scene, 720, () => { scene.classList.add("baseline-active"); announce(scene, "Check: baseline contracts pass"); }); - schedule(scene, 1900, () => { scene.classList.remove("baseline-active"); scene.classList.add("candidate-active"); announce(scene, "Candidate swapped: schema remains valid"); }); - schedule(scene, 2550, () => { scene.classList.add("broken-active"); announce(scene, "Break: researcher to verifier"); }); - schedule(scene, 2870, () => { scene.classList.add("impact-active"); announce(scene, "Trace: downstream impact identified"); }); - schedule(scene, 3370, () => { scene.classList.add("witness-active"); announce(scene, "Explain: trace-backed witness revealed"); }); - schedule(scene, 3870, () => finish(scene)); + announce(scene, "Loading recorded nodes"); + + // Flow, check, break, trace, explain. The order is the product. + // Hop length and blast stagger are mirrored in styles.css. + after(scene, 160, () => { scene.classList.add("nodes-active"); announce(scene, "Flow · nodes resolved in topology order"); }); + after(scene, 700, () => { scene.classList.add("baseline-active"); announce(scene, "Check · baseline pulse crosses every edge"); }); + after(scene, 2060, () => { scene.classList.add("resetting"); }); + after(scene, 2400, () => { + scene.classList.remove("baseline-active", "resetting"); + scene.classList.add("candidate-active"); + announce(scene, "Candidate swapped · schema still valid"); + }); + after(scene, 3340, () => { scene.classList.add("broken-active"); announce(scene, "Break · researcher → verifier"); }); + after(scene, 3760, () => { scene.classList.add("impact-active"); announce(scene, "Trace · downstream impact identified"); }); + after(scene, 4560, () => { scene.classList.add("witness-active"); announce(scene, "Explain · trace-backed witness recorded"); }); + after(scene, 5200, () => finish(scene)); }; - document.querySelectorAll("[data-pulse-scene]").forEach((scene) => { - const button = scene.querySelector("[data-replay]"); + $$("[data-pulse-scene]").forEach((scene) => { + const button = $("[data-replay]", scene); if (button) button.addEventListener("click", () => play(scene)); - window.setTimeout(() => play(scene), 260); + + // Autoplay once, only when the scene is actually on screen, and never + // move focus while it runs. + if ("IntersectionObserver" in window) { + const io = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting) return; + io.disconnect(); + setTimeout(() => play(scene), 240); + }); + }, { threshold: 0.25 }); + io.observe(scene); + } else { + setTimeout(() => play(scene), 240); + } }); - const playground = document.querySelector("[data-playground]"); + /* ------------------------------------------------------- playground --- */ + + const playground = $("[data-playground]"); if (playground) { + const result = $("[data-play-result]", playground); + const choices = $$("[data-play-choice]", playground); + const thumb = $(".segment-thumb", playground); + const run = $("[data-play-run]", playground); + const witness = $("[data-play-witness]", playground); let choice = "baseline"; - const result = playground.querySelector("[data-play-result]"); - const choices = [...playground.querySelectorAll("[data-play-choice]")]; + let witnessTimer = 0; + + const moveThumb = () => { + const active = choices.find((b) => b.classList.contains("is-selected")); + if (!active || !thumb) return; + thumb.style.width = active.offsetWidth + "px"; + thumb.style.transform = `translateX(${active.offsetLeft - choices[0].offsetLeft}px)`; + }; + + const reset = () => { + clearTimeout(witnessTimer); + playground.className = "playground"; + if (witness) { + witness.hidden = true; + witness.classList.remove("is-open"); + } + if (result) { + result.innerHTML = + "Select an output and run the check." + + "The graph will report the observed contract state here."; + } + }; + + const sources = $("[data-play-sources]", playground); + const setChoice = (value) => { choice = value; + // The candidate keeps the schema and changes one recorded value. + if (sources) { + sources.textContent = value === "candidate" ? "0" : "1"; + sources.classList.toggle("is-breaking", value === "candidate"); + sources.classList.toggle("is-passing", value !== "candidate"); + } choices.forEach((button) => { - const selected = button.dataset.playChoice === value; - button.classList.toggle("is-selected", selected); - button.setAttribute("aria-pressed", String(selected)); + const on = button.dataset.playChoice === value; + button.classList.toggle("is-selected", on); + button.setAttribute("aria-pressed", String(on)); }); - playground.className = "playground"; - if (result) result.innerHTML = "Select an output and run the check.The graph will report the observed contract state here."; + moveThumb(); + reset(); }; - choices.forEach((button) => button.addEventListener("click", () => setChoice(button.dataset.playChoice))); - const run = playground.querySelector("[data-play-run]"); - if (run) run.addEventListener("click", () => { - playground.className = `playground is-running ${choice === "candidate" ? "is-candidate" : "is-baseline"}`; - if (result) result.innerHTML = choice === "candidate" - ? "BREAK: verified=true, opened_sources_count=0.The pulse stopped at researcher to verifier. The witness is the missing source access." - : "PASS: verified=true, opened_sources_count=1.The pulse reached verifier with the supporting source recorded."; - if (reducedMotion.matches) playground.classList.add("is-settled"); - }); + + choices.forEach((b) => b.addEventListener("click", () => setChoice(b.dataset.playChoice))); + setChoice("baseline"); + requestAnimationFrame(moveThumb); + window.addEventListener("resize", moveThumb, { passive: true }); + // Inter arriving late changes the label widths the thumb is sized from. + if (document.fonts && document.fonts.ready) document.fonts.ready.then(moveThumb); + + if (run) { + run.addEventListener("click", () => { + // Restart cleanly so a second run replays rather than doing nothing. + playground.className = "playground"; + void playground.offsetWidth; + playground.className = `playground is-running ${choice === "candidate" ? "is-candidate" : "is-baseline"}`; + if (!result) return; + if (choice === "candidate") { + result.innerHTML = + "BREAKING · verified=true with opened_sources_count=0." + + "First break: researcher → verifier. Affected: verifier, decision_maker, publisher."; + if (witness) { + witnessTimer = setTimeout(() => { + witness.hidden = false; + requestAnimationFrame(() => witness.classList.add("is-open")); + }, reduced.matches ? 0 : 980); + } + } else { + result.innerHTML = + "PASS · verified=true with opened_sources_count=1." + + "The observed baseline satisfied the verifier's explicit contract."; + } + }); + } } - const modal = document.querySelector("[data-setup-modal]"); + /* ------------------------------------------------------------ modal --- */ + + const modal = $("[data-setup-modal]"); if (modal) { - const dialog = modal.querySelector(".setup-dialog"); - const openers = [...document.querySelectorAll("[data-open-setup]")]; - const closers = [...modal.querySelectorAll("[data-close-setup]")]; - const tabs = [...modal.querySelectorAll("[data-setup-option]")]; - const panels = [...modal.querySelectorAll("[data-setup-panel]")]; - let previousFocus; - const focusables = () => [...modal.querySelectorAll('button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])')]; - const close = () => { - modal.classList.remove("is-open"); - window.setTimeout(() => { modal.hidden = true; document.body.classList.remove("modal-open"); }, reducedMotion.matches ? 0 : 240); - if (previousFocus) previousFocus.focus(); + const dialog = $(".setup-dialog", modal); + const tabs = $$("[data-setup-option]", modal); + const panels = $$("[data-setup-panel]", modal); + const rail = $(".tab-rail", modal); + let previous = null; + + const focusable = () => + $$('button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])', modal) + .filter((el) => el.offsetParent !== null || el === dialog); + + const moveRail = () => { + const active = tabs.find((t) => t.classList.contains("is-active")); + if (!active || !rail) return; + rail.style.width = active.offsetWidth + "px"; + rail.style.transform = `translateX(${active.offsetLeft}px)`; }; - const open = () => { - previousFocus = document.activeElement; + + const select = (value, focus) => { + tabs.forEach((tab) => { + const on = tab.dataset.setupOption === value; + tab.classList.toggle("is-active", on); + tab.setAttribute("aria-selected", String(on)); + tab.tabIndex = on ? 0 : -1; + if (on && focus) tab.focus(); + }); + panels.forEach((panel) => { + const on = panel.dataset.setupPanel === value; + panel.hidden = !on; + panel.classList.toggle("is-active", on); + }); + moveRail(); + }; + + // The dialog expands from whichever control opened it, so the panel reads + // as that node unfolding rather than as a sheet arriving from nowhere. + const open = (event) => { + previous = document.activeElement; modal.hidden = false; document.body.classList.add("modal-open"); - window.requestAnimationFrame(() => { modal.classList.add("is-open"); dialog.focus(); }); - }; - const select = (value) => { - tabs.forEach((tab) => { - const active = tab.dataset.setupOption === value; - tab.classList.toggle("is-active", active); - tab.setAttribute("aria-selected", String(active)); + + const trigger = event && event.currentTarget; + if (trigger && !reduced.matches) { + const t = trigger.getBoundingClientRect(); + requestAnimationFrame(() => { + const d = dialog.getBoundingClientRect(); + if (d.width && d.height) { + const ox = ((t.left + t.width / 2 - d.left) / d.width) * 100; + const oy = ((t.top + t.height / 2 - d.top) / d.height) * 100; + dialog.style.transformOrigin = + `${Math.max(-20, Math.min(120, ox))}% ${Math.max(-20, Math.min(120, oy))}%`; + } + }); + } else { + dialog.style.transformOrigin = ""; + } + + requestAnimationFrame(() => { + modal.classList.add("is-open"); + moveRail(); + const activeTab = tabs.find((tab) => tab.getAttribute("aria-selected") === "true"); + (activeTab || dialog).focus(); }); - panels.forEach((panel) => { panel.hidden = panel.dataset.setupPanel !== value; panel.classList.toggle("is-active", panel.dataset.setupPanel === value); }); }; - openers.forEach((button) => button.addEventListener("click", open)); - closers.forEach((button) => button.addEventListener("click", close)); + + const close = () => { + modal.classList.remove("is-open"); + const done = () => { + modal.hidden = true; + document.body.classList.remove("modal-open"); + if (previous && previous.isConnected) previous.focus(); + }; + reduced.matches ? done() : setTimeout(done, 240); + }; + + $$("[data-open-setup]").forEach((b) => b.addEventListener("click", open)); + $$("[data-close-setup]", modal).forEach((b) => b.addEventListener("click", close)); tabs.forEach((tab) => tab.addEventListener("click", () => select(tab.dataset.setupOption))); + modal.addEventListener("keydown", (event) => { - if (event.key === "Escape") { event.preventDefault(); close(); return; } + if (event.key === "Escape") { event.preventDefault(); return close(); } + + // Roving tabs, as the tablist pattern expects. + if (event.target.matches("[data-setup-option]") && /^Arrow(Left|Right)$/.test(event.key)) { + event.preventDefault(); + const i = tabs.indexOf(event.target); + const next = event.key === "ArrowRight" ? (i + 1) % tabs.length : (i - 1 + tabs.length) % tabs.length; + return select(tabs[next].dataset.setupOption, true); + } + if (event.key !== "Tab") return; - const items = focusables(); + const items = focusable(); if (!items.length) return; - const first = items[0]; const last = items[items.length - 1]; + const first = items[0]; + const last = items[items.length - 1]; if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } }); - modal.querySelectorAll("[data-copy-command]").forEach((button) => button.addEventListener("click", async () => { + + window.addEventListener("resize", moveRail, { passive: true }); + } + + /* -------------------------------------------------------------- copy --- */ + + const copy = async (text) => { + try { + await navigator.clipboard.writeText(text); + return true; + } catch (_) { + const area = document.createElement("textarea"); + area.value = text; + area.setAttribute("readonly", ""); + area.style.cssText = "position:fixed;top:0;left:0;opacity:0"; + document.body.appendChild(area); + area.select(); + let ok = false; + try { ok = document.execCommand("copy"); } catch (__) { ok = false; } + area.remove(); + return ok; + } + }; + + $$("[data-copy-command]").forEach((button) => { + button.addEventListener("click", async () => { const panel = button.closest(".setup-panel"); - const command = panel.querySelector("[data-setup-command]").textContent; - try { await navigator.clipboard.writeText(command); } catch (error) { - const area = document.createElement("textarea"); area.value = command; area.setAttribute("readonly", ""); area.style.position = "fixed"; area.style.opacity = "0"; document.body.appendChild(area); area.select(); document.execCommand("copy"); area.remove(); - } - button.classList.add("copied"); - const feedback = panel.querySelector("[data-copy-feedback]"); - if (feedback) feedback.textContent = "Copied"; - window.setTimeout(() => { button.classList.remove("copied"); if (feedback) feedback.textContent = ""; }, 1600); - })); + const source = $("[data-setup-command]", panel); + const feedback = $("[data-copy-feedback]", panel); + const ok = await copy(source.textContent.trim()); + button.classList.toggle("copied", ok); + if (feedback) feedback.textContent = ok ? "Copied" : "Press Ctrl+C to copy"; + setTimeout(() => { + button.classList.remove("copied"); + if (feedback) feedback.textContent = ""; + }, 1800); + }); + }); + + $$("[data-copy-inline]").forEach((button) => { + const line = button.closest(".install-line"); + const source = $("[data-copy-source]", line); + button.addEventListener("click", async () => { + const ok = await copy(source.textContent.trim()); + button.classList.toggle("copied", ok); + button.setAttribute("aria-label", ok ? "Command copied" : "Copy demo command"); + setTimeout(() => { + button.classList.remove("copied"); + button.setAttribute("aria-label", "Copy demo command"); + }, 1800); + }); + }); + + /* -------------------------------------------------- scroll traversal --- + Reaching a section is arriving at a node: the rail from the previous + section draws down, then the node resolves. Once only, never reversed. */ + + if ("IntersectionObserver" in window) { + const io = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting) return; + entry.target.classList.add("is-reached"); + io.unobserve(entry.target); + }); + }, { rootMargin: "0px 0px -22% 0px", threshold: 0 }); + $$(".section").forEach((section) => io.observe(section)); + } else { + $$(".section").forEach((section) => section.classList.add("is-reached")); } - reducedMotion.addEventListener("change", () => document.querySelectorAll("[data-pulse-scene]").forEach(finish)); + /* ------------------------------------------------------------ header --- */ + + const header = $(".site-header"); + if (header) { + const sentinel = document.createElement("div"); + sentinel.setAttribute("aria-hidden", "true"); + sentinel.style.cssText = "position:absolute;top:0;height:1px;width:1px"; + document.body.prepend(sentinel); + if ("IntersectionObserver" in window) { + new IntersectionObserver( + ([entry]) => header.classList.toggle("is-stuck", !entry.isIntersecting), + { threshold: 0 } + ).observe(sentinel); + } + } + + reduced.addEventListener("change", () => { + if (reduced.matches) $$("[data-pulse-scene]").forEach(finish); + }); })(); diff --git a/proof.json b/proof.json new file mode 100644 index 0000000..ee33713 --- /dev/null +++ b/proof.json @@ -0,0 +1,20 @@ +{ + "tests": 112, + "coverage_percent": 92.94, + "python_versions": [ + "3.12", + "3.13" + ], + "evaluator_names": [ + "authority", + "completeness", + "freshness", + "implication", + "provenance", + "set_preservation", + "unit_consistency" + ], + "maintained_adapters": [ + "langgraph" + ] +} diff --git a/scripts/check_site.py b/scripts/check_site.py new file mode 100644 index 0000000..3bc4d97 --- /dev/null +++ b/scripts/check_site.py @@ -0,0 +1,88 @@ +"""Verify local links and progressive-enhancement invariants.""" + +from __future__ import annotations + +import json +from html.parser import HTMLParser +from pathlib import Path +from urllib.parse import urlsplit + + +ROOT = Path(__file__).parents[1] + + +class SiteParser(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.ids: set[str] = set() + self.references: list[tuple[str, str]] = [] + self.field_is_hidden = False + self.proof_values: dict[str, str] = {} + self.active_proof: str | None = None + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + values = dict(attrs) + if identifier := values.get("id"): + self.ids.add(identifier) + for name in ("href", "src"): + if reference := values.get(name): + self.references.append((name, reference)) + if tag == "canvas" and "data-field" in values: + self.field_is_hidden = values.get("aria-hidden") == "true" + if proof := values.get("data-proof"): + self.active_proof = proof + + def handle_data(self, data: str) -> None: + if self.active_proof: + self.proof_values[self.active_proof] = ( + self.proof_values.get(self.active_proof, "") + data + ) + + def handle_endtag(self, tag: str) -> None: + if tag == "strong" and self.active_proof: + self.proof_values[self.active_proof] = self.proof_values[self.active_proof].strip() + self.active_proof = None + + +def main() -> int: + parser = SiteParser() + parser.feed((ROOT / "index.html").read_text(encoding="utf-8")) + errors: list[str] = [] + + for attribute, reference in parser.references: + parsed = urlsplit(reference) + if parsed.scheme or parsed.netloc: + continue + if parsed.path: + target = ROOT / parsed.path + if not target.exists(): + errors.append(f"{attribute} references missing local file: {reference}") + if parsed.fragment and parsed.fragment not in parser.ids: + errors.append(f"{attribute} references missing fragment: #{parsed.fragment}") + + if not parser.field_is_hidden: + errors.append("The decorative field must remain aria-hidden.") + + proof = json.loads((ROOT / "proof.json").read_text(encoding="utf-8")) + expected_proof = { + "tests": str(proof["tests"]), + "coverage": f"{proof['coverage_percent']:.2f}%", + "python": " · ".join(proof["python_versions"]), + "evaluators": f"{len(proof['evaluator_names'])} types", + } + if parser.proof_values != expected_proof: + errors.append( + f"Public proof does not match proof.json: {parser.proof_values!r} != {expected_proof!r}" + ) + + if errors: + print("Site verification failed:") + for error in errors: + print(f"- {error}") + return 1 + print("Local links, fragments, and field accessibility checks passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check_text.py b/scripts/check_text.py new file mode 100644 index 0000000..71afe6d --- /dev/null +++ b/scripts/check_text.py @@ -0,0 +1,36 @@ +"""Reject prohibited punctuation in tracked text files.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).parents[1] + + +def main() -> int: + names = subprocess.check_output(["git", "ls-files", "-z"], cwd=ROOT).split(b"\0") + offenders: list[str] = [] + for name in names: + if not name: + continue + path = ROOT / name.decode() + try: + text = path.read_text(encoding="utf-8") + except (UnicodeDecodeError, OSError): + continue + if chr(0x2014) in text: + offenders.append(str(path.relative_to(ROOT))) + + if offenders: + print("Em dash characters are not allowed in tracked project text:") + for offender in offenders: + print(f"- {offender}") + return 1 + print("Tracked project text contains no em dash characters.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/styles.css b/styles.css index 627e2a4..1c5d976 100644 --- a/styles.css +++ b/styles.css @@ -1,275 +1,2156 @@ +/* ========================================================================== + GraphABI: Semantic Pulse + Two graph languages, in deliberate opposition: + - the ambient field is organic, continuous and never resolves; + - every product surface is precise, deterministic, and stops. + ========================================================================== */ + :root { color-scheme: light dark; - --bg:#F8FAFC; - --surface:#FFFFFF; - --surface-strong:#F1F5F9; - --primary:#7C3AED; - --pulse:#7C3AED; - --pass:#15803D; - --fail:#DC2626; - --unknown:#B45309; - --edge:#64748B; - --text:#0B0F14; - --muted:#475569; - --subtle:#64748B; - --border:#CBD5E1; - --space-1:8px; --space-2:16px; --space-3:24px; --space-4:32px; --space-5:40px; --space-6:48px; --space-7:64px; --space-8:80px; --space-9:96px; - --sans:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; - --mono:"SFMono-Regular","Cascadia Code","Roboto Mono",ui-monospace,monospace; - --ease-out-quint:cubic-bezier(.22,1,.36,1); + + /* Light theme is designed, not inverted. */ + --bg: #FBFCFD; + --bg-sunk: #F1F4F8; + --surface: #FFFFFF; + --surface-2: #F5F8FA; + --surface-code: #F7F9FB; + --border: #E3E8EF; + --border-strong: #CBD5E1; + --text: #0B0F14; + --muted: #4C5866; + --subtle: #5D6B7A; + --primary: #6D28D9; + --primary-hover: #5B21B6; + --on-primary: #FFFFFF; + --pulse: #7C3AED; + --pass: #15803D; + --fail: #DC2626; + --unknown: #B45309; + --edge: #A3B0C0; + --code: #1E293B; + + /* Status colours split by role. Strokes, rails and marks only have to + clear 3:1 as non-text UI; anything set as small text has to clear 4.5:1 + on every surface it can land on, including the status tints. */ + --pass-text: #136B33; + --fail-text: #C81E1E; + --unknown-text: #9A4708; + + --tint-pulse: rgba(124, 58, 237, .07); + --tint-pass: rgba(21, 128, 61, .08); + --tint-fail: rgba(220, 38, 38, .06); + --edge-pulse: rgba(124, 58, 237, .35); + --edge-pass: rgba(21, 128, 61, .35); + --edge-fail: rgba(220, 38, 38, .35); + --header-bg: rgba(251, 252, 253, .78); + --shadow-card: 0 1px 2px rgba(11, 15, 20, .04), 0 8px 24px -12px rgba(11, 15, 20, .10); + --shadow-lift: 0 1px 2px rgba(11, 15, 20, .05), 0 18px 48px -20px rgba(11, 15, 20, .22); + --shadow-modal: 0 32px 80px -24px rgba(11, 15, 20, .32); + --field-alpha: .82; + + /* 8px spacing system. --s0 is the only half-step, for optical alignment. */ + --s0: 4px; --s1: 8px; --s2: 16px; --s3: 24px; --s4: 32px; + --s5: 40px; --s6: 48px; --s7: 64px; --s8: 80px; --s9: 96px; + --s10: 128px; --s11: 160px; --s12: 192px; + + --sans: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --mono: "SFMono-Regular", "Cascadia Code", "Roboto Mono", ui-monospace, monospace; + + --ease: cubic-bezier(.22, 1, .36, 1); + --ease-io: cubic-bezier(.65, 0, .35, 1); + --t-fast: 220ms; + --t-mid: 320ms; + --t-slow: 500ms; + + /* Where a stopped pulse rests on an edge, as a fraction of edge length. + The cut sits at 50%, so the pulse must always come to rest short of it. */ + --break-stop: .34; + + --radius: 12px; + --radius-sm: 8px; + --radius-lg: 16px; + --gutter: var(--s6); } @media (prefers-color-scheme: dark) { :root { - color-scheme:dark; - --bg:#0B0F14; - --surface:#121820; - --surface-strong:#18212C; - --primary:#8B5CF6; - --pulse:#A78BFA; - --pass:#22C55E; - --fail:#EF4444; - --unknown:#F59E0B; - --edge:#556070; - --text:#F8FAFC; - --muted:#94A3B8; - --subtle:#64748B; - --border:#273241; - --sans:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; - --mono:"SFMono-Regular","Cascadia Code","Roboto Mono",ui-monospace,monospace; - --ease-out-quint:cubic-bezier(.22,1,.36,1); + color-scheme: dark; + --bg: #0B0F14; + --bg-sunk: #080B0F; + --surface: #111820; + --surface-2: #18212C; + --surface-code: #0D131A; + --border: #222E3C; + --border-strong: #33414F; + --text: #F8FAFC; + --muted: #97A5B6; + --subtle: #8695A6; + --primary: #8B5CF6; + --primary-hover: #A78BFA; + --on-primary: #0B0F14; + --pulse: #A78BFA; + --pass: #22C55E; + --fail: #EF4444; + --unknown: #F59E0B; + --edge: #46556A; + --code: #D5DDE8; + + --pass-text: #22C55E; + --fail-text: #F87171; + --unknown-text: #F59E0B; + + --tint-pulse: rgba(167, 139, 250, .10); + --tint-pass: rgba(34, 197, 94, .10); + --tint-fail: rgba(239, 68, 68, .08); + --edge-pulse: rgba(167, 139, 250, .45); + --edge-pass: rgba(34, 197, 94, .40); + --edge-fail: rgba(239, 68, 68, .42); + --header-bg: rgba(11, 15, 20, .72); + --shadow-card: 0 1px 0 rgba(255, 255, 255, .02) inset, 0 12px 32px -18px rgba(0, 0, 0, .8); + --shadow-lift: 0 1px 0 rgba(255, 255, 255, .03) inset, 0 24px 60px -24px rgba(0, 0, 0, .9); + --shadow-modal: 0 40px 96px -28px rgba(0, 0, 0, .85); + --field-alpha: 1; } } -* { box-sizing:border-box } -html { scroll-behavior:smooth; scroll-padding-top:96px } -body { margin:0; overflow-x:hidden; background:var(--bg); color:var(--text); font:16px/1.65 var(--sans) } -img { display:block; max-width:100%; height:auto } -a { color:inherit; text-decoration-color:var(--primary); text-underline-offset:4px } -button,a { -webkit-tap-highlight-color:transparent } -:focus-visible { outline:2px solid var(--pulse); outline-offset:3px } -.skip-link { position:fixed; top:12px; left:16px; z-index:100; min-height:44px; padding:9px 14px; transform:translateY(-180%); border-radius:8px; background:var(--primary); color:white; font-weight:700 } -.skip-link:focus { transform:none } -.shell { width:min(1200px,calc(100% - 64px)); margin-inline:auto } -.sr-only { position:absolute!important; width:1px!important; height:1px!important; padding:0!important; margin:-1px!important; overflow:hidden!important; clip:rect(0 0 0 0)!important; white-space:nowrap!important; border:0!important } - -.site-header { position:relative; z-index:10; border-bottom:1px solid rgba(39,50,65,.72); background:rgba(11,15,20,.9) } -.nav { display:flex; align-items:center; justify-content:space-between; gap:32px; min-height:84px } -.brand { display:flex; align-items:center; min-height:44px; text-decoration:none } -.brand img { width:207px } -.nav-links { display:flex; align-items:center; gap:6px } -.nav-links a { display:flex; align-items:center; min-height:44px; padding:0 13px; color:var(--muted); text-decoration:none; font-size:13px; font-weight:600 } -.nav-links a:hover { color:var(--text) } -.nav-links .nav-github { margin-left:8px; border:1px solid var(--border); border-radius:8px; color:var(--text) } -.nav-links .nav-github:hover { border-color:var(--primary); background:#171324 } - -.hero { padding:104px 0 42px } -.hero-copy { max-width:990px } -.eyebrow { margin:0 0 18px; color:var(--pulse); font-size:11px; font-weight:700; letter-spacing:.14em; text-transform:uppercase } -h1,h2,h3,p { margin-top:0 } -h1,h2,h3 { letter-spacing:-.035em } -h1 { margin-bottom:30px; font-size:clamp(3.3rem,8.4vw,7rem); font-weight:650; line-height:.94 } -h1 span { color:var(--fail) } -h2 { margin-bottom:0; font-size:clamp(2.2rem,5vw,4.25rem); font-weight:640; line-height:1.02 } -.hero-lede { max-width:750px; margin-bottom:0; color:var(--muted); font-size:clamp(1.08rem,2vw,1.3rem); line-height:1.65 } -.hero-actions { display:flex; flex-wrap:wrap; gap:12px; margin-top:32px } -.button { display:inline-flex; align-items:center; justify-content:center; min-height:48px; padding:0 18px; border:1px solid var(--border); border-radius:8px; background:transparent; color:var(--text); font:700 14px var(--sans); text-decoration:none; cursor:pointer; transition:border-color 220ms var(--ease-out-quint),background 220ms var(--ease-out-quint),color 220ms var(--ease-out-quint) } -.button.primary { border-color:var(--primary); background:var(--primary); color:var(--bg) } -.button.primary:hover { background:#A78BFA } -.button.secondary:hover { border-color:var(--primary); background:#171324 } -.install-line { max-width:820px; margin-top:24px; padding:14px 16px; overflow-x:auto; border:1px solid var(--border); border-left:3px solid var(--pulse); border-radius:8px; background:var(--surface); color:#D8DEE9; white-space:nowrap } -.install-line code { font:12px/1.5 var(--mono) } - -.pulse-scene { margin-top:74px; overflow:hidden; border:1px solid var(--border); border-radius:14px; background:var(--surface) } -.scene-toolbar { display:flex; align-items:center; justify-content:space-between; gap:24px; min-height:70px; padding:13px 18px 13px 24px; border-bottom:1px solid var(--border) } -.scene-toolbar > div { display:flex; flex-direction:column; gap:3px } -.scene-kicker { color:var(--muted); font:700 10px/1.3 var(--mono); letter-spacing:.12em } -.scene-phase { color:var(--text); font:650 12px/1.4 var(--mono) } -.replay { min-height:44px; padding:0 15px; border:1px solid var(--border); border-radius:8px; background:transparent; color:var(--text); font:650 13px var(--sans); cursor:pointer } -.replay:hover { border-color:var(--primary); background:#171324 } -.replay:disabled { cursor:wait; opacity:.62 } -.scene-status { display:flex; gap:12px; padding:20px 24px 0 } -.status { min-width:185px; padding:12px 14px; border:1px solid var(--border); border-radius:9px; background:var(--bg) } -.status span,.status small { display:block; color:var(--muted); font:700 9px/1.35 var(--mono); letter-spacing:.09em } -.status strong { display:block; margin:4px 0; font:700 15px/1.3 var(--mono) } -.schema-status { border-color:#205E39 }.schema-status strong { color:var(--pass) } -.semantic-status { border-color:#6F2429 }.semantic-status strong { color:var(--fail) } -.graph-flow { display:grid; grid-template-columns:1.08fr minmax(64px,.55fr) 1fr minmax(58px,.5fr) 1.15fr minmax(58px,.5fr) 1.06fr; align-items:center; padding:46px 24px 42px } -.graph-node { position:relative; min-width:0; min-height:104px; padding:22px 18px; border:1px solid var(--edge); border-radius:11px; background:var(--surface-strong) } -.graph-node strong,.graph-node small { display:block; overflow-wrap:anywhere } -.graph-node strong { font:700 14px/1.4 var(--mono) } -.graph-node small { margin-top:10px; color:var(--muted); font-size:10px } -.graph-node.direct { border:2px solid var(--fail); background:#1C1418 } -.graph-node.direct small { color:var(--fail) } -.graph-node.affected { border-color:#66363B; background:#17171C } -.graph-node.affected small { color:#EF8080 } -.node-pulse { display:block; width:9px; height:9px; margin-bottom:11px; border-radius:50%; background:var(--pulse) } -.graph-edge { position:relative; height:48px } -.edge-rail { position:absolute; top:23px; left:0; width:100%; height:3px; background:var(--edge) } -.first-edge .edge-rail { right:52%; width:48%; height:5px; border-radius:3px; background:var(--pulse) } -.pulse { position:absolute; top:18px; left:calc(48% - 5px); width:13px; height:13px; border-radius:50%; background:var(--pulse) } -.break-mark { position:absolute; top:10px; left:calc(50% + 4px); width:22px; height:30px } -.break-mark i { position:absolute; left:3px; width:19px; height:4px; border-radius:3px; background:var(--fail); transform:rotate(42deg); transform-origin:left center } -.break-mark i:first-child { top:2px }.break-mark i:last-child { top:17px } -.edge-caption { position:absolute; top:-12px; left:50%; color:var(--fail); font:700 9px var(--sans); letter-spacing:.1em; white-space:nowrap; transform:translateX(-50%) } -.blast-edge .edge-rail { height:2px; background:repeating-linear-gradient(90deg,var(--fail) 0 6px,transparent 6px 13px) } -.scene-witness { margin:0 24px 24px; border:1px solid #49282D; border-left:5px solid var(--fail); border-radius:10px; background:var(--bg) } -.witness-heading { padding:18px 20px; border-bottom:1px solid var(--border) } -.witness-heading span,.witness-values span { display:block; color:var(--fail); font:700 9px/1.4 var(--mono); letter-spacing:.1em } -.witness-heading strong { display:block; margin-top:8px; font-size:16px; line-height:1.4 } -.witness-values { display:grid; grid-template-columns:1fr 1fr; gap:1px; background:var(--border) } -.witness-values > div { padding:16px 20px; background:var(--bg) } -.witness-values code { display:block; margin-top:7px; color:var(--pass); font:12px/1.5 var(--mono) } -.witness-values > div:last-child code { color:var(--fail) } -.scene-witness > p { margin:0; padding:13px 20px; color:var(--muted); font-size:11px } - -.pulse-scene.is-playing .graph-node,.pulse-scene.is-playing .scene-status,.pulse-scene.is-playing .scene-witness,.pulse-scene.is-playing .pulse,.pulse-scene.is-playing .break-mark,.pulse-scene.is-playing .edge-caption { opacity:0 } -.pulse-scene.is-playing .graph-node { transform:translateY(8px) } -.pulse-scene.is-playing .graph-node,.pulse-scene.is-playing .scene-status { transition:opacity 320ms var(--ease-out-quint),transform 320ms var(--ease-out-quint) } -.pulse-scene.is-playing.nodes-active .graph-node { opacity:1; transform:none } -.pulse-scene.is-playing.baseline-active .scene-status { opacity:1 } -.pulse-scene.is-playing.baseline-active .semantic-status { visibility:hidden; opacity:0 } -.pulse-scene.is-playing.baseline-active .edge-rail { background:var(--pass) } -.pulse-scene.is-playing.candidate-active .scene-status { opacity:1 } -.pulse-scene.is-playing.candidate-active .semantic-status { visibility:hidden; opacity:0 } -.pulse-scene.is-playing.candidate-active .first-edge .edge-rail { background:var(--pulse) } -.pulse-scene.is-playing.candidate-active .pulse { opacity:1; transition:opacity 220ms var(--ease-out-quint) } -.pulse-scene.is-playing:not(.broken-active) .graph-node.direct,.pulse-scene.is-playing:not(.impact-active) .graph-node.affected { border-color:var(--edge); background:var(--surface-strong) } -.pulse-scene.is-playing:not(.broken-active) .graph-node.direct small,.pulse-scene.is-playing:not(.impact-active) .graph-node.affected small { color:var(--muted) } -.pulse-scene.is-playing:not(.impact-active) .blast-edge .edge-rail { background:var(--edge) } -.pulse-scene.is-playing.broken-active .semantic-status { visibility:visible; opacity:1 } -.pulse-scene.is-playing.broken-active .break-mark,.pulse-scene.is-playing.broken-active .edge-caption { opacity:1; transition:opacity 220ms var(--ease-out-quint) } -.pulse-scene.is-playing.impact-active .blast-edge .edge-rail { background:repeating-linear-gradient(90deg,var(--fail) 0 6px,transparent 6px 13px) } -.pulse-scene.is-playing.witness-active .scene-witness { opacity:1; transform:none; transition:opacity 500ms var(--ease-out-quint),transform 500ms var(--ease-out-quint) } -.pulse-scene.is-playing:not(.witness-active) .scene-witness { transform:translateY(12px) } - -.section { padding-top:150px } -.section-heading { display:flex; align-items:end; justify-content:space-between; gap:48px; margin-bottom:50px } -.section-heading > div { flex:1 1 60% } -.section-heading > p { flex:0 1 480px; margin:0; color:var(--muted); font-size:17px } - -.meaning-diff { display:grid; grid-template-columns:1fr 94px 1fr; align-items:center } -.payload { overflow:hidden; border:1px solid var(--border); border-radius:12px; background:var(--surface) } -.payload-header { display:flex; align-items:center; justify-content:space-between; gap:18px; min-height:55px; padding:0 18px; border-bottom:1px solid var(--border); color:var(--muted); font:700 10px var(--mono); letter-spacing:.09em } -.badge { padding:4px 8px; border:1px solid currentColor; border-radius:7px }.pass { color:var(--pass)!important }.fail { color:var(--fail)!important }.muted { color:var(--muted)!important } -.payload pre,.terminal pre,.yaml { margin:0; overflow:auto; background:var(--bg); font:13px/1.75 var(--mono) } -.payload pre { padding:24px 20px } -.payload > p { min-height:72px; margin:0; padding:16px 18px; border-top:1px solid var(--border); color:var(--muted); font-size:13px } -.flow-dot,.break-dot { display:inline-block; width:8px; height:8px; margin-right:7px; border-radius:50%; background:var(--pass) }.break-dot { background:var(--fail) } -.contract-cut { position:relative; height:4px; background:linear-gradient(90deg,var(--pulse) 0 42%,transparent 42% 58%,var(--edge) 58%) } -.contract-cut i { position:absolute; left:46%; width:22px; height:4px; border-radius:2px; background:var(--fail); transform:rotate(42deg); transform-origin:left center }.contract-cut i:nth-of-type(1) { top:-10px }.contract-cut i:nth-of-type(2) { top:7px } -.diff-caption { margin:22px 0 0; color:var(--muted); text-align:center }.diff-caption strong { color:var(--text) }.diff-caption code { color:var(--pulse); font-family:var(--mono) } - -.edge-anatomy { padding:74px 54px; border:1px solid var(--border); border-radius:14px; background:var(--surface) } -.anatomy-flow { display:grid; grid-template-columns:170px 1fr 170px; align-items:center } -.anatomy-node { padding:18px; border:1px solid var(--edge); border-radius:10px; background:var(--surface-strong); text-align:center; font:700 13px var(--mono) }.anatomy-node.consumer { border-color:var(--fail) } -.long-rail { position:relative; height:5px; background:linear-gradient(90deg,var(--pulse) 0 49%,transparent 49% 54%,var(--edge) 54%); border-radius:3px } -.long-rail b { position:absolute; top:-5px; left:calc(49% - 5px); width:15px; height:15px; border-radius:50%; background:var(--pulse) } -.long-rail i { position:absolute; left:50.3%; width:24px; height:4px; border-radius:3px; background:var(--fail); transform:rotate(42deg); transform-origin:left center }.long-rail .cut-one { top:-11px }.long-rail .cut-two { top:7px } -.anatomy-labels { display:grid; grid-template-columns:1fr 1fr 1fr; gap:20px; margin-top:40px; color:var(--muted); font:700 10px var(--mono); letter-spacing:.08em; text-align:center } - -.method-rail { display:grid; grid-template-columns:repeat(6,1fr); margin:0; padding:0; border:1px solid var(--border); border-radius:12px; overflow:hidden; list-style:none; background:var(--surface) } -.method-rail li { position:relative; min-height:220px; padding:24px 20px; border-right:1px solid var(--border) }.method-rail li:last-child { border-right:0 } -.method-rail li::before { content:""; position:absolute; top:53px; left:0; right:0; height:2px; background:var(--edge) }.method-rail li:first-child::before { left:24px }.method-rail li:last-child::before { right:calc(100% - 26px) } -.method-rail span { position:relative; z-index:1; display:grid; place-items:center; width:26px; height:26px; border:2px solid var(--pulse); border-radius:50%; background:var(--surface); color:var(--pulse); font:700 9px var(--mono) } -.method-rail strong { display:block; margin-top:42px; font-size:17px }.method-rail p { margin:9px 0 0; color:var(--muted); font-size:12px; line-height:1.55 } -.method-rail li:nth-child(3) span { border-color:var(--fail); color:var(--fail) }.method-rail li:nth-child(3)::after { content:""; position:absolute; top:45px; left:55%; width:15px; height:3px; border-radius:2px; background:var(--fail); transform:rotate(42deg) } - -.code-layout { display:grid; grid-template-columns:1.55fr .75fr; border:1px solid var(--border); border-radius:12px; overflow:hidden; background:var(--surface) } -.yaml { padding:30px; border-right:1px solid var(--border); color:#D8DEE9 }.yaml .key { color:var(--pulse) }.yaml .string { color:var(--pass) } -.evaluator-list { padding:30px }.list-label { color:var(--muted); font:700 10px var(--mono); letter-spacing:.1em }.evaluator-list ul { margin:24px 0 30px; padding:0; list-style:none }.evaluator-list li { display:flex; gap:14px; padding:12px 0; border-bottom:1px solid var(--border); font-size:14px }.evaluator-list li span { color:var(--muted); font:11px var(--mono) }.evaluator-list a { font-size:13px; font-weight:700 } - -.terminal { overflow:hidden; border:1px solid var(--border); border-radius:12px; background:var(--bg) } -.terminal-bar { display:flex; align-items:center; gap:7px; min-height:50px; padding:0 18px; border-bottom:1px solid var(--border); background:var(--surface) }.terminal-bar span { width:8px; height:8px; border-radius:50%; background:var(--edge) }.terminal-bar strong { margin-left:10px; color:var(--muted); font:600 11px var(--mono) } -.terminal pre { padding:28px; color:#D8DEE9 } - -.architecture-visual,.report-figure { width:100%; border:1px solid var(--border); border-radius:14px; background:var(--surface) } -.report-figure { margin:0; overflow:hidden }.report-figure img { width:100% }.report-figure figcaption { padding:13px 18px; border-top:1px solid var(--border); color:var(--muted); font:11px var(--mono) } - -.roadmap-rail { border-top:1px solid var(--border) }.roadmap-rail a { display:grid; grid-template-columns:60px 1fr auto; align-items:center; gap:20px; min-height:82px; border-bottom:1px solid var(--border); text-decoration:none }.roadmap-rail a:hover { background:var(--surface) }.roadmap-rail span,.roadmap-rail small { color:var(--muted); font:11px var(--mono) }.roadmap-rail strong { font-size:17px }.roadmap-rail small { color:var(--unknown); text-transform:uppercase } - -.final-cta { margin-top:150px; padding:90px 52px; border:1px solid var(--border); border-radius:14px; background:var(--surface) }.final-cta h2 { max-width:700px }.final-cta > p:not(.eyebrow) { max-width:640px; margin:24px 0 0; color:var(--muted); font-size:18px }.cta-mark { position:relative; width:168px; height:18px; margin-bottom:46px; background:linear-gradient(90deg,var(--pulse) 0 44%,transparent 44% 56%,var(--edge) 56%); border-radius:5px }.cta-mark::before,.cta-mark::after { content:""; position:absolute; top:4px; width:10px; height:10px; border-radius:50%; background:var(--pulse) }.cta-mark::before { left:-5px }.cta-mark::after { right:-5px; background:var(--edge) }.cta-mark i { position:absolute; left:46%; width:22px; height:4px; border-radius:3px; background:var(--fail); transform:rotate(42deg); transform-origin:left center }.cta-mark i:first-of-type { top:-6px }.cta-mark i:last-of-type { top:10px } - -.site-footer { margin-top:100px; border-top:1px solid var(--border) }.footer-inner { display:flex; align-items:center; justify-content:space-between; gap:32px; min-height:116px; color:var(--muted); font-size:12px }.footer-inner > div { display:flex; align-items:center; gap:14px }.footer-inner nav { display:flex; gap:24px }.footer-inner a:hover { color:var(--text) } - -@media (max-width:920px) { - .shell { width:min(100% - 40px,1200px) } - .nav-links a:not(.nav-github) { display:none } - .section { padding-top:112px } - .section-heading { align-items:flex-start; flex-direction:column; gap:22px } - .meaning-diff { grid-template-columns:1fr; gap:44px }.contract-cut { width:4px; height:44px; margin:auto; background:linear-gradient(180deg,var(--pulse) 0 42%,transparent 42% 58%,var(--edge) 58%) }.contract-cut i { left:-8px; transform:rotate(-42deg) }.contract-cut i:nth-of-type(1) { top:17px }.contract-cut i:nth-of-type(2) { top:31px } - .method-rail { grid-template-columns:repeat(3,1fr) }.method-rail li:nth-child(3) { border-right:0 }.method-rail li:nth-child(-n+3) { border-bottom:1px solid var(--border) } - .code-layout { grid-template-columns:1fr }.yaml { border-right:0; border-bottom:1px solid var(--border) } -} - -/* Semantic Pulse product controls. These are state changes, not ambient decoration. */ -.playground { border:1px solid var(--border); border-radius:14px; background:var(--surface); overflow:hidden } -.playground-toolbar { display:flex; align-items:center; gap:var(--space-2); flex-wrap:wrap; padding:var(--space-3); border-bottom:1px solid var(--border) } -.playground-choice { min-height:44px; padding:0 var(--space-2); border:1px solid var(--border); border-radius:8px; background:transparent; color:var(--muted); font:700 13px var(--sans); cursor:pointer } -.playground-choice:hover,.playground-choice.is-selected { border-color:var(--pulse); color:var(--text) } -.playground-run { margin-left:auto } -.playground-graph { display:grid; grid-template-columns:1fr minmax(120px,.8fr) 1fr; align-items:center; gap:var(--space-3); padding:var(--space-7) var(--space-6) var(--space-6) } -.playground-node { min-height:96px; padding:var(--space-3); border:1px solid var(--edge); border-radius:10px; background:var(--surface-strong) } -.playground-node strong,.playground-node small { display:block }.playground-node strong { font:700 14px var(--mono) }.playground-node small { margin-top:var(--space-1); color:var(--muted); font-size:12px } -.playground-edge { position:relative; height:32px; border-top:3px solid var(--edge) }.playground-edge small { position:absolute; top:-32px; left:50%; transform:translateX(-50%); color:var(--muted); font:700 10px var(--mono); white-space:nowrap } -.playground-pulse { position:absolute; top:-8px; left:0; width:13px; height:13px; border-radius:50%; background:var(--pulse); opacity:0 } -.playground-break { position:absolute; top:-14px; left:50%; width:24px; height:28px; opacity:0 }.playground-break i { position:absolute; width:22px; height:4px; background:var(--fail); transform:rotate(42deg) }.playground-break i:last-child { top:15px } -.playground.is-running .playground-pulse { opacity:1; animation:semantic-pulse 700ms var(--ease-out-quint) forwards } -.playground.is-baseline .playground-edge { border-color:var(--pass) }.playground.is-baseline .playground-node:last-child { border-color:var(--pass) } -.playground.is-candidate .playground-edge { border-color:var(--fail) }.playground.is-candidate .playground-pulse { animation-play-state:paused; left:calc(50% - 6px); background:var(--fail) }.playground.is-candidate .playground-break { opacity:1 }.playground.is-candidate .playground-node:last-child { border-color:var(--fail) } -.playground-result { display:flex; flex-direction:column; gap:var(--space-1); min-height:82px; padding:var(--space-3) var(--space-6); border-top:1px solid var(--border); background:var(--bg); font-size:14px }.playground-result span { color:var(--muted); font-size:13px } -@keyframes semantic-pulse { from { left:0 } to { left:calc(100% - 13px) } } - -.setup-modal[hidden] { display:none } -.setup-modal { position:fixed; inset:0; z-index:80; display:grid; place-items:center; padding:var(--space-3); color:var(--text) } -.modal-backdrop { position:absolute; inset:0; background:rgba(11,15,20,.66); backdrop-filter:blur(6px) } -.setup-dialog { position:relative; width:min(720px,100%); max-height:calc(100dvh - 48px); overflow:auto; border:1px solid var(--border); border-radius:16px; background:var(--surface); opacity:0; transform:translateY(8px); transition:opacity 320ms var(--ease-out-quint),transform 320ms var(--ease-out-quint) } -.setup-modal.is-open .setup-dialog { opacity:1; transform:none } -.setup-dialog-header { display:flex; justify-content:space-between; gap:var(--space-4); padding:var(--space-5) var(--space-5) var(--space-3) } -.setup-dialog-header h2 { font-size:clamp(1.8rem,4vw,2.6rem) }.setup-dialog-header p:not(.eyebrow) { max-width:560px; margin:var(--space-2) 0 0; color:var(--muted); font-size:14px } -.modal-close { position:sticky; top:var(--space-2); flex:0 0 44px; width:44px; height:44px; border:1px solid var(--border); border-radius:8px; background:var(--surface); color:var(--text); font-size:24px; line-height:1; cursor:pointer } -.setup-tabs { display:flex; gap:4px; overflow-x:auto; padding:0 var(--space-5); border-bottom:1px solid var(--border) }.setup-tab { min-height:48px; padding:0 var(--space-2); border:0; border-bottom:2px solid transparent; background:transparent; color:var(--muted); font:700 12px var(--sans); white-space:nowrap; cursor:pointer }.setup-tab:hover,.setup-tab.is-active { border-bottom-color:var(--pulse); color:var(--text) } -.setup-panel { padding:var(--space-5) }.setup-panel-meta { display:flex; align-items:center; justify-content:space-between; gap:var(--space-2) }.setup-panel-meta strong { font-size:16px }.setup-panel p { margin:var(--space-2) 0 var(--space-3); color:var(--muted); font-size:14px } -.support-badge { padding:4px 7px; border:1px solid currentColor; border-radius:999px; font:700 10px var(--mono); white-space:nowrap }.support-badge.available { color:var(--pass) }.support-badge.cli { color:var(--pulse) }.support-badge.planned { color:var(--unknown) } -.command-line { display:flex; align-items:center; gap:var(--space-2); padding:var(--space-2); border:1px solid var(--border); border-left:3px solid var(--pulse); border-radius:8px; background:var(--bg) }.command-line code { flex:1; overflow:auto; color:var(--text); font:12px/1.6 var(--mono); white-space:pre-wrap; overflow-wrap:anywhere }.copy-button { flex:0 0 auto; min-height:40px; padding:0 12px; border:1px solid var(--border); border-radius:7px; background:var(--surface); color:var(--text); font:700 12px var(--sans); cursor:pointer }.copy-button:hover,.copy-button.copied { border-color:var(--pass); color:var(--pass) }.copy-feedback { display:block; min-height:22px; margin-top:var(--space-1); color:var(--pass); font:11px var(--mono) } -body.modal-open { overflow:hidden } +/* --------------------------------------------------------------- base --- */ -@media (prefers-color-scheme: dark) { - .site-header { background:rgba(11,15,20,.9) }.install-line,.yaml,.terminal pre { color:#D8DEE9 }.button.primary { color:var(--bg) }.button.secondary:hover,.replay:hover { background:#171324 } -} - -@media (max-width:920px) { - .section { padding-top:var(--space-8) }.hero { padding-top:var(--space-7) }.final-cta { margin-top:var(--space-8) } -} -@media (max-width:700px) { - .section { padding-top:var(--space-7) }.hero { padding-top:var(--space-6) }.section-heading { margin-bottom:var(--space-4) }.final-cta { margin-top:var(--space-7); padding:var(--space-6) var(--space-3) } - .playground-toolbar { align-items:stretch; flex-direction:column }.playground-run { margin-left:0 }.playground-graph { grid-template-columns:1fr; gap:var(--space-4); padding:var(--space-5) var(--space-3) }.playground-edge { width:calc(100% - 24px); margin-inline:auto; transform:rotate(90deg) }.playground-edge small { transform:translateX(-50%) rotate(-90deg); top:-50px }.playground-pulse { animation-name:semantic-pulse-vertical!important; top:-8px; left:0 }.playground.is-candidate .playground-pulse { left:calc(50% - 6px) }.playground-break { transform:rotate(-90deg); left:calc(50% - 12px) }.playground-result { padding-inline:var(--space-3) } - .setup-modal { align-items:end; padding:0 }.setup-dialog { width:100%; max-height:92dvh; border-radius:16px 16px 0 0; padding-bottom:env(safe-area-inset-bottom) }.setup-dialog-header { padding:var(--space-4) var(--space-3) var(--space-2) }.setup-tabs,.setup-panel { padding-left:var(--space-3); padding-right:var(--space-3) }.setup-tab { min-height:52px }.command-line { align-items:stretch; flex-direction:column }.copy-button { width:100% }.modal-close { position:absolute; top:var(--space-3); right:var(--space-3) } -} -@keyframes semantic-pulse-vertical { from { top:-8px } to { top:calc(100% - 8px) } } -@media (prefers-reduced-motion:reduce) { - .setup-dialog { transition:none }.playground.is-running .playground-pulse { animation:none; left:calc(100% - 13px) }.playground.is-candidate .playground-pulse { left:calc(50% - 6px) } -} - -@media (max-width:700px) { - .shell { width:min(100% - 32px,1200px) } - .site-header { position:static }.nav { min-height:72px }.brand img { width:166px }.nav-github { font-size:0!important }.nav-github span { font-size:16px } - .hero { padding-top:70px } h1 { font-size:clamp(3rem,15vw,4.35rem) }.hero-lede { font-size:1rem }.hero-actions .button { flex:1 1 160px } - .install-line { margin-top:18px }.install-line code { font-size:11px } - .pulse-scene { margin-top:50px }.scene-toolbar { align-items:flex-start; flex-direction:column }.replay { width:100% }.scene-status { display:grid; grid-template-columns:1fr 1fr; padding:16px 16px 0 }.status { min-width:0 }.status small { display:none } - .graph-flow { grid-template-columns:1fr; padding:30px 16px }.graph-node { width:100%; min-height:86px }.graph-edge { width:100%; height:64px }.edge-rail,.first-edge .edge-rail,.blast-edge .edge-rail { top:0; left:calc(50% - 1px); width:3px; height:100%; background:var(--edge) }.first-edge .edge-rail { bottom:54%; height:46%; background:var(--pulse) }.blast-edge .edge-rail { background:repeating-linear-gradient(180deg,var(--fail) 0 6px,transparent 6px 13px) }.pulse { top:calc(46% - 6px); left:calc(50% - 6px) }.break-mark { top:47%; left:calc(50% + 4px); transform:rotate(90deg) }.edge-caption { top:42%; left:calc(50% + 68px) } - .scene-witness { margin:0 16px 16px }.witness-values { grid-template-columns:1fr }.witness-heading strong { font-size:14px } - .section { padding-top:100px }.section-heading { margin-bottom:36px }.section-heading > p { font-size:15px } - .edge-anatomy { padding:54px 18px }.anatomy-flow { grid-template-columns:1fr; gap:38px }.long-rail { width:5px; height:90px; margin:auto; background:linear-gradient(180deg,var(--pulse) 0 49%,transparent 49% 54%,var(--edge) 54%) }.long-rail b { top:calc(49% - 7px); left:-5px }.long-rail i { left:-9px; transform:rotate(-42deg) }.long-rail .cut-one { top:43% }.long-rail .cut-two { top:53% }.anatomy-labels { grid-template-columns:1fr; gap:10px } - .method-rail { grid-template-columns:1fr }.method-rail li { min-height:156px; border-right:0; border-bottom:1px solid var(--border)!important }.method-rail li:last-child { border-bottom:0!important }.method-rail li::before { top:50px; right:auto; bottom:-1px; left:32px!important; width:2px; height:auto }.method-rail li:last-child::before { display:none }.method-rail strong { margin-top:20px } - .yaml,.evaluator-list,.terminal pre { padding:20px 16px }.yaml,.terminal pre { font-size:11px } - .roadmap-rail a { grid-template-columns:40px 1fr; padding:15px 0 }.roadmap-rail small { grid-column:2 } - .final-cta { margin-top:100px; padding:60px 24px }.final-cta > p:not(.eyebrow) { font-size:16px } - .footer-inner { align-items:flex-start; flex-direction:column; justify-content:center; padding:28px 0 }.footer-inner nav { flex-wrap:wrap; gap:14px 22px } -} - -@media (prefers-reduced-motion:reduce) { - html { scroll-behavior:auto } - *,*::before,*::after { animation-duration:.01ms!important; animation-iteration-count:1!important; transition-duration:.01ms!important } - .pulse-scene.is-playing .graph-node,.pulse-scene.is-playing .scene-status,.pulse-scene.is-playing .scene-witness,.pulse-scene.is-playing .pulse,.pulse-scene.is-playing .break-mark,.pulse-scene.is-playing .edge-caption { opacity:1; transform:none } +*, *::before, *::after { box-sizing: border-box } + +html { + scroll-behavior: smooth; + scroll-padding-top: var(--s9); + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; + overflow-x: hidden; + background: var(--bg); + color: var(--text); + font: 400 16px/1.65 var(--sans); + font-synthesis-weight: none; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +img { display: block; max-width: 100%; height: auto } +svg { display: block } +a { color: inherit; text-decoration: none } +button, a { -webkit-tap-highlight-color: transparent } +button { font: inherit } + +:focus-visible { + outline: 2px solid var(--pulse); + outline-offset: 3px; + border-radius: 3px; +} + +.skip-link { + position: fixed; + top: var(--s2); + left: var(--s2); + z-index: 200; + display: grid; + place-items: center; + min-height: 44px; + padding: 0 var(--s2); + border-radius: var(--radius-sm); + background: var(--primary); + color: var(--on-primary); + font-weight: 650; + transform: translateY(-200%); + transition: transform var(--t-fast) var(--ease); +} +.skip-link:focus { transform: none } + +.shell { + position: relative; + width: min(1200px, 100% - (var(--gutter) * 2)); + margin-inline: auto; +} + +.sr-only { + position: absolute !important; + width: 1px !important; height: 1px !important; + padding: 0 !important; margin: -1px !important; + overflow: hidden !important; + clip-path: inset(50%) !important; + white-space: nowrap !important; + border: 0 !important; +} + +/* -------------------------------------------------------- typography --- */ + +h1, h2, h3, p, ol, ul, figure, pre { margin: 0 } +h1, h2, h3 { font-weight: 640; text-wrap: balance } + +h1 { + font-size: clamp(2.125rem, 7.6vw, 6.5rem); + line-height: .93; + letter-spacing: -.042em; +} +h1 .line { display: block } +h1 em { font-style: normal; color: var(--fail) } + +h2 { + font-size: clamp(2rem, 4.4vw, 3.5rem); + line-height: 1.04; + letter-spacing: -.034em; +} + +.eyebrow { + display: flex; + align-items: center; + gap: var(--s1); + margin-bottom: var(--s3); + color: var(--pulse); + font-size: .6875rem; + font-weight: 650; + letter-spacing: .13em; + text-transform: uppercase; +} +/* The eyebrow bullet is a graph node: sections are nodes on one traversal. */ +.eyebrow i { + flex: 0 0 auto; + width: 7px; height: 7px; + border-radius: 50%; + background: var(--pulse); + box-shadow: 0 0 0 4px var(--tint-pulse); +} + +.hero-lede, .cta-lede { + color: var(--muted); + font-size: clamp(1.0625rem, 1.5vw, 1.3125rem); + line-height: 1.6; + text-wrap: pretty; +} + +code, pre, .mono { font-family: var(--mono); font-variant-ligatures: none } + +/* -------------------------------------------------------- the field --- */ + +.field { + position: fixed; + inset: 0; + z-index: 0; + width: 100%; + height: 100%; + opacity: var(--field-alpha); + pointer-events: none; + /* Density falls away from the top so the field never fights body copy. */ + -webkit-mask-image: linear-gradient(180deg, #000 0%, #000 42%, rgba(0,0,0,.55) 72%, rgba(0,0,0,.28) 100%); + mask-image: linear-gradient(180deg, #000 0%, #000 42%, rgba(0,0,0,.55) 72%, rgba(0,0,0,.28) 100%); +} + +.field-veil { + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background: + radial-gradient(58% 58% at 30% 30%, var(--bg) 0%, var(--bg) 38%, transparent 78%), + radial-gradient(120% 70% at 50% 0%, transparent 40%, var(--bg) 100%), + radial-gradient(80% 50% at 12% 22%, var(--tint-pulse), transparent 70%); +} + +.site-header, main, .site-footer { position: relative; z-index: 1 } + +/* ----------------------------------------------------------- header --- */ + +.site-header { + position: sticky; + top: 0; + z-index: 60; + border-bottom: 1px solid transparent; + transition: border-color var(--t-mid) var(--ease), background var(--t-mid) var(--ease); +} +.site-header.is-stuck { + border-bottom-color: var(--border); + background: var(--header-bg); + -webkit-backdrop-filter: saturate(180%) blur(12px); + backdrop-filter: saturate(180%) blur(12px); +} + +.nav { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--s4); + min-height: 72px; +} + +.brand { + display: inline-flex; + align-items: center; + gap: var(--s1); + min-height: 44px; + margin-left: -2px; +} +.brand-word { + font-size: 1.125rem; + font-weight: 650; + letter-spacing: -.028em; +} +.brand-mark { display: block; flex: 0 0 auto } +.brand-mark svg { overflow: visible } + +/* One mark, theme-aware, no second asset and no font dependency. */ +.m-live { fill: var(--pulse) } +.m-dead { fill: var(--edge) } +.m-live-stroke { fill: none; stroke: var(--pulse); stroke-width: 4; stroke-linecap: round } +.m-dead-stroke { fill: none; stroke: var(--edge); stroke-width: 4; stroke-linecap: round } +.m-cut { fill: none; stroke: var(--fail); stroke-width: 3.5; stroke-linecap: round } + +/* The mark's live segment carries a pulse when the brand is hovered. */ +.brand .m-live-stroke { + stroke-dasharray: 5 9; + stroke-dashoffset: 0; +} +.brand:hover .m-live-stroke, +.brand:focus-visible .m-live-stroke { + animation: mark-flow 900ms var(--ease-io) infinite; +} +@keyframes mark-flow { to { stroke-dashoffset: -14 } } + +.nav-links { display: flex; align-items: center; gap: var(--s0) } +.nav-links a { + position: relative; + display: inline-flex; + align-items: center; + min-height: 44px; + padding: 0 var(--s2); + color: var(--muted); + font-size: .875rem; + font-weight: 550; + transition: color var(--t-fast) var(--ease); +} +.nav-links a:hover { color: var(--text) } + +/* Hovering a link completes an edge underneath it. */ +.nav-links a:not(.nav-github) span { position: relative } +.nav-links a:not(.nav-github) span::after { + content: ""; + position: absolute; + left: 0; right: 0; bottom: -6px; + height: 1.5px; + border-radius: 2px; + background: var(--pulse); + transform: scaleX(0); + transform-origin: left; + transition: transform var(--t-mid) var(--ease); +} +.nav-links a:not(.nav-github):hover span::after, +.nav-links a:not(.nav-github):focus-visible span::after { transform: scaleX(1) } + +.nav-github { + gap: 6px; + margin-left: var(--s1); + padding-inline: var(--s2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text) !important; + font-weight: 600; + box-shadow: var(--shadow-card); + transition: border-color var(--t-fast) var(--ease), transform var(--t-fast) var(--ease); +} +.nav-github svg { fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round } +.nav-github:hover { border-color: var(--border-strong); transform: translateY(-1px) } + +/* ------------------------------------------------------------- hero --- */ + +.hero { padding: var(--s9) 0 var(--s7) } +.hero-copy { max-width: 1000px } + +h1 { margin-bottom: var(--s4) } +.hero-lede { max-width: 52ch; margin-bottom: var(--s6) } + +.hero-actions { display: flex; flex-wrap: wrap; gap: var(--s1) } + +/* ---------------------------------------------------------- buttons --- */ + +.button { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 48px; + padding: 0 var(--s3); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text); + font: 650 .875rem var(--sans); + letter-spacing: -.006em; + cursor: pointer; + overflow: hidden; + box-shadow: var(--shadow-card); + transition: transform var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease), background var(--t-fast) var(--ease); +} +.button svg { fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round } +.button:hover { transform: translateY(-1px) } +.button:active { transform: translateY(0) } + +.button.primary { + border-color: transparent; + background: var(--primary); + color: var(--on-primary); +} +.button.primary:hover { background: var(--primary-hover) } +.button.secondary:hover { border-color: var(--border-strong) } + +/* A pulse crosses the control's own edge on hover: the same primitive, + at the smallest possible scale. */ +.button::after { + content: ""; + position: absolute; + left: 0; bottom: 0; + width: 100%; height: 1.5px; + background: linear-gradient(90deg, transparent, currentColor, transparent); + opacity: 0; + transform: translateX(-100%); +} +.button:hover::after, .button:focus-visible::after { + opacity: .55; + animation: edge-cross 620ms var(--ease-io); +} +@keyframes edge-cross { + from { transform: translateX(-100%) } + to { transform: translateX(100%) } +} + +/* ----------------------------------------------------- install line --- */ + +.install-line { + display: flex; + align-items: center; + gap: var(--s1); + max-width: 780px; + margin-top: var(--s5); + padding: var(--s0) var(--s0) var(--s0) var(--s2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + box-shadow: var(--shadow-card); + transition: border-color var(--t-fast) var(--ease); +} +.install-line:hover { border-color: var(--border-strong) } +.install-prompt { flex: 0 0 auto; color: var(--pulse); font: 600 .8125rem var(--mono) } +.install-line code { + flex: 1 1 auto; + min-width: 0; + overflow-x: auto; + padding: var(--s2) 0; + color: var(--code); + font-size: .8125rem; + line-height: 1.5; + white-space: nowrap; + scrollbar-width: none; +} +.install-line code::-webkit-scrollbar { display: none } + +.install-copy { + position: relative; + flex: 0 0 auto; + display: grid; + place-items: center; + width: 40px; height: 40px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface-2); + color: var(--muted); + cursor: pointer; + transition: color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease); +} +.install-copy:hover { color: var(--text); border-color: var(--border-strong) } +.install-copy svg { + grid-area: 1 / 1; + fill: none; stroke: currentColor; + stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; + transition: opacity var(--t-fast) var(--ease), transform var(--t-fast) var(--ease); +} +.install-copy .i-done { opacity: 0; transform: scale(.6) } +.install-copy.copied { color: var(--pass-text); border-color: var(--edge-pass) } +.install-copy.copied .i-copy { opacity: 0; transform: scale(.6) } +.install-copy.copied .i-done { opacity: 1; transform: none } + +/* =========================================================== sections === + Each section is a node on one traversal. A rail descends from the + previous section and lands on the section's eyebrow dot. + ======================================================================== */ + +.section > .shell { padding-top: var(--s12) } + +/* The rail and its node are structure, so they render regardless. Only the + arrival animation is withheld, and only when something can play it. */ +.section > .shell::before { + content: ""; + position: absolute; + left: 3px; + top: var(--s6); + width: 1px; + height: calc(var(--s12) - var(--s6) - 3px); + background: linear-gradient(180deg, transparent, var(--border-strong) 22%, var(--pulse)); + transform-origin: top; +} + +.js .section > .shell::before { + transform: scaleY(0); + transition: transform 900ms var(--ease); +} +.js .section.is-reached > .shell::before { transform: scaleY(1) } + +.js .section .eyebrow i { + transform: scale(.35); + opacity: 0; + transition: transform var(--t-slow) var(--ease) 260ms, opacity var(--t-mid) var(--ease) 260ms; +} +.js .section.is-reached .eyebrow i { transform: none; opacity: 1 } + +.section-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: var(--s7); + margin-bottom: var(--s7); +} +.section-heading > div { flex: 1 1 58% } +.section-heading > p { + flex: 0 1 440px; + color: var(--muted); + font-size: 1.0625rem; + line-height: 1.6; + text-wrap: pretty; +} + +/* Section composition changes with the kind of evidence being presented. */ +.bug-heading { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(280px, .6fr); + align-items: start; +} +.bug-heading h2 { max-width: 9ch; font-size: clamp(2.75rem, 6.4vw, 5.75rem) } +.bug-heading > p { + margin-top: var(--s9); + padding: var(--s3) 0 var(--s3) var(--s3); + border-left: 1px solid var(--pulse); +} + +.terminal-section > .shell { + display: grid; + grid-template-columns: minmax(260px, .56fr) minmax(0, 1.44fr); + align-items: start; + gap: var(--s8); +} +.terminal-section .section-heading { display: block; margin: var(--s3) 0 0 } +.terminal-section .section-heading > p { margin-top: var(--s3); font-size: .9375rem } + +.report-section { + margin-top: var(--s8); + border-block: 1px solid var(--border); + background: color-mix(in srgb, var(--bg-sunk) 78%, transparent); +} +.report-section > .shell { padding-bottom: var(--s10) } +.report-section .section-heading { align-items: start } +.report-section .section-heading h2 { max-width: 12ch } + +/* Shared surface treatment for every instrument panel. */ +.pulse-scene, .playground, .payload, .method-rail, .code-layout, +.terminal, .arch-frame, .report-figure, .final-cta { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); + box-shadow: var(--shadow-card); +} + +/* ==================================================== the instrument === + The canonical replay. Precise, causal, and it comes to rest. + ====================================================================== */ + +.pulse-scene { + margin-top: var(--s9); + overflow: hidden; + box-shadow: var(--shadow-lift); +} + +.scene-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--s3); + padding: var(--s2) var(--s2) var(--s2) var(--s3); + border-bottom: 1px solid var(--border); + background: var(--surface-2); +} +.scene-meta { display: flex; flex-direction: column; gap: 3px; min-width: 0 } +.scene-kicker { + color: var(--subtle); + font: 650 .75rem/1.3 var(--mono); + letter-spacing: .12em; + text-transform: uppercase; +} +.scene-phase { + color: var(--text); + font: 550 .8125rem/1.4 var(--mono); + letter-spacing: -.01em; +} + +.replay { + display: inline-flex; + align-items: center; + gap: 7px; + min-height: 40px; + padding: 0 var(--s2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text); + font: 600 .8125rem var(--sans); + white-space: nowrap; + cursor: pointer; + transition: border-color var(--t-fast) var(--ease), color var(--t-fast) var(--ease); +} +.replay svg { fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round } +.replay:hover { border-color: var(--pulse); color: var(--pulse) } +.replay:hover svg { animation: spin-once 620ms var(--ease) } +@keyframes spin-once { from { transform: rotate(0) } to { transform: rotate(-360deg) } } +.replay:disabled { cursor: default; opacity: .5 } +.replay:disabled:hover { border-color: var(--border); color: var(--text) } + +.scene-status { display: flex; flex-wrap: wrap; gap: var(--s1); padding: var(--s3) var(--s3) 0 } +.status { + flex: 1 1 200px; + min-width: 0; + padding: var(--s2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); +} +.status span, .status small { + display: block; + color: var(--subtle); + font: 650 .6875rem/1.35 var(--mono); + letter-spacing: .1em; + text-transform: uppercase; +} +.status strong { display: block; margin: var(--s0) 0; font: 650 .9375rem/1.3 var(--mono) } +.schema-status { border-color: var(--edge-pass); background: var(--tint-pass) } +.schema-status strong { color: var(--pass-text) } +.semantic-status { border-color: var(--edge-fail); background: var(--tint-fail) } +.semantic-status strong { color: var(--fail-text) } + +.graph-flow { + display: grid; + grid-template-columns: 1.08fr minmax(72px, .6fr) 1fr minmax(64px, .52fr) 1.14fr minmax(64px, .52fr) 1.06fr; + align-items: center; + padding: var(--s8) var(--s3) var(--s7); +} + +.graph-node { + position: relative; + min-width: 0; + padding: var(--s2); + border: 1px solid var(--edge); + border-radius: 10px; + background: var(--surface-2); + transition: border-color var(--t-mid) var(--ease), background var(--t-mid) var(--ease), transform var(--t-mid) var(--ease); +} +.graph-node strong, .graph-node small { display: block; overflow-wrap: anywhere } +.graph-node strong { font: 650 .8125rem/1.4 var(--mono); letter-spacing: -.01em } +.graph-node small { margin-top: 6px; color: var(--subtle); font-size: .6875rem; line-height: 1.4 } + +.node-dot { + display: block; + width: 7px; height: 7px; + margin-bottom: var(--s1); + border-radius: 50%; + background: var(--edge); + transition: background var(--t-mid) var(--ease), box-shadow var(--t-mid) var(--ease); +} +.graph-node.producer .node-dot { background: var(--pulse); box-shadow: 0 0 0 4px var(--tint-pulse) } + +.graph-node.direct { border-color: var(--fail); background: var(--tint-fail) } +.graph-node.direct .node-dot { background: var(--fail); box-shadow: 0 0 0 4px var(--tint-fail) } +.graph-node.direct small { color: var(--fail-text) } +.graph-node.affected { border-color: var(--edge-fail); background: var(--tint-fail) } +.graph-node.affected .node-dot { background: var(--edge-fail) } +.graph-node.affected small { color: var(--fail-text); opacity: .9 } +.graph-node.terminal::after { + content: ""; + position: absolute; + top: var(--s2); right: var(--s2); + width: 7px; height: 7px; + border: 1.5px solid var(--edge-fail); +} + +/* ---- edges -------------------------------------------------------- */ + +.graph-edge { position: relative; height: 44px } + +.graph-edge .rail { + position: absolute; + top: 50%; left: 0; + width: 100%; height: 2px; + margin-top: -1px; + border-radius: 2px; + background: var(--edge); +} + +/* One resolved-portion element per edge, scaled to however far meaning got. */ +.graph-edge .rail-live { + position: absolute; + top: 50%; left: 0; + width: 100%; height: 3px; + margin-top: -1.5px; + border-radius: 2px; + background: var(--pulse); + transform: scaleX(0); + transform-origin: left center; +} +.first-edge .rail-live { transform: scaleX(var(--break-stop)) } + +.graph-edge .pulse { + position: absolute; + top: 50%; left: 0; + width: 11px; height: 11px; + margin-top: -5.5px; + border-radius: 50%; + background: var(--pulse); + box-shadow: 0 0 0 4px var(--tint-pulse); + opacity: 0; + transform: translateX(calc(var(--edge-w, 120px) * var(--break-stop) - 5.5px)); +} +.first-edge .pulse { opacity: 1 } + +/* Trailing arc, so the pulse reads as travel and not a floating dot. */ +.graph-edge .pulse::before { + content: ""; + position: absolute; + top: 4px; right: 6px; + width: 34px; height: 3px; + border-radius: 2px; + background: linear-gradient(270deg, var(--pulse), transparent); + opacity: 0; +} + +.break-mark { + position: absolute; + top: 50%; left: 50%; + width: 20px; height: 26px; + margin: -13px 0 0 -4px; +} +.break-mark i { + position: absolute; + left: 0; + width: 17px; height: 3px; + border-radius: 2px; + background: var(--fail); + transform: rotate(42deg); + transform-origin: left center; +} +.break-mark i:first-child { top: 3px } +.break-mark i:last-child { top: 15px } + +.edge-caption { + position: absolute; + top: -2px; left: 50%; + color: var(--fail-text); + font: 650 .6875rem var(--sans); + letter-spacing: .09em; + text-transform: uppercase; + white-space: nowrap; + transform: translateX(-50%); +} + +/* Blast radius: dashed, and clearly not successful propagation. Dashes come + from a background image so one `background` override swaps edge state. */ +.blast-edge .rail { + height: 2px; + border-radius: 0; + background: repeating-linear-gradient(90deg, var(--edge-fail) 0 6px, transparent 6px 13px); +} + +/* ---- witness ------------------------------------------------------ */ + +.scene-witness { + margin: 0 var(--s3) var(--s3); + overflow: hidden; + border: 1px solid var(--edge-fail); + border-left: 3px solid var(--fail); + border-radius: var(--radius); + background: var(--bg); +} +.witness-heading { padding: var(--s2) var(--s3) } +.witness-tag { + display: flex; + align-items: center; + gap: 6px; + color: var(--fail-text); + font: 650 .75rem/1.4 var(--mono); + letter-spacing: .1em; + text-transform: uppercase; +} +.witness-tag i { + width: 5px; height: 5px; + border-radius: 50%; + background: var(--fail); +} +.witness-heading strong { + display: block; + margin-top: var(--s1); + font-size: 1rem; + font-weight: 600; + line-height: 1.45; + letter-spacing: -.012em; +} +.witness-heading strong code { color: var(--fail-text); font-size: .9375rem } + +.witness-values { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1px; + border-top: 1px solid var(--border); + background: var(--border); +} +.witness-values > div { padding: var(--s2) var(--s3); background: var(--bg) } +.witness-values span { + display: block; + color: var(--subtle); + font: 650 .6875rem/1.4 var(--mono); + letter-spacing: .1em; + text-transform: uppercase; +} +.witness-values code { display: block; margin-top: 6px; font-size: .8125rem; line-height: 1.5 } +.witness-values code.ok { color: var(--pass-text) } +.witness-values code.bad { color: var(--fail-text) } +.scene-witness > p { + padding: var(--s2) var(--s3); + border-top: 1px solid var(--border); + color: var(--subtle); + font-size: .75rem; +} +.scene-witness > p b { color: var(--muted); font-weight: 600 } + +/* ---- replay timeline ---------------------------------------------- + Baseline: one pulse traverses every edge in turn, each resolving green + behind it. Candidate: the same pulse leaves and never arrives. Trace: + the blast propagates outward from the break, hop by hop. + ------------------------------------------------------------------- */ + +.pulse-scene.is-playing .graph-node, +.pulse-scene.is-playing .scene-status, +.pulse-scene.is-playing .scene-witness, +.pulse-scene.is-playing .break-mark, +.pulse-scene.is-playing .edge-caption { opacity: 0 } +.pulse-scene.is-playing .graph-edge .pulse { opacity: 0 } + +.pulse-scene.is-playing .graph-node { transform: translateY(6px) } +/* Colour has to stay in the transition list or the blast stagger below has + nothing to delay, and every affected node lights at once, ahead of the + edges that are supposed to be carrying the blast to it. */ +.pulse-scene.is-playing .graph-node, +.pulse-scene.is-playing .scene-status { + transition: + opacity var(--t-mid) var(--ease), + transform var(--t-mid) var(--ease), + border-color var(--t-mid) var(--ease), + background var(--t-mid) var(--ease); + transition-delay: calc(var(--n, 0) * 70ms); +} +.pulse-scene.is-playing .graph-node small { transition: color var(--t-mid) var(--ease) } +.pulse-scene.is-playing.nodes-active .graph-node { opacity: 1; transform: none } + +.pulse-scene.is-playing.baseline-active .scene-status, +.pulse-scene.is-playing.candidate-active .scene-status { opacity: 1 } +.pulse-scene.is-playing.baseline-active .semantic-status, +.pulse-scene.is-playing.candidate-active .semantic-status { visibility: hidden; opacity: 0 } + +/* --- baseline: the pulse actually walks the graph --- */ +.pulse-scene.is-playing.baseline-active .graph-edge .pulse { + background: var(--pass); + box-shadow: 0 0 0 4px var(--tint-pass); + animation: hop-travel 380ms linear forwards; + animation-delay: calc(var(--e, 0) * 380ms); +} +.pulse-scene.is-playing.baseline-active .graph-edge .pulse::before { + background: linear-gradient(270deg, var(--pass), transparent); + opacity: .8; +} +.pulse-scene.is-playing.baseline-active .graph-edge .rail-live { + background: var(--pass); + animation: rail-fill 380ms linear forwards; + animation-delay: calc(var(--e, 0) * 380ms); +} +.pulse-scene.is-playing.baseline-active .blast-edge .rail { background: var(--edge) } + +@keyframes hop-travel { + from { opacity: 1; transform: translateX(-5.5px) } + to { opacity: 1; transform: translateX(calc(var(--edge-w, 120px) - 5.5px)) } +} +@keyframes rail-fill { from { transform: scaleX(0) } to { transform: scaleX(1) } } + +/* Draining the baseline before the candidate runs. Without this the graph + cuts from fully resolved to empty in one frame, which reads as a glitch + rather than as a second run starting from a clean state. */ +.pulse-scene.is-playing.baseline-active.resetting .graph-edge .rail-live { + animation: rail-drain 300ms var(--ease-io) forwards; + animation-delay: 0s; +} +.pulse-scene.is-playing.baseline-active.resetting .graph-edge .pulse { + animation: pulse-out 200ms var(--ease) forwards; + animation-delay: 0s; +} +@keyframes rail-drain { from { transform: scaleX(1); opacity: 1 } to { transform: scaleX(1); opacity: 0 } } +@keyframes pulse-out { to { opacity: 0 } } + +/* --- candidate: it leaves, and it does not arrive --- */ +.pulse-scene.is-playing.candidate-active .first-edge .rail-live { + background: var(--pulse); + animation: rail-stop 900ms var(--ease-io) forwards; +} +.pulse-scene.is-playing.candidate-active .first-edge .pulse { + animation: pulse-stop 900ms var(--ease-io) forwards; +} +.pulse-scene.is-playing.candidate-active .first-edge .pulse::before { + opacity: .8; + transition: opacity 200ms linear; +} +@keyframes pulse-stop { + from { opacity: 1; transform: translateX(-5.5px) } + to { opacity: 1; transform: translateX(calc(var(--edge-w, 120px) * var(--break-stop) - 5.5px)) } +} +@keyframes rail-stop { from { transform: scaleX(0) } to { transform: scaleX(var(--break-stop)) } } + +/* Nothing downstream may be asserted before the break is found. */ +.pulse-scene.is-playing:not(.broken-active) .graph-node.direct, +.pulse-scene.is-playing:not(.impact-active) .graph-node.affected { + border-color: var(--edge); + background: var(--surface-2); +} +.pulse-scene.is-playing:not(.broken-active) .graph-node.direct .node-dot, +.pulse-scene.is-playing:not(.impact-active) .graph-node.affected .node-dot { + background: var(--edge); box-shadow: none; +} +.pulse-scene.is-playing:not(.broken-active) .graph-node.direct small, +.pulse-scene.is-playing:not(.impact-active) .graph-node.affected small { color: var(--subtle) } +.pulse-scene.is-playing:not(.impact-active) .blast-edge .rail { background: var(--edge) } + +/* --- break --- */ +.pulse-scene.is-playing.broken-active .semantic-status { visibility: visible; opacity: 1 } +.pulse-scene.is-playing.broken-active .break-mark, +.pulse-scene.is-playing.broken-active .edge-caption { + opacity: 1; + transition: opacity var(--t-fast) var(--ease); +} +.pulse-scene.is-playing.broken-active .break-mark i { animation: cut-in 240ms var(--ease) backwards } +.pulse-scene.is-playing.broken-active .break-mark i:last-child { animation-delay: 60ms } +@keyframes cut-in { + from { transform: rotate(42deg) scaleX(0) } + to { transform: rotate(42deg) scaleX(1) } +} +.pulse-scene.is-playing.broken-active .first-edge .pulse::before { opacity: 0 } + +/* --- trace: the blast propagates hop by hop, it does not appear at once --- */ +.pulse-scene.is-playing.impact-active .blast-edge .rail { + animation: blast-in 300ms var(--ease) backwards; + animation-delay: calc((var(--e, 1) - 1) * 190ms); +} +.pulse-scene.is-playing.impact-active .graph-node.affected, +.pulse-scene.is-playing.impact-active .graph-node.direct { + transition-delay: calc((var(--n, 1) - 1) * 240ms); +} +.pulse-scene.is-playing.impact-active .graph-node.affected small, +.pulse-scene.is-playing.impact-active .graph-node.direct small { + transition-delay: calc((var(--n, 1) - 1) * 240ms); +} +/* The blast arriving at a node is a discrete event, so the node marks it. */ +.pulse-scene.is-playing.impact-active .graph-node.affected .node-dot, +.pulse-scene.is-playing.impact-active .graph-node.direct .node-dot { + animation: blast-hit 420ms var(--ease) backwards; + animation-delay: calc((var(--n, 1) - 1) * 240ms); +} +@keyframes blast-hit { + 0% { box-shadow: 0 0 0 0 var(--tint-fail) } + 40% { box-shadow: 0 0 0 7px var(--tint-fail) } + 100% { box-shadow: 0 0 0 4px var(--tint-fail) } +} +@keyframes blast-in { + from { background: var(--edge) } + to { background: repeating-linear-gradient(90deg, var(--edge-fail) 0 6px, transparent 6px 13px) } +} + +.pulse-scene.is-playing.witness-active .scene-witness { + opacity: 1; + transform: none; + transition: opacity var(--t-slow) var(--ease), transform var(--t-slow) var(--ease); +} +.pulse-scene.is-playing:not(.witness-active) .scene-witness { transform: translateY(10px) } + +/* ======================================================== playground === */ + +.playground { overflow: hidden } +.playground-toolbar { + display: flex; + align-items: center; + gap: var(--s2); + flex-wrap: wrap; + padding: var(--s2); + border-bottom: 1px solid var(--border); + background: var(--surface-2); +} + +/* Segmented control with a sliding thumb: state change, not decoration. */ +.segmented { + position: relative; + display: flex; + padding: 3px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg); +} +.playground-choice { + position: relative; + z-index: 1; + min-height: 38px; + padding: 0 var(--s2); + border: 0; + border-radius: 7px; + background: transparent; + color: var(--muted); + font: 600 .8125rem var(--sans); + white-space: nowrap; + cursor: pointer; + transition: color var(--t-fast) var(--ease); +} +.playground-choice.is-selected { color: var(--text) } +.segment-thumb { + position: absolute; + z-index: 0; + top: 3px; left: 3px; + height: calc(100% - 6px); + border-radius: 7px; + background: var(--surface); + border: 1px solid var(--border); + box-shadow: var(--shadow-card); + transition: transform var(--t-mid) var(--ease), width var(--t-mid) var(--ease); +} +.playground-run { margin-left: auto } + +.playground-graph { + display: grid; + grid-template-columns: minmax(180px, 1.2fr) minmax(120px, .72fr) minmax(180px, 1.1fr) 72px minmax(120px, .72fr) 72px minmax(108px, .64fr); + align-items: center; + gap: var(--s2); + padding: var(--s5); +} +.playground-node { + padding: var(--s3); + border: 1px solid var(--edge); + border-radius: 10px; + background: var(--surface-2); + transition: border-color var(--t-mid) var(--ease), background var(--t-mid) var(--ease); +} +.playground-node strong, .playground-node small { display: block } +.playground-node strong { font: 650 .875rem var(--mono); letter-spacing: -.01em } +.playground-node small { margin-top: 6px; color: var(--subtle); font-size: .75rem } +.playground-node-impact { padding: var(--s2) } +.playground-node-impact strong { overflow-wrap: anywhere; font-size: .75rem } +.playground-node-impact small { font-size: .6875rem; line-height: 1.45 } + +/* The producer's recorded values and the consumer's contract, side by side. + Switching the producer changes them, which is the whole point of the + control: the schema never moves, one number does. */ +.node-values { + margin-top: var(--s2); + padding-top: var(--s2); + border-top: 1px solid var(--border); +} +.node-values > div { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--s2); + padding: 5px 0; +} +.node-values dt { + min-width: 0; + overflow-wrap: anywhere; + color: var(--subtle); + font: .6875rem/1.4 var(--mono); +} +.node-values dd { + flex: 0 0 auto; + margin: 0; + color: var(--text); + font: 600 .75rem/1.4 var(--mono); +} +.node-values dd.is-breaking { color: var(--fail-text) } +.node-values dd.is-passing { color: var(--pass-text) } +.playground-node[data-play-node="producer"] .node-dot { background: var(--pulse); box-shadow: 0 0 0 4px var(--tint-pulse) } +.playground.is-running.is-baseline .playground-node[data-play-node="consumer"] .node-dot { background: var(--pass); box-shadow: 0 0 0 4px var(--tint-pass) } +.playground.is-running.is-candidate .playground-node[data-play-node="consumer"] .node-dot { background: var(--fail); box-shadow: 0 0 0 4px var(--tint-fail) } +.playground.is-running.is-baseline .playground-node-impact .node-dot { background: var(--pass); box-shadow: 0 0 0 4px var(--tint-pass) } +.playground.is-running.is-candidate .playground-node-impact .node-dot { background: var(--fail); box-shadow: 0 0 0 4px var(--tint-fail) } + +.playground-edge { position: relative; height: 44px } +.playground-edge .rail { + position: absolute; + top: 50%; left: 0; + width: 100%; height: 2px; + margin-top: -1px; + border-radius: 2px; + background: var(--edge); + transition: background var(--t-mid) var(--ease); +} +.playground-edge .rail-live { + position: absolute; + top: 50%; left: 0; + width: 100%; height: 3px; + margin-top: -1.5px; + border-radius: 2px; + background: var(--pulse); + transform: scaleX(0); + transform-origin: left center; +} +.playground-edge small { + position: absolute; + left: 50%; top: -20px; + color: var(--subtle); + font: 650 .6875rem var(--mono); + letter-spacing: .1em; + text-transform: uppercase; + white-space: nowrap; + transform: translateX(-50%); +} +.playground-pulse { + position: absolute; + top: 50%; left: 0; + width: 11px; height: 11px; + margin-top: -5.5px; + border-radius: 50%; + background: var(--pulse); + box-shadow: 0 0 0 4px var(--tint-pulse); + opacity: 0; + transform: translateX(-5.5px); +} +.playground-break { + position: absolute; + top: 50%; left: 50%; + width: 20px; height: 26px; + margin: -13px 0 0 -4px; + opacity: 0; +} +.playground-break i { + position: absolute; + left: 0; + width: 17px; height: 3px; + border-radius: 2px; + background: var(--fail); + transform: rotate(42deg); + transform-origin: left center; +} +.playground-break i:first-child { top: 3px } +.playground-break i:last-child { top: 15px } + +.playground.is-running .playground-pulse { opacity: 1 } +.playground.is-running.is-baseline .playground-pulse { + animation: play-travel-full 900ms var(--ease-io) forwards; +} +.playground.is-running.is-baseline .playground-edge .rail-live { + background: var(--pass); + animation: rail-resolve 900ms var(--ease-io) forwards; +} +.playground.is-running.is-baseline .playground-node:not([data-play-node="producer"]) { border-color: var(--pass); background: var(--tint-pass) } +.playground.is-running.is-baseline .playground-edge-impact .rail { background: var(--pass) } +.playground.is-running.is-candidate .playground-pulse { + background: var(--fail); + box-shadow: 0 0 0 4px var(--tint-fail); + animation: play-travel-half 640ms var(--ease-io) forwards; +} +.playground.is-running.is-candidate .playground-edge .rail-live { + animation: rail-half 640ms var(--ease-io) forwards; +} +.playground.is-running.is-candidate .playground-break { + opacity: 1; + transition: opacity var(--t-fast) var(--ease) 640ms; +} +.playground.is-running.is-candidate .playground-node:not([data-play-node="producer"]) { border-color: var(--edge-fail); background: var(--tint-fail) } +.playground.is-running.is-candidate .playground-edge-impact .rail { + background: repeating-linear-gradient(90deg, var(--fail) 0 6px, transparent 6px 13px); + opacity: .78; + animation: impact-arrive var(--t-mid) var(--ease) backwards; +} +.playground.is-running.is-candidate .playground-edge-impact:nth-of-type(4) .rail { animation-delay: 720ms } +.playground.is-running.is-candidate .playground-edge-impact:nth-of-type(6) .rail { animation-delay: 880ms } +@keyframes impact-arrive { from { opacity: 0 } } + +@keyframes play-travel-full { + from { transform: translateX(-5.5px) } + to { transform: translateX(calc(var(--edge-w, 160px) - 5.5px)) } +} +@keyframes play-travel-half { + from { transform: translateX(-5.5px) } + to { transform: translateX(calc(var(--edge-w, 160px) * var(--break-stop) - 5.5px)) } +} +@keyframes rail-resolve { from { transform: scaleX(0) } to { transform: scaleX(1) } } +@keyframes rail-half { from { transform: scaleX(0) } to { transform: scaleX(var(--break-stop)) } } + +.playground-result { + display: flex; + flex-direction: column; + gap: var(--s0); + min-height: 88px; + padding: var(--s3) var(--s6); + border-top: 1px solid var(--border); + background: var(--bg); + font-size: .9375rem; +} +.playground-result strong { font-weight: 600; letter-spacing: -.012em } +.playground-result strong.pass { color: var(--pass-text) } +.playground-result strong.fail { color: var(--fail-text) } +.playground-result span { color: var(--muted); font-size: .875rem } + +.playground-witness { + margin: 0 var(--s2) var(--s2); + overflow: hidden; + border: 1px solid var(--edge-fail); + border-radius: var(--radius); + background: var(--surface); + box-shadow: inset 4px 0 0 var(--fail); + opacity: 0; + transform: translateY(8px); + transition: opacity var(--t-mid) var(--ease), transform var(--t-mid) var(--ease); +} +.playground-witness[hidden] { display: none } +.playground-witness.is-open { opacity: 1; transform: none } +.playground-witness > div { display: flex; flex-direction: column; gap: var(--s1); padding: var(--s3) var(--s4); border-bottom: 1px solid var(--border) } +.playground-witness > div > span { color: var(--fail-text); font: 650 .6875rem var(--mono); letter-spacing: .1em; text-transform: uppercase } +.playground-witness > div > strong { font-size: 1rem; font-weight: 600 } +.playground-witness dl { display: grid; grid-template-columns: repeat(4, 1fr); margin: 0 } +.playground-witness dl > div { min-width: 0; padding: var(--s2) var(--s3); border-right: 1px solid var(--border) } +.playground-witness dl > div:last-child { border-right: 0 } +.playground-witness dt { color: var(--subtle); font: 650 .6875rem var(--mono); letter-spacing: .08em; text-transform: uppercase } +.playground-witness dd { margin: var(--s1) 0 0; overflow-wrap: anywhere; color: var(--text); font-size: .8125rem } +.playground-witness dl > div:nth-child(1) dd { color: var(--pass-text) } +.playground-witness dl > div:nth-child(2) dd { color: var(--fail-text) } + +/* ====================================================== bug section === */ + +.meaning-diff { display: grid; grid-template-columns: 1fr 96px 1fr; align-items: center } +.payload { overflow: hidden } +.payload-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--s2); + padding: var(--s2) var(--s3); + border-bottom: 1px solid var(--border); + background: var(--surface-2); + color: var(--subtle); + font: 650 .6875rem var(--mono); + letter-spacing: .1em; + text-transform: uppercase; +} +.badge { padding: 3px 7px; border: 1px solid currentColor; border-radius: 6px } +.badge.pass { color: var(--pass-text) } +.pass { color: var(--pass-text) } +.fail { color: var(--fail-text) } +.muted { color: var(--subtle) } + +.payload pre, .terminal pre, .yaml { + overflow: auto; + background: var(--surface-code); + font-size: .8125rem; + line-height: 1.75; + color: var(--code); +} +.payload pre { padding: var(--s3) } +.payload pre .k { color: var(--pulse) } +.payload pre .s { color: var(--pass-text) } +.payload pre .b, .payload pre .n { color: var(--unknown-text) } +.payload > p { + display: flex; + align-items: flex-start; + gap: var(--s1); + min-height: 72px; + padding: var(--s2) var(--s3); + border-top: 1px solid var(--border); + color: var(--muted); + font-size: .8125rem; + line-height: 1.55; +} +.flow-dot, .break-dot { + flex: 0 0 auto; + width: 7px; height: 7px; + margin-top: 7px; + border-radius: 50%; + background: var(--pass); +} +.break-dot { background: var(--fail) } + +.contract-cut { position: relative; display: grid; place-items: center; height: 3px } +.cut-rail { + display: block; + width: 100%; height: 3px; + border-radius: 2px; + background: linear-gradient(90deg, var(--pulse) 0 42%, transparent 42% 58%, var(--edge) 58%); +} +.contract-cut i { + position: absolute; + left: 46%; + width: 20px; height: 3px; + border-radius: 2px; + background: var(--fail); + transform: rotate(42deg); + transform-origin: left center; +} +.contract-cut i:nth-of-type(1) { top: -9px } +.contract-cut i:nth-of-type(2) { top: 6px } + +.diff-caption { margin-top: var(--s4); color: var(--muted); text-align: center } +.diff-caption strong { color: var(--text); font-weight: 600 } +.diff-caption code { color: var(--pulse); font-size: .9375rem } + +/* ========================================================== method === */ + +.method-rail { + display: grid; + grid-template-columns: repeat(6, 1fr); + padding: 0; + overflow: hidden; + list-style: none; +} +.method-rail li { + position: relative; + min-height: 200px; + padding: var(--s3); + border-right: 1px solid var(--border); +} +.method-rail li:last-child { border-right: 0 } +.method-rail li::before { + content: ""; + position: absolute; + top: 47px; left: 0; right: 0; + height: 1.5px; + background: var(--edge); + opacity: .55; +} +.method-rail li:first-child::before { left: var(--s3) } +.method-rail li:last-child::before { right: calc(100% - var(--s3) - 28px) } +.method-rail span { + position: relative; + z-index: 1; + display: grid; + place-items: center; + width: 28px; height: 28px; + border: 1.5px solid var(--pulse); + border-radius: 50%; + background: var(--surface); + color: var(--pulse); + font: 650 .6875rem var(--mono); +} +.method-rail strong { display: block; margin-top: var(--s5); font-size: 1.0625rem; letter-spacing: -.018em } +.method-rail p { margin-top: var(--s1); color: var(--muted); font-size: .8125rem; line-height: 1.55 } +.method-rail .step-break span { border-color: var(--fail); color: var(--fail-text) } +/* The method rail is itself a graph edge, so step 03 has to actually cut it: + the rail opens a gap and the canonical two-stroke mark sits in the gap. */ +.method-rail .step-break::before { + background: linear-gradient(90deg, var(--edge) 0 52%, transparent 52% 74%, var(--edge) 74%); +} +.rail-cut { + position: absolute; + top: 47px; left: 60%; + width: 16px; height: 0; +} +.rail-cut b { + position: absolute; + left: 0; + width: 15px; height: 2.5px; + border-radius: 2px; + background: var(--fail); + transform: rotate(42deg); + transform-origin: left center; +} +.rail-cut b:first-child { top: -6px } +.rail-cut b:last-child { top: 4px } + +/* ======================================================= contracts === */ + +.code-layout { display: grid; grid-template-columns: 1.6fr .8fr; overflow: hidden } +.code-pane { border-right: 1px solid var(--border); min-width: 0 } +.pane-bar { + display: flex; + align-items: center; + gap: var(--s1); + padding: var(--s2) var(--s3); + border-bottom: 1px solid var(--border); + background: var(--surface-2); + color: var(--subtle); + font: 600 .75rem var(--mono); +} +.pane-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--pulse) } +.yaml { padding: var(--s4) var(--s3) } +.yaml .key { color: var(--pulse) } +.yaml .string { color: var(--pass-text) } +.yaml .sev { color: var(--fail-text) } +.yaml .bool, .yaml .num { color: var(--unknown-text) } + +.evaluator-list { padding: var(--s4) var(--s3) } +.list-label { + color: var(--subtle); + font: 650 .75rem var(--mono); + letter-spacing: .1em; + text-transform: uppercase; +} +.evaluator-list ul { margin: var(--s3) 0; padding: 0; list-style: none } +.evaluator-list li { + display: flex; + gap: var(--s2); + padding: var(--s2) 0; + border-bottom: 1px solid var(--border); + font-size: .875rem; +} +.evaluator-list li span { color: var(--subtle); font: .75rem var(--mono) } + +/* Text link as a graph edge that completes on hover. */ +.edge-link { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: .8125rem; + font-weight: 600; + color: var(--pulse); +} +.edge-link svg { fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; transition: transform var(--t-fast) var(--ease) } +.edge-link:hover svg { transform: translate(2px, -2px) } + +/* ======================================================== terminal === */ + +.terminal { overflow: hidden } +.terminal-bar { + display: flex; + align-items: center; + gap: 6px; + padding: var(--s2) var(--s3); + border-bottom: 1px solid var(--border); + background: var(--surface-2); +} +.tl { width: 8px; height: 8px; border-radius: 50%; background: var(--border-strong) } +.terminal-bar strong { margin-left: var(--s1); color: var(--subtle); font: 600 .75rem var(--mono) } +.terminal pre { padding: var(--s4) var(--s3) } + +/* ===================================================== architecture === + Built from the same primitives as every other graph on the page rather + than shipped as an image: theme-aware, selectable, and it reflows to a + vertical topology instead of scaling its labels into illegibility. + ====================================================================== */ + +.arch-frame { padding: var(--s6) var(--s5) var(--s5) } +.pane-label { + color: var(--subtle); + font: 650 .75rem var(--mono); + letter-spacing: .12em; + text-transform: uppercase; +} + +.pipeline { + display: grid; + grid-template-columns: 1fr minmax(40px, .55fr) 1fr minmax(40px, .55fr) 1fr minmax(40px, .55fr) 1fr minmax(40px, .55fr) 1fr; + align-items: stretch; + margin-top: var(--s6); + padding: 0; + list-style: none; +} +.stage { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + padding: var(--s2); + border: 1px solid var(--border-strong); + border-radius: 10px; + background: var(--surface-2); +} +.stage-tag { + color: var(--subtle); + font: 650 .75rem var(--mono); + letter-spacing: .1em; + text-transform: uppercase; +} +.stage strong { font-size: 1rem; font-weight: 600; letter-spacing: -.018em } +.stage small { color: var(--subtle); font-size: .75rem; line-height: 1.4 } +.stage-outside { border-style: dashed } +.stage-check { border-color: var(--pulse) } +.stage-check .stage-tag { color: var(--pulse) } +.stage-trace { border-color: var(--edge-fail) } +.stage-trace .stage-tag { color: var(--fail-text) } +.stage-explain { border-color: var(--edge-pass) } +.stage-explain .stage-tag { color: var(--pass-text) } + +.stage-edge { position: relative; display: grid; place-items: center; grid-row: 1 } +.stage-edge .rail { + width: 100%; height: 1.5px; + border-radius: 2px; + background: var(--edge); +} +/* Contract labels drop below the row: the edge columns are far too narrow + to hold them without colliding with the stages either side. */ +.stage-edge em { + position: absolute; + top: calc(100% + 10px); + left: 50%; + color: var(--subtle); + font: .6875rem var(--mono); + font-style: normal; + white-space: nowrap; + transform: translateX(-50%); +} +.stage { grid-row: 1 } + +/* Brackets say exactly which stages sit either side of the trace boundary. */ +.bracket { + grid-row: 3; + padding-top: var(--s1); + border-top: 1px dashed var(--border-strong); +} +.bracket span { + color: var(--subtle); + font: 650 .6875rem var(--mono); + letter-spacing: .1em; + text-transform: uppercase; +} +.bracket-outside { grid-column: 1 } +.bracket-core { grid-column: 3 / -1 } +.bracket-core { border-top-color: var(--pulse); border-top-style: solid } +.bracket-core span { color: var(--pulse) } + +.pipeline { row-gap: var(--s6) } +.arch-note { margin-top: var(--s5); color: var(--muted); font-size: .8125rem } + +/* ========================================================== report === */ + +.report-split { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s2) } +.report-pane { + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); + box-shadow: var(--shadow-card); +} +.report-pane .yaml { flex: 1 } +.pane-note { margin-left: auto; color: var(--subtle); font: .6875rem var(--mono) } + +.report-mock { display: flex; flex-direction: column; flex: 1 } +.mock-verdict { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--border) } +.mock-verdict > div { padding: var(--s2) var(--s3); background: var(--surface) } +.mock-verdict span { + display: block; + color: var(--subtle); + font: 650 .6875rem var(--mono); + letter-spacing: .1em; + text-transform: uppercase; +} +.mock-verdict strong { display: block; margin-top: 4px; font: 650 .9375rem var(--mono) } + +.mock-row { padding: var(--s2) var(--s3); border-top: 1px solid var(--border) } +.mock-label { + display: block; + margin-bottom: 6px; + color: var(--subtle); + font: 650 .75rem var(--mono); + letter-spacing: .1em; + text-transform: uppercase; +} +.mock-row p { font-size: .8125rem; line-height: 1.6; color: var(--muted) } +.mock-row code { font-size: .75rem } +.mock-row code.ok { color: var(--pass-text) } +.mock-row code.bad { color: var(--fail-text) } +.mock-row b { color: var(--text); font-weight: 600 } + +.mock-edge { display: flex; align-items: center; gap: var(--s1); font-family: var(--mono) } +.mock-edge i { + position: relative; + width: 34px; height: 2px; + border-radius: 2px; + background: linear-gradient(90deg, var(--pulse) 0 40%, transparent 40% 60%, var(--edge) 60%); +} +.mock-edge i::before, .mock-edge i::after { + content: ""; + position: absolute; + left: 44%; + width: 9px; height: 2px; + border-radius: 2px; + background: var(--fail); + transform: rotate(42deg); + transform-origin: left center; +} +.mock-edge i::before { top: -4px } +.mock-edge i::after { top: 3px } + +.mock-chips { display: flex; flex-wrap: wrap; gap: 6px } +.mock-chips em { + padding: 3px 8px; + border: 1px solid var(--edge-fail); + border-radius: 6px; + color: var(--fail-text); + font: .6875rem var(--mono); + font-style: normal; +} +.mock-repair { margin-top: auto; background: var(--tint-pulse) } + +/* =========================================================== proof === */ + +.proof-section > .shell { + display: grid; + grid-template-columns: minmax(250px, .55fr) minmax(0, 1.45fr); + gap: var(--s8); +} +.proof-intro h2 { max-width: 10ch } +.proof-intro > p:last-child { max-width: 34ch; margin-top: var(--s3); color: var(--muted) } +.proof-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + margin: 0; + border-top: 1px solid var(--border-strong); +} +.proof-grid > div { min-width: 0; padding: var(--s4) var(--s3); border-right: 1px solid var(--border); border-bottom: 1px solid var(--border) } +.proof-grid > div:nth-child(3n) { border-right: 0 } +.proof-grid dt { color: var(--subtle); font: 650 .6875rem var(--mono); letter-spacing: .1em; text-transform: uppercase } +.proof-grid dd { margin: var(--s2) 0 0 } +.proof-grid dd strong { display: block; overflow-wrap: anywhere; color: var(--text); font-size: clamp(1.4rem, 2.6vw, 2.25rem); font-weight: 620; letter-spacing: -.035em } +.proof-grid small { display: block; margin-top: var(--s1); color: var(--muted); font-size: .75rem } +.proof-note { grid-column: 2; color: var(--muted); font-size: .8125rem } +.proof-note a { color: var(--text); text-decoration: underline; text-decoration-color: var(--edge-pulse); text-underline-offset: 4px } + +/* ===================================================== limitations === */ + +.limitations-section { + margin-top: var(--s12); + border-block: 1px solid var(--border); + background: var(--bg-sunk); +} +.limitations-section > .shell { + display: grid; + grid-template-columns: minmax(260px, .7fr) minmax(0, 1.3fr); + gap: var(--s8); + padding-bottom: var(--s10); +} +.limitations-lead > span { color: var(--unknown-text); font: 650 .6875rem var(--mono); letter-spacing: .13em; text-transform: uppercase } +.limitations-lead h2 { max-width: 11ch; margin-top: var(--s3) } +.limitations-lead p { margin-top: var(--s3); color: var(--muted) } +.limitations-list { padding: 0; border-top: 1px solid var(--border-strong); list-style: none } +.limitations-list li { display: grid; grid-template-columns: 48px 1fr; gap: var(--s2); padding: var(--s3) 0; border-bottom: 1px solid var(--border) } +.limitations-list span { color: var(--unknown-text); font: .75rem var(--mono) } +.limitations-list p { max-width: 60ch; color: var(--text); font-size: 1rem } +.limitations-link { grid-column: 2; justify-self: start; display: inline-flex; gap: var(--s1); min-height: 44px; align-items: center; color: var(--muted); font-size: .875rem } +.limitations-link:hover { color: var(--text) } + +/* ==================================================== contribution === */ + +.contribution-section > .shell { + display: grid; + grid-template-columns: minmax(260px, .62fr) minmax(0, 1.38fr); + gap: var(--s8); +} +.contribution-lead h2 { max-width: 10ch } +.contribution-lead > p:last-child { max-width: 34ch; margin-top: var(--s3); color: var(--muted) } +.contribution-paths { border-top: 1px solid var(--border-strong) } +.contribution-paths a { + position: relative; + display: grid; + grid-template-columns: 110px minmax(0, 1fr) minmax(190px, .72fr); + gap: var(--s3); + align-items: center; + min-height: 116px; + padding: var(--s3) var(--s6) var(--s3) 0; + border-bottom: 1px solid var(--border); +} +.contribution-paths a::after { content: "↗"; position: absolute; right: var(--s1); color: var(--subtle); transition: color var(--t-fast) var(--ease), transform var(--t-fast) var(--ease) } +.contribution-paths a:hover::after, .contribution-paths a:focus-visible::after { color: var(--pulse); transform: translate(2px, -2px) } +.contribution-paths span { color: var(--pulse); font: 650 .6875rem var(--mono); letter-spacing: .1em; text-transform: uppercase } +.contribution-paths strong { font-size: 1.125rem; font-weight: 600; line-height: 1.35 } +.contribution-paths small { color: var(--muted); font-size: .8125rem; line-height: 1.55 } +.contribution-footer { grid-column: 2; display: flex; flex-wrap: wrap; gap: var(--s4) } +.contribution-footer a { min-height: 44px; display: inline-flex; align-items: center; color: var(--muted); font-size: .875rem; text-decoration: underline; text-decoration-color: var(--border-strong); text-underline-offset: 4px } +.contribution-footer a:hover { color: var(--text) } + +/* ============================================================= cta === */ + +.final-cta { + display: flex; + flex-direction: column; + align-items: center; + padding: var(--s9) var(--s7); + text-align: center; + box-shadow: var(--shadow-lift); +} +.final-cta h2 { max-width: 16ch } +.final-cta .eyebrow { justify-content: center } +.cta-lede { max-width: 52ch; margin: var(--s3) 0 var(--s6) } +.final-cta .hero-actions { justify-content: center } +.cta-mark { position: relative; width: 160px; height: 3px; margin-bottom: var(--s6) } +.cta-rail { + display: block; + width: 100%; height: 3px; + border-radius: 2px; + background: linear-gradient(90deg, var(--pulse) 0 44%, transparent 44% 56%, var(--edge) 56%); +} +.cta-mark::before, .cta-mark::after { + content: ""; + position: absolute; + top: -3px; + width: 9px; height: 9px; + border-radius: 50%; + background: var(--pulse); +} +.cta-mark::before { left: -4px } +.cta-mark::after { right: -4px; background: var(--edge) } +.cta-mark i { + position: absolute; + left: 46%; + width: 20px; height: 3px; + border-radius: 2px; + background: var(--fail); + transform: rotate(42deg); + transform-origin: left center; +} +.cta-mark i:first-of-type { top: -8px } +.cta-mark i:last-of-type { top: 7px } + +/* =========================================================== modal === */ + +.setup-modal[hidden] { display: none } +.setup-modal { + position: fixed; + inset: 0; + z-index: 100; + display: grid; + place-items: center; + padding: var(--s3); +} +.modal-backdrop { + position: absolute; + inset: 0; + background: rgba(11, 15, 20, .5); + -webkit-backdrop-filter: blur(6px); + backdrop-filter: blur(6px); + opacity: 0; + transition: opacity var(--t-mid) var(--ease); +} +.setup-modal.is-open .modal-backdrop { opacity: 1 } + +/* Opening the dialog expands a subgraph: it grows from the edge outward. */ +.setup-dialog { + position: relative; + width: min(720px, 100%); + max-height: calc(100dvh - var(--s6)); + overflow: auto; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); + box-shadow: var(--shadow-modal); + opacity: 0; + transform: scale(.9); + transition: opacity 260ms var(--ease), transform 380ms var(--ease); +} +.setup-modal.is-open .setup-dialog { opacity: 1; transform: none } + +.setup-dialog-header { + display: flex; + justify-content: space-between; + gap: var(--s3); + padding: var(--s5) var(--s5) var(--s3); +} +.setup-dialog-header h2 { font-size: clamp(1.75rem, 3.6vw, 2.25rem) } +.setup-dialog-header p:not(.eyebrow) { + max-width: 52ch; + margin-top: var(--s2); + color: var(--muted); + font-size: .875rem; + line-height: 1.6; +} +.modal-close { + position: sticky; + top: 0; + flex: 0 0 40px; + display: grid; + place-items: center; + width: 40px; height: 40px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--muted); + cursor: pointer; + transition: color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease); +} +.modal-close svg { fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round } +.modal-close:hover { color: var(--text); border-color: var(--border-strong) } + +.setup-tabs { + position: relative; + display: flex; + gap: var(--s0); + overflow-x: auto; + padding: 0 var(--s5); + border-bottom: 1px solid var(--border); + scrollbar-width: none; +} +.setup-tabs::-webkit-scrollbar { display: none } +.setup-tab { + min-height: 48px; + padding: 0 var(--s2); + border: 0; + background: transparent; + color: var(--muted); + font: 600 .8125rem var(--sans); + white-space: nowrap; + cursor: pointer; + transition: color var(--t-fast) var(--ease); +} +.setup-tab:hover, .setup-tab.is-active { color: var(--text) } +/* The active tab indicator is an edge that travels between tabs. */ +.tab-rail { + position: absolute; + bottom: -1px; left: 0; + height: 2px; + border-radius: 2px; + background: var(--pulse); + transition: transform var(--t-mid) var(--ease), width var(--t-mid) var(--ease); +} + +.setup-panel { padding: var(--s5) var(--s5) var(--s4) } +.setup-panel[hidden] { display: none } +.setup-panel-meta { display: flex; align-items: center; justify-content: space-between; gap: var(--s2) } +.setup-panel-meta strong { font-size: 1rem; font-weight: 600; letter-spacing: -.015em } +.setup-panel p { margin: var(--s2) 0 var(--s3); color: var(--muted); font-size: .875rem; line-height: 1.6 } +.support-badge { + padding: 4px 8px; + border: 1px solid currentColor; + border-radius: 999px; + font: 650 .6875rem var(--mono); + white-space: nowrap; +} +.support-badge.available { color: var(--pass-text) } +.support-badge.cli { color: var(--pulse) } +.support-badge.planned { color: var(--unknown-text) } + +.command-line { + display: flex; + align-items: center; + gap: var(--s1); + padding: var(--s1) var(--s1) var(--s1) var(--s2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-code); +} +.command-line code { + flex: 1; + min-width: 0; + overflow: auto; + color: var(--code); + font-size: .75rem; + line-height: 1.6; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.copy-button { + flex: 0 0 auto; + min-height: 36px; + padding: 0 var(--s2); + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface); + color: var(--text); + font: 600 .75rem var(--sans); + cursor: pointer; + transition: color var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease); +} +.copy-button:hover { border-color: var(--border-strong) } +.copy-button.copied { border-color: var(--edge-pass); color: var(--pass-text) } +.copy-feedback { display: block; min-height: 20px; margin-top: var(--s1); color: var(--pass-text); font: .6875rem var(--mono) } + +body.modal-open { overflow: hidden } + +/* ========================================================== footer === */ + +.site-footer { margin-top: var(--s11); border-top: 1px solid var(--border) } +.footer-inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--s4); + min-height: 112px; + color: var(--subtle); + font-size: .8125rem; +} +.footer-brand { display: flex; align-items: center; gap: var(--s1) } +.footer-inner nav { display: flex; flex-wrap: wrap; gap: var(--s3) } +.footer-inner nav a { transition: color var(--t-fast) var(--ease) } +.footer-inner nav a:hover { color: var(--text) } + +/* ====================================================== responsive === */ + +@media (max-width: 1080px) { + :root { --gutter: var(--s5) } + .section > .shell { padding-top: var(--s10) } + .section > .shell::before { height: calc(var(--s10) - var(--s6) - 3px) } + .method-rail { grid-template-columns: repeat(3, 1fr) } + .method-rail li:nth-child(3) { border-right: 0 } + .method-rail li:nth-child(-n+3) { border-bottom: 1px solid var(--border) } + .method-rail li:last-child::before, + .method-rail li:nth-child(3)::before { right: calc(100% - var(--s3) - 28px) } + .method-rail li:nth-child(4)::before { left: var(--s3) } + .code-layout { grid-template-columns: 1fr } + .code-pane { border-right: 0; border-bottom: 1px solid var(--border) } + .final-cta { padding: var(--s8) var(--s5) } +} + +@media (max-width: 900px) { + .nav-links a:not(.nav-github) { display: none } + .hero { padding: var(--s8) 0 var(--s7) } + .section-heading { flex-direction: column; align-items: flex-start; gap: var(--s3); margin-bottom: var(--s6) } + .section-heading > p { flex: 1 1 auto } + .bug-heading { grid-template-columns: 1fr } + .bug-heading > p { margin-top: 0 } + .terminal-section > .shell, + .proof-section > .shell, + .limitations-section > .shell, + .contribution-section > .shell { grid-template-columns: 1fr; gap: var(--s6) } + .terminal-section .section-heading { margin-top: 0 } + .proof-note, + .limitations-link, + .contribution-footer { grid-column: 1 } + .contribution-paths a { grid-template-columns: 92px minmax(0, 1fr) } + .contribution-paths small { grid-column: 2 } + .meaning-diff { grid-template-columns: 1fr; gap: var(--s6) } + .contract-cut { width: 3px; height: 56px; margin-inline: auto } + .cut-rail { width: 3px; height: 100%; background: linear-gradient(180deg, var(--pulse) 0 42%, transparent 42% 58%, var(--edge) 58%) } + .contract-cut i { left: -9px; top: 42%; transform: rotate(-42deg) } + .contract-cut i:nth-of-type(1) { top: 38% } + .contract-cut i:nth-of-type(2) { top: 52% } + .playground-graph { padding: var(--s7) var(--s4) } + .playground-result { padding-inline: var(--s4) } + + /* The pipeline becomes a vertical topology rather than shrinking. */ + .arch-frame { padding: var(--s5) var(--s3) } + .pipeline { grid-template-columns: 1fr; row-gap: 0; margin-top: var(--s4) } + .stage, .stage-edge, .bracket { grid-row: auto; grid-column: 1 } + .stage-edge { height: 48px } + .stage-edge .rail { width: 1.5px; height: 100% } + .stage-edge em { + top: 50%; left: calc(50% + 14px); + max-width: calc(50% - 20px); + white-space: normal; + overflow-wrap: anywhere; + line-height: 1.35; + transform: translateY(-50%); + } + /* Brackets become inline captions; a vertical stack has no row to span. */ + .bracket { padding: var(--s1) 0 var(--s3); border-top: 0 } + .bracket-outside { order: -1; padding-top: 0 } + .bracket-core { display: none } + + .report-split { grid-template-columns: 1fr } +} + +/* ---- mobile is rethought, not compressed ---- */ +@media (max-width: 700px) { + :root { --gutter: var(--s3) } + + .nav { min-height: 64px } + .nav-github { padding-inline: var(--s2) } + + .hero { padding: var(--s7) 0 var(--s6) } + h1 { margin-bottom: var(--s3) } + .hero-lede { margin-bottom: var(--s5) } + .hero-actions { gap: var(--s1) } + .hero-actions .button { flex: 1 1 140px } + .install-line { margin-top: var(--s4) } + /* Signal that the command runs past the edge instead of just clipping it. */ + .install-line code { + -webkit-mask-image: linear-gradient(90deg, #000 88%, transparent); + mask-image: linear-gradient(90deg, #000 88%, transparent); + } + + .section > .shell { padding-top: var(--s9) } + .section > .shell::before { top: var(--s5); height: calc(var(--s9) - var(--s5) - 3px) } + .section-heading { margin-bottom: var(--s5) } + .section-heading > p { font-size: .9375rem } + .bug-heading h2 { font-size: clamp(2.6rem, 14vw, 4.25rem) } + .bug-heading > p { padding-left: var(--s2) } + .proof-grid { grid-template-columns: 1fr 1fr } + .proof-grid > div:nth-child(3n) { border-right: 1px solid var(--border) } + .proof-grid > div:nth-child(2n) { border-right: 0 } + .proof-grid > div { padding: var(--s3) var(--s2) } + .proof-grid dd strong { font-size: 1.45rem } + .limitations-list li { grid-template-columns: 36px 1fr } + .contribution-paths a { grid-template-columns: 1fr; gap: var(--s1); padding-right: var(--s5) } + .contribution-paths small { grid-column: 1 } + .contribution-footer { gap: var(--s2) var(--s4) } + + .pulse-scene { margin-top: var(--s6); border-radius: var(--radius) } + .scene-toolbar { flex-direction: column; align-items: stretch; gap: var(--s2); padding: var(--s2) } + .replay { justify-content: center; width: 100% } + .scene-status { padding: var(--s2) var(--s2) 0 } + .status { flex: 1 1 120px; padding: var(--s1) var(--s2) } + .status small { display: none } + + /* Vertical topology, built from the same primitives. No rotation hacks. */ + .graph-flow { grid-template-columns: 1fr; padding: var(--s4) var(--s2) var(--s5) } + .graph-node { padding: var(--s2) } + /* A vertical edge needs more length than a horizontal one: the cut is a + fixed pixel height, so the pulse needs real distance to stop short of it. */ + .graph-edge { height: 88px; --break-stop: .28 } + .graph-edge .rail { + top: 0; left: 50%; + width: 2px; height: 100%; + margin: 0 0 0 -1px; + } + .first-edge .rail-live { + top: 0; left: 50%; + width: 3px; height: calc(var(--break-stop) * 100%); + margin: 0 0 0 -1.5px; + transform-origin: top center; + } + .blast-edge .rail { + background: repeating-linear-gradient(180deg, var(--edge-fail) 0 6px, transparent 6px 13px); + } + .pulse-scene.is-playing:not(.impact-active) .blast-edge .rail { background: var(--edge) } + .pulse-scene.is-playing.baseline-active .blast-edge .rail { background: var(--pass) } + .pulse { + top: 0; left: 50%; + margin: -5.5px 0 0 -5.5px; + transform: translateY(calc(var(--edge-h, 88px) * var(--break-stop))); + } + .pulse::before { display: none } + .break-mark { top: 50%; left: 50%; margin: -9px 0 0 -8px } + .break-mark i:first-child { top: 0 } + .break-mark i:last-child { top: 12px } + .edge-caption { top: calc(50% - 30px); left: calc(50% + 16px); transform: none } + .pulse-scene.is-playing.candidate-active .pulse { animation-name: pulse-travel-v } + @keyframes pulse-travel-v { + from { transform: translateY(0) } + to { transform: translateY(calc(var(--edge-h, 88px) * var(--break-stop)) ) } + } + + .scene-witness { margin: 0 var(--s2) var(--s2) } + .witness-heading, .witness-values > div, .scene-witness > p { padding-inline: var(--s2) } + .witness-heading strong { font-size: .9375rem } + .witness-values { grid-template-columns: 1fr } + + .playground-toolbar { flex-direction: column; align-items: stretch; padding: var(--s2) } + .segmented { width: 100% } + .playground-choice { flex: 1 1 0; min-width: 0; padding-inline: var(--s1); font-size: .75rem } + .playground-run { margin-left: 0; width: 100% } + .playground-graph { grid-template-columns: 1fr; gap: var(--s2); padding: var(--s5) var(--s3) } + .playground-node { padding: var(--s2) } + .playground-edge { height: 96px; --break-stop: .30 } + .playground-edge .rail { top: 0; left: 50%; width: 2px; height: 100%; margin: 0 0 0 -1px } + .playground-edge .rail-live { top: 0; left: 50%; width: 3px; height: 100%; margin: 0 0 0 -1.5px; transform: scaleY(0); transform-origin: top center } + .playground-edge small { + top: 50%; left: calc(50% + 14px); + max-width: calc(50% - 20px); + white-space: normal; + overflow-wrap: anywhere; + line-height: 1.35; + text-align: left; + transform: translateY(-50%); + } + .playground-pulse { top: 0; left: 50%; margin: -5.5px 0 0 -5.5px; transform: translateY(0) } + .playground-break { top: 50%; left: 50%; margin: -9px 0 0 -8px } + .playground-break i:first-child { top: 0 } + .playground-break i:last-child { top: 12px } + .playground.is-running.is-baseline .playground-pulse { animation-name: play-travel-full-v } + .playground.is-running.is-candidate .playground-pulse { animation-name: play-travel-half-v } + .playground.is-running.is-baseline .playground-edge .rail-live { animation-name: rail-resolve-v } + .playground.is-running.is-candidate .playground-edge .rail-live { animation-name: rail-half-v } + .playground.is-running.is-candidate .playground-edge-impact .rail { background: repeating-linear-gradient(180deg, var(--fail) 0 6px, transparent 6px 13px) } + @keyframes play-travel-full-v { from { transform: translateY(0) } to { transform: translateY(calc(var(--edge-h, 96px) - 11px)) } } + @keyframes play-travel-half-v { from { transform: translateY(0) } to { transform: translateY(calc(var(--edge-h, 64px) * .46)) } } + @keyframes rail-resolve-v { from { transform: scaleY(0) } to { transform: scaleY(1) } } + @keyframes rail-half-v { from { transform: scaleY(0) } to { transform: scaleY(var(--break-stop)) } } + .playground-result { padding: var(--s3) } + .playground-witness > div { padding: var(--s3) } + .playground-witness dl { grid-template-columns: 1fr 1fr } + .playground-witness dl > div { border-bottom: 1px solid var(--border) } + .playground-witness dl > div:nth-child(2) { border-right: 0 } + .playground-witness dl > div:nth-last-child(-n+2) { border-bottom: 0 } + + .method-rail { grid-template-columns: 1fr } + .method-rail li { min-height: 0; border-right: 0; border-bottom: 1px solid var(--border) } + .method-rail li:last-child { border-bottom: 0 } + .method-rail li::before { + top: var(--s3); bottom: calc(var(--s3) * -1); left: calc(var(--s3) + 12px) !important; + right: auto !important; + width: 1.5px; height: auto; + } + .method-rail li:last-child::before { display: none } + .method-rail strong { margin-top: var(--s2) } + .method-rail .step-break::before { + background: linear-gradient(180deg, var(--edge) 0 30%, transparent 30% 58%, var(--edge) 58%); + } + .rail-cut { top: 68px; left: calc(var(--s3) + 12px) } + .rail-cut b { transform: rotate(-42deg); left: -7px } + .rail-cut b:first-child { top: 0 } + .rail-cut b:last-child { top: 10px } + + .yaml, .evaluator-list, .terminal pre { padding: var(--s3) var(--s2) } + .yaml, .terminal pre, .payload pre { font-size: .75rem } + .arch-frame { padding: var(--s2) } + + .final-cta { padding: var(--s6) var(--s3); border-radius: var(--radius) } + .cta-mark { width: 120px } + + .setup-modal { align-items: flex-end; padding: 0 } + .setup-dialog { + width: 100%; + max-height: 92dvh; + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + padding-bottom: env(safe-area-inset-bottom); + transform: translateY(16px); + } + .setup-dialog-header { padding: var(--s4) var(--s3) var(--s2) } + .setup-tabs { padding-inline: var(--s3) } + .setup-panel { padding: var(--s3) } + .command-line { flex-direction: column; align-items: stretch; padding: var(--s2) } + .command-line .install-prompt { display: none } + .copy-button { width: 100%; min-height: 40px } + + .footer-inner { flex-direction: column; align-items: flex-start; gap: var(--s3); padding: var(--s5) 0 } +} + +@media (max-width: 360px) { + .proof-grid { grid-template-columns: 1fr } + .proof-grid > div, + .proof-grid > div:nth-child(2n), + .proof-grid > div:nth-child(3n) { border-right: 0 } +} + +/* ================================================= reduced motion === */ + +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto } + *, *::before, *::after { + animation-duration: .01ms !important; + animation-iteration-count: 1 !important; + transition-duration: .01ms !important; + } + /* Every state the motion would have revealed is present without it. */ + .js .section > .shell::before { transform: scaleY(1) } + .js .section .eyebrow i { transform: none; opacity: 1 } + .pulse-scene.is-playing .graph-node, + .pulse-scene.is-playing .scene-status, + .pulse-scene.is-playing .scene-witness, + .pulse-scene.is-playing .pulse, + .pulse-scene.is-playing .break-mark, + .pulse-scene.is-playing .edge-caption { opacity: 1; transform: none } + .playground.is-running .playground-pulse { opacity: 1 } + .playground.is-running .playground-edge .rail-live { transform: scaleX(1) } + .playground.is-running.is-candidate .playground-edge .rail-live { transform: scaleX(var(--break-stop)) } + .playground.is-running.is-candidate .playground-break { opacity: 1 } + .button::after { display: none } }