[object Object]

← back to Model Arena

DW Suite index page (/suite, /dw) — categorized landing for the 20 agent-built DW builds (Tools/Games/Social/Art), reads /api/arcade; +category tags on featured entries + API exposes them

bf43f1b26a79295fc1b6d09d8b9ba2c0bbf1b7df · 2026-07-25 12:26:05 -0700 · Steve Abrams

Files touched

Diff

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

    DW Suite index page (/suite, /dw) — categorized landing for the 20 agent-built DW builds (Tools/Games/Social/Art), reads /api/arcade; +category tags on featured entries + API exposes them
---
 data/arcade-games.json                |  74 ++-
 public/games/dw-spot-the-pattern.html | 718 +++++++++++++++++++++++++++
 public/games/dw-stripe-studio.html    | 878 ++++++++++++++++++++++++++++++++++
 public/suite.html                     | 100 ++++
 server.js                             |   8 +-
 5 files changed, 1757 insertions(+), 21 deletions(-)

diff --git a/data/arcade-games.json b/data/arcade-games.json
index 538b8e6..3c16199 100644
--- a/data/arcade-games.json
+++ b/data/arcade-games.json
@@ -4,121 +4,155 @@
       "slug": "tetris",
       "title": "Tetris",
       "credit": "claude-code",
-      "note": "Arena champion \u2014 All-Models Tetris"
+      "note": "Arena champion \u2014 All-Models Tetris",
+      "category": "Classics"
     },
     {
       "slug": "star-clock",
       "title": "Constellation Clock",
       "credit": "claude-code",
-      "note": "Arena champion \u2014 Free-Roster Constellation Clock"
+      "note": "Arena champion \u2014 Free-Roster Constellation Clock",
+      "category": "Classics"
     },
     {
       "slug": "dw-color-story-studio",
       "title": "DW Color Story Studio",
       "credit": "frontend-developer",
-      "note": "Agent-built flagship \u2014 seasonal wallcovering color-story tool for the DW social team"
+      "note": "Agent-built flagship \u2014 seasonal wallcovering color-story tool for the DW social team",
+      "category": "Tools"
     },
     {
       "slug": "dw-mood-board-studio",
       "title": "DW Mood Board Studio",
       "credit": "frontend-developer",
-      "note": "Agent-built \u2014 draggable interior mood board with 5 style presets + PNG export"
+      "note": "Agent-built \u2014 draggable interior mood board with 5 style presets + PNG export",
+      "category": "Tools"
     },
     {
       "slug": "dw-room-visualizer",
       "title": "DW Room Visualizer",
       "credit": "frontend-developer",
-      "note": "Agent-built \u2014 perspectival room scene, swap 8 wallcoverings on the feature wall, day/night + scale + room types"
+      "note": "Agent-built \u2014 perspectival room scene, swap 8 wallcoverings on the feature wall, day/night + scale + room types",
+      "category": "Tools"
     },
     {
       "slug": "dw-style-quiz",
       "title": "DW Find Your Style",
       "credit": "frontend-developer",
-      "note": "Agent-built \u2014 interactive interior-style quiz (6 visual questions \u2192 5 archetypes \u2192 shareable result card)"
+      "note": "Agent-built \u2014 interactive interior-style quiz (6 visual questions \u2192 5 archetypes \u2192 shareable result card)",
+      "category": "Social & Editorial"
     },
     {
       "slug": "dw-swatch-memory",
       "title": "DW Swatch Memory",
       "credit": "frontend-developer",
-      "note": "Agent-built game \u2014 memory pairs with 32 luxury wallcovering textures, 3 difficulties, 3D flips"
+      "note": "Agent-built game \u2014 memory pairs with 32 luxury wallcovering textures, 3 difficulties, 3D flips",
+      "category": "Games"
     },
     {
       "slug": "dw-shop-by-color",
       "title": "DW Shop by Color",
       "credit": "frontend-developer",
-      "note": "Agent-built \u2014 interactive color-wheel discovery, hue\u2192swatch families, warm/cool + value controls"
+      "note": "Agent-built \u2014 interactive color-wheel discovery, hue\u2192swatch families, warm/cool + value controls",
+      "category": "Tools"
     },
     {
       "slug": "dw-reel-storyboard",
       "title": "DW Reel Storyboard",
       "credit": "frontend-developer",
-      "note": "Agent-built social tool \u2014 9:16 Reel preview + drag-reorder storyboard editor, scene presets, caption/hashtag export"
+      "note": "Agent-built social tool \u2014 9:16 Reel preview + drag-reorder storyboard editor, scene presets, caption/hashtag export",
+      "category": "Social & Editorial"
     },
     {
       "slug": "dw-design-iq",
       "title": "DW Design IQ",
       "credit": "frontend-developer",
-      "note": "Agent-built trivia game \u2014 12 interior-design/wallcovering questions, SVG pattern prompts, timer + streak scoring"
+      "note": "Agent-built trivia game \u2014 12 interior-design/wallcovering questions, SVG pattern prompts, timer + streak scoring",
+      "category": "Games"
     },
     {
       "slug": "dw-pattern-match",
       "title": "DW Pattern Match",
       "credit": "frontend-developer",
-      "note": "Agent-built match-3 game \u2014 swap luxury swatch tiles, cascading combos, 6 textures"
+      "note": "Agent-built match-3 game \u2014 swap luxury swatch tiles, cascading combos, 6 textures",
+      "category": "Games"
     },
     {
       "slug": "dw-lookbook",
       "title": "DW The Lookbook",
       "credit": "frontend-developer",
-      "note": "Agent-built editorial \u2014 6 swipeable style spreads (Grandmillennial\u2192Modern Minimal), each with SVG hero + palette"
+      "note": "Agent-built editorial \u2014 6 swipeable style spreads (Grandmillennial\u2192Modern Minimal), each with SVG hero + palette",
+      "category": "Social & Editorial"
     },
     {
       "slug": "dw-damask-kaleidoscope",
       "title": "DW Damask Kaleidoscope",
       "credit": "frontend-developer",
-      "note": "Agent-built generative-art toy \u2014 8-fold bezier damask kaleidoscope, mouse-warp, 5 palettes, PNG export"
+      "note": "Agent-built generative-art toy \u2014 8-fold bezier damask kaleidoscope, mouse-warp, 5 palettes, PNG export",
+      "category": "Art Toys"
     },
     {
       "slug": "dw-content-calendar",
       "title": "DW Content Calendar",
       "credit": "frontend-developer",
-      "note": "Agent-built social planner \u2014 month grid, post-type chips, editable captions + hashtags, week-at-a-glance"
+      "note": "Agent-built social planner \u2014 month grid, post-type chips, editable captions + hashtags, week-at-a-glance",
+      "category": "Social & Editorial"
     },
     {
       "slug": "dw-wallpaper-calculator",
       "title": "DW Wallpaper Calculator",
       "credit": "frontend-developer",
-      "note": "Agent-built tool \u2014 rolls-needed estimator with pattern-repeat waste, multi-wall, imperial/metric, cost breakdown"
+      "note": "Agent-built tool \u2014 rolls-needed estimator with pattern-repeat waste, multi-wall, imperial/metric, cost breakdown",
+      "category": "Tools"
     },
     {
       "slug": "dw-pattern-runner",
       "title": "DW Pattern Runner",
       "credit": "frontend-developer",
-      "note": "Agent-built endless-runner game \u2014 decorator dodges obstacles, collects rolls, wall papers grasscloth\u2192damask\u2192chinoiserie"
+      "note": "Agent-built endless-runner game \u2014 decorator dodges obstacles, collects rolls, wall papers grasscloth\u2192damask\u2192chinoiserie",
+      "category": "Games"
     },
     {
       "slug": "dw-this-or-that",
       "title": "DW This or That",
       "credit": "frontend-developer",
-      "note": "Agent-built taste game \u2014 pick between two patterns 15 rounds, builds a taste profile + shareable result"
+      "note": "Agent-built taste game \u2014 pick between two patterns 15 rounds, builds a taste profile + shareable result",
+      "category": "Social & Editorial"
     },
     {
       "slug": "dw-palette-mixer",
       "title": "DW Palette Mixer",
       "credit": "frontend-developer",
-      "note": "Agent-built tool \u2014 HSL palette builder, 5 harmony modes, lock+regenerate, texture preview, PNG export"
+      "note": "Agent-built tool \u2014 HSL palette builder, 5 harmony modes, lock+regenerate, texture preview, PNG export",
+      "category": "Tools"
     },
     {
       "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"
+      "note": "Agent-built Simon-style memory game \u2014 repeat the growing sequence of luxury pattern pads, tones + glow",
+      "category": "Games"
     },
     {
       "slug": "dw-texture-weave-studio",
       "title": "DW Texture Weave Studio",
       "credit": "frontend-developer",
-      "note": "Agent-built tool \u2014 live woven-material generator (6 weaves), 2-tone fiber, light-sweep, magnifier, PNG export"
+      "note": "Agent-built tool \u2014 live woven-material generator (6 weaves), 2-tone fiber, light-sweep, magnifier, PNG export",
+      "category": "Tools"
+    },
+    {
+      "slug": "dw-spot-the-pattern",
+      "title": "DW Spot the Pattern",
+      "credit": "frontend-developer",
+      "note": "Agent-built game \u2014 spot 5 differences across luxe wallcovering patterns, 5 levels, timer + hints",
+      "category": "Games"
+    },
+    {
+      "slug": "dw-stripe-studio",
+      "title": "DW Stripe Studio",
+      "credit": "frontend-developer",
+      "note": "Agent-built tool \u2014 custom stripe wallcovering designer, 6 styles, drag bands, room preview, seamless PNG",
+      "category": "Tools"
     }
   ]
 }
\ No newline at end of file
diff --git a/public/games/dw-spot-the-pattern.html b/public/games/dw-spot-the-pattern.html
new file mode 100644
index 0000000..6efe083
--- /dev/null
+++ b/public/games/dw-spot-the-pattern.html
@@ -0,0 +1,718 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Spot the Pattern — Designer Wallcoverings</title>
+<style>
+:root{
+  --bg:#0d0b09;--surface:#181410;--card:#211d17;--border:#3a3228;
+  --accent:#c9a961;--accent2:#e8c97a;--ink:#f0ead8;--ink2:#b8a88a;
+  --serif:'Cormorant Garamond','Playfair Display','EB Garamond',Georgia,serif;
+  --sans:system-ui,-apple-system,sans-serif;
+  --ease:cubic-bezier(.4,0,.2,1);
+  --r:8px;
+  --found:#c9a961;--wrong:#c0392b;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+body{background:var(--bg);color:var(--ink);font-family:var(--sans);min-height:100vh;overflow-x:hidden}
+header{
+  display:flex;align-items:center;justify-content:space-between;
+  padding:18px 28px;border-bottom:1px solid var(--border);
+  background:var(--surface);position:sticky;top:0;z-index:100;
+}
+.wordmark{
+  font-family:var(--serif);font-size:clamp(22px,3vw,40px);
+  text-transform:uppercase;letter-spacing:.18em;color:var(--ink);
+  font-weight:400;line-height:1;
+}
+.wordmark span{color:var(--accent);display:block;font-size:.45em;letter-spacing:.28em;margin-top:3px}
+.hud{display:flex;gap:20px;align-items:center;flex-wrap:wrap}
+.hud-item{text-align:center}
+.hud-label{font-size:10px;text-transform:uppercase;letter-spacing:.14em;color:var(--ink2);display:block}
+.hud-val{font-family:var(--serif);font-size:22px;color:var(--accent)}
+#mute-btn{
+  background:none;border:1px solid var(--border);color:var(--ink2);
+  padding:6px 12px;border-radius:var(--r);cursor:pointer;font-size:14px;
+  transition:all .2s var(--ease);
+}
+#mute-btn:hover{border-color:var(--accent);color:var(--accent)}
+main{padding:24px 20px;max-width:1200px;margin:0 auto}
+.level-intro{text-align:center;margin-bottom:18px}
+.level-intro h2{font-family:var(--serif);font-size:clamp(20px,3vw,32px);letter-spacing:.08em;color:var(--accent)}
+.level-intro p{color:var(--ink2);font-size:13px;margin-top:4px}
+.game-area{display:flex;gap:20px;justify-content:center;align-items:flex-start;flex-wrap:wrap}
+.panel-wrap{flex:1;min-width:280px;max-width:520px}
+.panel-label{font-size:11px;text-transform:uppercase;letter-spacing:.16em;color:var(--ink2);margin-bottom:8px;text-align:center}
+.pattern-panel{
+  position:relative;border:1px solid var(--border);border-radius:var(--r);
+  overflow:hidden;cursor:crosshair;
+  box-shadow:0 8px 40px rgba(0,0,0,.6);
+  aspect-ratio:1;width:100%;
+  background:var(--card);
+  user-select:none;
+}
+.pattern-panel svg{width:100%;height:100%;display:block;pointer-events:none}
+.ring-marker{
+  position:absolute;border-radius:50%;pointer-events:none;
+  border:2.5px solid var(--found);
+  box-shadow:0 0 12px var(--found),inset 0 0 8px rgba(201,169,97,.15);
+  transform:translate(-50%,-50%) scale(0);
+  transition:transform .35s var(--ease),opacity .4s;
+  opacity:0;width:48px;height:48px;
+}
+.ring-marker.pop{transform:translate(-50%,-50%) scale(1);opacity:1}
+.ring-marker.fade{opacity:0;transform:translate(-50%,-50%) scale(1.4)}
+.wrong-flash{animation:wshake .4s var(--ease)}
+@keyframes wshake{0%,100%{transform:translateX(0)}20%{transform:translateX(-8px)}40%{transform:translateX(8px)}60%{transform:translateX(-5px)}80%{transform:translateX(5px)}}
+.progress-row{display:flex;justify-content:center;gap:12px;margin:16px 0}
+.diff-pip{
+  width:24px;height:24px;border-radius:50%;border:2px solid var(--border);
+  background:transparent;transition:all .4s var(--ease);
+}
+.diff-pip.found{background:var(--accent);border-color:var(--accent);box-shadow:0 0 10px var(--accent)}
+.hint-row{text-align:center;margin-top:8px}
+#hint-btn{
+  background:none;border:1px solid var(--border);color:var(--ink2);
+  padding:7px 18px;border-radius:var(--r);cursor:pointer;font-family:var(--serif);
+  font-size:15px;transition:all .25s var(--ease);letter-spacing:.08em;
+}
+#hint-btn:hover:not(:disabled){border-color:var(--accent);color:var(--accent)}
+#hint-btn:disabled{opacity:.35;cursor:default}
+/* overlays */
+.overlay{
+  position:fixed;inset:0;z-index:200;
+  display:flex;align-items:center;justify-content:center;
+  background:rgba(0,0,0,.82);backdrop-filter:blur(6px);
+  visibility:hidden;opacity:0;transition:all .3s var(--ease);
+}
+.overlay.show{visibility:visible;opacity:1}
+.modal{
+  background:var(--surface);border:1px solid var(--border);border-radius:12px;
+  padding:40px 48px;text-align:center;max-width:420px;width:90%;
+  box-shadow:0 32px 80px rgba(0,0,0,.7);
+}
+.modal h2{font-family:var(--serif);font-size:36px;color:var(--accent);letter-spacing:.1em;margin-bottom:12px}
+.modal p{color:var(--ink2);font-size:15px;margin-bottom:24px;line-height:1.6}
+.modal-btn{
+  background:var(--accent);color:#0d0b09;border:none;padding:13px 36px;
+  border-radius:var(--r);font-family:var(--serif);font-size:18px;
+  cursor:pointer;letter-spacing:.08em;font-weight:600;
+  transition:all .2s var(--ease);margin:6px;
+}
+.modal-btn:hover{background:var(--accent2);transform:translateY(-1px)}
+.modal-btn.sec{background:transparent;color:var(--ink2);border:1px solid var(--border)}
+.modal-btn.sec:hover{border-color:var(--accent);color:var(--accent)}
+.star-row{font-size:28px;margin-bottom:12px;letter-spacing:4px}
+.stat-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:20px}
+.stat-box{background:var(--card);border-radius:6px;padding:10px}
+.stat-box .sv{font-family:var(--serif);font-size:24px;color:var(--accent)}
+.stat-box .sl{font-size:11px;text-transform:uppercase;letter-spacing:.12em;color:var(--ink2)}
+#timer-arc{transition:stroke-dashoffset .5s linear}
+.timer-svg{width:44px;height:44px}
+</style>
+</head>
+<body>
+
+<header>
+  <div class="wordmark">
+    Designer Wallcoverings
+    <span>Spot the Pattern</span>
+  </div>
+  <div class="hud">
+    <div class="hud-item">
+      <svg class="timer-svg" viewBox="0 0 44 44">
+        <circle cx="22" cy="22" r="18" fill="none" stroke="#3a3228" stroke-width="3"/>
+        <circle id="timer-arc" cx="22" cy="22" r="18" fill="none" stroke="#c9a961" stroke-width="3"
+          stroke-dasharray="113" stroke-dashoffset="0" stroke-linecap="round"
+          transform="rotate(-90 22 22)"/>
+        <text x="22" y="27" text-anchor="middle" fill="#f0ead8" font-size="13"
+          font-family="Cormorant Garamond,serif" id="timer-text">60</text>
+      </svg>
+      <span class="hud-label">Time</span>
+    </div>
+    <div class="hud-item"><span class="hud-val" id="hud-score">0</span><span class="hud-label">Score</span></div>
+    <div class="hud-item"><span class="hud-val" id="hud-level">1</span><span class="hud-label">Level</span></div>
+    <div class="hud-item"><span class="hud-val" id="hud-best">—</span><span class="hud-label">Best</span></div>
+    <button id="mute-btn">♪ On</button>
+  </div>
+</header>
+
+<main>
+  <div class="level-intro">
+    <h2 id="level-name">Level 1 — Damask Royale</h2>
+    <p id="level-desc">Find 5 subtle differences between the two panels. Click to mark them.</p>
+  </div>
+  <div class="game-area">
+    <div class="panel-wrap">
+      <div class="panel-label">Original</div>
+      <div class="pattern-panel" id="panel-a"></div>
+    </div>
+    <div class="panel-wrap">
+      <div class="panel-label">Find the Differences</div>
+      <div class="pattern-panel" id="panel-b"></div>
+    </div>
+  </div>
+  <div class="progress-row" id="pip-row"></div>
+  <div class="hint-row"><button id="hint-btn">Use Hint (3 left)</button></div>
+</main>
+
+<!-- overlays -->
+<div class="overlay" id="ov-level-complete">
+  <div class="modal">
+    <div class="star-row" id="star-display">★★★</div>
+    <h2>Well Found!</h2>
+    <div class="stat-grid">
+      <div class="stat-box"><div class="sv" id="lc-found">5/5</div><div class="sl">Differences</div></div>
+      <div class="stat-box"><div class="sv" id="lc-time">42s</div><div class="sl">Time Left</div></div>
+      <div class="stat-box"><div class="sv" id="lc-score">+350</div><div class="sl">Score</div></div>
+      <div class="stat-box"><div class="sv" id="lc-hints">2</div><div class="sl">Hints Used</div></div>
+    </div>
+    <button class="modal-btn" onclick="G.nextLevel()">Next Level →</button>
+  </div>
+</div>
+
+<div class="overlay" id="ov-time-up">
+  <div class="modal">
+    <h2 style="color:#c0392b">Time's Up</h2>
+    <p id="tu-msg">You found <strong id="tu-found">3</strong> of 5 differences.<br>Score: <strong id="tu-score">180</strong></p>
+    <button class="modal-btn" onclick="G.restart()">Try Again</button>
+    <button class="modal-btn sec" onclick="G.nextLevel()">Skip Level</button>
+  </div>
+</div>
+
+<div class="overlay" id="ov-win">
+  <div class="modal">
+    <div class="star-row">✦✦✦</div>
+    <h2>Collection Complete</h2>
+    <p>You've mastered all pattern levels!<br>Final score: <strong id="win-score">0</strong></p>
+    <button class="modal-btn" onclick="G.resetAll()">Play Again</button>
+  </div>
+</div>
+
+<script>
+// ─── Audio ───────────────────────────────────────────────────────────────────
+const AC = new (window.AudioContext||window.webkitAudioContext)();
+let muted = false;
+document.getElementById('mute-btn').onclick = function(){
+  muted = !muted;
+  this.textContent = muted ? '♪ Off' : '♪ On';
+};
+function beep(type){
+  if(muted) return;
+  AC.resume();
+  const g = AC.createGain(); g.connect(AC.destination);
+  if(type==='found'){
+    [660,880,1100].forEach((f,i)=>{
+      const o = AC.createOscillator(); o.type='sine'; o.frequency.value=f;
+      o.connect(g); g.gain.setValueAtTime(0,AC.currentTime+i*.08);
+      g.gain.linearRampToValueAtTime(.18,AC.currentTime+i*.08+.04);
+      g.gain.linearRampToValueAtTime(0,AC.currentTime+i*.08+.22);
+      o.start(AC.currentTime+i*.08); o.stop(AC.currentTime+i*.08+.25);
+    });
+  } else if(type==='wrong'){
+    const o = AC.createOscillator(); o.type='sawtooth'; o.frequency.value=180;
+    o.connect(g); g.gain.setValueAtTime(.12,AC.currentTime);
+    g.gain.linearRampToValueAtTime(0,AC.currentTime+.25);
+    o.start(); o.stop(AC.currentTime+.28);
+  } else if(type==='levelup'){
+    [440,554,659,880].forEach((f,i)=>{
+      const o = AC.createOscillator(); o.type='triangle'; o.frequency.value=f;
+      o.connect(g); g.gain.setValueAtTime(0,AC.currentTime+i*.12);
+      g.gain.linearRampToValueAtTime(.2,AC.currentTime+i*.12+.06);
+      g.gain.linearRampToValueAtTime(0,AC.currentTime+i*.12+.3);
+      o.start(AC.currentTime+i*.12); o.stop(AC.currentTime+i*.12+.35);
+    });
+  }
+}
+
+// ─── Seeded RNG ───────────────────────────────────────────────────────────────
+function mkRng(seed){
+  let s = seed|0;
+  return ()=>{ s=(s^s<<13)>>>0;s=(s^s>>7)>>>0;s=(s^s<<17)>>>0; return s/4294967296; };
+}
+
+// ─── SVG Pattern Generators ──────────────────────────────────────────────────
+// Each returns {bg, motifs:[]} where motifs are reusable SVG snippets
+
+const PALETTES = [
+  {bg:'#1a0f0a',line:'#6b4c2a',fill:'#2a1a0f',acc:'#c9a961',acc2:'#8b6914'},
+  {bg:'#0a0f1a',line:'#2a4a6b',fill:'#0f1a2a',acc:'#7ab3d4',acc2:'#3a7a9e'},
+  {bg:'#0f1a0a',line:'#3a6b2a',fill:'#1a2a0f',acc:'#8bd48b',acc2:'#3a8a3a'},
+  {bg:'#1a0a0f',line:'#6b2a3a',fill:'#2a0f1a',acc:'#d47ab3',acc2:'#9e3a7a'},
+  {bg:'#14100a',line:'#5a4a2a',fill:'#221a0f',acc:'#d4b87a',acc2:'#8a6a2a'},
+];
+
+function damaskMotifsAt(x,y,s,p,rng){
+  // A lush damask: central urn, 4 scrolls, petal ring, diamond lattice frame
+  const sc = s; const cx=x+sc/2, cy=y+sc/2;
+  const c=p.acc, l=p.line;
+  return `
+  <g transform="translate(${cx},${cy})">
+    <!-- diamond frame -->
+    <polygon points="0,${-sc*.42} ${sc*.42},0 0,${sc*.42} ${-sc*.42},0"
+      fill="none" stroke="${l}" stroke-width="${sc*.018}" opacity=".5"/>
+    <!-- petal ring -->
+    ${[0,45,90,135,180,225,270,315].map(a=>`
+    <ellipse transform="rotate(${a})" cx="0" cy="${-sc*.22}" rx="${sc*.055}" ry="${sc*.11}"
+      fill="${p.fill}" stroke="${l}" stroke-width="${sc*.014}" opacity=".85"/>`).join('')}
+    <!-- central urn body -->
+    <ellipse cx="0" cy="${sc*.05}" rx="${sc*.09}" ry="${sc*.13}" fill="${p.fill}" stroke="${c}" stroke-width="${sc*.02}"/>
+    <ellipse cx="0" cy="${-sc*.1}" rx="${sc*.07}" ry="${sc*.05}" fill="${p.fill}" stroke="${c}" stroke-width="${sc*.018}"/>
+    <!-- urn neck -->
+    <rect x="${-sc*.025}" y="${-sc*.06}" width="${sc*.05}" height="${sc*.12}" fill="${p.fill}" stroke="${c}" stroke-width="${sc*.012}"/>
+    <!-- scrolls: 4 corners -->
+    ${[[-1,-1],[1,-1],[1,1],[-1,1]].map(([dx,dy])=>`
+    <path d="M${dx*sc*.14},${dy*sc*.14} Q${dx*sc*.26},${dy*sc*.2} ${dx*sc*.18},${dy*sc*.3} Q${dx*sc*.1},${dy*sc*.38} ${dx*sc*.06},${dy*sc*.3}"
+      fill="none" stroke="${c}" stroke-width="${sc*.016}" stroke-linecap="round"/>`).join('')}
+    <!-- top flourish -->
+    <path d="M0,${-sc*.42} Q${sc*.04},${-sc*.37} 0,${-sc*.32} Q${-sc*.04},${-sc*.37} 0,${-sc*.42}"
+      fill="${c}" stroke="none" opacity=".8"/>
+    <!-- center jewel -->
+    <circle cx="0" cy="${sc*.05}" r="${sc*.028}" fill="${c}" opacity=".9"/>
+  </g>`;
+}
+
+function toileMotifsAt(x,y,s,p,rng){
+  const cx=x+s/2, cy=y+s/2; const l=p.line;
+  // Pastoral toile: tree + bird + fern fronds
+  return `
+  <g transform="translate(${cx},${cy})">
+    <!-- trunk -->
+    <line x1="0" y1="${s*.15}" x2="0" y2="${s*.4}" stroke="${l}" stroke-width="${s*.022}" stroke-linecap="round"/>
+    <!-- canopy layers -->
+    <ellipse cx="0" cy="${-s*.05}" rx="${s*.22}" ry="${s*.18}" fill="none" stroke="${l}" stroke-width="${s*.016}"/>
+    <ellipse cx="${-s*.1}" cy="${s*.08}" rx="${s*.15}" ry="${s*.13}" fill="none" stroke="${l}" stroke-width="${s*.015}"/>
+    <ellipse cx="${s*.12}" cy="${s*.07}" rx="${s*.13}" ry="${s*.12}" fill="none" stroke="${l}" stroke-width="${s*.015}"/>
+    <!-- inner detail lines -->
+    <path d="M${-s*.12},${-s*.02} Q0,${-s*.15} ${s*.12},${-s*.02}" fill="none" stroke="${l}" stroke-width="${s*.009}" opacity=".5"/>
+    <!-- bird perched on branch -->
+    <ellipse cx="${s*.15}" cy="${s*.02}" rx="${s*.05}" ry="${s*.032}" fill="${p.fill}" stroke="${l}" stroke-width="${s*.013}"/>
+    <path d="M${s*.18},${s*.01} L${s*.24},${-s*.01} L${s*.2},${s*.015}" fill="${l}" stroke="none"/>
+    <circle cx="${s*.18}" cy="${s*.005}" r="${s*.012}" fill="${l}"/>
+    <!-- fern left -->
+    ${[-3,-2,-1,0,1,2,3].map(i=>`
+    <path d="M${-s*.28},${s*.3} Q${-s*.28+i*s*.025},${s*.3-Math.abs(i)*s*.04} ${-s*.28+i*s*.03},${s*.3-s*.12}"
+      fill="none" stroke="${l}" stroke-width="${s*.009}" stroke-linecap="round" opacity=".6"/>`).join('')}
+    <!-- ground line -->
+    <path d="M${-s*.38},${s*.4} Q0,${s*.35} ${s*.38},${s*.4}" fill="none" stroke="${l}" stroke-width="${s*.013}" opacity=".4"/>
+  </g>`;
+}
+
+function chinoMotifsAt(x,y,s,p,rng){
+  const cx=x+s/2, cy=y+s/2; const l=p.line; const c=p.acc;
+  // Chinoiserie: pagoda + blossoms + bamboo
+  return `
+  <g transform="translate(${cx},${cy})">
+    <!-- bamboo stalks -->
+    ${[-1,0,1].map(i=>`
+    <line x1="${i*s*.12}" y1="${-s*.45}" x2="${i*s*.12}" y2="${s*.45}" stroke="${l}" stroke-width="${s*.016}" opacity=".35"/>
+    ${[-3,-1,1,3].map(j=>`<line x1="${i*s*.12}" x2="${i*s*.12+s*.08}" y1="${j*s*.09}" y2="${j*s*.09+s*.03}" stroke="${l}" stroke-width="${s*.009}" opacity=".3"/>`).join('')}
+    `).join('')}
+    <!-- pagoda silhouette -->
+    <polygon points="0,${-s*.38} ${s*.09},${-s*.26} ${-s*.09},${-s*.26}" fill="${p.fill}" stroke="${l}" stroke-width="${s*.016}"/>
+    <rect x="${-s*.12}" y="${-s*.26}" width="${s*.24}" height="${s*.09}" fill="${p.fill}" stroke="${l}" stroke-width="${s*.014}"/>
+    <polygon points="0,${-s*.17} ${s*.14},${-s*.04} ${-s*.14},${-s*.04}" fill="${p.fill}" stroke="${l}" stroke-width="${s*.014}"/>
+    <rect x="${-s*.14}" y="${-s*.04}" width="${s*.28}" height="${s*.1}" fill="${p.fill}" stroke="${l}" stroke-width="${s*.014}"/>
+    <!-- blossoms -->
+    ${[[.32,-.1],[-.3,-.18],[.25,.22],[-.27,.3]].map(([bx,by])=>`
+    <circle cx="${bx*s}" cy="${by*s}" r="${s*.07}" fill="none" stroke="${l}" stroke-width="${s*.01}"/>
+    ${[0,72,144,216,288].map(a=>`
+    <ellipse transform="rotate(${a},${bx*s},${by*s})" cx="${bx*s}" cy="${by*s-s*.055}" rx="${s*.025}" ry="${s*.04}"
+      fill="${p.fill}" stroke="${l}" stroke-width="${s*.009}"/>`).join('')}
+    <circle cx="${bx*s}" cy="${by*s}" r="${s*.016}" fill="${c}" opacity=".8"/>
+    `).join('')}
+  </g>`;
+}
+
+function geoMotifsAt(x,y,s,p,rng){
+  const cx=x+s/2, cy=y+s/2; const l=p.line; const c=p.acc;
+  // Art-deco geometric: sunburst + hexagon grid + fan motifs
+  return `
+  <g transform="translate(${cx},${cy})">
+    <!-- hexagon frame -->
+    <polygon points="${hex(s*.42)}" fill="none" stroke="${l}" stroke-width="${s*.02}"/>
+    <polygon points="${hex(s*.32)}" fill="none" stroke="${c}" stroke-width="${s*.012}" opacity=".6"/>
+    <!-- sunburst rays -->
+    ${Array.from({length:12},(_,i)=>i*30).map(a=>`
+    <line x1="0" y1="0" x2="${Math.cos(a*Math.PI/180)*s*.38}" y2="${Math.sin(a*Math.PI/180)*s*.38}"
+      stroke="${l}" stroke-width="${s*.01}" opacity=".3"/>`).join('')}
+    <!-- fan arcs -->
+    ${[0,90,180,270].map(a=>`
+    <path d="M${Math.cos(a*Math.PI/180)*s*.22},${Math.sin(a*Math.PI/180)*s*.22}
+      A${s*.18},${s*.18} 0 0,1 ${Math.cos((a+60)*Math.PI/180)*s*.22},${Math.sin((a+60)*Math.PI/180)*s*.22}"
+      fill="none" stroke="${c}" stroke-width="${s*.018}" stroke-linecap="round" opacity=".75"/>`).join('')}
+    <!-- center rosette -->
+    ${[0,60,120,180,240,300].map(a=>`
+    <ellipse transform="rotate(${a})" cx="0" cy="${-s*.1}" rx="${s*.04}" ry="${s*.07}"
+      fill="${p.fill}" stroke="${c}" stroke-width="${s*.015}"/>`).join('')}
+    <circle cx="0" cy="0" r="${s*.04}" fill="${c}" opacity=".9"/>
+    <!-- corner diamonds -->
+    ${[[-1,-1],[1,-1],[1,1],[-1,1]].map(([dx,dy])=>`
+    <polygon points="${dx*s*.38},${dy*s*.28} ${dx*s*.32},${dy*s*.38} ${dx*s*.26},${dy*s*.28} ${dx*s*.32},${dy*s*.18}"
+      fill="${p.fill}" stroke="${l}" stroke-width="${s*.013}" opacity=".65"/>`).join('')}
+  </g>`;
+}
+
+function florMotifsAt(x,y,s,p,rng){
+  const cx=x+s/2, cy=y+s/2; const l=p.line; const c=p.acc;
+  // Botanical floral: rose bloom + leaves + stem tendrils
+  return `
+  <g transform="translate(${cx},${cy})">
+    <!-- stem and tendrils -->
+    <path d="M0,${s*.42} Q${s*.04},${s*.2} 0,0 Q${-s*.04},${-s*.2} 0,${-s*.42}"
+      fill="none" stroke="${l}" stroke-width="${s*.016}" stroke-linecap="round"/>
+    <path d="M0,${s*.15} Q${s*.22},${s*.05} ${s*.28},${-s*.08}" fill="none" stroke="${l}" stroke-width="${s*.012}" opacity=".7"/>
+    <path d="M0,${s*.15} Q${-s*.22},${s*.05} ${-s*.28},${-s*.08}" fill="none" stroke="${l}" stroke-width="${s*.012}" opacity=".7"/>
+    <!-- leaves -->
+    <ellipse transform="rotate(35,${s*.08},${s*.12})" cx="${s*.08}" cy="${s*.12}" rx="${s*.07}" ry="${s*.14}"
+      fill="${p.fill}" stroke="${l}" stroke-width="${s*.015}"/>
+    <ellipse transform="rotate(-35,${-s*.08},${s*.12})" cx="${-s*.08}" cy="${s*.12}" rx="${s*.07}" ry="${s*.14}"
+      fill="${p.fill}" stroke="${l}" stroke-width="${s*.015}"/>
+    <!-- leaf veins -->
+    <line x1="${s*.08}" y1="${s*.05}" x2="${s*.14}" y2="${s*.22}" stroke="${l}" stroke-width="${s*.007}" opacity=".5"/>
+    <line x1="${-s*.08}" y1="${s*.05}" x2="${-s*.14}" y2="${s*.22}" stroke="${l}" stroke-width="${s*.007}" opacity=".5"/>
+    <!-- rose: petal layers -->
+    ${[s*.38,s*.28,s*.18,s*.1].map((r,li)=>`
+    ${Array.from({length:6-li},(_,i)=>`
+    <ellipse transform="rotate(${i*(360/(6-li))+(li*15)})"
+      cx="0" cy="${-r*.6}" rx="${r*.25}" ry="${r*.42}"
+      fill="${p.fill}" stroke="${l}" stroke-width="${s*.013*(1-li*.15)}" opacity="${.9-li*.05}"/>`).join('')}
+    `).join('')}
+    <circle cx="0" cy="0" r="${s*.06}" fill="${c}" opacity=".9"/>
+    <!-- small buds on sides -->
+    ${[[-1,0],[1,0]].map(([dx])=>`
+    <ellipse cx="${dx*s*.3}" cy="${-s*.05}" rx="${s*.04}" ry="${s*.07}"
+      fill="${p.fill}" stroke="${l}" stroke-width="${s*.012}" opacity=".7"/>`).join('')}
+  </g>`;
+}
+
+function hex(r){
+  return Array.from({length:6},(_,i)=>{
+    const a=i*60*Math.PI/180;
+    return `${Math.cos(a)*r},${Math.sin(a)*r}`;
+  }).join(' ');
+}
+
+// ─── Levels ───────────────────────────────────────────────────────────────────
+const LEVELS = [
+  {name:'Damask Royale',    gen:damaskMotifsAt, time:75, seed:1001, desc:'Grand damask — look for missing motifs and shifted ornaments.'},
+  {name:'Toile de Jouy',    gen:toileMotifsAt,  time:70, seed:2002, desc:'Pastoral toile — a bird, a branch, a subtle change awaits.'},
+  {name:'Chinoiserie Luxe', gen:chinoMotifsAt,  time:65, seed:3003, desc:'Eastern chinoiserie — find the altered blossoms and pagoda details.'},
+  {name:'Art Deco Prestige',gen:geoMotifsAt,    time:60, seed:4004, desc:'Geometric deco — precision lines hide five clever differences.'},
+  {name:'Botanical Bloom',  gen:florMotifsAt,   time:70, seed:5005, desc:'Botanical floral — petals and leaves conceal subtle changes.'},
+];
+
+// ─── Difference Engine ────────────────────────────────────────────────────────
+// Each diff is a patch applied to panel B's SVG at a grid position
+function makeDiffs(rng, gridW, gridH, cellSize){
+  const NDIFF = 5;
+  const diffs = [];
+  const used = new Set();
+  const candidates = [];
+  for(let r=0;r<gridH;r++) for(let c=0;c<gridW;c++) candidates.push([r,c]);
+  // shuffle
+  for(let i=candidates.length-1;i>0;i--){
+    const j=Math.floor(rng()*( i+1));
+    [candidates[i],candidates[j]]=[candidates[j],candidates[i]];
+  }
+  const types = ['missing','colorshift','rotate','extra','scale'];
+  for(let i=0;i<NDIFF;i++){
+    const [row,col] = candidates[i];
+    const cx = col*cellSize + cellSize/2;
+    const cy = row*cellSize + cellSize/2;
+    diffs.push({row,col,cx,cy,type:types[i % types.length],found:false});
+  }
+  return diffs;
+}
+
+// Render panel SVG. If diffs provided, apply them.
+function renderPanel(lvl, palette, gridW, gridH, cellSize, SVGSize, diffs, isB){
+  const rng = mkRng(lvl.seed);
+  let inner = '';
+  // background tile
+  inner += `<rect width="${SVGSize}" height="${SVGSize}" fill="${palette.bg}"/>`;
+  // subtle grid lines
+  for(let i=1;i<gridW;i++)
+    inner += `<line x1="${i*cellSize}" y1="0" x2="${i*cellSize}" y2="${SVGSize}" stroke="${palette.line}" stroke-width=".5" opacity=".12"/>`;
+  for(let i=1;i<gridH;i++)
+    inner += `<line x1="0" y1="${i*cellSize}" x2="${SVGSize}" y2="${i*cellSize}" stroke="${palette.line}" stroke-width=".5" opacity=".12"/>`;
+
+  const diffMap = {};
+  if(isB && diffs) diffs.forEach(d=>{ diffMap[`${d.row},${d.col}`] = d; });
+
+  for(let row=0;row<gridH;row++){
+    for(let col=0;col<gridW;col++){
+      const x = col*cellSize, y = row*cellSize;
+      const key = `${row},${col}`;
+      const diff = diffMap[key];
+
+      if(isB && diff){
+        // Apply difference patch
+        switch(diff.type){
+          case 'missing':
+            // just skip rendering the motif (cell stays blank-ish)
+            inner += `<rect x="${x+cellSize*.15}" y="${y+cellSize*.15}" width="${cellSize*.7}" height="${cellSize*.7}" fill="${palette.fill}" opacity=".3"/>`;
+            break;
+          case 'colorshift':
+            // render motif but with accent color swapped
+            const altPal = {...palette, acc:palette.line, line:palette.acc};
+            inner += lvl.gen(x,y,cellSize,altPal,rng);
+            break;
+          case 'rotate':
+            inner += `<g transform="translate(${x+cellSize/2},${y+cellSize/2}) rotate(45) translate(${-cellSize/2},${-cellSize/2})">`;
+            inner += lvl.gen(0,0,cellSize,palette,rng);
+            inner += `</g>`;
+            break;
+          case 'extra':
+            inner += lvl.gen(x,y,cellSize,palette,rng);
+            // add a small extra flourish
+            inner += `<circle cx="${x+cellSize*.75}" cy="${y+cellSize*.25}" r="${cellSize*.06}" fill="${palette.acc}" opacity=".7"/>`;
+            inner += `<circle cx="${x+cellSize*.75}" cy="${y+cellSize*.25}" r="${cellSize*.035}" fill="${palette.bg}" opacity=".8"/>`;
+            break;
+          case 'scale':
+            // render slightly smaller
+            inner += `<g transform="translate(${x+cellSize*.5},${y+cellSize*.5}) scale(0.68) translate(${-cellSize*.5},${-cellSize*.5})">`;
+            inner += lvl.gen(0,0,cellSize,palette,rng);
+            inner += `</g>`;
+            break;
+        }
+      } else {
+        inner += lvl.gen(x,y,cellSize,palette,rng);
+      }
+    }
+  }
+  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${SVGSize} ${SVGSize}" width="${SVGSize}" height="${SVGSize}">${inner}</svg>`;
+}
+
+// ─── Game State ───────────────────────────────────────────────────────────────
+const G = {
+  level:0, score:0, hintsLeft:3, hintsUsed:0,
+  diffs:[],  timerSec:0, timerMax:0, timerID:null,
+  palette:null, gridW:3, gridH:3, cellSize:180, SVGSize:540,
+
+  init(){
+    this.level = 0; this.score = 0;
+    this.loadBest();
+    // bind click handlers once
+    ['panel-a','panel-b'].forEach(id=>{
+      const panel = document.getElementById(id);
+      panel.addEventListener('click', e=>this.onClick(e, panel, id==='panel-b'));
+    });
+    this.startLevel();
+  },
+
+  loadBest(){
+    const b = localStorage.getItem('dw-spot-best');
+    document.getElementById('hud-best').textContent = b ? b : '—';
+  },
+
+  saveBest(){
+    const prev = parseInt(localStorage.getItem('dw-spot-best'))||0;
+    if(this.score > prev){
+      localStorage.setItem('dw-spot-best', this.score);
+      document.getElementById('hud-best').textContent = this.score;
+    }
+  },
+
+  startLevel(){
+    const lvl = LEVELS[this.level % LEVELS.length];
+    this.hintsLeft = 3; this.hintsUsed = 0;
+    document.getElementById('hud-level').textContent = this.level+1;
+    document.getElementById('level-name').textContent = `Level ${this.level+1} — ${lvl.name}`;
+    document.getElementById('level-desc').textContent = lvl.desc;
+
+    const pi = this.level % PALETTES.length;
+    this.palette = PALETTES[pi];
+
+    const rng = mkRng(lvl.seed + this.level*77);
+    this.diffs = makeDiffs(rng, this.gridW, this.gridH, this.cellSize);
+
+    this.renderPanels(lvl);
+    this.buildPips();
+    this.updateHintBtn();
+
+    // clear rings
+    document.querySelectorAll('.ring-marker').forEach(e=>e.remove());
+
+    this.timerMax = lvl.time + this.level*5;
+    this.timerSec = this.timerMax;
+    this.updateTimer();
+    clearInterval(this.timerID);
+    this.timerID = setInterval(()=>this.tick(), 1000);
+
+    this.closeAll();
+  },
+
+  renderPanels(lvl){
+    const svgA = renderPanel(lvl,this.palette,this.gridW,this.gridH,this.cellSize,this.SVGSize,this.diffs,false);
+    const svgB = renderPanel(lvl,this.palette,this.gridW,this.gridH,this.cellSize,this.SVGSize,this.diffs,true);
+    document.getElementById('panel-a').innerHTML = svgA;
+    document.getElementById('panel-b').innerHTML = svgB;
+  },
+
+  onClick(e, panel, isB){
+    const rect = panel.getBoundingClientRect();
+    const px = (e.clientX - rect.left) / rect.width;
+    const py = (e.clientY - rect.top) / rect.height;
+    // map to SVG coords
+    const svgX = px * this.SVGSize;
+    const svgY = py * this.SVGSize;
+    const col = Math.floor(svgX / this.cellSize);
+    const row = Math.floor(svgY / this.cellSize);
+
+    // check if this cell has an unfound diff
+    const diff = this.diffs.find(d=>d.row===row && d.col===col && !d.found);
+    if(diff){
+      diff.found = true;
+      beep('found');
+      // place ring on both panels
+      this.placeRing(panel, px, py, true);
+      const otherID = isB ? 'panel-a' : 'panel-b';
+      const otherPanel = document.getElementById(otherID);
+      const otherPx = (diff.cx / this.SVGSize);
+      const otherPy = (diff.cy / this.SVGSize);
+      this.placeRing(otherPanel, otherPx, otherPy, true);
+      this.updatePip(this.diffs.filter(d=>d.found).length - 1);
+      this.score += 50 + Math.floor(this.timerSec * 2);
+      document.getElementById('hud-score').textContent = this.score;
+      if(this.diffs.every(d=>d.found)){
+        clearInterval(this.timerID);
+        setTimeout(()=>this.showLevelComplete(), 600);
+      }
+    } else {
+      // wrong — don't double-penalize found diffs
+      const alreadyFound = this.diffs.find(d=>d.row===row && d.col===col && d.found);
+      if(alreadyFound) return;
+      beep('wrong');
+      panel.classList.add('wrong-flash');
+      setTimeout(()=>panel.classList.remove('wrong-flash'), 420);
+      this.score = Math.max(0, this.score - 10);
+      document.getElementById('hud-score').textContent = this.score;
+    }
+  },
+
+  placeRing(panel, px, py, found){
+    const ring = document.createElement('div');
+    ring.className = 'ring-marker';
+    ring.style.left = (px*100)+'%';
+    ring.style.top  = (py*100)+'%';
+    panel.appendChild(ring);
+    requestAnimationFrame(()=>requestAnimationFrame(()=>{
+      ring.classList.add('pop');
+    }));
+  },
+
+  buildPips(){
+    const row = document.getElementById('pip-row');
+    row.innerHTML = '';
+    for(let i=0;i<5;i++){
+      const d = document.createElement('div');
+      d.className = 'diff-pip';
+      d.id = `pip-${i}`;
+      row.appendChild(d);
+    }
+  },
+
+  updatePip(idx){
+    const p = document.getElementById(`pip-${idx}`);
+    if(p) p.classList.add('found');
+  },
+
+  updateHintBtn(){
+    const btn = document.getElementById('hint-btn');
+    btn.textContent = `Use Hint (${this.hintsLeft} left)`;
+    btn.disabled = this.hintsLeft <= 0;
+    btn.onclick = ()=>this.useHint();
+  },
+
+  useHint(){
+    if(this.hintsLeft <= 0) return;
+    const unfound = this.diffs.filter(d=>!d.found);
+    if(!unfound.length) return;
+    this.hintsLeft--; this.hintsUsed++;
+    const d = unfound[0];
+    const px = d.cx / this.SVGSize, py = d.cy / this.SVGSize;
+    // flash ring on panel B briefly
+    const pb = document.getElementById('panel-b');
+    const ring = document.createElement('div');
+    ring.className = 'ring-marker';
+    ring.style.left=(px*100)+'%'; ring.style.top=(py*100)+'%';
+    ring.style.borderColor='#c0392b'; ring.style.width='64px'; ring.style.height='64px';
+    pb.appendChild(ring);
+    requestAnimationFrame(()=>requestAnimationFrame(()=>ring.classList.add('pop')));
+    setTimeout(()=>{ring.classList.add('fade'); setTimeout(()=>ring.remove(),450);},1800);
+    this.score = Math.max(0, this.score - 30);
+    document.getElementById('hud-score').textContent = this.score;
+    this.updateHintBtn();
+  },
+
+  tick(){
+    this.timerSec--;
+    this.updateTimer();
+    if(this.timerSec <= 0){
+      clearInterval(this.timerID);
+      this.showTimeUp();
+    }
+  },
+
+  updateTimer(){
+    const pct = this.timerSec / this.timerMax;
+    const circ = 113;
+    document.getElementById('timer-arc').style.strokeDashoffset = circ*(1-pct);
+    document.getElementById('timer-arc').style.stroke = pct > .4 ? '#c9a961' : '#c0392b';
+    document.getElementById('timer-text').textContent = this.timerSec;
+  },
+
+  showLevelComplete(){
+    beep('levelup');
+    this.saveBest();
+    const found = this.diffs.filter(d=>d.found).length;
+    const stars = found===5 && this.timerSec>20 ? '★★★' : found===5 ? '★★☆' : '★☆☆';
+    document.getElementById('star-display').textContent = stars;
+    document.getElementById('lc-found').textContent = `${found}/5`;
+    document.getElementById('lc-time').textContent = `${this.timerSec}s`;
+    document.getElementById('lc-score').textContent = `+${this.score}`;
+    document.getElementById('lc-hints').textContent = this.hintsUsed;
+    document.getElementById('ov-level-complete').classList.add('show');
+  },
+
+  showTimeUp(){
+    const found = this.diffs.filter(d=>d.found).length;
+    document.getElementById('tu-found').textContent = found;
+    document.getElementById('tu-score').textContent = this.score;
+    document.getElementById('ov-time-up').classList.add('show');
+  },
+
+  nextLevel(){
+    this.closeAll();
+    this.level++;
+    if(this.level >= LEVELS.length){
+      document.getElementById('win-score').textContent = this.score;
+      document.getElementById('ov-win').classList.add('show');
+      return;
+    }
+    this.startLevel();
+  },
+
+  restart(){
+    this.closeAll();
+    this.startLevel();
+  },
+
+  resetAll(){
+    this.closeAll();
+    this.level = 0; this.score = 0;
+    document.getElementById('hud-score').textContent = 0;
+    this.startLevel();
+  },
+
+  closeAll(){
+    document.querySelectorAll('.overlay').forEach(o=>o.classList.remove('show'));
+  }
+};
+
+G.init();
+</script>
+</body>
+</html>
diff --git a/public/games/dw-stripe-studio.html b/public/games/dw-stripe-studio.html
new file mode 100644
index 0000000..57f6d6e
--- /dev/null
+++ b/public/games/dw-stripe-studio.html
@@ -0,0 +1,878 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Stripe Studio — Designer Wallcoverings</title>
+<style>
+:root{
+  --bg:#f4f1ea;--bg2:#edeae1;--bg3:#e5e1d6;--ink:#1c1a17;--ink2:#4a4540;--ink3:#8a8070;
+  --accent:#c9a961;--accent2:#a8883e;--accent-pale:#f0e6c8;
+  --white:#fff;--err:#b04040;
+  --serif:'Cormorant Garamond','Playfair Display','EB Garamond',Georgia,serif;
+  --sans:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
+  --ease:cubic-bezier(.4,0,.2,1);
+  --radius:6px;--radius-lg:12px;
+  --shadow:0 2px 12px rgba(28,26,23,.10);--shadow-lg:0 8px 32px rgba(28,26,23,.15);
+}
+*{box-sizing:border-box;margin:0;padding:0}
+body{background:var(--bg);color:var(--ink);font-family:var(--sans);min-height:100vh;display:flex;flex-direction:column;overflow-x:hidden}
+
+/* HEADER */
+header{background:var(--ink);padding:14px 24px;display:flex;align-items:center;gap:16px;position:sticky;top:0;z-index:100;box-shadow:0 2px 16px rgba(0,0,0,.3)}
+.wordmark{font-family:var(--serif);font-size:clamp(18px,2.8vw,28px);letter-spacing:.18em;text-transform:uppercase;color:var(--accent);font-weight:600;line-height:1;white-space:nowrap}
+.wordmark-sub{font-family:var(--serif);font-size:clamp(11px,1.4vw,14px);letter-spacing:.22em;text-transform:uppercase;color:#8a7550;margin-top:2px}
+.header-tool{font-family:var(--serif);font-size:clamp(14px,1.8vw,18px);letter-spacing:.12em;text-transform:uppercase;color:var(--accent-pale);margin-left:auto;opacity:.85}
+.header-divider{width:1px;height:32px;background:rgba(201,169,97,.3)}
+
+/* LAYOUT */
+.app{display:grid;grid-template-columns:320px 1fr;gap:0;flex:1;min-height:0}
+@media(max-width:860px){.app{grid-template-columns:1fr;grid-template-rows:auto 1fr}}
+
+/* PANEL */
+.panel{background:var(--bg2);border-right:1px solid var(--bg3);overflow-y:auto;display:flex;flex-direction:column;max-height:calc(100vh - 57px)}
+.panel-inner{padding:18px;flex:1;display:flex;flex-direction:column;gap:18px}
+
+/* SECTIONS */
+.section{background:var(--white);border-radius:var(--radius-lg);padding:14px 16px;box-shadow:var(--shadow)}
+.section-title{font-family:var(--serif);font-size:13px;letter-spacing:.16em;text-transform:uppercase;color:var(--ink3);margin-bottom:12px;display:flex;align-items:center;gap:8px}
+.section-title svg{opacity:.5}
+
+/* PRESETS */
+.presets{display:flex;flex-direction:column;gap:6px}
+.preset-btn{border:1px solid var(--bg3);border-radius:var(--radius);padding:8px 10px;cursor:pointer;background:var(--bg);font-family:var(--sans);font-size:12px;color:var(--ink2);display:flex;align-items:center;gap:10px;transition:all .18s var(--ease);text-align:left}
+.preset-btn:hover{border-color:var(--accent);background:var(--accent-pale);color:var(--ink)}
+.preset-swatch{width:40px;height:20px;border-radius:3px;flex-shrink:0;border:1px solid rgba(0,0,0,.08)}
+
+/* STYLE TOGGLE */
+.style-pills{display:flex;flex-wrap:wrap;gap:5px}
+.style-pill{border:1px solid var(--bg3);border-radius:20px;padding:5px 11px;font-size:11px;cursor:pointer;background:var(--bg);color:var(--ink2);transition:all .15s var(--ease);white-space:nowrap}
+.style-pill.active{background:var(--ink);color:var(--accent);border-color:var(--ink)}
+
+/* SCALE SLIDER */
+.slider-row{display:flex;align-items:center;gap:10px}
+.slider-row input[type=range]{flex:1;accent-color:var(--accent);height:4px}
+.slider-val{font-size:12px;color:var(--ink3);min-width:36px;text-align:right}
+
+/* BANDS */
+.bands-list{display:flex;flex-direction:column;gap:6px}
+.band-row{display:flex;align-items:center;gap:8px;padding:8px;background:var(--bg);border-radius:var(--radius);border:1px solid transparent;transition:border-color .15s var(--ease);cursor:grab;user-select:none;position:relative}
+.band-row:hover{border-color:var(--bg3)}
+.band-row.dragging{opacity:.5;border-color:var(--accent)}
+.band-drag-handle{color:var(--ink3);font-size:14px;cursor:grab;padding:0 2px;line-height:1}
+.band-color-swatch{width:28px;height:28px;border-radius:4px;border:2px solid rgba(0,0,0,.10);cursor:pointer;flex-shrink:0;position:relative;overflow:hidden}
+.band-color-swatch input[type=color]{position:absolute;inset:-4px;width:calc(100%+8px);height:calc(100%+8px);opacity:0;cursor:pointer}
+.band-name{flex:1;font-size:11px;color:var(--ink2);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.band-width-input{width:46px;border:1px solid var(--bg3);border-radius:4px;padding:3px 6px;font-size:12px;text-align:right;background:var(--white);color:var(--ink);outline:none;transition:border-color .15s}
+.band-width-input:focus{border-color:var(--accent)}
+.band-px{font-size:11px;color:var(--ink3)}
+.band-remove{background:none;border:none;cursor:pointer;color:var(--ink3);font-size:16px;line-height:1;padding:2px 4px;border-radius:4px;transition:all .15s}
+.band-remove:hover{color:var(--err);background:rgba(176,64,64,.08)}
+
+.add-band-row{display:flex;gap:6px;margin-top:4px}
+.add-band-btn{flex:1;border:1px dashed var(--accent);border-radius:var(--radius);padding:8px;background:none;cursor:pointer;font-family:var(--serif);font-size:12px;letter-spacing:.1em;color:var(--accent);transition:all .18s var(--ease)}
+.add-band-btn:hover{background:var(--accent-pale)}
+
+/* PALETTE */
+.palette-grid{display:grid;grid-template-columns:repeat(6,1fr);gap:5px}
+.pal-chip{width:100%;aspect-ratio:1;border-radius:4px;border:2px solid transparent;cursor:pointer;transition:transform .12s var(--ease),border-color .12s;position:relative}
+.pal-chip:hover{transform:scale(1.12);border-color:var(--ink)}
+.pal-chip .pal-tip{position:absolute;bottom:calc(100%+4px);left:50%;transform:translateX(-50%);background:var(--ink);color:var(--white);font-size:10px;padding:3px 7px;border-radius:3px;white-space:nowrap;opacity:0;pointer-events:none;transition:opacity .12s;z-index:10}
+.pal-chip:hover .pal-tip{opacity:1}
+
+/* RANDOMIZE */
+.action-row{display:flex;gap:8px}
+.btn-primary{flex:1;background:var(--ink);color:var(--accent);border:none;border-radius:var(--radius);padding:10px 14px;cursor:pointer;font-family:var(--serif);font-size:12px;letter-spacing:.14em;text-transform:uppercase;transition:background .18s var(--ease)}
+.btn-primary:hover{background:#2d2a24}
+.btn-secondary{flex:1;background:var(--white);color:var(--ink);border:1px solid var(--bg3);border-radius:var(--radius);padding:10px 14px;cursor:pointer;font-family:var(--serif);font-size:12px;letter-spacing:.14em;text-transform:uppercase;transition:all .18s var(--ease)}
+.btn-secondary:hover{border-color:var(--accent);background:var(--accent-pale)}
+
+/* PREVIEW AREA */
+.preview-area{display:flex;flex-direction:column;background:var(--bg);overflow:hidden}
+.preview-toolbar{background:var(--bg2);border-bottom:1px solid var(--bg3);padding:10px 16px;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
+.preview-label{font-family:var(--serif);font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink3)}
+.texture-toggle{display:flex;align-items:center;gap:6px;cursor:pointer;font-size:12px;color:var(--ink2);user-select:none}
+.texture-toggle input{accent-color:var(--accent)}
+.dl-btn{margin-left:auto;background:var(--accent);color:var(--ink);border:none;border-radius:var(--radius);padding:7px 16px;cursor:pointer;font-family:var(--serif);font-size:11px;letter-spacing:.14em;text-transform:uppercase;transition:background .18s}
+.dl-btn:hover{background:var(--accent2)}
+
+/* STRIPE CANVAS */
+.preview-canvas-wrap{flex:1;display:flex;flex-direction:column;gap:16px;padding:20px;overflow-y:auto;align-items:center}
+.canvas-tile-wrap{position:relative;border-radius:var(--radius-lg);overflow:hidden;box-shadow:var(--shadow-lg);flex-shrink:0}
+#stripeCanvas{display:block;image-rendering:pixelated}
+
+/* ROOM VIGNETTE */
+.room-wrap{background:var(--white);border-radius:var(--radius-lg);padding:14px;box-shadow:var(--shadow);width:100%;max-width:480px}
+.room-label{font-family:var(--serif);font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--ink3);margin-bottom:10px}
+.room-svg-wrap{border-radius:var(--radius);overflow:hidden;box-shadow:inset 0 0 0 1px rgba(0,0,0,.07)}
+
+/* SPECS */
+.specs-bar{background:var(--ink);color:var(--accent-pale);padding:8px 16px;font-size:11px;letter-spacing:.1em;display:flex;gap:16px;flex-wrap:wrap;font-family:var(--sans)}
+.spec-item{display:flex;gap:6px;align-items:center}
+.spec-val{color:var(--accent);font-weight:600}
+
+/* TOAST */
+.toast{position:fixed;bottom:24px;right:24px;background:var(--ink);color:var(--accent-pale);padding:10px 18px;border-radius:var(--radius);font-size:13px;z-index:999;opacity:0;pointer-events:none;transform:translateY(8px);transition:all .25s var(--ease)}
+.toast.show{opacity:1;transform:translateY(0)}
+</style>
+</head>
+<body>
+
+<header>
+  <div>
+    <div class="wordmark">Designer Wallcoverings</div>
+    <div class="wordmark-sub">Est. 1985 · New York</div>
+  </div>
+  <div class="header-divider"></div>
+  <div class="header-tool">Stripe Studio</div>
+</header>
+
+<div class="app">
+  <!-- LEFT PANEL -->
+  <aside class="panel" id="panel">
+    <div class="panel-inner">
+
+      <!-- PRESETS -->
+      <div class="section">
+        <div class="section-title">
+          <svg width="12" height="12" viewBox="0 0 12 12" fill="none"><rect x="1" y="1" width="10" height="10" rx="1.5" stroke="currentColor" stroke-width="1.4"/><path d="M4 6h4M6 4v4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
+          Presets
+        </div>
+        <div class="presets" id="presetList"></div>
+      </div>
+
+      <!-- STYLE -->
+      <div class="section">
+        <div class="section-title">Style</div>
+        <div class="style-pills" id="stylePills"></div>
+      </div>
+
+      <!-- SCALE -->
+      <div class="section">
+        <div class="section-title">Scale</div>
+        <div class="slider-row">
+          <input type="range" id="scaleRange" min="40" max="220" value="100" step="4">
+          <span class="slider-val" id="scaleVal">100%</span>
+        </div>
+      </div>
+
+      <!-- BANDS -->
+      <div class="section">
+        <div class="section-title">
+          <svg width="12" height="12" viewBox="0 0 12 12" fill="none"><rect x="1" y="2" width="10" height="2" rx="1" fill="currentColor"/><rect x="1" y="5" width="10" height="2" rx="1" fill="currentColor" opacity=".6"/><rect x="1" y="8" width="10" height="2" rx="1" fill="currentColor" opacity=".3"/></svg>
+          Stripe Bands
+        </div>
+        <div class="bands-list" id="bandsList"></div>
+        <div class="add-band-row">
+          <button class="add-band-btn" id="addBandBtn">+ Add Band</button>
+        </div>
+      </div>
+
+      <!-- PALETTE -->
+      <div class="section">
+        <div class="section-title">Designer Palette</div>
+        <div class="palette-grid" id="paletteGrid"></div>
+      </div>
+
+      <!-- ACTIONS -->
+      <div class="section">
+        <div class="action-row">
+          <button class="btn-primary" id="randomizeBtn">Randomize Tasteful</button>
+          <button class="btn-secondary" id="mirrorBtn">Mirror</button>
+        </div>
+      </div>
+
+    </div><!-- panel-inner -->
+  </aside>
+
+  <!-- RIGHT: PREVIEW -->
+  <main class="preview-area">
+    <div class="preview-toolbar">
+      <span class="preview-label">Live Preview</span>
+      <label class="texture-toggle">
+        <input type="checkbox" id="textureCb" checked>
+        Paper grain
+      </label>
+      <label class="texture-toggle">
+        <input type="checkbox" id="repeatCb" checked>
+        Seamless repeat
+      </label>
+      <button class="dl-btn" id="dlBtn">Download PNG</button>
+    </div>
+
+    <div class="preview-canvas-wrap">
+      <div class="canvas-tile-wrap" id="canvasTileWrap">
+        <canvas id="stripeCanvas"></canvas>
+      </div>
+
+      <!-- ROOM VIGNETTE -->
+      <div class="room-wrap">
+        <div class="room-label">Room Vignette</div>
+        <div class="room-svg-wrap">
+          <svg id="roomSvg" width="100%" viewBox="0 0 480 220" preserveAspectRatio="xMidYMid slice" style="display:block"></svg>
+        </div>
+      </div>
+    </div>
+
+    <div class="specs-bar" id="specsBar"></div>
+  </main>
+</div>
+
+<div class="toast" id="toast"></div>
+
+<script>
+/* ===== DESIGNER PALETTE ===== */
+const PALETTE = [
+  {n:'Ivory Mist',h:'#f5f0e6'},{n:'Alabaster',h:'#ede8dc'},{n:'Bone',h:'#e2d9c8'},
+  {n:'Warm Linen',h:'#d4c9b0'},{n:'Sand Dune',h:'#c2b08a'},{n:'Antique Parchment',h:'#b8a878'},
+  {n:'Chamois',h:'#c8a96a'},{n:'Brass',h:'#c9a961'},{n:'Old Gold',h:'#a8883e'},
+  {n:'Sienna Buff',h:'#b08050'},{n:'Toffee',h:'#8a6040'},{n:'Mahogany',h:'#5c3420'},
+  {n:'Blush Bisque',h:'#ddb8a8'},{n:'Dusty Rose',h:'#c09080'},{n:'Mauve',h:'#9a7070'},
+  {n:'Aubergine',h:'#4a2840'},{n:'Plum Shadow',h:'#3a2035'},{n:'Midnight Ink',h:'#1c1a17'},
+  {n:'Slate Grey',h:'#7a8090'},{n:'Storm',h:'#5a6070'},{n:'Iron',h:'#3c4048'},
+  {n:'Navy',h:'#2a3655'},{n:'Ocean Depth',h:'#1e3050'},{n:'Pewter',h:'#6a7075'},
+  {n:'Sea Glass',h:'#7aada0'},{n:'Seafoam',h:'#a8cac0'},{n:'Sage',h:'#8aaa88'},
+  {n:'Moss',h:'#607055'},{n:'Forest',h:'#3a5038'},{n:'Willow',h:'#b0c0a0'},
+  {n:'Champagne',h:'#e8dcc0'},{n:'Cream',h:'#f0eada'},{n:'Pearl',h:'#e8e4dc'},
+  {n:'Silver',h:'#c8c4bc'},{n:'Graphite',h:'#5a5850'},{n:'Charcoal',h:'#3a3830'},
+];
+
+/* ===== PRESETS ===== */
+const PRESETS = [
+  {
+    name:'Regency Cream & Gold',
+    style:'Regency',
+    scale:100,
+    bands:[
+      {color:'#f5f0e6',name:'Ivory Mist',w:28},
+      {color:'#c9a961',name:'Brass',w:4},
+      {color:'#ede8dc',name:'Alabaster',w:52},
+      {color:'#c9a961',name:'Brass',w:2},
+      {color:'#ede8dc',name:'Alabaster',w:52},
+      {color:'#c9a961',name:'Brass',w:4},
+      {color:'#f5f0e6',name:'Ivory Mist',w:28},
+    ]
+  },
+  {
+    name:'Coastal Ticking',
+    style:'Ticking',
+    scale:80,
+    bands:[
+      {color:'#f0eada',name:'Cream',w:44},
+      {color:'#2a3655',name:'Navy',w:4},
+      {color:'#f0eada',name:'Cream',w:10},
+      {color:'#2a3655',name:'Navy',w:2},
+      {color:'#f0eada',name:'Cream',w:10},
+      {color:'#2a3655',name:'Navy',w:4},
+    ]
+  },
+  {
+    name:'Aubergine Awning',
+    style:'Awning',
+    scale:120,
+    bands:[
+      {color:'#4a2840',name:'Aubergine',w:60},
+      {color:'#e8dcc0',name:'Champagne',w:60},
+    ]
+  },
+  {
+    name:'Alabaster Pinstripe',
+    style:'Pinstripe',
+    scale:70,
+    bands:[
+      {color:'#ede8dc',name:'Alabaster',w:60},
+      {color:'#3a3830',name:'Charcoal',w:2},
+      {color:'#ede8dc',name:'Alabaster',w:18},
+      {color:'#3a3830',name:'Charcoal',w:1},
+    ]
+  },
+];
+
+const STYLES = ['Awning','Regency','Pinstripe','Ombré','Ticking','Metallic-edge'];
+
+/* ===== STATE ===== */
+let state = {
+  bands: JSON.parse(JSON.stringify(PRESETS[0].bands)),
+  style: PRESETS[0].style,
+  scale: PRESETS[0].scale,
+  texture: true,
+  repeat: true,
+};
+
+let dragSrc = null;
+let selectedBandIdx = null;
+let animRaf = null;
+let pendingDraw = false;
+
+/* ===== CANVAS ===== */
+const canvas = document.getElementById('stripeCanvas');
+const ctx = canvas.getContext('2d');
+
+function totalBandPx() {
+  return state.bands.reduce((s,b)=>s+b.w,0);
+}
+
+function canvasSize() {
+  const wrap = document.getElementById('canvasTileWrap');
+  const avail = wrap.parentElement.clientWidth - 40;
+  const maxW = Math.min(avail, 640);
+  return { w: maxW, h: Math.min(Math.round(maxW * 1.2), 520) };
+}
+
+/* Perlin-ish noise for grain */
+function makeNoiseCanvas(w, h, alpha) {
+  const nc = document.createElement('canvas'); nc.width=w; nc.height=h;
+  const nc2 = nc.getContext('2d');
+  const id = nc2.createImageData(w, h);
+  const d = id.data;
+  for (let i=0;i<d.length;i+=4) {
+    const v = Math.random()*255|0;
+    d[i]=d[i+1]=d[i+2]=v; d[i+3]=Math.random()*alpha|0;
+  }
+  nc2.putImageData(id,0,0);
+  return nc;
+}
+
+function hexToRgb(hex) {
+  const r = parseInt(hex.slice(1,3),16), g=parseInt(hex.slice(3,5),16), b=parseInt(hex.slice(5,7),16);
+  return [r,g,b];
+}
+
+function lerpColor(a,b,t) {
+  const ra=hexToRgb(a),rb=hexToRgb(b);
+  return `rgb(${Math.round(ra[0]+(rb[0]-ra[0])*t)},${Math.round(ra[1]+(rb[1]-ra[1])*t)},${Math.round(ra[2]+(rb[2]-ra[2])*t)})`;
+}
+
+function drawStripe() {
+  const {w,h} = canvasSize();
+  canvas.width = w; canvas.height = h;
+
+  const sc = state.scale / 100;
+  const bands = state.bands;
+  if (!bands.length) { ctx.fillStyle='#eee'; ctx.fillRect(0,0,w,h); return; }
+
+  const totalW = totalBandPx() * sc;
+  const style = state.style;
+
+  // Draw vertical bands, tiling horizontally, full canvas height
+  let x = 0;
+  const cols = Math.ceil(w / totalW) + 1;
+  for (let col=0; col<cols; col++) {
+    let bx = col * totalW;
+    for (let bi=0; bi<bands.length; bi++) {
+      const band = bands[bi];
+      const bw = band.w * sc;
+
+      if (bx + bw < 0 || bx > w) { bx += bw; continue; }
+
+      if (style === 'Ombré') {
+        // Gradient from this band to next band color
+        const nextBand = bands[(bi+1)%bands.length];
+        const grad = ctx.createLinearGradient(bx,0,bx+bw,0);
+        grad.addColorStop(0, band.color);
+        grad.addColorStop(1, nextBand.color);
+        ctx.fillStyle = grad;
+      } else if (style === 'Metallic-edge') {
+        ctx.fillStyle = band.color;
+        ctx.fillRect(bx, 0, bw, h);
+        // metallic edge shimmer
+        const shimW = Math.max(2, bw * 0.08);
+        const sg = ctx.createLinearGradient(bx, 0, bx+shimW, 0);
+        sg.addColorStop(0,'rgba(255,255,255,0)');
+        sg.addColorStop(0.5,'rgba(255,255,255,0.35)');
+        sg.addColorStop(1,'rgba(255,255,255,0)');
+        ctx.fillStyle = sg;
+        ctx.fillRect(bx, 0, shimW, h);
+        bx += bw; continue;
+      } else if (style === 'Ticking') {
+        // alternating bands get slight warp simulation (soft edge)
+        ctx.fillStyle = band.color;
+        ctx.fillRect(bx, 0, bw, h);
+        if (bi % 2 === 0 && bw > 6) {
+          const vg = ctx.createLinearGradient(bx,0,bx+bw,0);
+          vg.addColorStop(0,'rgba(0,0,0,0.04)');
+          vg.addColorStop(0.3,'rgba(0,0,0,0)');
+          vg.addColorStop(0.7,'rgba(0,0,0,0)');
+          vg.addColorStop(1,'rgba(0,0,0,0.04)');
+          ctx.fillStyle = vg;
+        }
+        bx += bw; continue;
+      } else if (style === 'Regency') {
+        // central highlight on wide bands
+        ctx.fillStyle = band.color;
+        ctx.fillRect(bx, 0, bw, h);
+        if (bw > 20) {
+          const rg = ctx.createLinearGradient(bx,0,bx+bw,0);
+          rg.addColorStop(0,'rgba(0,0,0,0.05)');
+          rg.addColorStop(0.5,'rgba(255,255,255,0.06)');
+          rg.addColorStop(1,'rgba(0,0,0,0.05)');
+          ctx.fillStyle = rg;
+        } else { bx += bw; continue; }
+      } else {
+        ctx.fillStyle = band.color;
+      }
+      ctx.fillRect(bx, 0, bw, h);
+      bx += bw;
+    }
+  }
+
+  // Subtle horizontal woven texture lines
+  if (style === 'Ticking' || style === 'Awning') {
+    ctx.globalAlpha = 0.03;
+    ctx.fillStyle = '#000';
+    for (let y=0; y<h; y+=3) {
+      ctx.fillRect(0, y, w, 1);
+    }
+    ctx.globalAlpha = 1;
+  }
+
+  // Paper grain texture overlay
+  if (state.texture) {
+    const grain = makeNoiseCanvas(w, h, 28);
+    ctx.drawImage(grain, 0, 0);
+    // Additional soft vignette
+    const vign = ctx.createRadialGradient(w/2, h/2, h*0.3, w/2, h/2, h*0.9);
+    vign.addColorStop(0,'rgba(0,0,0,0)');
+    vign.addColorStop(1,'rgba(0,0,0,0.07)');
+    ctx.fillStyle = vign;
+    ctx.fillRect(0,0,w,h);
+  }
+
+  // Awning: horizontal satin sheen lines
+  if (style === 'Awning') {
+    ctx.globalAlpha = 0.04;
+    for (let y=0;y<h;y+=12) {
+      ctx.fillStyle = (y%24===0)?'rgba(255,255,255,1)':'rgba(0,0,0,1)';
+      ctx.fillRect(0,y,w,6);
+    }
+    ctx.globalAlpha = 1;
+  }
+
+  // Pinstripe: very fine vertical separation lines
+  if (style === 'Pinstripe') {
+    ctx.globalAlpha = 0.15;
+    ctx.strokeStyle = '#fff';
+    ctx.lineWidth = 0.5;
+    let px = 0;
+    for (let col=0; col<cols; col++) {
+      let bx2 = col * totalW;
+      for (let bi=0; bi<bands.length; bi++) {
+        const bw = bands[bi].w * sc;
+        if (bx2 > 0 && bx2 < w) {
+          ctx.beginPath(); ctx.moveTo(bx2, 0); ctx.lineTo(bx2, h); ctx.stroke();
+        }
+        bx2 += bw;
+      }
+    }
+    ctx.globalAlpha = 1;
+  }
+
+  updateSpecs();
+  drawRoom();
+}
+
+/* ===== ROOM VIGNETTE ===== */
+function drawRoom() {
+  const svg = document.getElementById('roomSvg');
+  const W=480, H=220;
+  const bands = state.bands;
+  const sc = state.scale/100;
+  const totalW = totalBandPx() * sc;
+
+  // Build stripe pattern as SVG <pattern>
+  let bandDefs = '';
+  let bx = 0;
+  const rects = [];
+  const cols = Math.ceil(totalW > 0 ? W/totalW : 1) + 2;
+  for (let col=0; col<cols; col++) {
+    let px = col*totalW;
+    for (let bi=0; bi<bands.length; bi++) {
+      const bw = bands[bi].w * sc * 0.55; // room scale
+      rects.push(`<rect x="${(px).toFixed(1)}" y="0" width="${bw.toFixed(1)}" height="${H}" fill="${bands[bi].color}"/>`);
+      px += bw;
+    }
+  }
+
+  const style = state.style;
+  let overlayStr = '';
+  if (style==='Awning') overlayStr='<rect x="0" y="0" width="480" height="220" fill="url(#awningOver)" opacity="0.05"/>';
+
+  svg.innerHTML = `
+    <defs>
+      <linearGradient id="roomGrad" x1="0" y1="0" x2="1" y2="0">
+        <stop offset="0%" stop-color="rgba(0,0,0,0.18)"/>
+        <stop offset="60%" stop-color="rgba(0,0,0,0)"/>
+      </linearGradient>
+      <linearGradient id="floorGrad" x1="0" y1="0" x2="0" y2="1">
+        <stop offset="0%" stop-color="#c8b898"/>
+        <stop offset="100%" stop-color="#a89870"/>
+      </linearGradient>
+      <linearGradient id="ceilingGrad" x1="0" y1="0" x2="0" y2="1">
+        <stop offset="0%" stop-color="#f0ece2"/>
+        <stop offset="100%" stop-color="#e4dfd4"/>
+      </linearGradient>
+      <filter id="roomBlur"><feGaussianBlur stdDeviation="0.6"/></filter>
+    </defs>
+    <!-- CEILING -->
+    <rect x="0" y="0" width="480" height="30" fill="url(#ceilingGrad)"/>
+    <!-- WALL: stripe pattern -->
+    <g clip-path="url(#wallClip)">
+      ${rects.join('')}
+      <!-- wall shadow left -->
+      <rect x="0" y="30" width="480" height="160" fill="url(#roomGrad)" opacity="0.6"/>
+    </g>
+    <defs>
+      <clipPath id="wallClip"><rect x="0" y="30" width="480" height="160"/></clipPath>
+    </defs>
+    <!-- BASEBOARD -->
+    <rect x="0" y="188" width="480" height="5" fill="#d4c8b0"/>
+    <!-- FLOOR -->
+    <rect x="0" y="193" width="480" height="27" fill="url(#floorGrad)"/>
+    <!-- Floor reflection -->
+    <rect x="0" y="193" width="480" height="27" fill="rgba(255,255,255,0.08)"/>
+    <!-- WAINSCOT LINE -->
+    <rect x="0" y="188" width="480" height="1" fill="rgba(100,90,70,0.4)"/>
+    <!-- MOLDING top of wall -->
+    <rect x="0" y="30" width="480" height="4" fill="#e8e2d4"/>
+    <rect x="0" y="33" width="480" height="1" fill="rgba(0,0,0,0.08)"/>
+    <!-- PICTURE FRAME hint -->
+    <rect x="180" y="70" width="120" height="80" rx="2" fill="none" stroke="rgba(180,160,120,0.5)" stroke-width="6"/>
+    <rect x="186" y="76" width="108" height="68" rx="1" fill="rgba(240,236,228,0.55)"/>
+    <!-- soft window light hit -->
+    <rect x="320" y="30" width="160" height="160" fill="rgba(255,250,235,0.10)"/>
+  `;
+}
+
+/* ===== SPECS BAR ===== */
+function updateSpecs() {
+  const bar = document.getElementById('specsBar');
+  const totalPx = totalBandPx();
+  const sc = state.scale/100;
+  const repeatW = (totalPx * sc).toFixed(0);
+  const bands = state.bands;
+  bar.innerHTML = `
+    <div class="spec-item">Repeat width <span class="spec-val">${repeatW}px</span></div>
+    <div class="spec-item">Bands <span class="spec-val">${bands.length}</span></div>
+    <div class="spec-item">Style <span class="spec-val">${state.style}</span></div>
+    <div class="spec-item">Scale <span class="spec-val">${state.scale}%</span></div>
+  `;
+}
+
+/* ===== SCHEDULE DRAW ===== */
+function scheduleDraw() {
+  if (pendingDraw) return;
+  pendingDraw = true;
+  requestAnimationFrame(()=>{ pendingDraw=false; drawStripe(); });
+}
+
+/* ===== BANDS UI ===== */
+function renderBands() {
+  const list = document.getElementById('bandsList');
+  list.innerHTML = '';
+  state.bands.forEach((band, i) => {
+    const row = document.createElement('div');
+    row.className = 'band-row';
+    row.draggable = true;
+    row.dataset.idx = i;
+
+    const colorSwatchBg = band.color;
+    row.innerHTML = `
+      <span class="band-drag-handle" title="Drag to reorder">&#8597;</span>
+      <span class="band-color-swatch" style="background:${colorSwatchBg}" title="${band.name}">
+        <input type="color" value="${band.color}" data-idx="${i}" aria-label="Band color">
+      </span>
+      <span class="band-name" title="${band.name}">${band.name}</span>
+      <input class="band-width-input" type="number" min="1" max="400" value="${band.w}" data-idx="${i}" aria-label="Band width">
+      <span class="band-px">px</span>
+      <button class="band-remove" data-idx="${i}" aria-label="Remove band">&times;</button>
+    `;
+
+    // Color input
+    const colorInput = row.querySelector('input[type=color]');
+    colorInput.addEventListener('input', e => {
+      const idx = +e.target.dataset.idx;
+      state.bands[idx].color = e.target.value;
+      // Update swatch background
+      e.target.parentElement.style.background = e.target.value;
+      // Try to find palette match
+      const match = PALETTE.find(p=>p.h.toLowerCase()===e.target.value.toLowerCase());
+      state.bands[idx].name = match ? match.n : 'Custom';
+      row.querySelector('.band-name').textContent = state.bands[idx].name;
+      row.querySelector('.band-name').title = state.bands[idx].name;
+      scheduleDraw();
+    });
+
+    // Width input
+    const widthInput = row.querySelector('.band-width-input');
+    widthInput.addEventListener('input', e => {
+      const idx = +e.target.dataset.idx;
+      const v = Math.max(1, Math.min(400, parseInt(e.target.value)||1));
+      state.bands[idx].w = v;
+      scheduleDraw();
+    });
+
+    // Remove
+    row.querySelector('.band-remove').addEventListener('click', e => {
+      const idx = +e.target.dataset.idx;
+      if (state.bands.length <= 1) { showToast('Need at least one band'); return; }
+      state.bands.splice(idx, 1);
+      renderBands(); scheduleDraw();
+    });
+
+    // Drag events
+    row.addEventListener('dragstart', e => {
+      dragSrc = i;
+      e.dataTransfer.effectAllowed = 'move';
+      row.classList.add('dragging');
+    });
+    row.addEventListener('dragend', () => { row.classList.remove('dragging'); });
+    row.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect='move'; });
+    row.addEventListener('drop', e => {
+      e.preventDefault();
+      if (dragSrc === null || dragSrc === i) return;
+      const moved = state.bands.splice(dragSrc, 1)[0];
+      state.bands.splice(i, 0, moved);
+      dragSrc = null;
+      renderBands(); scheduleDraw();
+    });
+
+    list.appendChild(row);
+  });
+}
+
+/* ===== ADD BAND ===== */
+document.getElementById('addBandBtn').addEventListener('click', () => {
+  const pal = PALETTE[Math.floor(Math.random()*PALETTE.length)];
+  state.bands.push({ color: pal.h, name: pal.n, w: 24 });
+  renderBands(); scheduleDraw();
+});
+
+/* ===== STYLE PILLS ===== */
+function renderStylePills() {
+  const container = document.getElementById('stylePills');
+  container.innerHTML = '';
+  STYLES.forEach(s => {
+    const btn = document.createElement('button');
+    btn.className = 'style-pill' + (s===state.style?' active':'');
+    btn.textContent = s;
+    btn.addEventListener('click', () => {
+      state.style = s;
+      renderStylePills();
+      scheduleDraw();
+    });
+    container.appendChild(btn);
+  });
+}
+
+/* ===== SCALE ===== */
+document.getElementById('scaleRange').addEventListener('input', e => {
+  state.scale = +e.target.value;
+  document.getElementById('scaleVal').textContent = state.scale + '%';
+  scheduleDraw();
+});
+
+/* ===== PALETTE ===== */
+function renderPalette() {
+  const grid = document.getElementById('paletteGrid');
+  grid.innerHTML = '';
+  PALETTE.forEach(p => {
+    const chip = document.createElement('div');
+    chip.className = 'pal-chip';
+    chip.style.background = p.h;
+    chip.title = p.n;
+    chip.setAttribute('aria-label', p.n);
+    chip.setAttribute('role','button');
+    chip.setAttribute('tabindex','0');
+    chip.innerHTML = `<span class="pal-tip">${p.n}</span>`;
+    chip.addEventListener('click', () => {
+      // Apply to selected band or last band
+      const idx = (selectedBandIdx !== null && selectedBandIdx < state.bands.length) ? selectedBandIdx : state.bands.length - 1;
+      state.bands[idx].color = p.h;
+      state.bands[idx].name = p.n;
+      renderBands(); scheduleDraw();
+      showToast(`Applied ${p.n} to band ${idx+1}`);
+    });
+    chip.addEventListener('keydown', e => { if(e.key==='Enter'||e.key===' ') chip.click(); });
+    grid.appendChild(chip);
+  });
+}
+
+/* ===== PRESETS ===== */
+function buildPresetSwatch(preset) {
+  const c = document.createElement('canvas'); c.width=40; c.height=20;
+  const cx = c.getContext('2d');
+  const total = preset.bands.reduce((s,b)=>s+b.w,0);
+  let x=0;
+  preset.bands.forEach(b=>{
+    cx.fillStyle=b.color;
+    cx.fillRect(x, 0, (b.w/total)*40, 20);
+    x += (b.w/total)*40;
+  });
+  return c.toDataURL();
+}
+
+function renderPresets() {
+  const list = document.getElementById('presetList');
+  list.innerHTML = '';
+  PRESETS.forEach((preset, i) => {
+    const btn = document.createElement('button');
+    btn.className = 'preset-btn';
+    const swatchSrc = buildPresetSwatch(preset);
+    btn.innerHTML = `<img class="preset-swatch" src="${swatchSrc}" alt="${preset.name}"><span>${preset.name}</span>`;
+    btn.addEventListener('click', () => {
+      state.bands = JSON.parse(JSON.stringify(preset.bands));
+      state.style = preset.style;
+      state.scale = preset.scale;
+      document.getElementById('scaleRange').value = preset.scale;
+      document.getElementById('scaleVal').textContent = preset.scale + '%';
+      renderBands(); renderStylePills(); scheduleDraw();
+      showToast(`Loaded: ${preset.name}`);
+    });
+    list.appendChild(btn);
+  });
+}
+
+/* ===== RANDOMIZE ===== */
+const LUXURY_COMBOS = [
+  ['#f5f0e6','#c9a961','#ede8dc','#c9a961'],
+  ['#f0eada','#2a3655','#f0eada','#2a3655','#f0eada'],
+  ['#ede8dc','#4a2840','#ede8dc'],
+  ['#e8e4dc','#5a6070','#e8e4dc','#5a6070','#e8e4dc'],
+  ['#f0eada','#8aaa88','#f0eada','#8aaa88'],
+  ['#1c1a17','#c9a961','#1c1a17'],
+  ['#e2d9c8','#3a5038','#e2d9c8','#c9a961','#e2d9c8'],
+];
+const LUXURY_WIDTHS = [
+  [28,4,52,4,52,4,28],
+  [44,4,10,2,10,4],
+  [60,8,60],
+  [50,2,20,2,50],
+  [40,4,40,4],
+  [50,6,50],
+  [36,4,60,2,60,4,36],
+];
+const STYLE_OPTS = STYLES;
+
+document.getElementById('randomizeBtn').addEventListener('click', () => {
+  const ci = Math.floor(Math.random()*LUXURY_COMBOS.length);
+  const colors = LUXURY_COMBOS[ci];
+  const wi = Math.floor(Math.random()*LUXURY_WIDTHS.length);
+  const widths = LUXURY_WIDTHS[wi];
+  const len = Math.min(colors.length, widths.length);
+  state.bands = [];
+  for (let i=0;i<len;i++) {
+    const match = PALETTE.find(p=>p.h===colors[i%colors.length]);
+    state.bands.push({color:colors[i%colors.length], name:match?match.n:'Custom', w:widths[i%widths.length]});
+  }
+  state.style = STYLE_OPTS[Math.floor(Math.random()*STYLE_OPTS.length)];
+  state.scale = [70,80,100,110,120][Math.floor(Math.random()*5)];
+  document.getElementById('scaleRange').value = state.scale;
+  document.getElementById('scaleVal').textContent = state.scale + '%';
+  renderBands(); renderStylePills(); scheduleDraw();
+  showToast('Tasteful stripe generated');
+});
+
+/* ===== MIRROR ===== */
+document.getElementById('mirrorBtn').addEventListener('click', () => {
+  const mirrored = [...state.bands].reverse();
+  state.bands = [...state.bands, ...mirrored];
+  renderBands(); scheduleDraw();
+  showToast('Mirrored — pattern doubled');
+});
+
+/* ===== TEXTURE + REPEAT TOGGLES ===== */
+document.getElementById('textureCb').addEventListener('change', e => {
+  state.texture = e.target.checked; scheduleDraw();
+});
+document.getElementById('repeatCb').addEventListener('change', e => {
+  state.repeat = e.target.checked; scheduleDraw();
+});
+
+/* ===== DOWNLOAD ===== */
+document.getElementById('dlBtn').addEventListener('click', () => {
+  // Draw a clean tile at 2x for download
+  const sc = state.scale/100;
+  const tilePx = Math.round(totalBandPx() * sc);
+  if (tilePx < 2) { showToast('Add some bands first'); return; }
+  const tileH = Math.max(tilePx * 3, 300);
+  const tc = document.createElement('canvas');
+  tc.width = tilePx * 2; tc.height = tileH * 2;
+  const tcx = tc.getContext('2d');
+
+  const bands = state.bands;
+  const style = state.style;
+  const tsc = sc * 2;
+  for (let col=0; col<3; col++) {
+    let bx = col * tilePx * 2;
+    for (let bi=0; bi<bands.length; bi++) {
+      const bw = bands[bi].w * tsc;
+      if (style === 'Ombré') {
+        const nextBand = bands[(bi+1)%bands.length];
+        const g = tcx.createLinearGradient(bx,0,bx+bw,0);
+        g.addColorStop(0, bands[bi].color); g.addColorStop(1, nextBand.color);
+        tcx.fillStyle = g;
+      } else {
+        tcx.fillStyle = bands[bi].color;
+      }
+      tcx.fillRect(bx, 0, bw, tileH*2);
+      if (style==='Metallic-edge') {
+        const mw = Math.max(2, bw*0.08);
+        const mg = tcx.createLinearGradient(bx,0,bx+mw,0);
+        mg.addColorStop(0,'rgba(255,255,255,0)'); mg.addColorStop(0.5,'rgba(255,255,255,0.35)'); mg.addColorStop(1,'rgba(255,255,255,0)');
+        tcx.fillStyle=mg; tcx.fillRect(bx,0,mw,tileH*2);
+      }
+      bx += bw;
+    }
+  }
+  if (state.texture) {
+    const grain = makeNoiseCanvas(tilePx*2, tileH*2, 28);
+    tcx.drawImage(grain, 0, 0);
+  }
+
+  const link = document.createElement('a');
+  link.download = 'dw-stripe-tile.png';
+  link.href = tc.toDataURL('image/png');
+  link.click();
+  showToast('Seamless tile downloaded');
+});
+
+/* ===== TOAST ===== */
+function showToast(msg) {
+  const t = document.getElementById('toast');
+  t.textContent = msg;
+  t.classList.add('show');
+  clearTimeout(t._timer);
+  t._timer = setTimeout(()=>t.classList.remove('show'), 2200);
+}
+
+/* ===== RESIZE ===== */
+let resizeTimer;
+window.addEventListener('resize', () => {
+  clearTimeout(resizeTimer);
+  resizeTimer = setTimeout(scheduleDraw, 120);
+});
+
+/* ===== KEYBOARD ===== */
+document.addEventListener('keydown', e => {
+  if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
+    if (document.activeElement && document.activeElement.classList.contains('band-width-input')) return;
+    const si = STYLES.indexOf(state.style);
+    if (e.key === 'ArrowUp') state.style = STYLES[(si-1+STYLES.length)%STYLES.length];
+    else state.style = STYLES[(si+1)%STYLES.length];
+    renderStylePills(); scheduleDraw(); e.preventDefault();
+  }
+});
+
+/* ===== INIT ===== */
+function init() {
+  renderPresets();
+  renderPalette();
+  renderStylePills();
+  renderBands();
+  scheduleDraw();
+}
+
+init();
+</script>
+</body>
+</html>
diff --git a/public/suite.html b/public/suite.html
new file mode 100644
index 0000000..cac8521
--- /dev/null
+++ b/public/suite.html
@@ -0,0 +1,100 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Designer Wallcoverings — Digital Suite</title>
+<style>
+  :root{
+    --bg:#f4f1ea; --panel:#fbf9f4; --ink:#1c1a17; --muted:#7a6f60; --line:#e2dccf;
+    --accent:#c9a961; --accent-deep:#a9884f; --shadow:0 10px 40px -18px rgba(28,26,23,.28);
+    --serif:'Cormorant Garamond','Playfair Display','EB Garamond',Georgia,serif;
+    --sans:'Jost','Avenir Next','Helvetica Neue',system-ui,sans-serif;
+    --ease:cubic-bezier(.4,0,.2,1);
+  }
+  *{box-sizing:border-box}
+  body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);-webkit-font-smoothing:antialiased}
+  a{color:inherit;text-decoration:none}
+  header{padding:34px 40px 26px;border-bottom:1px solid var(--line);position:sticky;top:0;background:#f4f1eae8;backdrop-filter:blur(8px);z-index:5}
+  .wordmark{font-family:var(--serif);font-weight:600;text-transform:uppercase;letter-spacing:.18em;font-size:40px;line-height:1;color:var(--ink)}
+  .sub{font-family:var(--serif);font-style:italic;color:var(--muted);font-size:18px;margin-top:6px}
+  .count{float:right;font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--muted);margin-top:10px}
+  main{max-width:1240px;margin:0 auto;padding:44px 40px 90px}
+  .cat{margin:0 0 46px}
+  .cat h2{font-family:var(--serif);font-size:26px;font-weight:600;margin:0 0 4px;letter-spacing:.01em}
+  .cat .rule{height:1px;background:var(--line);margin:14px 0 22px}
+  .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:22px}
+  .card{background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden;cursor:pointer;
+    transition:transform .18s var(--ease),box-shadow .18s var(--ease),border-color .18s var(--ease);display:flex;flex-direction:column}
+  .card:hover{transform:translateY(-4px);box-shadow:var(--shadow);border-color:var(--accent)}
+  .thumb{aspect-ratio:16/10;background:#efeadf center/cover no-repeat;border-bottom:1px solid var(--line);position:relative;overflow:hidden}
+  .thumb .mono{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
+    font-family:var(--serif);font-size:34px;letter-spacing:.12em;color:var(--accent-deep);opacity:.5}
+  .thumb .play{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:34px;color:#fff;background:#1c1a1755;opacity:0;transition:opacity .18s}
+  .card:hover .thumb .play{opacity:1}
+  .meta{padding:14px 16px 16px}
+  .meta .t{font-family:var(--serif);font-size:19px;margin:0 0 6px;line-height:1.25}
+  .meta .n{font-size:12.5px;color:var(--muted);line-height:1.5}
+  footer{padding:22px 40px;border-top:1px solid var(--line);color:var(--muted);font-size:11.5px;letter-spacing:.04em}
+  /* play overlay */
+  #ov{position:fixed;inset:0;background:#12100be8;z-index:20;display:none;flex-direction:column}
+  #ov.on{display:flex}
+  #ovbar{display:flex;align-items:center;gap:14px;padding:12px 20px;border-bottom:1px solid #3a342a}
+  #ovbar b{font-family:var(--serif);color:var(--accent);letter-spacing:.06em}
+  #ovbar .sp{flex:1}
+  #ovbar a,#ovbar .x{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:#d9cfbb;border:1px solid #4a4436;border-radius:6px;padding:4px 12px;cursor:pointer}
+  #ovbar .x:hover,#ovbar a:hover{border-color:var(--accent);color:#fff}
+  #ovframe{flex:1;width:100%;border:0;background:#000}
+</style>
+</head>
+<body>
+<header>
+  <div class="wordmark">Designer Wallcoverings</div>
+  <div class="sub">The Digital Suite <span class="count" id="count">loading…</span></div>
+</header>
+
+<main id="main"></main>
+
+<footer>An agent-built suite of tools, games &amp; social builders for Designer Wallcoverings — each a self-contained luxury single-file build. Also in the full arcade at <a href="/arcade" style="color:var(--accent-deep)">/arcade</a>.</footer>
+
+<div id="ov">
+  <div id="ovbar"><b id="ovtitle"></b><span class="sp"></span><a id="ovnew" target="_blank">open ↗</a><span class="x" id="ovx">✕ close</span></div>
+  <iframe id="ovframe" sandbox="allow-scripts allow-pointer-lock"></iframe>
+</div>
+
+<script>
+const $=s=>document.querySelector(s);
+const ORDER=['Tools','Games','Social & Editorial','Art Toys','Classics'];
+fetch('/api/arcade').then(r=>r.json()).then(d=>{
+  // DW suite = featured builds whose slug starts dw- (standalone featured), grouped by category
+  const dw=(d.games||[]).filter(g=>g.standalone && String(g.id).startsWith('featured-dw-'));
+  $('#count').textContent=dw.length+' builds';
+  const by={};
+  for(const g of dw){ (by[g.category||'Featured']=by[g.category||'Featured']||[]).push(g); }
+  const cats=Object.keys(by).sort((a,b)=>{
+    const ia=ORDER.indexOf(a),ib=ORDER.indexOf(b);
+    return (ia<0?99:ia)-(ib<0?99:ib);
+  });
+  const main=$('#main'); main.innerHTML='';
+  for(const c of cats){
+    const sec=document.createElement('section'); sec.className='cat';
+    sec.innerHTML=`<h2>${c}</h2><div class="rule"></div><div class="grid"></div>`;
+    const grid=sec.querySelector('.grid');
+    for(const g of by[c]){
+      const el=document.createElement('div'); el.className='card';
+      const initials=(g.title||'DW').replace(/^DW\s*/,'').split(/\s+/).slice(0,2).map(w=>w[0]||'').join('').toUpperCase()||'DW';
+      el.innerHTML=`<div class="thumb"><div class="mono">${initials}</div><div class="play">▶</div></div>
+        <div class="meta"><p class="t">${(g.title||'').replace(/^DW\s*/,'')}</p><p class="n">${g.note||''}</p></div>`;
+      el.onclick=()=>play(g);
+      grid.appendChild(el);
+    }
+    main.appendChild(sec);
+  }
+}).catch(()=>{ $('#count').textContent='failed to load'; });
+
+function play(g){ $('#ovtitle').textContent=g.title; $('#ovnew').href=g.play; $('#ovframe').src=g.play; $('#ov').classList.add('on'); }
+$('#ovx').onclick=()=>{ $('#ov').classList.remove('on'); $('#ovframe').src='about:blank'; };
+document.addEventListener('keydown',e=>{ if(e.key==='Escape') $('#ovx').click(); });
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
index c353232..eacfb60 100644
--- a/server.js
+++ b/server.js
@@ -913,7 +913,7 @@ iframe{width:100%;height:460px;border:0;background:#000}.c.win iframe{height:560
       if (!fs.existsSync(path.join(DIR, 'public', 'games', g.slug + '.html'))) continue;
       games.push({
         id: 'featured-' + g.slug, title: g.title, model: g.credit || '', label: mLabelServer(g.credit || ''),
-        created_at: null, category: 'Featured', crowned: true, aiPick: false, aiScore: null,
+        created_at: null, category: g.category || 'Featured', crowned: true, aiPick: false, aiScore: null,
         play: '/game/' + g.slug, thumb: null, standalone: true, note: g.note || '',
       });
     }
@@ -925,6 +925,12 @@ iframe{width:100%;height:460px;border:0;background:#000}.c.win iframe{height:560
     res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
     return res.end(fs.readFileSync(fp));
   }
+  if (p === '/suite' || p === '/suite.html' || p === '/dw') {
+    const fp = path.join(DIR, 'public', 'suite.html');
+    if (!fs.existsSync(fp)) { res.writeHead(404); return res.end('no suite'); }
+    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
+    return res.end(fs.readFileSync(fp));
+  }
   if ((m = p.match(/^\/game\/([a-z0-9_-]+)$/i))) {
     const fp = path.join(DIR, 'public', 'games', path.basename(m[1]) + '.html');
     if (!fs.existsSync(fp)) { res.writeHead(404); return res.end('no game'); }

← c318ca7 Agent-driven tool: DW Texture Weave Studio (frontend-develop  ·  back to Model Arena  ·  Agent-driven game: DW Parquet (frontend-developer) — herring 170d67a →