← 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
A .gitignoreA README.mdA deploy/deploy.shA deploy/graphics.agentabrams.com.nginxA graphics.jsonA graphics/spirograph/Spirograph.aaA graphics/spirograph/index.htmlA index.htmlA tests/e2e.mjs
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>
+ 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>
+ 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 →