← 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
M data/arcade-games.jsonA public/games/dw-pattern-sequence.htmlA public/games/dw-texture-weave-studio.html
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 · ←↑→↓ 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 · Texture Weave Studio · © 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 →