[object Object]

← back to Model Arena

Agent-driven game: DW Pattern Sequence (frontend-developer) — Simon-style memory, 4 pattern pads, tones+glow. Registered.

05f0c2c507bb8a1db1e6e6597250e0511de9e227 · 2026-07-25 12:17:38 -0700 · Steve Abrams

Files touched

Diff

commit 05f0c2c507bb8a1db1e6e6597250e0511de9e227
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Jul 25 12:17:38 2026 -0700

    Agent-driven game: DW Pattern Sequence (frontend-developer) — Simon-style memory, 4 pattern pads, tones+glow. Registered.
---
 data/arcade-games.json                    |   6 +
 public/games/dw-pattern-sequence.html     | 667 +++++++++++++++++++++++
 public/games/dw-texture-weave-studio.html | 873 ++++++++++++++++++++++++++++++
 3 files changed, 1546 insertions(+)

diff --git a/data/arcade-games.json b/data/arcade-games.json
index 6b73a30..944e566 100644
--- a/data/arcade-games.json
+++ b/data/arcade-games.json
@@ -107,6 +107,12 @@
       "title": "DW Palette Mixer",
       "credit": "frontend-developer",
       "note": "Agent-built tool \u2014 HSL palette builder, 5 harmony modes, lock+regenerate, texture preview, PNG export"
+    },
+    {
+      "slug": "dw-pattern-sequence",
+      "title": "DW Pattern Sequence",
+      "credit": "frontend-developer",
+      "note": "Agent-built Simon-style memory game \u2014 repeat the growing sequence of luxury pattern pads, tones + glow"
     }
   ]
 }
\ No newline at end of file
diff --git a/public/games/dw-pattern-sequence.html b/public/games/dw-pattern-sequence.html
new file mode 100644
index 0000000..f559ce9
--- /dev/null
+++ b/public/games/dw-pattern-sequence.html
@@ -0,0 +1,667 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Pattern Sequence — Designer Wallcoverings</title>
+<style>
+:root{
+  --bg:#0d0b09;
+  --surface:#1a1714;
+  --surface2:#221f1b;
+  --ink:#f4f1ea;
+  --ink-dim:#9a9080;
+  --accent:#c9a961;
+  --accent-dim:#7a6235;
+  --accent-glow:rgba(201,169,97,0.55);
+  --accent-pale:rgba(201,169,97,0.12);
+  --red:#c96161;
+  --serif:'Cormorant Garamond','Playfair Display','EB Garamond',Georgia,serif;
+  --sans:'Helvetica Neue',Arial,sans-serif;
+  --ease:cubic-bezier(.4,0,.2,1);
+  --r:14px;
+}
+*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
+html,body{height:100%;background:var(--bg);color:var(--ink);font-family:var(--sans);overscroll-behavior:none}
+body{display:flex;flex-direction:column;min-height:100vh}
+
+/* HEADER */
+header{
+  display:flex;align-items:center;justify-content:space-between;
+  padding:18px 28px 14px;
+  border-bottom:1px solid rgba(201,169,97,0.15);
+  flex-shrink:0;
+}
+.wordmark{
+  font-family:var(--serif);
+  font-size:clamp(22px,3.5vw,40px);
+  font-weight:700;
+  letter-spacing:.18em;
+  text-transform:uppercase;
+  color:var(--ink);
+  line-height:1;
+  white-space:nowrap;
+}
+.wordmark span{color:var(--accent)}
+.header-controls{display:flex;gap:12px;align-items:center}
+button.hbtn{
+  background:none;border:1px solid rgba(201,169,97,0.3);
+  color:var(--ink-dim);border-radius:6px;
+  padding:5px 12px;font-size:11px;letter-spacing:.1em;text-transform:uppercase;
+  cursor:pointer;transition:all .2s var(--ease);font-family:var(--sans);
+}
+button.hbtn:hover{border-color:var(--accent);color:var(--accent)}
+button.hbtn.active{border-color:var(--accent);color:var(--accent);background:var(--accent-pale)}
+
+/* MAIN */
+main{
+  flex:1;display:flex;flex-direction:column;align-items:center;
+  padding:20px 16px 28px;gap:20px;
+}
+
+/* STATUS BAR */
+.status-bar{
+  display:flex;gap:28px;align-items:center;
+  font-size:12px;letter-spacing:.12em;text-transform:uppercase;color:var(--ink-dim);
+}
+.stat{display:flex;flex-direction:column;align-items:center;gap:2px}
+.stat-val{
+  font-family:var(--serif);font-size:28px;font-weight:700;
+  color:var(--accent);line-height:1;letter-spacing:.04em;
+}
+.stat-val.dim{color:var(--ink-dim)}
+.sep{width:1px;height:36px;background:rgba(201,169,97,0.2)}
+
+/* PROGRESS STRIP */
+.progress-strip{
+  width:min(480px,90vw);height:3px;background:var(--surface2);
+  border-radius:2px;overflow:hidden;
+}
+.progress-fill{
+  height:100%;background:var(--accent);border-radius:2px;
+  width:0%;transition:width .35s var(--ease);
+}
+
+/* PHASE LABEL */
+.phase-label{
+  font-family:var(--serif);font-size:15px;letter-spacing:.1em;
+  color:var(--ink-dim);min-height:22px;text-align:center;
+  transition:opacity .3s;
+}
+
+/* PAD GRID */
+.pad-grid{
+  display:grid;
+  grid-template-columns:1fr 1fr;
+  gap:clamp(10px,2vw,20px);
+  width:min(560px,92vw);
+}
+.pad{
+  position:relative;border-radius:var(--r);overflow:hidden;
+  cursor:pointer;
+  aspect-ratio:1;
+  border:2px solid rgba(201,169,97,0.18);
+  transition:transform .12s var(--ease),border-color .15s;
+  user-select:none;-webkit-user-select:none;
+  outline:none;
+}
+.pad:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
+.pad svg{
+  width:100%;height:100%;display:block;
+  transition:filter .18s var(--ease);
+}
+.pad-overlay{
+  position:absolute;inset:0;border-radius:calc(var(--r) - 2px);
+  background:rgba(201,169,97,0);
+  transition:background .15s var(--ease);
+  pointer-events:none;
+}
+.pad-label{
+  position:absolute;bottom:10px;left:0;right:0;text-align:center;
+  font-family:var(--serif);font-size:clamp(11px,1.6vw,14px);
+  letter-spacing:.12em;text-transform:uppercase;color:rgba(244,241,234,0.55);
+  pointer-events:none;
+}
+
+/* Lit state */
+.pad.lit{
+  border-color:var(--accent);
+  transform:scale(1.035);
+  box-shadow:0 0 32px var(--accent-glow),0 0 8px var(--accent-glow);
+}
+.pad.lit .pad-overlay{background:rgba(201,169,97,0.28)}
+.pad.lit svg{filter:brightness(1.35) saturate(1.2)}
+
+/* Press feedback */
+.pad.pressed{transform:scale(0.96)}
+
+/* Wrong flash */
+.pad.wrong{
+  border-color:var(--red)!important;
+  box-shadow:0 0 28px rgba(201,97,97,0.55)!important;
+}
+.pad.wrong .pad-overlay{background:rgba(201,97,97,0.22)!important}
+
+/* GAME OVER OVERLAY */
+#overlay{
+  position:fixed;inset:0;
+  display:flex;align-items:center;justify-content:center;
+  background:rgba(13,11,9,0.88);
+  backdrop-filter:blur(6px);
+  z-index:100;
+  visibility:hidden;opacity:0;
+  transition:opacity .35s var(--ease),visibility 0s .35s;
+}
+#overlay.show{
+  visibility:visible;opacity:1;
+  transition:opacity .35s var(--ease);
+}
+.overlay-card{
+  background:var(--surface);border:1px solid rgba(201,169,97,0.3);
+  border-radius:20px;padding:44px 52px;text-align:center;
+  max-width:380px;width:90%;
+  transform:translateY(12px);
+  transition:transform .35s var(--ease);
+  box-shadow:0 24px 64px rgba(0,0,0,0.6);
+}
+#overlay.show .overlay-card{transform:translateY(0)}
+.overlay-eyebrow{
+  font-size:11px;letter-spacing:.18em;text-transform:uppercase;
+  color:var(--accent);margin-bottom:10px;
+}
+.overlay-title{
+  font-family:var(--serif);font-size:38px;font-weight:700;
+  color:var(--ink);line-height:1.1;margin-bottom:6px;
+}
+.overlay-sub{font-size:13px;color:var(--ink-dim);margin-bottom:28px;letter-spacing:.05em}
+.overlay-score{
+  font-family:var(--serif);font-size:64px;font-weight:700;
+  color:var(--accent);line-height:1;margin-bottom:4px;
+}
+.overlay-score-label{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-dim);margin-bottom:28px}
+button.play-btn{
+  background:var(--accent);color:#1c1a17;
+  border:none;border-radius:8px;
+  padding:14px 36px;font-family:var(--serif);font-size:18px;font-weight:700;
+  letter-spacing:.08em;cursor:pointer;
+  transition:background .2s,transform .12s;
+  text-transform:uppercase;
+}
+button.play-btn:hover{background:#d4b870;transform:scale(1.03)}
+button.play-btn:active{transform:scale(0.97)}
+
+/* WIN flourish ring */
+@keyframes ringPulse{
+  0%{transform:scale(1);opacity:.6}
+  100%{transform:scale(2.2);opacity:0}
+}
+.ring-pulse{
+  position:fixed;
+  border:2px solid var(--accent);border-radius:50%;
+  pointer-events:none;z-index:200;
+  animation:ringPulse .7s var(--ease) forwards;
+}
+
+/* KEYBOARD hint */
+.kbd-hint{
+  font-size:10px;letter-spacing:.1em;color:rgba(154,144,128,0.5);
+  text-transform:uppercase;margin-top:4px;
+}
+
+@media(max-width:480px){
+  header{padding:14px 16px 12px}
+  .wordmark{font-size:18px;letter-spacing:.12em}
+  .pad-grid{gap:10px;width:96vw}
+  .overlay-card{padding:32px 24px}
+  .overlay-title{font-size:30px}
+  .overlay-score{font-size:50px}
+}
+</style>
+</head>
+<body>
+
+<header>
+  <div class="wordmark">Designer<span> </span>Wallcoverings</div>
+  <div class="header-controls">
+    <button class="hbtn" id="btn-strict" title="Strict mode: one wrong move ends the game immediately">Strict</button>
+    <button class="hbtn" id="btn-mute" title="Mute / unmute tones">Mute</button>
+  </div>
+</header>
+
+<main>
+  <div class="status-bar">
+    <div class="stat">
+      <span class="stat-val" id="val-round">—</span>
+      <span>Round</span>
+    </div>
+    <div class="sep"></div>
+    <div class="stat">
+      <span class="stat-val dim" id="val-best">—</span>
+      <span>Best</span>
+    </div>
+  </div>
+
+  <div class="progress-strip"><div class="progress-fill" id="progress"></div></div>
+
+  <div class="phase-label" id="phase">Tap Play to begin</div>
+
+  <div class="pad-grid" id="pad-grid">
+    <!-- pads injected by JS -->
+  </div>
+
+  <div class="kbd-hint">Keys 1–4 &nbsp;·&nbsp; ←↑→↓ arrows</div>
+</main>
+
+<!-- Game Over / Start Overlay -->
+<div id="overlay" role="dialog" aria-modal="true" aria-labelledby="ov-title">
+  <div class="overlay-card">
+    <div class="overlay-eyebrow" id="ov-eyebrow">Designer Wallcoverings</div>
+    <div class="overlay-title" id="ov-title">Pattern Sequence</div>
+    <div class="overlay-sub" id="ov-sub">Watch the sequence. Repeat it. How far can you go?</div>
+    <div class="overlay-score" id="ov-score" style="display:none"></div>
+    <div class="overlay-score-label" id="ov-score-label" style="display:none"></div>
+    <button class="play-btn" id="btn-play">Play</button>
+  </div>
+</div>
+
+<script>
+// ── PAD DEFINITIONS ────────────────────────────────────────────────────────
+const PADS=[
+  {
+    label:'Damask',
+    freq:261.63,// C4
+    color:'#2a1f0e',
+    svgFn:()=>`<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
+      <defs>
+        <pattern id="dam" x="0" y="0" width="100" height="100" patternUnits="userSpaceOnUse">
+          <rect width="100" height="100" fill="#1e1509"/>
+          <!-- Damask medallion -->
+          <ellipse cx="50" cy="50" rx="18" ry="28" fill="none" stroke="#c9a961" stroke-width="1.2"/>
+          <ellipse cx="50" cy="50" rx="10" ry="18" fill="none" stroke="#c9a961" stroke-width=".8"/>
+          <circle cx="50" cy="50" r="4" fill="#c9a961" opacity=".9"/>
+          <!-- Top/bottom petals -->
+          <ellipse cx="50" cy="22" rx="5" ry="9" fill="#c9a961" opacity=".45"/>
+          <ellipse cx="50" cy="78" rx="5" ry="9" fill="#c9a961" opacity=".45"/>
+          <!-- Side petals -->
+          <ellipse cx="22" cy="50" rx="9" ry="5" fill="#c9a961" opacity=".45"/>
+          <ellipse cx="78" cy="50" rx="9" ry="5" fill="#c9a961" opacity=".45"/>
+          <!-- Corner acanthus -->
+          <path d="M0,0 Q15,15 5,25 Q18,8 30,10" fill="none" stroke="#c9a961" stroke-width=".7" opacity=".5"/>
+          <path d="M100,0 Q85,15 95,25 Q82,8 70,10" fill="none" stroke="#c9a961" stroke-width=".7" opacity=".5"/>
+          <path d="M0,100 Q15,85 5,75 Q18,92 30,90" fill="none" stroke="#c9a961" stroke-width=".7" opacity=".5"/>
+          <path d="M100,100 Q85,85 95,75 Q82,92 70,90" fill="none" stroke="#c9a961" stroke-width=".7" opacity=".5"/>
+          <!-- Diamond grid -->
+          <path d="M50,0 L100,50 L50,100 L0,50 Z" fill="none" stroke="#c9a961" stroke-width=".4" opacity=".2"/>
+        </pattern>
+      </defs>
+      <rect width="200" height="200" fill="url(#dam)"/>
+    </svg>`
+  },
+  {
+    label:'Chinoiserie',
+    freq:329.63,// E4
+    color:'#0a1a0d',
+    svgFn:()=>`<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
+      <defs>
+        <pattern id="chi" x="0" y="0" width="100" height="100" patternUnits="userSpaceOnUse">
+          <rect width="100" height="100" fill="#081510"/>
+          <!-- Branch -->
+          <path d="M10,90 Q30,60 50,50 Q70,40 90,15" stroke="#c9a961" stroke-width="1.5" fill="none" opacity=".8"/>
+          <path d="M50,50 Q60,70 55,85" stroke="#c9a961" stroke-width="1" fill="none" opacity=".6"/>
+          <path d="M50,50 Q35,45 25,55" stroke="#c9a961" stroke-width="1" fill="none" opacity=".6"/>
+          <!-- Blossoms -->
+          <circle cx="90" cy="15" r="5" fill="#c9a961" opacity=".7"/>
+          <circle cx="87" cy="12" r="2.5" fill="none" stroke="#c9a961" stroke-width=".8" opacity=".8"/>
+          <circle cx="93" cy="18" r="2.5" fill="none" stroke="#c9a961" stroke-width=".8" opacity=".8"/>
+          <circle cx="55" cy="85" r="4" fill="#c9a961" opacity=".5"/>
+          <circle cx="25" cy="55" r="3.5" fill="#c9a961" opacity=".5"/>
+          <!-- Leaves -->
+          <ellipse cx="70" cy="35" rx="7" ry="3" transform="rotate(-30 70 35)" fill="#c9a961" opacity=".35"/>
+          <ellipse cx="38" cy="62" rx="6" ry="2.5" transform="rotate(20 38 62)" fill="#c9a961" opacity=".35"/>
+          <!-- Pagoda hint -->
+          <path d="M5,60 L15,60 L12,52 L8,52 Z" fill="none" stroke="#c9a961" stroke-width=".7" opacity=".4"/>
+          <path d="M3,60 L17,60" stroke="#c9a961" stroke-width=".5" opacity=".4"/>
+          <path d="M4,55 L16,55" stroke="#c9a961" stroke-width=".5" opacity=".3"/>
+          <!-- Birds -->
+          <path d="M60,25 Q65,20 70,23 M60,25 Q65,30 70,27" stroke="#c9a961" stroke-width=".7" fill="none" opacity=".6"/>
+          <circle cx="58" cy="25" r="2" fill="#c9a961" opacity=".6"/>
+        </pattern>
+      </defs>
+      <rect width="200" height="200" fill="url(#chi)"/>
+    </svg>`
+  },
+  {
+    label:'Grasscloth',
+    freq:392.00,// G4
+    color:'#141008',
+    svgFn:()=>`<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
+      <defs>
+        <pattern id="grass" x="0" y="0" width="12" height="200" patternUnits="userSpaceOnUse">
+          <rect width="12" height="200" fill="#18130a"/>
+          <!-- Vertical weave strands -->
+          <line x1="2" y1="0" x2="2" y2="200" stroke="#c9a961" stroke-width=".6" opacity=".18"/>
+          <line x1="5" y1="0" x2="5" y2="200" stroke="#c9a961" stroke-width=".4" opacity=".12"/>
+          <line x1="9" y1="0" x2="9" y2="200" stroke="#c9a961" stroke-width=".6" opacity=".18"/>
+        </pattern>
+        <pattern id="grassH" x="0" y="0" width="200" height="8" patternUnits="userSpaceOnUse">
+          <rect width="200" height="8" fill="none"/>
+          <!-- Horizontal weft -->
+          <line x1="0" y1="2" x2="200" y2="2" stroke="#a07840" stroke-width=".5" opacity=".13"/>
+          <line x1="0" y1="5.5" x2="200" y2="5.5" stroke="#c9a961" stroke-width=".35" opacity=".1"/>
+        </pattern>
+        <!-- Texture noise via tiny rects -->
+        <pattern id="grassNoise" x="0" y="0" width="40" height="40" patternUnits="userSpaceOnUse">
+          <rect x="3" y="7" width="1.5" height="6" fill="#c9a961" opacity=".07" transform="rotate(2 3 7)"/>
+          <rect x="12" y="2" width="1" height="8" fill="#a07840" opacity=".06" transform="rotate(-1 12 2)"/>
+          <rect x="22" y="15" width="1.5" height="5" fill="#c9a961" opacity=".07" transform="rotate(1 22 15)"/>
+          <rect x="33" y="5" width="1" height="7" fill="#a07840" opacity=".05"/>
+          <rect x="7" y="25" width="1.5" height="6" fill="#c9a961" opacity=".06" transform="rotate(-2 7 25)"/>
+          <rect x="28" y="30" width="1" height="8" fill="#c9a961" opacity=".07" transform="rotate(1.5 28 30)"/>
+          <rect x="18" y="32" width="1.5" height="5" fill="#a07840" opacity=".05"/>
+          <rect x="38" y="22" width="1" height="7" fill="#c9a961" opacity=".06"/>
+        </pattern>
+      </defs>
+      <rect width="200" height="200" fill="#18130a"/>
+      <rect width="200" height="200" fill="url(#grass)"/>
+      <rect width="200" height="200" fill="url(#grassH)"/>
+      <rect width="200" height="200" fill="url(#grassNoise)"/>
+    </svg>`
+  },
+  {
+    label:'Art Déco',
+    freq:523.25,// C5
+    color:'#0b0f1a',
+    svgFn:()=>`<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
+      <defs>
+        <pattern id="deco" x="0" y="0" width="100" height="100" patternUnits="userSpaceOnUse">
+          <rect width="100" height="100" fill="#0b0f1a"/>
+          <!-- Sunburst fan -->
+          <g transform="translate(50,50)" opacity=".85">
+            <line x1="0" y1="0" x2="0" y2="-32" stroke="#c9a961" stroke-width="1.4"/>
+            <line x1="0" y1="0" x2="32" y2="0" stroke="#c9a961" stroke-width="1.4"/>
+            <line x1="0" y1="0" x2="0" y2="32" stroke="#c9a961" stroke-width="1.4"/>
+            <line x1="0" y1="0" x2="-32" y2="0" stroke="#c9a961" stroke-width="1.4"/>
+            <!-- Diagonals -->
+            <line x1="0" y1="0" x2="23" y2="-23" stroke="#c9a961" stroke-width=".8" opacity=".7"/>
+            <line x1="0" y1="0" x2="23" y2="23" stroke="#c9a961" stroke-width=".8" opacity=".7"/>
+            <line x1="0" y1="0" x2="-23" y2="23" stroke="#c9a961" stroke-width=".8" opacity=".7"/>
+            <line x1="0" y1="0" x2="-23" y2="-23" stroke="#c9a961" stroke-width=".8" opacity=".7"/>
+            <!-- Concentric arcs -->
+            <circle r="12" fill="none" stroke="#c9a961" stroke-width=".8" opacity=".6"/>
+            <circle r="22" fill="none" stroke="#c9a961" stroke-width=".6" opacity=".4"/>
+            <circle r="32" fill="none" stroke="#c9a961" stroke-width=".5" opacity=".3"/>
+            <!-- Center gem -->
+            <polygon points="0,-6 5,0 0,6 -5,0" fill="#c9a961" opacity=".95"/>
+          </g>
+          <!-- Corner chevrons -->
+          <path d="M0,0 L10,0 L10,2 L2,2 L2,10 L0,10 Z" fill="#c9a961" opacity=".3"/>
+          <path d="M100,0 L90,0 L90,2 L98,2 L98,10 L100,10 Z" fill="#c9a961" opacity=".3"/>
+          <path d="M0,100 L10,100 L10,98 L2,98 L2,90 L0,90 Z" fill="#c9a961" opacity=".3"/>
+          <path d="M100,100 L90,100 L90,98 L98,98 L98,90 L100,90 Z" fill="#c9a961" opacity=".3"/>
+          <!-- Step borders -->
+          <rect x="3" y="3" width="94" height="94" fill="none" stroke="#c9a961" stroke-width=".4" opacity=".2"/>
+          <rect x="7" y="7" width="86" height="86" fill="none" stroke="#c9a961" stroke-width=".3" opacity=".15"/>
+        </pattern>
+      </defs>
+      <rect width="200" height="200" fill="url(#deco)"/>
+    </svg>`
+  }
+];
+
+// ── AUDIO ENGINE ───────────────────────────────────────────────────────────
+let ctx=null,muted=false;
+function getCtx(){
+  if(!ctx) ctx=new(window.AudioContext||window.webkitAudioContext)();
+  if(ctx.state==='suspended') ctx.resume();
+  return ctx;
+}
+function playTone(freq,dur=0.22,type='sine',vol=0.22){
+  if(muted)return;
+  try{
+    const c=getCtx();
+    const o=c.createOscillator();
+    const g=c.createGain();
+    o.connect(g);g.connect(c.destination);
+    o.type=type;o.frequency.setValueAtTime(freq,c.currentTime);
+    g.gain.setValueAtTime(vol,c.currentTime);
+    g.gain.exponentialRampToValueAtTime(0.001,c.currentTime+dur);
+    o.start();o.stop(c.currentTime+dur+0.05);
+  }catch(e){}
+}
+function playChord(freqs,dur=0.4,vol=0.14){
+  freqs.forEach(f=>playTone(f,dur,'sine',vol));
+}
+function playSfx(type){
+  if(muted)return;
+  if(type==='win'){
+    // Ascending glide
+    playChord([523.25,659.25,783.99],0.6,0.12);
+    setTimeout(()=>playChord([659.25,783.99,1046.5],0.5,0.1),200);
+  } else if(type==='fail'){
+    try{
+      const c=getCtx();
+      const o=c.createOscillator();
+      const g=c.createGain();
+      o.connect(g);g.connect(c.destination);
+      o.type='sawtooth';
+      o.frequency.setValueAtTime(180,c.currentTime);
+      o.frequency.exponentialRampToValueAtTime(80,c.currentTime+0.4);
+      g.gain.setValueAtTime(0.18,c.currentTime);
+      g.gain.exponentialRampToValueAtTime(0.001,c.currentTime+0.45);
+      o.start();o.stop(c.currentTime+0.5);
+    }catch(e){}
+  } else if(type==='start'){
+    playChord([261.63,329.63],0.35,0.1);
+  } else if(type==='roundup'){
+    playTone(880,0.18,'sine',0.12);
+    setTimeout(()=>playTone(1174.66,0.18,'sine',0.1),100);
+  }
+}
+
+// ── GAME STATE ─────────────────────────────────────────────────────────────
+let sequence=[],playerIdx=0,round=0,best=0,playing=false,inputAllowed=false;
+let strictMode=false;
+let playbackSpeed=600;// ms per step (decreases with rounds)
+const MIN_SPEED=250;
+const SPEED_DEC=18;
+
+function loadBest(){
+  best=parseInt(localStorage.getItem('dw-seq-best')||'0',10);
+  dom.valBest.textContent=best||'—';
+}
+function saveBest(r){
+  if(r>best){best=r;localStorage.setItem('dw-seq-best',r);dom.valBest.textContent=r;}
+}
+
+// ── DOM CACHE ──────────────────────────────────────────────────────────────
+const dom={
+  grid:document.getElementById('pad-grid'),
+  overlay:document.getElementById('overlay'),
+  ovTitle:document.getElementById('ov-title'),
+  ovSub:document.getElementById('ov-sub'),
+  ovEyebrow:document.getElementById('ov-eyebrow'),
+  ovScore:document.getElementById('ov-score'),
+  ovScoreLabel:document.getElementById('ov-score-label'),
+  btnPlay:document.getElementById('btn-play'),
+  btnStrict:document.getElementById('btn-strict'),
+  btnMute:document.getElementById('btn-mute'),
+  valRound:document.getElementById('val-round'),
+  valBest:document.getElementById('val-best'),
+  phase:document.getElementById('phase'),
+  progress:document.getElementById('progress'),
+};
+
+// ── BUILD PADS ─────────────────────────────────────────────────────────────
+const padEls=[];
+PADS.forEach((p,i)=>{
+  const el=document.createElement('div');
+  el.className='pad';
+  el.setAttribute('tabindex','0');
+  el.setAttribute('role','button');
+  el.setAttribute('aria-label',`${p.label} pad`);
+  el.innerHTML=p.svgFn()+`<div class="pad-overlay"></div><div class="pad-label">${p.label}</div>`;
+  el.addEventListener('click',()=>handleInput(i));
+  el.addEventListener('keydown',e=>{if(e.key===' '||e.key==='Enter'){e.preventDefault();handleInput(i);}});
+  el.addEventListener('pointerdown',()=>{if(inputAllowed){el.classList.add('pressed');}});
+  el.addEventListener('pointerup',()=>el.classList.remove('pressed'));
+  el.addEventListener('pointerleave',()=>el.classList.remove('pressed'));
+  dom.grid.appendChild(el);
+  padEls.push(el);
+});
+
+// ── LIT HELPERS ────────────────────────────────────────────────────────────
+function lightPad(i,dur=380){
+  return new Promise(res=>{
+    padEls[i].classList.add('lit');
+    playTone(PADS[i].freq,dur/1000*0.9,'sine',0.2);
+    setTimeout(()=>{padEls[i].classList.remove('lit');res();},dur);
+  });
+}
+function wrongFlash(i){
+  return new Promise(res=>{
+    padEls[i].classList.add('wrong','lit');
+    playSfx('fail');
+    setTimeout(()=>{padEls[i].classList.remove('wrong','lit');res();},500);
+  });
+}
+
+// ── SEQUENCE PLAYBACK ──────────────────────────────────────────────────────
+async function playSequence(){
+  inputAllowed=false;
+  setPhase('Watch the sequence…');
+  await delay(400);
+  for(let i=0;i<sequence.length;i++){
+    await lightPad(sequence[i],Math.round(playbackSpeed*0.72));
+    await delay(playbackSpeed*0.32);
+  }
+  await delay(200);
+  inputAllowed=true;
+  playerIdx=0;
+  setPhase(`Your turn — step 1 of ${sequence.length}`);
+  updateProgress(0);
+}
+
+// ── GAME LOOP ──────────────────────────────────────────────────────────────
+function startGame(){
+  sequence=[];playerIdx=0;round=0;playing=true;
+  playbackSpeed=600;
+  closeOverlay();
+  playSfx('start');
+  dom.valRound.textContent='—';
+  updateProgress(0);
+  setTimeout(()=>addStep(),300);
+}
+
+async function addStep(){
+  round++;
+  sequence.push(Math.floor(Math.random()*PADS.length));
+  dom.valRound.textContent=round;
+  playbackSpeed=Math.max(MIN_SPEED,600-(round-1)*SPEED_DEC);
+  if(round>1) playSfx('roundup');
+  await playSequence();
+}
+
+async function handleInput(i){
+  if(!inputAllowed||!playing)return;
+  // immediate press glow
+  padEls[i].classList.add('lit');
+  playTone(PADS[i].freq,0.18,'sine',0.18);
+  setTimeout(()=>padEls[i].classList.remove('lit'),180);
+
+  if(i===sequence[playerIdx]){
+    playerIdx++;
+    updateProgress(playerIdx/sequence.length*100);
+    setPhase(`Your turn — step ${playerIdx} of ${sequence.length}`);
+    if(playerIdx===sequence.length){
+      // Completed round
+      inputAllowed=false;
+      setPhase('Correct!');
+      updateProgress(100);
+      await delay(600);
+      addStep();
+    }
+  } else {
+    // Wrong
+    inputAllowed=false;
+    await wrongFlash(i);
+    if(strictMode){
+      endGame();
+    } else {
+      // One retry per round (lenient)
+      setPhase('Not quite — watch again');
+      await delay(700);
+      await playSequence();
+    }
+  }
+}
+
+function endGame(){
+  playing=false;inputAllowed=false;
+  saveBest(round-1>0?round-1:0);
+  const score=round>0?round-1:0;
+  // Show wrong flash on all pads briefly
+  padEls.forEach(p=>{p.classList.add('wrong');setTimeout(()=>p.classList.remove('wrong'),600);});
+  setTimeout(()=>showGameOver(score),700);
+}
+
+// ── OVERLAY ────────────────────────────────────────────────────────────────
+function showOverlay(){
+  dom.overlay.classList.add('show');
+}
+function closeOverlay(){
+  dom.overlay.classList.remove('show');
+}
+function showGameOver(score){
+  dom.ovEyebrow.textContent='Game Over';
+  dom.ovTitle.textContent='Pattern Sequence';
+  dom.ovSub.textContent=`You matched ${score} round${score!==1?'s':''} in sequence.`;
+  dom.ovScore.style.display='block';
+  dom.ovScoreLabel.style.display='block';
+  dom.ovScore.textContent=score;
+  dom.ovScoreLabel.textContent=`Round${score!==1?'s':''} reached`;
+  dom.btnPlay.textContent='Play Again';
+  setPhase('');
+  showOverlay();
+}
+
+// ── UTILS ──────────────────────────────────────────────────────────────────
+function delay(ms){return new Promise(r=>setTimeout(r,ms));}
+function setPhase(t){dom.phase.textContent=t;}
+function updateProgress(pct){dom.progress.style.width=pct+'%';}
+
+// ── CONTROLS ───────────────────────────────────────────────────────────────
+dom.btnPlay.addEventListener('click',startGame);
+dom.btnStrict.addEventListener('click',()=>{
+  strictMode=!strictMode;
+  dom.btnStrict.classList.toggle('active',strictMode);
+  dom.btnStrict.textContent=strictMode?'Strict ✓':'Strict';
+});
+dom.btnMute.addEventListener('click',()=>{
+  muted=!muted;
+  dom.btnMute.classList.toggle('active',muted);
+  dom.btnMute.textContent=muted?'Unmute':'Mute';
+});
+
+// Keyboard: 1-4 = pads, arrows = pads
+document.addEventListener('keydown',e=>{
+  const map={'1':0,'2':1,'3':2,'4':3,
+             'ArrowLeft':0,'ArrowUp':1,'ArrowRight':2,'ArrowDown':3};
+  if(e.key in map&&inputAllowed){
+    e.preventDefault();
+    handleInput(map[e.key]);
+  }
+});
+
+// ── INIT ───────────────────────────────────────────────────────────────────
+loadBest();
+// Show start overlay
+dom.ovEyebrow.textContent='Designer Wallcoverings';
+dom.ovTitle.textContent='Pattern Sequence';
+dom.ovSub.textContent='Watch the glowing sequence. Repeat it in order. Each round adds one step.';
+dom.ovScore.style.display='none';
+dom.ovScoreLabel.style.display='none';
+dom.btnPlay.textContent='Play';
+showOverlay();
+</script>
+</body>
+</html>
diff --git a/public/games/dw-texture-weave-studio.html b/public/games/dw-texture-weave-studio.html
new file mode 100644
index 0000000..dbb63d2
--- /dev/null
+++ b/public/games/dw-texture-weave-studio.html
@@ -0,0 +1,873 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Texture Weave Studio — Designer Wallcoverings</title>
+<style>
+:root{
+  --bg:#f4f1ea;--bg2:#edeae0;--ink:#1c1a17;--ink2:#4a4540;
+  --accent:#c9a961;--accent2:#a88540;--surface:#fff;
+  --border:#d6d0c4;--shadow:rgba(28,26,23,.13);
+  --serif:'Cormorant Garamond','Playfair Display','EB Garamond',Georgia,serif;
+  --sans:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
+  --ease:cubic-bezier(.4,0,.2,1);
+  --r:8px;
+}
+*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
+html{scroll-behavior:smooth}
+body{
+  background:var(--bg);color:var(--ink);
+  font-family:var(--sans);font-size:15px;line-height:1.5;
+  min-height:100vh;display:flex;flex-direction:column;
+}
+/* HEADER */
+header{
+  background:var(--ink);
+  padding:18px 32px 16px;
+  display:flex;align-items:center;justify-content:space-between;
+  gap:16px;flex-wrap:wrap;
+}
+.wordmark{
+  font-family:var(--serif);font-size:clamp(28px,3vw,44px);
+  font-weight:600;letter-spacing:.18em;text-transform:uppercase;
+  color:#f4f1ea;line-height:1.1;white-space:nowrap;
+}
+.subtitle{
+  font-family:var(--serif);font-size:14px;letter-spacing:.14em;
+  color:var(--accent);text-transform:uppercase;margin-top:3px;
+}
+.header-right{font-size:12px;color:#9a9080;letter-spacing:.06em;text-align:right}
+/* MAIN LAYOUT */
+main{flex:1;display:grid;grid-template-columns:300px 1fr;gap:0;max-width:1400px;width:100%;margin:0 auto;padding:28px 24px;gap:24px}
+@media(max-width:900px){main{grid-template-columns:1fr;}}
+/* PANEL */
+.panel{
+  background:var(--surface);border:1px solid var(--border);
+  border-radius:var(--r);padding:24px;display:flex;flex-direction:column;gap:20px;
+  box-shadow:0 2px 12px var(--shadow);align-self:start;
+  max-height:calc(100vh - 140px);overflow-y:auto;
+}
+.panel::-webkit-scrollbar{width:4px}
+.panel::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}
+.section-label{
+  font-family:var(--serif);font-size:11px;letter-spacing:.18em;
+  text-transform:uppercase;color:var(--accent2);margin-bottom:8px;
+  border-bottom:1px solid var(--border);padding-bottom:6px;
+}
+/* WEAVE TYPE GRID */
+.weave-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:6px}
+.weave-btn{
+  background:var(--bg);border:1.5px solid var(--border);
+  border-radius:5px;padding:8px 4px;font-size:11px;
+  font-family:var(--serif);letter-spacing:.04em;
+  color:var(--ink2);cursor:pointer;text-align:center;
+  transition:all .18s var(--ease);
+}
+.weave-btn:hover{border-color:var(--accent);color:var(--ink);background:var(--bg2)}
+.weave-btn.active{
+  border-color:var(--accent);background:var(--ink);color:var(--accent);
+  font-weight:600;
+}
+.weave-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
+/* CONTROLS */
+.ctrl-row{display:flex;flex-direction:column;gap:6px}
+.ctrl-row label{font-size:12px;color:var(--ink2);letter-spacing:.04em;display:flex;justify-content:space-between;align-items:center}
+.ctrl-row label span{color:var(--accent2);font-size:11px;min-width:32px;text-align:right}
+input[type=range]{
+  width:100%;-webkit-appearance:none;appearance:none;
+  height:4px;background:var(--border);border-radius:2px;outline:none;
+  cursor:pointer;transition:background .15s;
+}
+input[type=range]::-webkit-slider-thumb{
+  -webkit-appearance:none;width:16px;height:16px;border-radius:50%;
+  background:var(--accent);border:2px solid var(--surface);
+  box-shadow:0 1px 4px var(--shadow);cursor:pointer;
+  transition:transform .12s var(--ease);
+}
+input[type=range]:hover::-webkit-slider-thumb{transform:scale(1.2)}
+input[type=range]:focus-visible{outline:2px solid var(--accent);outline-offset:4px;border-radius:3px}
+/* COLOR PICKERS */
+.color-row{display:flex;gap:10px;align-items:center}
+.color-swatch{
+  display:flex;flex-direction:column;align-items:center;gap:5px;flex:1;
+}
+.color-swatch label{font-size:11px;color:var(--ink2)}
+input[type=color]{
+  width:52px;height:36px;border:1.5px solid var(--border);
+  border-radius:6px;cursor:pointer;background:none;padding:2px;
+  transition:border-color .15s;
+}
+input[type=color]:hover{border-color:var(--accent)}
+input[type=color]:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
+/* PRESETS */
+.preset-list{display:flex;flex-direction:column;gap:6px}
+.preset-btn{
+  background:var(--bg);border:1.5px solid var(--border);
+  border-radius:5px;padding:9px 12px;font-size:12px;
+  font-family:var(--serif);letter-spacing:.06em;
+  color:var(--ink2);cursor:pointer;text-align:left;
+  display:flex;align-items:center;gap:10px;
+  transition:all .16s var(--ease);
+}
+.preset-btn:hover{border-color:var(--accent);background:var(--bg2);color:var(--ink)}
+.preset-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
+.preset-swatch{width:28px;height:28px;border-radius:3px;flex-shrink:0;border:1px solid rgba(0,0,0,.08)}
+.preset-name{font-size:12px;letter-spacing:.06em}
+.preset-sub{font-size:10px;color:var(--ink2);letter-spacing:.03em;margin-top:1px}
+/* CANVAS AREA */
+.canvas-area{display:flex;flex-direction:column;gap:16px}
+.canvas-wrap{
+  position:relative;border-radius:var(--r);overflow:hidden;
+  border:1px solid var(--border);box-shadow:0 4px 24px var(--shadow);
+  background:#111;aspect-ratio:16/10;
+}
+canvas#weave{display:block;width:100%;height:100%;image-rendering:auto}
+.canvas-overlay{
+  position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
+  pointer-events:none;
+}
+/* ZOOM LENS */
+#zoom-lens{
+  position:absolute;width:160px;height:160px;border-radius:50%;
+  border:2px solid rgba(201,169,97,.7);box-shadow:0 4px 20px rgba(0,0,0,.4);
+  overflow:hidden;pointer-events:none;
+  visibility:hidden;opacity:0;transition:opacity .2s var(--ease);
+  transform:translate(-50%,-50%);
+}
+#zoom-canvas{display:block;width:100%;height:100%}
+/* TOOLBAR */
+.toolbar{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
+.tool-btn{
+  background:var(--surface);border:1.5px solid var(--border);
+  border-radius:5px;padding:9px 16px;font-size:12px;
+  font-family:var(--serif);letter-spacing:.08em;text-transform:uppercase;
+  color:var(--ink2);cursor:pointer;display:flex;align-items:center;gap:7px;
+  transition:all .16s var(--ease);white-space:nowrap;
+}
+.tool-btn:hover{border-color:var(--accent);background:var(--bg);color:var(--ink)}
+.tool-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
+.tool-btn.active{background:var(--ink);border-color:var(--ink);color:var(--accent)}
+.tool-btn svg{width:15px;height:15px;flex-shrink:0}
+.sweep-indicator{
+  margin-left:auto;font-size:11px;color:var(--ink2);letter-spacing:.06em;
+  display:flex;align-items:center;gap:6px;
+}
+.sweep-dot{
+  width:7px;height:7px;border-radius:50%;background:var(--accent);
+  animation:pulse 2s ease-in-out infinite;
+}
+@keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.4;transform:scale(.7)}}
+/* FOOTER */
+footer{
+  text-align:center;padding:20px;font-size:11px;
+  color:var(--ink2);letter-spacing:.08em;border-top:1px solid var(--border);
+  background:var(--bg2);
+}
+</style>
+</head>
+<body>
+
+<header>
+  <div>
+    <div class="wordmark">Designer Wallcoverings</div>
+    <div class="subtitle">Texture Weave Studio</div>
+  </div>
+  <div class="header-right">Interactive Material Generator<br>Canvas · Natural Fibers</div>
+</header>
+
+<main>
+  <!-- LEFT PANEL -->
+  <aside class="panel" aria-label="Weave Controls">
+
+    <div>
+      <div class="section-label">Weave Type</div>
+      <div class="weave-grid" role="group" aria-label="Select weave type">
+        <button class="weave-btn active" data-weave="grasscloth" aria-pressed="true">Grasscloth</button>
+        <button class="weave-btn" data-weave="linen" aria-pressed="false">Linen</button>
+        <button class="weave-btn" data-weave="silk" aria-pressed="false">Silk</button>
+        <button class="weave-btn" data-weave="raffia" aria-pressed="false">Raffia</button>
+        <button class="weave-btn" data-weave="paperweave" aria-pressed="false">Paperweave</button>
+        <button class="weave-btn" data-weave="herringbone" aria-pressed="false">Herringbone</button>
+      </div>
+    </div>
+
+    <div>
+      <div class="section-label">Fiber Colors</div>
+      <div class="color-row">
+        <div class="color-swatch">
+          <label for="color1">Warp</label>
+          <input type="color" id="color1" value="#c8b99a" aria-label="Warp fiber color">
+        </div>
+        <div class="color-swatch">
+          <label for="color2">Weft</label>
+          <input type="color" id="color2" value="#a89070" aria-label="Weft fiber color">
+        </div>
+      </div>
+    </div>
+
+    <div>
+      <div class="section-label">Weave Parameters</div>
+      <div class="ctrl-row">
+        <label for="density">Density / Scale <span id="density-val">18</span></label>
+        <input type="range" id="density" min="6" max="48" value="18" step="1" aria-label="Weave density">
+      </div>
+      <div class="ctrl-row">
+        <label for="warp-thick">Warp Thickness <span id="warp-val">0.55</span></label>
+        <input type="range" id="warp-thick" min="0.2" max="0.9" value="0.55" step="0.01" aria-label="Warp thread thickness">
+      </div>
+      <div class="ctrl-row">
+        <label for="weft-thick">Weft Thickness <span id="weft-val">0.50</span></label>
+        <input type="range" id="weft-thick" min="0.2" max="0.9" value="0.50" step="0.01" aria-label="Weft thread thickness">
+      </div>
+      <div class="ctrl-row">
+        <label for="sheen">Sheen Intensity <span id="sheen-val">0.35</span></label>
+        <input type="range" id="sheen" min="0" max="1" value="0.35" step="0.01" aria-label="Sheen intensity">
+      </div>
+    </div>
+
+    <div>
+      <div class="section-label">Presets</div>
+      <div class="preset-list" id="preset-list"></div>
+    </div>
+
+  </aside>
+
+  <!-- RIGHT: CANVAS -->
+  <div class="canvas-area">
+    <div class="canvas-wrap" id="canvas-wrap" aria-label="Texture preview canvas">
+      <canvas id="weave" aria-label="Woven texture preview"></canvas>
+      <div id="zoom-lens" aria-hidden="true">
+        <canvas id="zoom-canvas"></canvas>
+      </div>
+    </div>
+    <div class="toolbar" role="toolbar" aria-label="Canvas actions">
+      <button class="tool-btn" id="btn-sweep" aria-pressed="true">
+        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M2 12 Q12 4 22 12"/><path d="M2 16 Q12 8 22 16"/></svg>
+        Light Sweep
+      </button>
+      <button class="tool-btn" id="btn-zoom" aria-pressed="false">
+        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.35-4.35"/><path d="M11 8v6M8 11h6"/></svg>
+        Fiber Zoom
+      </button>
+      <button class="tool-btn" id="btn-download">
+        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
+        Download PNG
+      </button>
+      <div class="sweep-indicator" id="sweep-ind" aria-live="polite">
+        <div class="sweep-dot"></div>
+        <span>Sweeping</span>
+      </div>
+    </div>
+  </div>
+</main>
+
+<footer>Designer Wallcoverings &nbsp;&middot;&nbsp; Texture Weave Studio &nbsp;&middot;&nbsp; &copy; 2026</footer>
+
+<script>
+(function(){
+'use strict';
+
+// ─── PRESETS ───────────────────────────────────────────────────────────────────
+const PRESETS = [
+  {name:'Natural Seagrass',   sub:'Grasscloth · Earthy',      weave:'grasscloth', c1:'#c8b99a', c2:'#8a7256', density:16, warpT:.55, weftT:.50, sheen:.18},
+  {name:'Ivory Linen',        sub:'Linen · Clean & Airy',     weave:'linen',      c1:'#e8e0d0', c2:'#c8bea8', density:22, warpT:.45, weftT:.42, sheen:.28},
+  {name:'Champagne Silk',     sub:'Silk · Lustrous Sheen',    weave:'silk',       c1:'#f0e8d0', c2:'#d4c090', density:28, warpT:.38, weftT:.35, sheen:.82},
+  {name:'Charcoal Raffia',    sub:'Raffia · Textured Depth',  weave:'raffia',     c1:'#5a5248', c2:'#3a3430', density:14, warpT:.70, weftT:.65, sheen:.12},
+  {name:'Sand Paperweave',    sub:'Paperweave · Organic',     weave:'paperweave', c1:'#d8cc9a', c2:'#b8a870', density:20, warpT:.50, weftT:.48, sheen:.10},
+  {name:'Navy Herringbone',   sub:'Herringbone · Modern',     weave:'herringbone',c1:'#2a3a5a', c2:'#1a2a40', density:18, warpT:.60, weftT:.55, sheen:.35},
+];
+
+// ─── STATE ─────────────────────────────────────────────────────────────────────
+let state = {
+  weave:'grasscloth',
+  c1:'#c8b99a', c2:'#a89070',
+  density:18, warpT:.55, weftT:.50, sheen:.35,
+  sweepEnabled:true, zoomEnabled:false,
+  sweepX:0, // 0..1 normalized
+  mouseX:0.5, mouseY:0.5,
+};
+let dirty = true;
+let sweepDir = 1;
+const OFFSCREEN_SIZE = 1200; // offscreen tile size
+let offscreenCanvas, offscreenCtx;
+let rafId;
+let lastTime = 0;
+let transitioning = false;
+let transitionAlpha = 1; // 1=fully rendered
+let prevState = null;
+
+// ─── DOM REFS ──────────────────────────────────────────────────────────────────
+const canvas = document.getElementById('weave');
+const ctx = canvas.getContext('2d');
+const zoomLens = document.getElementById('zoom-lens');
+const zoomCanvas = document.getElementById('zoom-canvas');
+const zoomCtx = zoomCanvas.getContext('2d');
+const wrap = document.getElementById('canvas-wrap');
+const btnSweep = document.getElementById('btn-sweep');
+const btnZoom = document.getElementById('btn-zoom');
+const btnDown = document.getElementById('btn-download');
+const sweepInd = document.getElementById('sweep-ind');
+
+// ─── CANVAS SIZE ───────────────────────────────────────────────────────────────
+function resizeCanvas(){
+  const r = wrap.getBoundingClientRect();
+  const dpr = Math.min(window.devicePixelRatio||1, 2);
+  canvas.width = Math.round(r.width * dpr);
+  canvas.height = Math.round(r.height * dpr);
+  canvas.style.width = r.width+'px';
+  canvas.style.height = r.height+'px';
+  ctx.scale(dpr, dpr);
+  zoomCanvas.width = 320; zoomCanvas.height = 320;
+  dirty = true;
+}
+window.addEventListener('resize', ()=>{ resizeCanvas(); });
+
+// ─── COLOR UTILITIES ──────────────────────────────────────────────────────────
+function hexToRgb(h){
+  const r=parseInt(h.slice(1,3),16),g=parseInt(h.slice(3,5),16),b=parseInt(h.slice(5,7),16);
+  return {r,g,b};
+}
+function lerp(a,b,t){return a+(b-a)*t}
+function lerpColor(c1,c2,t){
+  return `rgb(${Math.round(lerp(c1.r,c2.r,t))},${Math.round(lerp(c1.g,c2.g,t))},${Math.round(lerp(c1.b,c2.b,t))})`;
+}
+function darken(c,f){return {r:Math.round(c.r*f),g:Math.round(c.g*f),b:Math.round(c.b*f)}}
+function lighten(c,f){return {r:Math.min(255,Math.round(c.r*f)),g:Math.min(255,Math.round(c.g*f)),b:Math.min(255,Math.round(c.b*f))}}
+function rgbStr(c){return `rgb(${c.r},${c.g},${c.b})`}
+
+// ─── WEAVE RENDERERS ──────────────────────────────────────────────────────────
+function renderTile(oc, ow, oh, s){
+  const {weave, density, warpT, weftT, sheen} = s;
+  const c1 = hexToRgb(s.c1), c2 = hexToRgb(s.c2);
+  const bg = lerpColor(c1,c2,.5);
+
+  oc.fillStyle = bg;
+  oc.fillRect(0,0,ow,oh);
+
+  if(weave==='grasscloth')   renderGrasscloth(oc,ow,oh,c1,c2,density,warpT,weftT);
+  else if(weave==='linen')   renderLinen(oc,ow,oh,c1,c2,density,warpT,weftT);
+  else if(weave==='silk')    renderSilk(oc,ow,oh,c1,c2,density,warpT,weftT);
+  else if(weave==='raffia')  renderRaffia(oc,ow,oh,c1,c2,density,warpT,weftT);
+  else if(weave==='paperweave') renderPaperweave(oc,ow,oh,c1,c2,density,warpT,weftT);
+  else if(weave==='herringbone') renderHerringbone(oc,ow,oh,c1,c2,density,warpT,weftT);
+}
+
+function renderGrasscloth(oc,ow,oh,c1,c2,density,warpT,weftT){
+  const cell = ow/density;
+  const warpW = cell*warpT;
+  const weftH = cell*weftT;
+  // WARP (vertical strands)
+  for(let col=0;col<density+1;col++){
+    const x = col*cell;
+    const noise = (Math.sin(col*3.7)*0.5+0.5)*0.18;
+    const shade = darken(c1, 0.82+noise*0.36);
+    const grad = oc.createLinearGradient(x,0,x+warpW,0);
+    grad.addColorStop(0, rgbStr(darken(shade,.75)));
+    grad.addColorStop(0.3, rgbStr(lighten(shade,1.12)));
+    grad.addColorStop(0.7, rgbStr(shade));
+    grad.addColorStop(1, rgbStr(darken(shade,.78)));
+    oc.fillStyle = grad;
+    oc.fillRect(x,0,warpW,oh);
+    // fiber texture lines
+    oc.strokeStyle = `rgba(${darken(c1,.6).r},${darken(c1,.6).g},${darken(c1,.6).b},.18)`;
+    oc.lineWidth = 0.5;
+    for(let fi=2;fi<oh;fi+=4+Math.random()*2){
+      oc.beginPath();oc.moveTo(x,fi);oc.lineTo(x+warpW,fi+Math.random()*1.5-0.75);oc.stroke();
+    }
+  }
+  // WEFT (horizontal over/under)
+  const rows = Math.ceil(oh/cell);
+  for(let row=0;row<rows+1;row++){
+    const y = row*cell;
+    const noise = (Math.sin(row*2.3)*0.5+0.5)*0.12;
+    const shade = darken(c2, 0.85+noise*0.28);
+    const grad = oc.createLinearGradient(0,y,0,y+weftH);
+    grad.addColorStop(0,rgbStr(darken(shade,.72)));
+    grad.addColorStop(0.35,rgbStr(lighten(shade,1.15)));
+    grad.addColorStop(0.65,rgbStr(shade));
+    grad.addColorStop(1,rgbStr(darken(shade,.75)));
+    oc.fillStyle = grad;
+    // over/under interlace: skip alternate warp strips
+    for(let col=0;col<density+1;col++){
+      if((col+row)%2===0){
+        const x = col*cell;
+        oc.fillRect(x, y, cell, weftH);
+        // shadow under weft
+        const sg = oc.createLinearGradient(0,y+weftH,0,y+weftH+cell*.25);
+        sg.addColorStop(0,'rgba(0,0,0,.18)');
+        sg.addColorStop(1,'rgba(0,0,0,0)');
+        oc.fillStyle = sg;
+        oc.fillRect(x, y+weftH, cell, cell*.25);
+        oc.fillStyle = grad;
+      }
+    }
+  }
+}
+
+function renderLinen(oc,ow,oh,c1,c2,density,warpT,weftT){
+  const cell = ow/density;
+  const warpW = cell*warpT*.8;
+  const weftH = cell*weftT*.8;
+  // fine balanced weave
+  for(let col=0;col<=density;col++){
+    const x=col*cell;
+    const v=(Math.sin(col*1.9)*0.5+0.5)*.1;
+    const sh=darken(c1,.88+v*.24);
+    const g=oc.createLinearGradient(x,0,x+warpW,0);
+    g.addColorStop(0,rgbStr(darken(sh,.8)));
+    g.addColorStop(.5,rgbStr(lighten(sh,1.08)));
+    g.addColorStop(1,rgbStr(darken(sh,.82)));
+    oc.fillStyle=g; oc.fillRect(x,0,warpW,oh);
+  }
+  const rows=Math.ceil(oh/cell);
+  for(let row=0;row<=rows;row++){
+    const y=row*cell;
+    const v=(Math.sin(row*2.1)*0.5+0.5)*.08;
+    const sh=darken(c2,.9+v*.2);
+    const g=oc.createLinearGradient(0,y,0,y+weftH);
+    g.addColorStop(0,rgbStr(darken(sh,.82)));
+    g.addColorStop(.5,rgbStr(lighten(sh,1.06)));
+    g.addColorStop(1,rgbStr(darken(sh,.84)));
+    oc.fillStyle=g;
+    for(let col=0;col<=density;col++){
+      if((col+row)%2===0){
+        const x=col*cell;
+        oc.fillRect(x,y,cell,weftH);
+        const sg=oc.createLinearGradient(0,y+weftH,0,y+weftH+cell*.15);
+        sg.addColorStop(0,'rgba(0,0,0,.13)'); sg.addColorStop(1,'rgba(0,0,0,0)');
+        oc.fillStyle=sg; oc.fillRect(x,y+weftH,cell,cell*.15); oc.fillStyle=g;
+      }
+    }
+  }
+}
+
+function renderSilk(oc,ow,oh,c1,c2,density,warpT,weftT){
+  const cell = ow/density;
+  // Satin-like: 5-1 satin weave float (long warp floats, smooth sheen)
+  for(let col=0;col<=density;col++){
+    const x=col*cell;
+    const shine=(Math.sin(col*.8)*.5+.5);
+    const sh=lerpColor(c1,{r:255,g:255,b:255},shine*.18);
+    const g=oc.createLinearGradient(x,0,x+cell*warpT,0);
+    g.addColorStop(0,lerpColor(hexToRgb(sh),{r:0,g:0,b:0},.15));
+    g.addColorStop(.4,sh);
+    g.addColorStop(.6,lerpColor(hexToRgb(sh),{r:255,g:255,b:255},.3));
+    g.addColorStop(1,lerpColor(hexToRgb(sh),{r:0,g:0,b:0},.12));
+    oc.fillStyle=g; oc.fillRect(x,0,cell*warpT,oh);
+  }
+  const rows=Math.ceil(oh/cell);
+  for(let row=0;row<=rows;row++){
+    const y=row*cell;
+    // only every 5th row has a weft crossover
+    if(row%5!==0) continue;
+    for(let col=0;col<=density;col++){
+      if((col+Math.floor(row/5)*2)%5===0){
+        const x=col*cell;
+        const g=oc.createLinearGradient(0,y,0,y+cell*weftT);
+        g.addColorStop(0,rgbStr(darken(hexToRgb(s_c2str(c2)),.7)));
+        g.addColorStop(.5,s_c2str(c2));
+        g.addColorStop(1,rgbStr(darken(hexToRgb(s_c2str(c2)),.75)));
+        oc.fillStyle=g; oc.fillRect(x,y,cell,cell*weftT);
+      }
+    }
+  }
+  function s_c2str(c){return `rgb(${c.r},${c.g},${c.b})`}
+  function hexToRgb(s){const m=s.match(/\d+/g);return m?{r:+m[0],g:+m[1],b:+m[2]}:{r:200,g:180,b:140}}
+}
+
+function renderRaffia(oc,ow,oh,c1,c2,density,warpT,weftT){
+  const cell = ow/density * 1.4; // raffia is chunky
+  const warpW = cell*warpT;
+  const weftH = cell*weftT*1.2;
+  for(let col=0;col*cell<ow+cell;col++){
+    const x=col*cell;
+    const nz=(Math.sin(col*2.3)*0.5+0.5)*.25;
+    const sh=darken(c1,.76+nz*.44);
+    const g=oc.createLinearGradient(x,0,x+warpW,0);
+    g.addColorStop(0,rgbStr(darken(sh,.6)));
+    g.addColorStop(.25,rgbStr(lighten(sh,1.2)));
+    g.addColorStop(.55,rgbStr(sh));
+    g.addColorStop(1,rgbStr(darken(sh,.65)));
+    oc.fillStyle=g; oc.fillRect(x,0,warpW,oh);
+    // rough fiber marks
+    for(let fi=0;fi<oh;fi+=3){
+      const jit=(Math.random()-.5)*warpW*.4;
+      oc.strokeStyle=`rgba(0,0,0,${Math.random()*.14})`;
+      oc.lineWidth=.7;
+      oc.beginPath();oc.moveTo(x+warpW*.5+jit,fi);oc.lineTo(x+warpW*.5+jit+(Math.random()-.5)*2,fi+3);oc.stroke();
+    }
+  }
+  let row=0;
+  for(let y=0;y<oh+weftH;y+=weftH*1.1,row++){
+    const nz=(Math.sin(row*1.8)*0.5+0.5)*.18;
+    const sh=darken(c2,.8+nz*.3);
+    const g=oc.createLinearGradient(0,y,0,y+weftH);
+    g.addColorStop(0,rgbStr(darken(sh,.65)));
+    g.addColorStop(.3,rgbStr(lighten(sh,1.18)));
+    g.addColorStop(.7,rgbStr(sh));
+    g.addColorStop(1,rgbStr(darken(sh,.68)));
+    oc.fillStyle=g;
+    for(let col=0;col*cell<ow+cell;col++){
+      if((col+row)%2===0){
+        const x=col*cell;
+        oc.fillRect(x,y,cell,weftH);
+        const sg=oc.createLinearGradient(0,y+weftH,0,y+weftH+weftH*.3);
+        sg.addColorStop(0,'rgba(0,0,0,.22)'); sg.addColorStop(1,'rgba(0,0,0,0)');
+        oc.fillStyle=sg; oc.fillRect(x,y+weftH,cell,weftH*.3); oc.fillStyle=g;
+      }
+    }
+  }
+}
+
+function renderPaperweave(oc,ow,oh,c1,c2,density,warpT,weftT){
+  const cell=ow/density;
+  const warpW=cell*warpT*.6;
+  const weftH=cell*weftT*1.6; // paper strips are wider vertically
+  for(let col=0;col<=density;col++){
+    const x=col*cell;
+    const nz=(Math.sin(col*3.1)*0.5+0.5)*.1;
+    const sh=darken(c1,.9+nz*.18);
+    const g=oc.createLinearGradient(x,0,x+warpW,0);
+    g.addColorStop(0,rgbStr(darken(sh,.82))); g.addColorStop(.5,rgbStr(sh)); g.addColorStop(1,rgbStr(darken(sh,.84)));
+    oc.fillStyle=g; oc.fillRect(x,0,warpW,oh);
+  }
+  let row=0;
+  for(let y=0;y<oh+weftH;y+=weftH+2,row++){
+    const nz=(Math.sin(row*2.7)*0.5+0.5)*.14;
+    const sh=darken(c2,.86+nz*.24);
+    // paper strip gradient
+    const g=oc.createLinearGradient(0,y,0,y+weftH);
+    g.addColorStop(0,rgbStr(darken(sh,.78)));
+    g.addColorStop(.15,rgbStr(lighten(sh,1.12)));
+    g.addColorStop(.5,rgbStr(sh));
+    g.addColorStop(.85,rgbStr(darken(sh,.88)));
+    g.addColorStop(1,rgbStr(darken(sh,.72)));
+    oc.fillStyle=g;
+    for(let col=0;col<=density;col++){
+      if((col+row)%2===0){
+        const x=col*cell; oc.fillRect(x,y,cell+warpW,weftH);
+      }
+    }
+    // strip edge shadow
+    oc.fillStyle='rgba(0,0,0,.08)';
+    oc.fillRect(0,y,ow,1);
+    oc.fillStyle='rgba(255,255,255,.06)';
+    oc.fillRect(0,y+1,ow,1);
+  }
+}
+
+function renderHerringbone(oc,ow,oh,c1,c2,density,warpT,weftT){
+  const cell=ow/density;
+  const tw=cell*warpT;
+  const th=cell*weftT;
+  // herringbone = alternating diagonal blocks
+  const blockW=cell*4, blockH=cell*4;
+  const cols=Math.ceil(ow/blockW)+1, rows=Math.ceil(oh/blockH)+1;
+  for(let br=0;br<rows;br++){
+    for(let bc=0;bc<cols;bc++){
+      const bx=bc*blockW, by=br*blockH;
+      const flip=(bc+br)%2;
+      // draw diagonal strips within block
+      const numStrips=Math.ceil(blockW/tw)*2+4;
+      for(let s=-numStrips;s<numStrips;s++){
+        const useC1=((s+flip)%2===0);
+        const col=useC1?c1:c2;
+        const shade=darken(col,.82+(Math.sin(s*.8)*.5+.5)*.2);
+        const g=oc.createLinearGradient(0,0,tw,tw);
+        g.addColorStop(0,rgbStr(darken(shade,.75)));
+        g.addColorStop(.4,rgbStr(lighten(shade,1.1)));
+        g.addColorStop(1,rgbStr(darken(shade,.8)));
+        oc.fillStyle=g;
+        oc.save();
+        oc.beginPath();
+        oc.rect(bx,by,blockW,blockH);
+        oc.clip();
+        if(!flip){
+          // NW-SE diagonal
+          const ox=s*tw*1.41;
+          oc.save();
+          oc.translate(bx+blockW/2,by+blockH/2);
+          oc.rotate(Math.PI/4);
+          oc.fillRect(ox-blockW,-(blockH*2),tw,blockH*4);
+          oc.restore();
+        } else {
+          // NE-SW
+          const ox=s*tw*1.41;
+          oc.save();
+          oc.translate(bx+blockW/2,by+blockH/2);
+          oc.rotate(-Math.PI/4);
+          oc.fillRect(ox-blockW,-(blockH*2),tw,blockH*4);
+          oc.restore();
+        }
+        oc.restore();
+      }
+    }
+  }
+  // fine cross-hatch shadow lines
+  oc.strokeStyle='rgba(0,0,0,.04)';
+  oc.lineWidth=.5;
+  for(let x=0;x<ow;x+=tw*1.41){
+    oc.beginPath();oc.moveTo(x,0);oc.lineTo(x+oh,oh);oc.stroke();
+  }
+}
+
+// ─── LIGHT SWEEP ──────────────────────────────────────────────────────────────
+function drawSweep(ctx, W, H, sweepX, sheen){
+  if(!state.sweepEnabled || sheen<0.01) return;
+  const cx = sweepX * (W + 300) - 150;
+  const bw = W*.35 + sheen*W*.2;
+  const g = ctx.createLinearGradient(cx-bw,0,cx+bw,0);
+  g.addColorStop(0,'rgba(255,255,255,0)');
+  g.addColorStop(.3,`rgba(255,255,255,${sheen*.28})`);
+  g.addColorStop(.5,`rgba(255,255,255,${sheen*.44})`);
+  g.addColorStop(.7,`rgba(255,255,255,${sheen*.28})`);
+  g.addColorStop(1,'rgba(255,255,255,0)');
+  ctx.fillStyle=g;
+  ctx.fillRect(0,0,W,H);
+  // secondary sheen band (sharper highlight)
+  const g2=ctx.createLinearGradient(cx-40,0,cx+40,0);
+  g2.addColorStop(0,'rgba(255,255,255,0)');
+  g2.addColorStop(.5,`rgba(255,255,255,${sheen*.18})`);
+  g2.addColorStop(1,'rgba(255,255,255,0)');
+  ctx.fillStyle=g2;
+  ctx.fillRect(0,0,W,H);
+}
+
+// ─── RENDER PIPELINE ──────────────────────────────────────────────────────────
+function buildOffscreen(){
+  if(!offscreenCanvas){
+    offscreenCanvas=document.createElement('canvas');
+    offscreenCtx=offscreenCanvas.getContext('2d');
+  }
+  offscreenCanvas.width=OFFSCREEN_SIZE;
+  offscreenCanvas.height=OFFSCREEN_SIZE;
+  renderTile(offscreenCtx, OFFSCREEN_SIZE, OFFSCREEN_SIZE, state);
+}
+
+function drawFrame(ts){
+  rafId=requestAnimationFrame(drawFrame);
+  const dt=Math.min(ts-lastTime, 50);
+  lastTime=ts;
+
+  // Advance sweep
+  if(state.sweepEnabled){
+    state.sweepX += sweepDir * dt * 0.00028;
+    if(state.sweepX>1.08){sweepDir=-1;}
+    if(state.sweepX<-0.08){sweepDir=1;}
+    dirty=true;
+  }
+
+  if(!dirty) return;
+  dirty=false;
+
+  const dpr=Math.min(window.devicePixelRatio||1,2);
+  const W=canvas.width/dpr, H=canvas.height/dpr;
+
+  ctx.save();
+  ctx.scale(1/dpr,1/dpr);
+  ctx.scale(dpr,dpr);
+  ctx.restore();
+
+  ctx.clearRect(0,0,W,H);
+
+  // Draw tiled offscreen texture
+  if(offscreenCanvas){
+    const scale=W/OFFSCREEN_SIZE*1.0;
+    ctx.save();
+    ctx.scale(scale,scale);
+    const tilesX=Math.ceil(W/(OFFSCREEN_SIZE*scale))+1;
+    const tilesY=Math.ceil(H/(OFFSCREEN_SIZE*scale))+1;
+    for(let ty=0;ty<tilesY;ty++){
+      for(let tx=0;tx<tilesX;tx++){
+        ctx.drawImage(offscreenCanvas, tx*OFFSCREEN_SIZE/scale*scale, ty*OFFSCREEN_SIZE/scale*scale);
+      }
+    }
+    ctx.restore();
+    // Actually just draw once, scaled to fit
+    ctx.clearRect(0,0,W,H);
+    ctx.drawImage(offscreenCanvas,0,0,W,H);
+  }
+
+  // Light sweep overlay
+  drawSweep(ctx, W, H, state.sweepX, state.sheen);
+
+  // Zoom lens
+  if(state.zoomEnabled && offscreenCanvas){
+    updateZoomLens(W,H);
+  }
+}
+
+function updateZoomLens(W,H){
+  const lx=state.mouseX*W, ly=state.mouseY*H;
+  zoomLens.style.left=lx+'px';
+  zoomLens.style.top=ly+'px';
+  zoomLens.style.visibility='visible';
+  zoomLens.style.opacity='1';
+  // sample a 80x80 region from offscreen, draw at 320x320
+  const srcSize=80;
+  const srcX=Math.max(0,Math.min(OFFSCREEN_SIZE-srcSize,(state.mouseX)*OFFSCREEN_SIZE - srcSize*.5));
+  const srcY=Math.max(0,Math.min(OFFSCREEN_SIZE-srcSize,(state.mouseY)*OFFSCREEN_SIZE - srcSize*.5));
+  zoomCtx.clearRect(0,0,320,320);
+  zoomCtx.drawImage(offscreenCanvas, srcX,srcY,srcSize,srcSize, 0,0,320,320);
+}
+
+// ─── INIT & REBUILD ───────────────────────────────────────────────────────────
+function rebuild(){
+  buildOffscreen();
+  dirty=true;
+}
+
+// ─── CONTROLS WIRING ──────────────────────────────────────────────────────────
+document.querySelectorAll('.weave-btn').forEach(btn=>{
+  btn.addEventListener('click',()=>{
+    document.querySelectorAll('.weave-btn').forEach(b=>{b.classList.remove('active');b.setAttribute('aria-pressed','false');});
+    btn.classList.add('active');
+    btn.setAttribute('aria-pressed','true');
+    state.weave=btn.dataset.weave;
+    rebuild();
+  });
+  btn.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();btn.click();}});
+});
+
+function wireRange(id, valId, key, transform){
+  const el=document.getElementById(id);
+  const vEl=document.getElementById(valId);
+  el.addEventListener('input',()=>{
+    const v=transform?transform(+el.value):+el.value;
+    state[key]=v;
+    vEl.textContent=v.toFixed(el.step.includes('.')?2:0);
+    rebuild();
+  });
+}
+wireRange('density','density-val','density');
+wireRange('warp-thick','warp-val','warpT');
+wireRange('weft-thick','weft-val','weftT');
+wireRange('sheen','sheen-val','sheen');
+
+document.getElementById('color1').addEventListener('input',e=>{state.c1=e.target.value;rebuild();});
+document.getElementById('color2').addEventListener('input',e=>{state.c2=e.target.value;rebuild();});
+
+// ─── PRESETS ──────────────────────────────────────────────────────────────────
+const presetList=document.getElementById('preset-list');
+PRESETS.forEach(p=>{
+  const btn=document.createElement('button');
+  btn.className='preset-btn';
+  btn.setAttribute('aria-label',`Apply preset: ${p.name}`);
+  const sw=document.createElement('div');
+  sw.className='preset-swatch';
+  sw.style.background=`linear-gradient(135deg, ${p.c1} 50%, ${p.c2} 50%)`;
+  const info=document.createElement('div');
+  info.innerHTML=`<div class="preset-name">${p.name}</div><div class="preset-sub">${p.sub}</div>`;
+  btn.appendChild(sw); btn.appendChild(info);
+  btn.addEventListener('click',()=>applyPreset(p));
+  btn.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();applyPreset(p);}});
+  presetList.appendChild(btn);
+});
+
+function applyPreset(p){
+  state.weave=p.weave; state.c1=p.c1; state.c2=p.c2;
+  state.density=p.density; state.warpT=p.warpT; state.weftT=p.weftT; state.sheen=p.sheen;
+  // sync UI
+  document.getElementById('color1').value=p.c1;
+  document.getElementById('color2').value=p.c2;
+  document.getElementById('density').value=p.density;
+  document.getElementById('density-val').textContent=p.density;
+  document.getElementById('warp-thick').value=p.warpT;
+  document.getElementById('warp-val').textContent=p.warpT.toFixed(2);
+  document.getElementById('weft-thick').value=p.weftT;
+  document.getElementById('weft-val').textContent=p.weftT.toFixed(2);
+  document.getElementById('sheen').value=p.sheen;
+  document.getElementById('sheen-val').textContent=p.sheen.toFixed(2);
+  document.querySelectorAll('.weave-btn').forEach(b=>{
+    const active=b.dataset.weave===p.weave;
+    b.classList.toggle('active',active);
+    b.setAttribute('aria-pressed',active?'true':'false');
+  });
+  rebuild();
+}
+
+// ─── SWEEP TOGGLE ─────────────────────────────────────────────────────────────
+btnSweep.addEventListener('click',()=>{
+  state.sweepEnabled=!state.sweepEnabled;
+  btnSweep.classList.toggle('active',state.sweepEnabled);
+  btnSweep.setAttribute('aria-pressed',state.sweepEnabled?'true':'false');
+  sweepInd.style.opacity=state.sweepEnabled?'1':'0.3';
+  dirty=true;
+});
+
+// ─── ZOOM TOGGLE ──────────────────────────────────────────────────────────────
+btnZoom.addEventListener('click',()=>{
+  state.zoomEnabled=!state.zoomEnabled;
+  btnZoom.classList.toggle('active',state.zoomEnabled);
+  btnZoom.setAttribute('aria-pressed',state.zoomEnabled?'true':'false');
+  if(!state.zoomEnabled){
+    zoomLens.style.visibility='hidden';
+    zoomLens.style.opacity='0';
+  }
+  wrap.style.cursor=state.zoomEnabled?'none':'default';
+});
+
+// Mouse tracking for zoom
+wrap.addEventListener('mousemove',e=>{
+  const r=wrap.getBoundingClientRect();
+  state.mouseX=(e.clientX-r.left)/r.width;
+  state.mouseY=(e.clientY-r.top)/r.height;
+  if(state.zoomEnabled) dirty=true;
+});
+wrap.addEventListener('mouseleave',()=>{
+  if(state.zoomEnabled){zoomLens.style.opacity='0';}
+});
+wrap.addEventListener('mouseenter',()=>{
+  if(state.zoomEnabled){zoomLens.style.opacity='1';}
+});
+
+// Touch support for zoom
+wrap.addEventListener('touchmove',e=>{
+  e.preventDefault();
+  const t=e.touches[0];
+  const r=wrap.getBoundingClientRect();
+  state.mouseX=(t.clientX-r.left)/r.width;
+  state.mouseY=(t.clientY-r.top)/r.height;
+  if(state.zoomEnabled) dirty=true;
+},{passive:false});
+
+// ─── DOWNLOAD ─────────────────────────────────────────────────────────────────
+btnDown.addEventListener('click',()=>{
+  // render at 2400x2400
+  const hc=document.createElement('canvas');
+  hc.width=2400; hc.height=2400;
+  const hctx=hc.getContext('2d');
+  renderTile(hctx,2400,2400,state);
+  // apply sweep at midpoint
+  if(state.sweepEnabled){
+    const g=hctx.createLinearGradient(2400*.35,0,2400*.65,0);
+    g.addColorStop(0,'rgba(255,255,255,0)');
+    g.addColorStop(.5,`rgba(255,255,255,${state.sheen*.3})`);
+    g.addColorStop(1,'rgba(255,255,255,0)');
+    hctx.fillStyle=g; hctx.fillRect(0,0,2400,2400);
+  }
+  const url=hc.toDataURL('image/png');
+  const a=document.createElement('a');
+  a.href=url;
+  a.download=`dw-${state.weave}-texture.png`;
+  a.click();
+});
+
+// ─── KEYBOARD ─────────────────────────────────────────────────────────────────
+document.addEventListener('keydown',e=>{
+  if(e.key==='s'||e.key==='S'){btnSweep.click();}
+  if(e.key==='z'||e.key==='Z'){btnZoom.click();}
+  if(e.key==='d'||e.key==='D'){btnDown.click();}
+});
+
+// ─── BOOT ─────────────────────────────────────────────────────────────────────
+resizeCanvas();
+rebuild();
+lastTime=performance.now();
+rafId=requestAnimationFrame(drawFrame);
+
+})();
+</script>
+</body>
+</html>

← e89161d Agent-driven tool: DW Palette Mixer (frontend-developer) — H  ·  back to Model Arena  ·  Agent-driven tool: DW Texture Weave Studio (frontend-develop c318ca7 →