← 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
M data/arcade-games.jsonA public/games/dw-spot-the-pattern.htmlA public/games/dw-stripe-studio.htmlA public/suite.htmlM server.js
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">↕</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">×</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 & 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 →