[object Object]

← back to Graphics Agentabrams

graphics.agentabrams.com hub + Spirograph.aa (improved from Model Arena artifact)

a0e8f7e0fa0741854ac1b24188822da1b1496fd8 · 2026-07-23 12:12:52 -0700 · Steve Abrams

- Static graphics hub mirroring games.agentabrams.com (left file-list, right iframe)
- Spirograph: hi-DPI crisp render, play/pause, randomize, presets, 3 color modes,
  solid ink picker, glow, gear schematic, localStorage persistence, keyboard shortcuts,
  live readout, save-PNG
- Fixed Opus defects: blurry on retina (no DPR), no pause, missing solid-color mode,
  [hidden] rows overridden by .ctrl display:flex
- deploy/ nginx + Steve-gated first-deploy script; tests/e2e.mjs (12/12 headless pass)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit a0e8f7e0fa0741854ac1b24188822da1b1496fd8
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 23 12:12:52 2026 -0700

    graphics.agentabrams.com hub + Spirograph.aa (improved from Model Arena artifact)
    
    - Static graphics hub mirroring games.agentabrams.com (left file-list, right iframe)
    - Spirograph: hi-DPI crisp render, play/pause, randomize, presets, 3 color modes,
      solid ink picker, glow, gear schematic, localStorage persistence, keyboard shortcuts,
      live readout, save-PNG
    - Fixed Opus defects: blurry on retina (no DPR), no pause, missing solid-color mode,
      [hidden] rows overridden by .ctrl display:flex
    - deploy/ nginx + Steve-gated first-deploy script; tests/e2e.mjs (12/12 headless pass)
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 .gitignore                            |   9 +
 README.md                             |  33 +++
 deploy/deploy.sh                      |  47 ++++
 deploy/graphics.agentabrams.com.nginx |  36 +++
 graphics.json                         |  12 +
 graphics/spirograph/Spirograph.aa     | 495 ++++++++++++++++++++++++++++++++++
 graphics/spirograph/index.html        | 495 ++++++++++++++++++++++++++++++++++
 index.html                            | 121 +++++++++
 tests/e2e.mjs                         | 107 ++++++++
 9 files changed, 1355 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..31b0a23
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+tests/screenshot.png
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..6617f76
--- /dev/null
+++ b/README.md
@@ -0,0 +1,33 @@
+# graphics.agentabrams.com — Agent Abrams Graphics
+
+Static graphics hub: left panel lists every graphic (file-browser style, `*.aa` names),
+right panel runs the selected graphic in an iframe. Zero dependencies, zero build step.
+Mirrors the `games.agentabrams.com` pattern.
+
+Live target: https://graphics.agentabrams.com (Kamatera, `/var/www/graphics.agentabrams.com`, nginx static)
+
+## Adding a graphic
+
+1. Drop a self-contained graphic at `graphics/<id>/index.html` (must run standalone in an iframe).
+2. Add an entry to `graphics.json`:
+
+```json
+{ "id": "<id>", "file": "<Name>.aa", "title": "...", "desc": "...", "path": "graphics/<id>/", "added": "YYYY-MM-DD" }
+```
+
+3. Deploy (Steve-gated): `deploy/deploy.sh`
+
+The hub auto-selects the last-viewed graphic (localStorage) and supports `#<id>` deep links.
+The `.aa` filename is a display label in the manifest; the iframe always loads `index.html`.
+
+## Graphics
+
+| File | What | Source |
+|---|---|---|
+| `Spirograph.aa` | Animated hypotrochoid plotter — R/r/d sliders, live rainbow color cycling, presets, glow, gear schematic, save-PNG, hi-DPI, keyboard shortcuts | Model Arena challenge (Free-Roster · Spirograph), improved from the Claude Opus starting artifact |
+
+## Tests
+
+```sh
+node tests/e2e.mjs   # headless: manifest render, iframe graphic renders, deep links
+```
diff --git a/deploy/deploy.sh b/deploy/deploy.sh
new file mode 100755
index 0000000..b5120d5
--- /dev/null
+++ b/deploy/deploy.sh
@@ -0,0 +1,47 @@
+#!/bin/bash
+# graphics.agentabrams.com — full first deploy (DNS + rsync + nginx + SSL).
+# Steve-gated: run only with explicit approval.
+set -euo pipefail
+KAMATERA=root@45.61.58.125
+SITE=graphics.agentabrams.com
+HERE="$(cd "$(dirname "$0")/.." && pwd)"
+
+# 1. DNS: A record graphics -> Kamatera (DNS-only, matches fleet pattern)
+export $(grep -E "^CLOUDFLARE_API_TOKEN=" ~/Projects/secrets-manager/.env)
+ZONE=$(curl -s -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
+  "https://api.cloudflare.com/client/v4/zones?name=agentabrams.com" \
+  | python3 -c "import sys,json; print(json.load(sys.stdin)['result'][0]['id'])")
+EXISTS=$(curl -s -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
+  "https://api.cloudflare.com/client/v4/zones/$ZONE/dns_records?name=$SITE" \
+  | python3 -c "import sys,json; print(len(json.load(sys.stdin)['result']))")
+if [ "$EXISTS" = "0" ]; then
+  curl -s -X POST -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json" \
+    "https://api.cloudflare.com/client/v4/zones/$ZONE/dns_records" \
+    -d "{\"type\":\"A\",\"name\":\"graphics\",\"content\":\"45.61.58.125\",\"ttl\":300,\"proxied\":false}" \
+    | python3 -c "import sys,json; r=json.load(sys.stdin); assert r['success'], r['errors']; print('DNS record created')"
+else
+  echo "DNS record already exists"
+fi
+
+# 2. Content
+rsync -az --exclude .git --exclude tests --exclude deploy "$HERE/" $KAMATERA:/var/www/$SITE/
+echo "content rsynced"
+
+# 3. nginx (80 first so certbot HTTP-01 can pass)
+scp -q "$HERE/deploy/$SITE.nginx" $KAMATERA:/etc/nginx/sites-available/$SITE
+ssh $KAMATERA "ln -sf /etc/nginx/sites-available/$SITE /etc/nginx/sites-enabled/$SITE && nginx -t && systemctl reload nginx"
+echo "nginx staged"
+
+# 4. Wait for DNS, then SSL
+for i in $(seq 1 30); do
+  [ -n "$(dig +short $SITE @1.1.1.1)" ] && break
+  sleep 10
+done
+ssh $KAMATERA "certbot --nginx -d $SITE --non-interactive --agree-tos -m steve@designerwallcoverings.com && systemctl reload nginx"
+
+# 5. Smoke test
+sleep 2
+code=$(curl -s -o /dev/null -w '%{http_code}' https://$SITE/)
+echo "https://$SITE -> HTTP $code"
+curl -s https://$SITE/graphics.json | head -c 200; echo
+[ "$code" = "200" ] && echo "DEPLOY OK" || { echo "DEPLOY FAILED"; exit 1; }
diff --git a/deploy/graphics.agentabrams.com.nginx b/deploy/graphics.agentabrams.com.nginx
new file mode 100644
index 0000000..e463666
--- /dev/null
+++ b/deploy/graphics.agentabrams.com.nginx
@@ -0,0 +1,36 @@
+# graphics.agentabrams.com — Agent Abrams Graphics (public static graphics hub)
+server {
+    server_name graphics.agentabrams.com;
+
+    access_log /var/log/nginx/graphics.agentabrams.com.access.log;
+    error_log /var/log/nginx/graphics.agentabrams.com.error.log;
+
+    add_header X-Content-Type-Options "nosniff" always;
+    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+
+    root /var/www/graphics.agentabrams.com;
+    index index.html;
+    autoindex off;
+
+    location / {
+        try_files $uri $uri/ $uri.html =404;
+    }
+
+    listen 45.61.58.125:443 ssl; # managed by Certbot
+    ssl_certificate /etc/letsencrypt/live/graphics.agentabrams.com/fullchain.pem; # managed by Certbot
+    ssl_certificate_key /etc/letsencrypt/live/graphics.agentabrams.com/privkey.pem; # managed by Certbot
+    include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
+    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
+
+}
+
+server {
+    if ($host = graphics.agentabrams.com) {
+        return 301 https://$host$request_uri;
+    } # managed by Certbot
+
+    listen 45.61.58.125:80;
+    server_name graphics.agentabrams.com;
+    return 404; # managed by Certbot
+
+}
diff --git a/graphics.json b/graphics.json
new file mode 100644
index 0000000..5592ba7
--- /dev/null
+++ b/graphics.json
@@ -0,0 +1,12 @@
+{
+  "graphics": [
+    {
+      "id": "spirograph",
+      "file": "Spirograph.aa",
+      "title": "Spirograph Studio",
+      "desc": "Animated hypotrochoid plotter — two gear radii + pen offset draw curves live with rainbow color cycling, presets, glow, gear schematic, and save-PNG.",
+      "path": "graphics/spirograph/",
+      "added": "2026-07-23"
+    }
+  ]
+}
diff --git a/graphics/spirograph/Spirograph.aa b/graphics/spirograph/Spirograph.aa
new file mode 100644
index 0000000..1d7b450
--- /dev/null
+++ b/graphics/spirograph/Spirograph.aa
@@ -0,0 +1,495 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>Spirograph Studio</title>
+<style>
+  :root{
+    --bg:#0d0f1a; --panel:#161a2e; --panel2:#1e2340;
+    --ink:#e8ecff; --muted:#8b91b8; --accent:#5eead4; --accent2:#a78bfa;
+    --line:#2a3055;
+  }
+  *{box-sizing:border-box}
+  [hidden]{display:none!important}
+  html,body{height:100%}
+  body{
+    margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
+    background:radial-gradient(1200px 800px at 70% -10%,#1b2145 0%,var(--bg) 55%);
+    color:var(--ink); display:flex; min-height:100%; overflow:hidden;
+  }
+  .wrap{display:flex; width:100%; height:100vh}
+  .stage{
+    flex:1; display:flex; align-items:center; justify-content:center;
+    position:relative; padding:24px; min-width:0;
+  }
+  #canvas{
+    background:#07080f;
+    border-radius:16px;
+    box-shadow:0 20px 60px rgba(0,0,0,.55), inset 0 0 0 1px rgba(255,255,255,.04);
+    max-width:100%; max-height:100%;
+    touch-action:none;
+  }
+  .panel{
+    width:320px; flex-shrink:0; background:linear-gradient(180deg,var(--panel),var(--panel2));
+    border-left:1px solid var(--line);
+    padding:22px 22px 26px; display:flex; flex-direction:column; gap:18px;
+    overflow-y:auto;
+  }
+  .brand{display:flex; align-items:center; gap:11px}
+  .brand .dot{
+    width:30px;height:30px;border-radius:9px;
+    background:conic-gradient(from 0deg,var(--accent),var(--accent2),#f472b6,var(--accent));
+    box-shadow:0 0 18px rgba(94,234,212,.4);
+  }
+  .brand h1{font-size:17px; margin:0; letter-spacing:.4px; font-weight:650}
+  .brand span{font-size:11px; color:var(--muted); display:block; margin-top:1px; letter-spacing:.5px}
+
+  .ctrl{display:flex; flex-direction:column; gap:8px}
+  .ctrl label{display:flex; justify-content:space-between; align-items:baseline; font-size:12.5px; color:var(--muted); letter-spacing:.3px}
+  .ctrl label b{color:var(--ink); font-weight:600; font-variant-numeric:tabular-nums; font-size:13px}
+  input[type=range]{
+    -webkit-appearance:none; appearance:none; width:100%; height:6px; border-radius:6px;
+    background:linear-gradient(90deg,var(--accent),var(--accent2)); outline:none; cursor:pointer;
+  }
+  input[type=range]::-webkit-slider-thumb{
+    -webkit-appearance:none; width:18px;height:18px;border-radius:50%;
+    background:#fff; border:3px solid var(--accent); box-shadow:0 2px 8px rgba(0,0,0,.5); cursor:pointer;
+  }
+  input[type=range]::-moz-range-thumb{
+    width:16px;height:16px;border-radius:50%; background:#fff; border:3px solid var(--accent); cursor:pointer;
+  }
+  select{
+    width:100%; font:inherit; font-size:12.5px; color:var(--ink);
+    background:rgba(255,255,255,.04); border:1px solid var(--line); border-radius:9px;
+    padding:8px 10px; cursor:pointer; outline:none;
+  }
+  select:focus{border-color:var(--accent2)}
+  input[type=color]{
+    -webkit-appearance:none; appearance:none; width:34px; height:26px; padding:0;
+    border:1px solid var(--line); border-radius:7px; background:none; cursor:pointer;
+  }
+  input[type=color]::-webkit-color-swatch-wrapper{padding:2px}
+  input[type=color]::-webkit-color-swatch{border:none; border-radius:5px}
+  .colorwrap{display:flex; align-items:center; gap:10px}
+  .row{display:flex; gap:10px}
+  .toggle{
+    display:flex; align-items:center; justify-content:space-between; gap:10px;
+    font-size:12.5px; color:var(--muted); background:rgba(255,255,255,.03);
+    padding:10px 12px; border-radius:10px; border:1px solid var(--line);
+  }
+  .switch{position:relative; width:42px; height:23px; flex-shrink:0}
+  .switch input{opacity:0; width:0; height:0}
+  .slider-sw{
+    position:absolute; inset:0; background:#3a4066; border-radius:23px; transition:.2s; cursor:pointer;
+  }
+  .slider-sw:before{
+    content:""; position:absolute; height:17px; width:17px; left:3px; top:3px;
+    background:#fff; border-radius:50%; transition:.2s;
+  }
+  .switch input:checked + .slider-sw{background:linear-gradient(90deg,var(--accent),var(--accent2))}
+  .switch input:checked + .slider-sw:before{transform:translateX(19px)}
+
+  button{
+    flex:1; font:inherit; font-size:13px; font-weight:600; letter-spacing:.3px;
+    padding:12px 10px; border-radius:11px; border:1px solid var(--line);
+    background:rgba(255,255,255,.04); color:var(--ink); cursor:pointer; transition:.15s;
+  }
+  button:hover{background:rgba(255,255,255,.09); transform:translateY(-1px)}
+  button:active{transform:translateY(0)}
+  button.primary{
+    background:linear-gradient(90deg,var(--accent),var(--accent2)); color:#0b0d18; border:none;
+  }
+  button.primary:hover{filter:brightness(1.08)}
+  .seg{display:flex; gap:6px; flex-wrap:wrap}
+  .seg button{flex:1 1 auto; padding:8px 6px; font-size:11.5px; font-weight:600}
+
+  .readout{
+    font-size:11px; color:var(--muted); line-height:1.6; letter-spacing:.2px;
+    background:rgba(255,255,255,.02); border:1px solid var(--line); border-radius:9px; padding:9px 11px;
+    font-variant-numeric:tabular-nums;
+  }
+  .readout b{color:var(--accent)}
+  .foot{margin-top:auto; font-size:11px; color:var(--muted); line-height:1.5; letter-spacing:.2px}
+  .hint{font-size:11px; color:var(--muted); margin-top:-4px}
+  kbd{
+    font-family:ui-monospace,monospace; font-size:10px; background:rgba(255,255,255,.06);
+    border:1px solid var(--line); border-bottom-width:2px; border-radius:5px; padding:1px 5px; color:var(--ink);
+  }
+  @media (max-width:760px){
+    body{overflow:auto}
+    .wrap{flex-direction:column; height:auto}
+    .panel{width:100%; border-left:none; border-top:1px solid var(--line)}
+    .stage{padding:16px; min-height:56vh}
+  }
+</style>
+</head>
+<body>
+<div class="wrap">
+  <div class="stage">
+    <canvas id="canvas"></canvas>
+  </div>
+
+  <aside class="panel">
+    <div class="brand">
+      <div class="dot"></div>
+      <div>
+        <h1>Spirograph Studio</h1>
+        <span>HYPOTROCHOID PLOTTER</span>
+      </div>
+    </div>
+
+    <div class="ctrl">
+      <label>Fixed gear radius <span>R</span> <b id="vR">140</b></label>
+      <input type="range" id="R" min="40" max="230" value="140" step="1">
+    </div>
+
+    <div class="ctrl">
+      <label>Rolling gear radius <span>r</span> <b id="vr">53</b></label>
+      <input type="range" id="r" min="8" max="220" value="53" step="1">
+    </div>
+
+    <div class="ctrl">
+      <label>Pen offset <span>d</span> <b id="vd">78</b></label>
+      <input type="range" id="d" min="0" max="180" value="78" step="1">
+    </div>
+
+    <div class="ctrl">
+      <label>Speed <b id="vs">6</b></label>
+      <input type="range" id="speed" min="1" max="40" value="6" step="1">
+    </div>
+
+    <div class="ctrl">
+      <label>Line width <b id="vw">1.6</b></label>
+      <input type="range" id="lw" min="0.4" max="6" value="1.6" step="0.1">
+    </div>
+
+    <div class="ctrl">
+      <label>Color mode</label>
+      <select id="colormode">
+        <option value="cycle">Rainbow — cycle</option>
+        <option value="angle">Rainbow — by angle</option>
+        <option value="solid">Solid</option>
+      </select>
+    </div>
+
+    <div class="ctrl" id="solidRow" hidden>
+      <label>Ink color</label>
+      <div class="colorwrap">
+        <input type="color" id="ink" value="#5eead4">
+        <span class="hint" style="margin:0">used in Solid mode</span>
+      </div>
+    </div>
+
+    <div class="toggle">
+      <span>Show gears</span>
+      <label class="switch"><input type="checkbox" id="gears" checked><span class="slider-sw"></span></label>
+    </div>
+
+    <div class="toggle">
+      <span>Glow</span>
+      <label class="switch"><input type="checkbox" id="glow"><span class="slider-sw"></span></label>
+    </div>
+
+    <div class="ctrl">
+      <label>Presets</label>
+      <div class="seg">
+        <button data-preset="rose">Rose</button>
+        <button data-preset="star">Star</button>
+        <button data-preset="web">Web</button>
+        <button data-preset="loop">Loops</button>
+      </div>
+    </div>
+
+    <div class="row">
+      <button id="play" class="primary">⏸ Pause</button>
+      <button id="random">🎲 Random</button>
+    </div>
+    <div class="row">
+      <button id="clear">Clear</button>
+      <button id="save" class="primary">Save PNG</button>
+    </div>
+
+    <div class="readout" id="readout">—</div>
+
+    <div class="hint">
+      <kbd>Space</kbd> play/pause · <kbd>C</kbd> clear · <kbd>R</kbd> random · <kbd>S</kbd> save
+    </div>
+
+    <div class="foot">
+      Curve: x = (R−r)·cos t + d·cos((R−r)/r·t)<br>
+      &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;y = (R−r)·sin t − d·sin((R−r)/r·t)
+    </div>
+  </aside>
+</div>
+
+<script>
+(function(){
+  "use strict";
+  const canvas = document.getElementById('canvas');
+  const ctx = canvas.getContext('2d');
+
+  // Logical drawing size (device-independent). Backing stores are scaled by DPR
+  // so the curve is crisp on retina displays — the original blurred on hi-DPI.
+  const W = 760, H = 760;
+  const cx = W/2, cy = H/2;
+  const DPR = Math.min(window.devicePixelRatio || 1, 2);
+
+  canvas.width = W*DPR; canvas.height = H*DPR;
+  canvas.style.width = W+'px'; canvas.style.height = H+'px';
+  ctx.scale(DPR, DPR);
+
+  // Offscreen trail layer (persists between frames; gear overlay is drawn fresh each frame)
+  const trail = document.createElement('canvas');
+  trail.width = W*DPR; trail.height = H*DPR;
+  const tctx = trail.getContext('2d');
+  tctx.scale(DPR, DPR);
+
+  // Controls
+  const el = id => document.getElementById(id);
+  const R = el('R'), r = el('r'), d = el('d'), speed = el('speed'), lw = el('lw');
+  const colormode = el('colormode'), ink = el('ink');
+  const gears = el('gears'), glow = el('glow');
+  const vR=el('vR'), vr=el('vr'), vd=el('vd'), vs=el('vs'), vw=el('vw');
+  const readout = el('readout'), playBtn = el('play'), solidRow = el('solidRow');
+
+  // Drawing state
+  let t = 0;                    // parameter angle
+  let hue = Math.random()*360;
+  let lastPt = null;            // last plotted point on the persistent trail
+  let period = 0;               // t value at which the curve closes
+  let running = true;
+  let laps = 0;                 // completed closed patterns since last clear
+
+  const num  = inp => parseFloat(inp.value);
+  const inti = inp => parseInt(inp.value,10);
+
+  function gcd(a,b){ a=Math.round(a); b=Math.round(b); while(b){ const tmp=a%b; a=b; b=tmp; } return a||1; }
+
+  function computePeriod(){
+    const rr = Math.max(1, inti(r));
+    const RR = inti(R);
+    // Full pattern closes after r/gcd(R,r) revolutions of the driving angle.
+    const revs = rr / gcd(RR, rr);
+    period = 2*Math.PI*revs;
+  }
+
+  function point(tt){
+    const RR = inti(R);
+    const rr = Math.max(1, inti(r));
+    const dd = inti(d);
+    const k = RR - rr;
+    const ratio = k / rr;
+    return {
+      x: cx + k*Math.cos(tt) + dd*Math.cos(ratio*tt),
+      y: cy + k*Math.sin(tt) - dd*Math.sin(ratio*tt)
+    };
+  }
+
+  function updateReadout(){
+    const RR = inti(R), rr = Math.max(1, inti(r)), dd = inti(d);
+    const g = gcd(RR, rr);
+    const revs = rr / g;
+    const pct = period ? Math.min(100, (t/period)*100) : 0;
+    readout.innerHTML =
+      'R/r ratio <b>'+ (RR/rr).toFixed(3) +'</b> · closes in <b>'+ revs +'</b> rev'+(revs===1?'':'s')+'<br>'+
+      'petals <b>'+ (RR/g) +'</b> · pen d <b>'+ dd +'</b> · pass <b>'+ Math.round(pct) +'%</b> · laps <b>'+ laps +'</b>';
+  }
+
+  function strokeFor(){
+    const mode = colormode.value;
+    if(mode === 'solid') return ink.value;
+    if(mode === 'angle'){
+      const h = period ? (t/period*360*3) % 360 : hue;
+      return 'hsl('+h.toFixed(1)+',85%,62%)';
+    }
+    return 'hsl('+hue.toFixed(1)+',85%,62%)'; // cycle
+  }
+
+  function resetTrail(){
+    tctx.clearRect(0,0,W,H);
+    t = 0; laps = 0;
+    lastPt = point(0);
+    computePeriod();
+    updateReadout();
+  }
+
+  // Called when a shape parameter changes: fresh curve, new base hue.
+  function restart(){
+    hue = (hue + 47) % 360;
+    resetTrail();
+    render();
+  }
+
+  // Rolling-gear schematic for the current t (overlay only).
+  function drawGears(octx, tt){
+    const RR = inti(R), rr = Math.max(1, inti(r));
+    const k = RR - rr;
+    octx.save();
+    octx.strokeStyle = 'rgba(255,255,255,.16)';
+    octx.lineWidth = 1;
+    octx.beginPath(); octx.arc(cx, cy, RR, 0, Math.PI*2); octx.stroke();
+    const gx = cx + k*Math.cos(tt);
+    const gy = cy + k*Math.sin(tt);
+    octx.strokeStyle = 'rgba(167,139,250,.55)';
+    octx.beginPath(); octx.arc(gx, gy, rr, 0, Math.PI*2); octx.stroke();
+    const p = point(tt);
+    octx.strokeStyle = 'rgba(94,234,212,.5)';
+    octx.beginPath(); octx.moveTo(gx,gy); octx.lineTo(p.x,p.y); octx.stroke();
+    octx.fillStyle = '#5eead4';
+    octx.beginPath(); octx.arc(p.x,p.y,3.2,0,Math.PI*2); octx.fill();
+    octx.fillStyle = 'rgba(167,139,250,.9)';
+    octx.beginPath(); octx.arc(gx,gy,2.5,0,Math.PI*2); octx.fill();
+    octx.restore();
+  }
+
+  function render(){
+    ctx.clearRect(0,0,W,H);
+    ctx.drawImage(trail,0,0,W,H);
+    if(gears.checked && running) drawGears(ctx, t);
+  }
+
+  function frame(){
+    if(running){
+      const steps = inti(speed);
+      const width = num(lw);
+      const dt = 0.012; // base angular step per micro-segment
+      tctx.lineCap = 'round';
+      tctx.lineJoin = 'round';
+      tctx.lineWidth = width;
+      if(glow.checked){ tctx.shadowBlur = 8; } else { tctx.shadowBlur = 0; }
+
+      for(let i=0;i<steps;i++){
+        const nt = t + dt;
+        const p = point(nt);
+        if(colormode.value === 'cycle') hue = (hue + 0.35) % 360;
+        const stroke = strokeFor();
+        tctx.strokeStyle = stroke;
+        if(glow.checked) tctx.shadowColor = stroke;
+        tctx.beginPath();
+        tctx.moveTo(lastPt.x,lastPt.y);
+        tctx.lineTo(p.x,p.y);
+        tctx.stroke();
+        lastPt = p;
+        t = nt;
+        if(t >= period){
+          // Pattern complete: keep it, start a fresh overlaid pass with a new base hue.
+          t = 0; laps++;
+          lastPt = point(0);
+          hue = (hue + 63) % 360;
+        }
+      }
+      tctx.shadowBlur = 0;
+      updateReadout();
+    }
+    render();
+    requestAnimationFrame(frame);
+  }
+
+  // ---- Value labels + persistence ----
+  function bind(input, label, fmt){
+    const upd = ()=> label.textContent = fmt ? fmt(input.value) : input.value;
+    input.addEventListener('input', upd); upd();
+  }
+  bind(R, vR); bind(r, vr); bind(d, vd); bind(speed, vs); bind(lw, vw);
+
+  const SETTINGS = ['R','r','d','speed','lw','colormode','ink'];
+  const TOGGLES = ['gears','glow'];
+  function saveSettings(){
+    try{
+      const s = {};
+      SETTINGS.forEach(k=> s[k] = el(k).value);
+      TOGGLES.forEach(k=> s[k] = el(k).checked);
+      localStorage.setItem('spiro-settings', JSON.stringify(s));
+    }catch(e){}
+  }
+  function loadSettings(){
+    try{
+      const s = JSON.parse(localStorage.getItem('spiro-settings')||'null');
+      if(!s) return;
+      SETTINGS.forEach(k=>{ if(s[k]!=null) el(k).value = s[k]; });
+      TOGGLES.forEach(k=>{ if(s[k]!=null) el(k).checked = s[k]; });
+      [vR,vr,vd,vs,vw].forEach(()=>{});
+      vR.textContent=R.value; vr.textContent=r.value; vd.textContent=d.value;
+      vs.textContent=speed.value; vw.textContent=lw.value;
+    }catch(e){}
+  }
+  loadSettings();
+
+  // Shape-changing sliders restart the curve; all controls persist.
+  [R,r,d].forEach(inp => inp.addEventListener('input', restart));
+  [R,r,d,speed,lw,colormode,ink,gears,glow].forEach(inp =>
+    inp.addEventListener('input', saveSettings));
+
+  colormode.addEventListener('input', ()=>{ solidRow.hidden = colormode.value !== 'solid'; });
+  solidRow.hidden = colormode.value !== 'solid';
+
+  // ---- Buttons ----
+  function setPlaying(on){
+    running = on;
+    playBtn.textContent = on ? '⏸ Pause' : '▶ Play';
+  }
+  playBtn.addEventListener('click', ()=> setPlaying(!running));
+  el('clear').addEventListener('click', restart);
+
+  el('random').addEventListener('click', ()=>{
+    const rnd = (min,max)=> Math.floor(min + Math.random()*(max-min+1));
+    R.value = rnd(90, 230);
+    r.value = rnd(20, Math.max(30, +R.value - 10)); // keep a real hypotrochoid (r < R mostly)
+    d.value = rnd(20, 170);
+    const modes = ['cycle','angle','solid'];
+    colormode.value = modes[rnd(0,2)];
+    solidRow.hidden = colormode.value !== 'solid';
+    vR.textContent=R.value; vr.textContent=r.value; vd.textContent=d.value;
+    saveSettings();
+    restart();
+    setPlaying(true);
+  });
+
+  const PRESETS = {
+    rose: {R:200, r:60, d:120, mode:'cycle'},
+    star: {R:150, r:37, d:90,  mode:'angle'},
+    web:  {R:220, r:99, d:150, mode:'cycle'},
+    loop: {R:120, r:29, d:170, mode:'solid'}
+  };
+  document.querySelectorAll('[data-preset]').forEach(btn=>{
+    btn.addEventListener('click', ()=>{
+      const p = PRESETS[btn.dataset.preset]; if(!p) return;
+      R.value=p.R; r.value=p.r; d.value=p.d; colormode.value=p.mode;
+      solidRow.hidden = colormode.value !== 'solid';
+      vR.textContent=R.value; vr.textContent=r.value; vd.textContent=d.value;
+      saveSettings(); restart(); setPlaying(true);
+    });
+  });
+
+  el('save').addEventListener('click', ()=>{
+    // Composite onto a dark background so the PNG isn't transparent.
+    const out = document.createElement('canvas');
+    out.width = trail.width; out.height = trail.height;
+    const octx = out.getContext('2d');
+    octx.fillStyle = '#07080f';
+    octx.fillRect(0,0,out.width,out.height);
+    octx.drawImage(trail,0,0);
+    const link = document.createElement('a');
+    link.download = 'spirograph-' + Date.now() + '.png';
+    link.href = out.toDataURL('image/png');
+    link.click();
+  });
+
+  // ---- Keyboard shortcuts ----
+  addEventListener('keydown', e=>{
+    if(e.target.matches('input,select,textarea')) return;
+    const k = e.key.toLowerCase();
+    if(k === ' '){ e.preventDefault(); setPlaying(!running); }
+    else if(k === 'c'){ restart(); }
+    else if(k === 'r'){ el('random').click(); }
+    else if(k === 's'){ el('save').click(); }
+  });
+
+  // Init
+  resetTrail();
+  requestAnimationFrame(frame);
+})();
+</script>
+</body>
+</html>
diff --git a/graphics/spirograph/index.html b/graphics/spirograph/index.html
new file mode 100644
index 0000000..1d7b450
--- /dev/null
+++ b/graphics/spirograph/index.html
@@ -0,0 +1,495 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>Spirograph Studio</title>
+<style>
+  :root{
+    --bg:#0d0f1a; --panel:#161a2e; --panel2:#1e2340;
+    --ink:#e8ecff; --muted:#8b91b8; --accent:#5eead4; --accent2:#a78bfa;
+    --line:#2a3055;
+  }
+  *{box-sizing:border-box}
+  [hidden]{display:none!important}
+  html,body{height:100%}
+  body{
+    margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
+    background:radial-gradient(1200px 800px at 70% -10%,#1b2145 0%,var(--bg) 55%);
+    color:var(--ink); display:flex; min-height:100%; overflow:hidden;
+  }
+  .wrap{display:flex; width:100%; height:100vh}
+  .stage{
+    flex:1; display:flex; align-items:center; justify-content:center;
+    position:relative; padding:24px; min-width:0;
+  }
+  #canvas{
+    background:#07080f;
+    border-radius:16px;
+    box-shadow:0 20px 60px rgba(0,0,0,.55), inset 0 0 0 1px rgba(255,255,255,.04);
+    max-width:100%; max-height:100%;
+    touch-action:none;
+  }
+  .panel{
+    width:320px; flex-shrink:0; background:linear-gradient(180deg,var(--panel),var(--panel2));
+    border-left:1px solid var(--line);
+    padding:22px 22px 26px; display:flex; flex-direction:column; gap:18px;
+    overflow-y:auto;
+  }
+  .brand{display:flex; align-items:center; gap:11px}
+  .brand .dot{
+    width:30px;height:30px;border-radius:9px;
+    background:conic-gradient(from 0deg,var(--accent),var(--accent2),#f472b6,var(--accent));
+    box-shadow:0 0 18px rgba(94,234,212,.4);
+  }
+  .brand h1{font-size:17px; margin:0; letter-spacing:.4px; font-weight:650}
+  .brand span{font-size:11px; color:var(--muted); display:block; margin-top:1px; letter-spacing:.5px}
+
+  .ctrl{display:flex; flex-direction:column; gap:8px}
+  .ctrl label{display:flex; justify-content:space-between; align-items:baseline; font-size:12.5px; color:var(--muted); letter-spacing:.3px}
+  .ctrl label b{color:var(--ink); font-weight:600; font-variant-numeric:tabular-nums; font-size:13px}
+  input[type=range]{
+    -webkit-appearance:none; appearance:none; width:100%; height:6px; border-radius:6px;
+    background:linear-gradient(90deg,var(--accent),var(--accent2)); outline:none; cursor:pointer;
+  }
+  input[type=range]::-webkit-slider-thumb{
+    -webkit-appearance:none; width:18px;height:18px;border-radius:50%;
+    background:#fff; border:3px solid var(--accent); box-shadow:0 2px 8px rgba(0,0,0,.5); cursor:pointer;
+  }
+  input[type=range]::-moz-range-thumb{
+    width:16px;height:16px;border-radius:50%; background:#fff; border:3px solid var(--accent); cursor:pointer;
+  }
+  select{
+    width:100%; font:inherit; font-size:12.5px; color:var(--ink);
+    background:rgba(255,255,255,.04); border:1px solid var(--line); border-radius:9px;
+    padding:8px 10px; cursor:pointer; outline:none;
+  }
+  select:focus{border-color:var(--accent2)}
+  input[type=color]{
+    -webkit-appearance:none; appearance:none; width:34px; height:26px; padding:0;
+    border:1px solid var(--line); border-radius:7px; background:none; cursor:pointer;
+  }
+  input[type=color]::-webkit-color-swatch-wrapper{padding:2px}
+  input[type=color]::-webkit-color-swatch{border:none; border-radius:5px}
+  .colorwrap{display:flex; align-items:center; gap:10px}
+  .row{display:flex; gap:10px}
+  .toggle{
+    display:flex; align-items:center; justify-content:space-between; gap:10px;
+    font-size:12.5px; color:var(--muted); background:rgba(255,255,255,.03);
+    padding:10px 12px; border-radius:10px; border:1px solid var(--line);
+  }
+  .switch{position:relative; width:42px; height:23px; flex-shrink:0}
+  .switch input{opacity:0; width:0; height:0}
+  .slider-sw{
+    position:absolute; inset:0; background:#3a4066; border-radius:23px; transition:.2s; cursor:pointer;
+  }
+  .slider-sw:before{
+    content:""; position:absolute; height:17px; width:17px; left:3px; top:3px;
+    background:#fff; border-radius:50%; transition:.2s;
+  }
+  .switch input:checked + .slider-sw{background:linear-gradient(90deg,var(--accent),var(--accent2))}
+  .switch input:checked + .slider-sw:before{transform:translateX(19px)}
+
+  button{
+    flex:1; font:inherit; font-size:13px; font-weight:600; letter-spacing:.3px;
+    padding:12px 10px; border-radius:11px; border:1px solid var(--line);
+    background:rgba(255,255,255,.04); color:var(--ink); cursor:pointer; transition:.15s;
+  }
+  button:hover{background:rgba(255,255,255,.09); transform:translateY(-1px)}
+  button:active{transform:translateY(0)}
+  button.primary{
+    background:linear-gradient(90deg,var(--accent),var(--accent2)); color:#0b0d18; border:none;
+  }
+  button.primary:hover{filter:brightness(1.08)}
+  .seg{display:flex; gap:6px; flex-wrap:wrap}
+  .seg button{flex:1 1 auto; padding:8px 6px; font-size:11.5px; font-weight:600}
+
+  .readout{
+    font-size:11px; color:var(--muted); line-height:1.6; letter-spacing:.2px;
+    background:rgba(255,255,255,.02); border:1px solid var(--line); border-radius:9px; padding:9px 11px;
+    font-variant-numeric:tabular-nums;
+  }
+  .readout b{color:var(--accent)}
+  .foot{margin-top:auto; font-size:11px; color:var(--muted); line-height:1.5; letter-spacing:.2px}
+  .hint{font-size:11px; color:var(--muted); margin-top:-4px}
+  kbd{
+    font-family:ui-monospace,monospace; font-size:10px; background:rgba(255,255,255,.06);
+    border:1px solid var(--line); border-bottom-width:2px; border-radius:5px; padding:1px 5px; color:var(--ink);
+  }
+  @media (max-width:760px){
+    body{overflow:auto}
+    .wrap{flex-direction:column; height:auto}
+    .panel{width:100%; border-left:none; border-top:1px solid var(--line)}
+    .stage{padding:16px; min-height:56vh}
+  }
+</style>
+</head>
+<body>
+<div class="wrap">
+  <div class="stage">
+    <canvas id="canvas"></canvas>
+  </div>
+
+  <aside class="panel">
+    <div class="brand">
+      <div class="dot"></div>
+      <div>
+        <h1>Spirograph Studio</h1>
+        <span>HYPOTROCHOID PLOTTER</span>
+      </div>
+    </div>
+
+    <div class="ctrl">
+      <label>Fixed gear radius <span>R</span> <b id="vR">140</b></label>
+      <input type="range" id="R" min="40" max="230" value="140" step="1">
+    </div>
+
+    <div class="ctrl">
+      <label>Rolling gear radius <span>r</span> <b id="vr">53</b></label>
+      <input type="range" id="r" min="8" max="220" value="53" step="1">
+    </div>
+
+    <div class="ctrl">
+      <label>Pen offset <span>d</span> <b id="vd">78</b></label>
+      <input type="range" id="d" min="0" max="180" value="78" step="1">
+    </div>
+
+    <div class="ctrl">
+      <label>Speed <b id="vs">6</b></label>
+      <input type="range" id="speed" min="1" max="40" value="6" step="1">
+    </div>
+
+    <div class="ctrl">
+      <label>Line width <b id="vw">1.6</b></label>
+      <input type="range" id="lw" min="0.4" max="6" value="1.6" step="0.1">
+    </div>
+
+    <div class="ctrl">
+      <label>Color mode</label>
+      <select id="colormode">
+        <option value="cycle">Rainbow — cycle</option>
+        <option value="angle">Rainbow — by angle</option>
+        <option value="solid">Solid</option>
+      </select>
+    </div>
+
+    <div class="ctrl" id="solidRow" hidden>
+      <label>Ink color</label>
+      <div class="colorwrap">
+        <input type="color" id="ink" value="#5eead4">
+        <span class="hint" style="margin:0">used in Solid mode</span>
+      </div>
+    </div>
+
+    <div class="toggle">
+      <span>Show gears</span>
+      <label class="switch"><input type="checkbox" id="gears" checked><span class="slider-sw"></span></label>
+    </div>
+
+    <div class="toggle">
+      <span>Glow</span>
+      <label class="switch"><input type="checkbox" id="glow"><span class="slider-sw"></span></label>
+    </div>
+
+    <div class="ctrl">
+      <label>Presets</label>
+      <div class="seg">
+        <button data-preset="rose">Rose</button>
+        <button data-preset="star">Star</button>
+        <button data-preset="web">Web</button>
+        <button data-preset="loop">Loops</button>
+      </div>
+    </div>
+
+    <div class="row">
+      <button id="play" class="primary">⏸ Pause</button>
+      <button id="random">🎲 Random</button>
+    </div>
+    <div class="row">
+      <button id="clear">Clear</button>
+      <button id="save" class="primary">Save PNG</button>
+    </div>
+
+    <div class="readout" id="readout">—</div>
+
+    <div class="hint">
+      <kbd>Space</kbd> play/pause · <kbd>C</kbd> clear · <kbd>R</kbd> random · <kbd>S</kbd> save
+    </div>
+
+    <div class="foot">
+      Curve: x = (R−r)·cos t + d·cos((R−r)/r·t)<br>
+      &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;y = (R−r)·sin t − d·sin((R−r)/r·t)
+    </div>
+  </aside>
+</div>
+
+<script>
+(function(){
+  "use strict";
+  const canvas = document.getElementById('canvas');
+  const ctx = canvas.getContext('2d');
+
+  // Logical drawing size (device-independent). Backing stores are scaled by DPR
+  // so the curve is crisp on retina displays — the original blurred on hi-DPI.
+  const W = 760, H = 760;
+  const cx = W/2, cy = H/2;
+  const DPR = Math.min(window.devicePixelRatio || 1, 2);
+
+  canvas.width = W*DPR; canvas.height = H*DPR;
+  canvas.style.width = W+'px'; canvas.style.height = H+'px';
+  ctx.scale(DPR, DPR);
+
+  // Offscreen trail layer (persists between frames; gear overlay is drawn fresh each frame)
+  const trail = document.createElement('canvas');
+  trail.width = W*DPR; trail.height = H*DPR;
+  const tctx = trail.getContext('2d');
+  tctx.scale(DPR, DPR);
+
+  // Controls
+  const el = id => document.getElementById(id);
+  const R = el('R'), r = el('r'), d = el('d'), speed = el('speed'), lw = el('lw');
+  const colormode = el('colormode'), ink = el('ink');
+  const gears = el('gears'), glow = el('glow');
+  const vR=el('vR'), vr=el('vr'), vd=el('vd'), vs=el('vs'), vw=el('vw');
+  const readout = el('readout'), playBtn = el('play'), solidRow = el('solidRow');
+
+  // Drawing state
+  let t = 0;                    // parameter angle
+  let hue = Math.random()*360;
+  let lastPt = null;            // last plotted point on the persistent trail
+  let period = 0;               // t value at which the curve closes
+  let running = true;
+  let laps = 0;                 // completed closed patterns since last clear
+
+  const num  = inp => parseFloat(inp.value);
+  const inti = inp => parseInt(inp.value,10);
+
+  function gcd(a,b){ a=Math.round(a); b=Math.round(b); while(b){ const tmp=a%b; a=b; b=tmp; } return a||1; }
+
+  function computePeriod(){
+    const rr = Math.max(1, inti(r));
+    const RR = inti(R);
+    // Full pattern closes after r/gcd(R,r) revolutions of the driving angle.
+    const revs = rr / gcd(RR, rr);
+    period = 2*Math.PI*revs;
+  }
+
+  function point(tt){
+    const RR = inti(R);
+    const rr = Math.max(1, inti(r));
+    const dd = inti(d);
+    const k = RR - rr;
+    const ratio = k / rr;
+    return {
+      x: cx + k*Math.cos(tt) + dd*Math.cos(ratio*tt),
+      y: cy + k*Math.sin(tt) - dd*Math.sin(ratio*tt)
+    };
+  }
+
+  function updateReadout(){
+    const RR = inti(R), rr = Math.max(1, inti(r)), dd = inti(d);
+    const g = gcd(RR, rr);
+    const revs = rr / g;
+    const pct = period ? Math.min(100, (t/period)*100) : 0;
+    readout.innerHTML =
+      'R/r ratio <b>'+ (RR/rr).toFixed(3) +'</b> · closes in <b>'+ revs +'</b> rev'+(revs===1?'':'s')+'<br>'+
+      'petals <b>'+ (RR/g) +'</b> · pen d <b>'+ dd +'</b> · pass <b>'+ Math.round(pct) +'%</b> · laps <b>'+ laps +'</b>';
+  }
+
+  function strokeFor(){
+    const mode = colormode.value;
+    if(mode === 'solid') return ink.value;
+    if(mode === 'angle'){
+      const h = period ? (t/period*360*3) % 360 : hue;
+      return 'hsl('+h.toFixed(1)+',85%,62%)';
+    }
+    return 'hsl('+hue.toFixed(1)+',85%,62%)'; // cycle
+  }
+
+  function resetTrail(){
+    tctx.clearRect(0,0,W,H);
+    t = 0; laps = 0;
+    lastPt = point(0);
+    computePeriod();
+    updateReadout();
+  }
+
+  // Called when a shape parameter changes: fresh curve, new base hue.
+  function restart(){
+    hue = (hue + 47) % 360;
+    resetTrail();
+    render();
+  }
+
+  // Rolling-gear schematic for the current t (overlay only).
+  function drawGears(octx, tt){
+    const RR = inti(R), rr = Math.max(1, inti(r));
+    const k = RR - rr;
+    octx.save();
+    octx.strokeStyle = 'rgba(255,255,255,.16)';
+    octx.lineWidth = 1;
+    octx.beginPath(); octx.arc(cx, cy, RR, 0, Math.PI*2); octx.stroke();
+    const gx = cx + k*Math.cos(tt);
+    const gy = cy + k*Math.sin(tt);
+    octx.strokeStyle = 'rgba(167,139,250,.55)';
+    octx.beginPath(); octx.arc(gx, gy, rr, 0, Math.PI*2); octx.stroke();
+    const p = point(tt);
+    octx.strokeStyle = 'rgba(94,234,212,.5)';
+    octx.beginPath(); octx.moveTo(gx,gy); octx.lineTo(p.x,p.y); octx.stroke();
+    octx.fillStyle = '#5eead4';
+    octx.beginPath(); octx.arc(p.x,p.y,3.2,0,Math.PI*2); octx.fill();
+    octx.fillStyle = 'rgba(167,139,250,.9)';
+    octx.beginPath(); octx.arc(gx,gy,2.5,0,Math.PI*2); octx.fill();
+    octx.restore();
+  }
+
+  function render(){
+    ctx.clearRect(0,0,W,H);
+    ctx.drawImage(trail,0,0,W,H);
+    if(gears.checked && running) drawGears(ctx, t);
+  }
+
+  function frame(){
+    if(running){
+      const steps = inti(speed);
+      const width = num(lw);
+      const dt = 0.012; // base angular step per micro-segment
+      tctx.lineCap = 'round';
+      tctx.lineJoin = 'round';
+      tctx.lineWidth = width;
+      if(glow.checked){ tctx.shadowBlur = 8; } else { tctx.shadowBlur = 0; }
+
+      for(let i=0;i<steps;i++){
+        const nt = t + dt;
+        const p = point(nt);
+        if(colormode.value === 'cycle') hue = (hue + 0.35) % 360;
+        const stroke = strokeFor();
+        tctx.strokeStyle = stroke;
+        if(glow.checked) tctx.shadowColor = stroke;
+        tctx.beginPath();
+        tctx.moveTo(lastPt.x,lastPt.y);
+        tctx.lineTo(p.x,p.y);
+        tctx.stroke();
+        lastPt = p;
+        t = nt;
+        if(t >= period){
+          // Pattern complete: keep it, start a fresh overlaid pass with a new base hue.
+          t = 0; laps++;
+          lastPt = point(0);
+          hue = (hue + 63) % 360;
+        }
+      }
+      tctx.shadowBlur = 0;
+      updateReadout();
+    }
+    render();
+    requestAnimationFrame(frame);
+  }
+
+  // ---- Value labels + persistence ----
+  function bind(input, label, fmt){
+    const upd = ()=> label.textContent = fmt ? fmt(input.value) : input.value;
+    input.addEventListener('input', upd); upd();
+  }
+  bind(R, vR); bind(r, vr); bind(d, vd); bind(speed, vs); bind(lw, vw);
+
+  const SETTINGS = ['R','r','d','speed','lw','colormode','ink'];
+  const TOGGLES = ['gears','glow'];
+  function saveSettings(){
+    try{
+      const s = {};
+      SETTINGS.forEach(k=> s[k] = el(k).value);
+      TOGGLES.forEach(k=> s[k] = el(k).checked);
+      localStorage.setItem('spiro-settings', JSON.stringify(s));
+    }catch(e){}
+  }
+  function loadSettings(){
+    try{
+      const s = JSON.parse(localStorage.getItem('spiro-settings')||'null');
+      if(!s) return;
+      SETTINGS.forEach(k=>{ if(s[k]!=null) el(k).value = s[k]; });
+      TOGGLES.forEach(k=>{ if(s[k]!=null) el(k).checked = s[k]; });
+      [vR,vr,vd,vs,vw].forEach(()=>{});
+      vR.textContent=R.value; vr.textContent=r.value; vd.textContent=d.value;
+      vs.textContent=speed.value; vw.textContent=lw.value;
+    }catch(e){}
+  }
+  loadSettings();
+
+  // Shape-changing sliders restart the curve; all controls persist.
+  [R,r,d].forEach(inp => inp.addEventListener('input', restart));
+  [R,r,d,speed,lw,colormode,ink,gears,glow].forEach(inp =>
+    inp.addEventListener('input', saveSettings));
+
+  colormode.addEventListener('input', ()=>{ solidRow.hidden = colormode.value !== 'solid'; });
+  solidRow.hidden = colormode.value !== 'solid';
+
+  // ---- Buttons ----
+  function setPlaying(on){
+    running = on;
+    playBtn.textContent = on ? '⏸ Pause' : '▶ Play';
+  }
+  playBtn.addEventListener('click', ()=> setPlaying(!running));
+  el('clear').addEventListener('click', restart);
+
+  el('random').addEventListener('click', ()=>{
+    const rnd = (min,max)=> Math.floor(min + Math.random()*(max-min+1));
+    R.value = rnd(90, 230);
+    r.value = rnd(20, Math.max(30, +R.value - 10)); // keep a real hypotrochoid (r < R mostly)
+    d.value = rnd(20, 170);
+    const modes = ['cycle','angle','solid'];
+    colormode.value = modes[rnd(0,2)];
+    solidRow.hidden = colormode.value !== 'solid';
+    vR.textContent=R.value; vr.textContent=r.value; vd.textContent=d.value;
+    saveSettings();
+    restart();
+    setPlaying(true);
+  });
+
+  const PRESETS = {
+    rose: {R:200, r:60, d:120, mode:'cycle'},
+    star: {R:150, r:37, d:90,  mode:'angle'},
+    web:  {R:220, r:99, d:150, mode:'cycle'},
+    loop: {R:120, r:29, d:170, mode:'solid'}
+  };
+  document.querySelectorAll('[data-preset]').forEach(btn=>{
+    btn.addEventListener('click', ()=>{
+      const p = PRESETS[btn.dataset.preset]; if(!p) return;
+      R.value=p.R; r.value=p.r; d.value=p.d; colormode.value=p.mode;
+      solidRow.hidden = colormode.value !== 'solid';
+      vR.textContent=R.value; vr.textContent=r.value; vd.textContent=d.value;
+      saveSettings(); restart(); setPlaying(true);
+    });
+  });
+
+  el('save').addEventListener('click', ()=>{
+    // Composite onto a dark background so the PNG isn't transparent.
+    const out = document.createElement('canvas');
+    out.width = trail.width; out.height = trail.height;
+    const octx = out.getContext('2d');
+    octx.fillStyle = '#07080f';
+    octx.fillRect(0,0,out.width,out.height);
+    octx.drawImage(trail,0,0);
+    const link = document.createElement('a');
+    link.download = 'spirograph-' + Date.now() + '.png';
+    link.href = out.toDataURL('image/png');
+    link.click();
+  });
+
+  // ---- Keyboard shortcuts ----
+  addEventListener('keydown', e=>{
+    if(e.target.matches('input,select,textarea')) return;
+    const k = e.key.toLowerCase();
+    if(k === ' '){ e.preventDefault(); setPlaying(!running); }
+    else if(k === 'c'){ restart(); }
+    else if(k === 'r'){ el('random').click(); }
+    else if(k === 's'){ el('save').click(); }
+  });
+
+  // Init
+  resetTrail();
+  requestAnimationFrame(frame);
+})();
+</script>
+</body>
+</html>
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..79728eb
--- /dev/null
+++ b/index.html
@@ -0,0 +1,121 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>graphics.agentabrams.com</title>
+<style>
+  * { box-sizing: border-box; }
+  html, body { height: 100%; margin: 0; }
+  body {
+    display: flex; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+    background: #12141a; color: #e8e9ef; overflow: hidden;
+  }
+  /* ---- left panel ---- */
+  #panel {
+    width: 260px; min-width: 260px; height: 100%; display: flex; flex-direction: column;
+    background: linear-gradient(180deg, #1a1e28, #14171f);
+    border-right: 1px solid #2b3140;
+  }
+  #brand {
+    padding: 18px 16px 14px; border-bottom: 1px solid #2b3140;
+  }
+  #brand .dot { color: #5eead4; }
+  #brand h1 {
+    margin: 0; font-size: 13px; font-weight: 700; letter-spacing: 1.5px;
+  }
+  #brand .sub { font-size: 10px; opacity: .45; letter-spacing: 1px; margin-top: 4px; }
+  #list { flex: 1; overflow-y: auto; padding: 10px 8px; }
+  #list .dir { font-size: 10px; opacity: .4; letter-spacing: 1px; padding: 4px 8px 8px; }
+  .item {
+    display: block; width: 100%; text-align: left; cursor: pointer;
+    background: none; border: 1px solid transparent; border-radius: 8px;
+    color: #e8e9ef; font: inherit; padding: 9px 10px; margin-bottom: 2px;
+  }
+  .item:hover { background: #222839; }
+  .item.active { background: #2a3147; border-color: #3d4763; }
+  .item .fname { font-size: 13px; font-weight: 600; }
+  .item .fname::before { content: "▸ "; color: #5eead4; }
+  .item.active .fname::before { content: "▾ "; }
+  .item .fdesc { font-size: 10.5px; opacity: .5; margin-top: 3px; line-height: 1.4;
+    display: none; }
+  .item.active .fdesc { display: block; }
+  #panel footer {
+    padding: 12px 16px; border-top: 1px solid #2b3140; font-size: 10px; opacity: .4;
+    letter-spacing: .5px; line-height: 1.6;
+  }
+  /* ---- right panel ---- */
+  #stage { flex: 1; height: 100%; position: relative; background: #0e1016; }
+  #frame { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
+  #empty {
+    position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
+    flex-direction: column; gap: 10px; opacity: .35; font-size: 13px; letter-spacing: 1px;
+  }
+  #empty .big { font-size: 40px; }
+  /* ---- mobile ---- */
+  @media (max-width: 700px) {
+    body { flex-direction: column; }
+    #panel { width: 100%; min-width: 0; height: auto; max-height: 45%; }
+    #panel footer { display: none; }
+  }
+</style>
+</head>
+<body>
+<aside id="panel">
+  <div id="brand">
+    <h1><span class="dot">●</span> AGENT ABRAMS GRAPHICS</h1>
+    <div class="sub">graphics.agentabrams.com</div>
+  </div>
+  <nav id="list"><div class="dir">~/graphics/</div></nav>
+  <footer>zero dependencies · pure browser<br>more graphics landing over time</footer>
+</aside>
+<main id="stage">
+  <iframe id="frame" title="graphic" hidden></iframe>
+  <div id="empty"><div class="big">🎨</div><div>select a graphic</div></div>
+</main>
+<script>
+(function () {
+  var list = document.getElementById('list');
+  var frame = document.getElementById('frame');
+  var empty = document.getElementById('empty');
+  var items = [], buttons = {};
+
+  function select(id, push) {
+    var g = items.find(function (x) { return x.id === id; });
+    if (!g) return;
+    Object.keys(buttons).forEach(function (k) { buttons[k].classList.toggle('active', k === id); });
+    frame.hidden = false; empty.style.display = 'none';
+    frame.src = g.path + 'index.html';
+    document.title = g.file + ' — graphics.agentabrams.com';
+    try { localStorage.setItem('aa-last-graphic', id); } catch (e) {}
+    if (push !== false) history.replaceState(null, '', '#' + id);
+  }
+
+  fetch('graphics.json').then(function (r) { return r.json(); }).then(function (data) {
+    items = data.graphics || [];
+    items.forEach(function (g) {
+      var b = document.createElement('button');
+      b.className = 'item';
+      b.innerHTML = '<div class="fname"></div><div class="fdesc"></div>';
+      b.querySelector('.fname').textContent = g.file;
+      b.querySelector('.fdesc').textContent = g.desc || '';
+      b.onclick = function () { select(g.id); };
+      list.appendChild(b);
+      buttons[g.id] = b;
+    });
+    var want = location.hash.replace('#', '');
+    if (!want) { try { want = localStorage.getItem('aa-last-graphic'); } catch (e) {} }
+    if (!want || !items.some(function (g) { return g.id === want; })) {
+      want = items.length ? items[0].id : null;
+    }
+    if (want) select(want, false);
+  });
+
+  addEventListener('hashchange', function () {
+    var id = location.hash.replace('#', '');
+    if (id) select(id, false);
+  });
+})();
+</script>
+</body>
+</html>
diff --git a/tests/e2e.mjs b/tests/e2e.mjs
new file mode 100644
index 0000000..5cfeaad
--- /dev/null
+++ b/tests/e2e.mjs
@@ -0,0 +1,107 @@
+// Headless E2E for the graphics hub. Needs a static server (fetch of graphics.json
+// fails on file://), so it spins one up on an ephemeral port.
+import { createRequire } from 'module';
+import { fileURLToPath } from 'url';
+import path from 'path';
+import http from 'http';
+import fs from 'fs';
+
+const require = createRequire(import.meta.url);
+let chromium;
+try {
+  ({ chromium } = require('/Users/macstudio3/.npm-global/lib/node_modules/playwright'));
+} catch {
+  ({ chromium } = require('playwright'));
+}
+
+const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
+const MIME = { '.html': 'text/html', '.json': 'application/json', '.png': 'image/png', '.aa': 'text/html' };
+const server = http.createServer((req, res) => {
+  let p = decodeURIComponent(req.url.split('?')[0].split('#')[0]);
+  if (p.endsWith('/')) p += 'index.html';
+  const f = path.join(root, p);
+  if (!f.startsWith(root) || !fs.existsSync(f) || fs.statSync(f).isDirectory()) {
+    res.writeHead(404); res.end('nf'); return;
+  }
+  res.writeHead(200, { 'Content-Type': MIME[path.extname(f)] || 'text/plain' });
+  fs.createReadStream(f).pipe(res);
+});
+await new Promise(r => server.listen(0, '127.0.0.1', r));
+const url = `http://127.0.0.1:${server.address().port}/`;
+
+const browser = await chromium.launch();
+const page = await browser.newPage({ viewport: { width: 1200, height: 800 } });
+const errors = [];
+page.on('pageerror', e => errors.push('pageerror: ' + e.message));
+page.on('console', m => { if (m.type() === 'error') errors.push('console: ' + m.text()); });
+
+const results = [];
+const check = (name, ok) => { results.push({ name, ok }); console.log((ok ? 'PASS' : 'FAIL') + '  ' + name); };
+
+await page.goto(url);
+await page.waitForSelector('.item');
+check('graphic list renders from manifest', await page.locator('.item').count() >= 1);
+check('Spirograph.aa listed', (await page.locator('.item .fname').first().textContent()) === 'Spirograph.aa');
+check('first graphic auto-selected', await page.locator('.item.active').count() === 1);
+
+const frame = page.frameLocator('#frame');
+await frame.locator('#canvas').waitFor({ timeout: 15000 });
+check('graphic iframe loads the canvas', await frame.locator('#canvas').count() === 1);
+
+const spiro = page.frames().find(f => f.url().includes('spirograph'));
+// canvas has a non-zero backing store (hi-DPI scaled)
+const dims = await spiro.evaluate(() => { const c = document.getElementById('canvas'); return { w: c.width, h: c.height }; });
+check('canvas backing store sized (hi-DPI)', dims.w >= 760 && dims.h >= 760);
+
+// the animation actually draws — trail pixel count grows after a moment
+const drew = await spiro.evaluate(async () => {
+  const c = document.getElementById('canvas');
+  const g = c.getContext('2d');
+  const count = () => { const d = g.getImageData(0, 0, c.width, c.height).data; let n = 0; for (let i = 3; i < d.length; i += 4) if (d[i] > 8) n++; return n; };
+  const before = count();
+  await new Promise(r => setTimeout(r, 700));
+  return count() - before;
+});
+check('spirograph animates (pixels drawn)', drew > 500);
+
+// controls exist and update
+const hasControls = await spiro.evaluate(() => ['R','r','d','speed','lw','colormode','play','random','clear','save']
+  .every(id => document.getElementById(id)));
+check('all controls present', hasControls);
+
+// ink-color row hidden in non-solid mode (regression: .ctrl display:flex vs [hidden])
+const inkHidden = await spiro.evaluate(() => {
+  const row = document.getElementById('solidRow');
+  return getComputedStyle(row).display === 'none';
+});
+check('ink-color row hidden outside Solid mode', inkHidden);
+
+// switching to Solid reveals the ink row
+const inkShown = await spiro.evaluate(() => {
+  const sel = document.getElementById('colormode');
+  sel.value = 'solid'; sel.dispatchEvent(new Event('input'));
+  return getComputedStyle(document.getElementById('solidRow')).display !== 'none';
+});
+check('ink-color row shown in Solid mode', inkShown);
+
+// let it run so the screenshot shows a real curve
+await spiro.evaluate(() => new Promise(r => setTimeout(r, 2500)));
+
+// pause button toggles running state
+const paused = await spiro.evaluate(() => { const b = document.getElementById('play'); b.click(); return b.textContent.includes('Play'); });
+check('pause toggles play/pause label', paused);
+
+// deep link
+await page.goto(url + '#spirograph');
+await page.waitForSelector('.item.active');
+check('hash deep-link selects graphic', await page.locator('.item.active').count() === 1);
+
+await page.screenshot({ path: path.join(root, 'tests', 'screenshot.png') });
+check('no console/page errors', errors.length === 0);
+if (errors.length) console.log(errors.join('\n'));
+
+await browser.close();
+server.close();
+const failed = results.filter(r => !r.ok);
+console.log(`\n${results.length - failed.length}/${results.length} passed`);
+process.exit(failed.length ? 1 : 0);

(oldest)  ·  back to Graphics Agentabrams  ·  deploy: bootstrap-safe first deploy (stage HTTP-only config d0e05ec →