← back to Model Arena
Agent-driven game: DW Parquet (frontend-developer) — herringbone sliding puzzle, wood-grain SVG, 3 sizes. Registered.
170d67a1f25b145f3740000616866f70ec92754d · 2026-07-25 12:32:38 -0700 · Steve Abrams
Files touched
M data/arcade-games.jsonA data/artifacts/724633a205b0/qwen3-14b.pngM data/challenges.jsonA public/games/dw-chinoiserie-studio.htmlA public/games/dw-parquet.html
Diff
commit 170d67a1f25b145f3740000616866f70ec92754d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat Jul 25 12:32:38 2026 -0700
Agent-driven game: DW Parquet (frontend-developer) — herringbone sliding puzzle, wood-grain SVG, 3 sizes. Registered.
---
data/arcade-games.json | 7 +
data/artifacts/724633a205b0/qwen3-14b.png | Bin 0 -> 310129 bytes
data/challenges.json | 3 +-
public/games/dw-chinoiserie-studio.html | 893 ++++++++++++++++++++++++++++++
public/games/dw-parquet.html | 733 ++++++++++++++++++++++++
5 files changed, 1635 insertions(+), 1 deletion(-)
diff --git a/data/arcade-games.json b/data/arcade-games.json
index 3c16199..3f0957a 100644
--- a/data/arcade-games.json
+++ b/data/arcade-games.json
@@ -153,6 +153,13 @@
"credit": "frontend-developer",
"note": "Agent-built tool \u2014 custom stripe wallcovering designer, 6 styles, drag bands, room preview, seamless PNG",
"category": "Tools"
+ },
+ {
+ "slug": "dw-parquet",
+ "title": "DW Parquet",
+ "credit": "frontend-developer",
+ "category": "Games",
+ "note": "Agent-built sliding puzzle \u2014 reassemble a herringbone/parquet wood-grain panel, 3x3/4x4/5x5, peek + timer"
}
]
}
\ No newline at end of file
diff --git a/data/artifacts/724633a205b0/qwen3-14b.png b/data/artifacts/724633a205b0/qwen3-14b.png
new file mode 100644
index 0000000..59a1674
Binary files /dev/null and b/data/artifacts/724633a205b0/qwen3-14b.png differ
diff --git a/data/challenges.json b/data/challenges.json
index 856e972..cda0c1b 100644
--- a/data/challenges.json
+++ b/data/challenges.json
@@ -14437,7 +14437,8 @@
"started_at": "2026-07-25T07:35:21.628Z",
"finished_at": "2026-07-25T07:35:48.331Z",
"queued_at": "2026-07-25T07:27:36.977Z",
- "bytes": 3574
+ "bytes": 3574,
+ "thumb": true
},
{
"model": "gemma3-12b",
diff --git a/public/games/dw-chinoiserie-studio.html b/public/games/dw-chinoiserie-studio.html
new file mode 100644
index 0000000..1897fe0
--- /dev/null
+++ b/public/games/dw-chinoiserie-studio.html
@@ -0,0 +1,893 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Chinoiserie Studio — Designer Wallcoverings</title>
+<style>
+:root{
+ --bg:#f4f1ea;--bg2:#edeae0;--ink:#1c1a17;--ink2:#3a3630;
+ --accent:#c9a961;--accent2:#a8863e;--silk:#f9f6ef;
+ --serif:'Cormorant Garamond','Playfair Display','EB Garamond',Georgia,serif;
+ --sans:'Optima','Gill Sans','Trebuchet MS',sans-serif;
+ --ease:cubic-bezier(.4,0,.2,1);
+ --radius:3px;
+ --panel-w:260px;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html,body{height:100%;background:var(--bg);color:var(--ink);font-family:var(--sans)}
+header{
+ position:fixed;top:0;left:0;right:0;z-index:100;
+ background:var(--bg);border-bottom:1px solid rgba(201,169,97,.35);
+ display:flex;align-items:center;gap:16px;
+ padding:10px 20px;
+}
+.wordmark{
+ font-family:var(--serif);font-size:22px;font-weight:400;
+ text-transform:uppercase;letter-spacing:.18em;color:var(--ink);
+ white-space:nowrap;line-height:1.1;
+}
+.wordmark span{display:block;font-size:10px;letter-spacing:.25em;color:var(--accent2);margin-top:1px}
+.header-sep{flex:1}
+.header-btn{
+ background:none;border:1px solid var(--accent);color:var(--accent2);
+ font-family:var(--sans);font-size:11px;letter-spacing:.1em;
+ padding:5px 12px;border-radius:var(--radius);cursor:pointer;
+ transition:background .2s var(--ease),color .2s var(--ease);
+}
+.header-btn:hover{background:var(--accent);color:#fff}
+.header-btn.primary{background:var(--accent);color:#fff;border-color:var(--accent)}
+.header-btn.primary:hover{background:var(--accent2)}
+
+/* LAYOUT */
+#app{display:flex;height:100vh;padding-top:50px}
+#sidebar{
+ width:var(--panel-w);min-width:var(--panel-w);
+ background:var(--bg2);border-right:1px solid rgba(28,26,23,.1);
+ display:flex;flex-direction:column;overflow-y:auto;
+}
+#stage{flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;background:#2a2520;padding:20px}
+canvas#panel{display:none}/* hidden; SVG is primary */
+#svg-wrap{position:relative;user-select:none}
+#silk{display:block;box-shadow:0 8px 40px rgba(0,0,0,.45);cursor:crosshair}
+#overlay{position:absolute;top:0;left:0;pointer-events:none}
+
+/* SIDEBAR */
+.sb-section{padding:14px 14px 8px;border-bottom:1px solid rgba(28,26,23,.1)}
+.sb-label{font-size:9px;letter-spacing:.2em;text-transform:uppercase;color:var(--accent2);margin-bottom:8px}
+.grounds{display:flex;flex-wrap:wrap;gap:7px}
+.ground-swatch{
+ width:36px;height:36px;border-radius:50%;cursor:pointer;border:3px solid transparent;
+ transition:border-color .2s var(--ease),transform .15s var(--ease);
+}
+.ground-swatch:hover{transform:scale(1.12)}
+.ground-swatch.active{border-color:var(--accent)}
+.tools-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px}
+.tool-btn{
+ background:var(--bg);border:1px solid rgba(28,26,23,.15);
+ border-radius:var(--radius);padding:8px 6px 6px;
+ cursor:pointer;text-align:center;transition:all .15s var(--ease);
+ display:flex;flex-direction:column;align-items:center;gap:4px;
+}
+.tool-btn:hover{border-color:var(--accent);background:#fff}
+.tool-btn.active{border-color:var(--accent);background:rgba(201,169,97,.12);box-shadow:0 0 0 2px rgba(201,169,97,.25)}
+.tool-btn svg{display:block}
+.tool-name{font-size:9px;letter-spacing:.08em;color:var(--ink2);line-height:1.2}
+.actions{display:flex;flex-direction:column;gap:7px;padding:12px 14px}
+.act-btn{
+ width:100%;padding:8px 10px;font-family:var(--sans);font-size:11px;
+ letter-spacing:.06em;border-radius:var(--radius);cursor:pointer;
+ border:1px solid rgba(28,26,23,.2);background:var(--bg);color:var(--ink);
+ transition:all .15s var(--ease);display:flex;align-items:center;gap:7px;
+}
+.act-btn:hover{border-color:var(--accent);background:rgba(201,169,97,.08)}
+.act-btn.compose{background:var(--ink);color:var(--silk);border-color:var(--ink)}
+.act-btn.compose:hover{background:var(--ink2)}
+.act-btn span{font-size:15px;line-height:1}
+
+/* SELECTION HANDLES */
+.sel-handle{
+ position:absolute;width:10px;height:10px;background:#fff;
+ border:2px solid var(--accent);border-radius:50%;cursor:pointer;
+ transform:translate(-50%,-50%);
+}
+.sel-ring{
+ position:absolute;border:1.5px dashed var(--accent);pointer-events:none;
+ border-radius:2px;
+}
+
+/* TOAST */
+#toast{
+ position:fixed;bottom:24px;left:50%;transform:translateX(-50%) translateY(20px);
+ background:var(--ink);color:var(--silk);font-size:12px;letter-spacing:.08em;
+ padding:8px 20px;border-radius:20px;opacity:0;transition:all .3s var(--ease);
+ pointer-events:none;z-index:200;
+}
+#toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
+
+/* RESPONSIVE */
+@media(max-width:600px){
+ :root{--panel-w:200px}
+ .wordmark{font-size:14px}
+ #stage{padding:8px}
+}
+</style>
+</head>
+<body>
+
+<header>
+ <div class="wordmark">
+ Designer Wallcoverings
+ <span>Chinoiserie Studio</span>
+ </div>
+ <div class="header-sep"></div>
+ <button class="header-btn" id="undoBtn" title="Undo (Ctrl+Z)">↶ Undo</button>
+ <button class="header-btn" id="redoBtn" title="Redo (Ctrl+Y)">↷ Redo</button>
+ <button class="header-btn primary" id="dlBtn">↓ Export PNG</button>
+</header>
+
+<div id="app">
+ <div id="sidebar">
+
+ <div class="sb-section">
+ <div class="sb-label">Silk Ground</div>
+ <div class="grounds" id="grounds"></div>
+ </div>
+
+ <div class="sb-section">
+ <div class="sb-label">Elements — Click to select tool, then click canvas</div>
+ <div class="tools-grid" id="toolGrid"></div>
+ </div>
+
+ <div class="actions">
+ <button class="act-btn compose" id="composeBtn"><span>✹</span>Compose for Me</button>
+ <button class="act-btn" id="clearBtn"><span>□</span>Clear Canvas</button>
+ <button class="act-btn" id="deleteSelBtn"><span>⌫</span>Delete Selected</button>
+ <button class="act-btn" id="bringFwdBtn"><span>⬆</span>Bring Forward</button>
+ <button class="act-btn" id="sendBkBtn"><span>⬇</span>Send Backward</button>
+ </div>
+
+ </div>
+
+ <div id="stage">
+ <div id="svg-wrap">
+ <svg id="silk" xmlns="http://www.w3.org/2000/svg"></svg>
+ <div id="overlay"></div>
+ </div>
+ </div>
+</div>
+
+<div id="toast"></div>
+
+<script>
+// ─── CONFIG ───────────────────────────────────────────────────────────────────
+const GROUNDS = [
+ {id:'celadon', label:'Celadon', color:'#a8c5b0', tint:'#7aa68a'},
+ {id:'blush', label:'Blush', color:'#e8c5b8', tint:'#c9906f'},
+ {id:'ink', label:'Ink', color:'#1d2535', tint:'#3a4a60'},
+ {id:'goldtea', label:'Gold Tea',color:'#d4b878', tint:'#a8883a'},
+ {id:'aubergine',label:'Aubergine',color:'#4a2d4e',tint:'#7a4d82'},
+];
+
+// ─── SVG ELEMENT LIBRARY (hand-painted chinoiserie style) ─────────────────────
+const ELEMENTS = {
+ blossom:{
+ label:'Blossom Branch',
+ w:160,h:180,
+ svg:(t)=>`
+ <g stroke="${t.ink}" stroke-linecap="round" fill="none">
+ <path d="M80,170 Q65,130 50,90 Q35,55 60,20" stroke-width="2.5"/>
+ <path d="M50,90 Q30,70 15,50" stroke-width="1.8"/>
+ <path d="M60,55 Q80,35 95,18" stroke-width="1.5"/>
+ <path d="M50,90 Q70,80 85,65" stroke-width="1.5"/>
+ </g>
+ ${blossom(t,50,30,10)}${blossom(t,62,45,9)}${blossom(t,95,18,8)}
+ ${blossom(t,18,52,9)}${blossom(t,30,38,7)}${blossom(t,82,65,8)}
+ ${petals(t,40,58,6)}${petals(t,72,30,5)}
+ `
+ },
+ plum:{
+ label:'Plum Branch',
+ w:150,h:170,
+ svg:(t)=>`
+ <g stroke="${t.ink}" stroke-linecap="round" fill="none">
+ <path d="M75,165 Q80,120 90,80 Q105,40 85,10" stroke-width="2.8"/>
+ <path d="M90,80 Q115,65 130,45" stroke-width="1.8"/>
+ <path d="M85,35 Q65,22 48,30" stroke-width="1.5"/>
+ </g>
+ ${plumFlower(t,83,10,10)}${plumFlower(t,45,30,9)}${plumFlower(t,130,45,8)}
+ ${plumFlower(t,115,65,7)}${plumFlower(t,95,52,9)}${plumFlower(t,60,70,7)}
+ `
+ },
+ songbird:{
+ label:'Songbird',
+ w:80,h:70,
+ svg:(t)=>`
+ <g fill="${t.accent}" opacity=".9">
+ <ellipse cx="40" cy="38" rx="22" ry="13"/>
+ <ellipse cx="57" cy="32" rx="13" ry="9"/>
+ </g>
+ <ellipse cx="40" cy="38" rx="22" ry="13" fill="${t.accent}" opacity=".2"/>
+ <g stroke="${t.ink}" fill="none" stroke-width="1">
+ <ellipse cx="40" cy="38" rx="22" ry="13"/>
+ <ellipse cx="57" cy="32" rx="13" ry="9"/>
+ <path d="M57,23 Q62,15 70,14 Q65,20 63,23"/>
+ <circle cx="65" cy="28" r="2" fill="${t.ink}"/>
+ <path d="M18,36 Q8,50 12,60 Q20,52 22,42"/>
+ <path d="M15,40 Q5,48 10,55"/>
+ <path d="M35,50 L30,62 M40,51 L38,63 M45,50 L43,62" stroke-width="1.2"/>
+ </g>
+ `
+ },
+ crane:{
+ label:'Crane',
+ w:120,h:160,
+ svg:(t)=>`
+ <g stroke="${t.ink}" fill="none" stroke-linecap="round">
+ <path d="M60,130 Q58,100 55,75 Q52,55 60,38" stroke-width="1.5"/>
+ <ellipse cx="60" cy="35" rx="14" ry="10" fill="white" stroke="${t.ink}" stroke-width="1.2"/>
+ <path d="M60,25 L65,15" stroke-width="1.8"/>
+ <rect x="63" y="12" width="9" height="3" rx="1" fill="${t.accent}" stroke="${t.accent}" stroke-width=".5"/>
+ <circle cx="68" cy="22" r="2.5" fill="${t.ink}"/>
+ <circle cx="57" cy="22" r="3.5" fill="#c0392b"/>
+ <path d="M55,75 Q30,65 10,80 Q25,72 38,80 Q50,75 55,75" fill="${t.silk}" stroke="${t.ink}" stroke-width="1"/>
+ <path d="M55,75 Q80,60 110,68 Q90,64 75,74 Q65,75 55,75" fill="${t.silk}" stroke="${t.ink}" stroke-width="1"/>
+ <path d="M58,130 L45,155 M60,130 L62,155 M62,130 L72,152" stroke-width="1.5"/>
+ <path d="M45,155 Q38,160 35,158 M62,155 Q64,162 62,160 M72,152 Q79,158 77,160" stroke-width="1.2"/>
+ </g>
+ `
+ },
+ butterfly:{
+ label:'Butterfly',
+ w:100,h:80,
+ svg:(t)=>`
+ <g opacity=".88">
+ <path d="M50,40 Q28,20 10,28 Q5,42 22,50 Q36,54 50,40Z" fill="${t.accent}" opacity=".7" stroke="${t.ink}" stroke-width=".8"/>
+ <path d="M50,40 Q72,20 90,28 Q95,42 78,50 Q64,54 50,40Z" fill="${t.accent}" opacity=".7" stroke="${t.ink}" stroke-width=".8"/>
+ <path d="M50,40 Q33,48 22,62 Q30,68 44,58 Q50,50 50,40Z" fill="${t.tint}" opacity=".55" stroke="${t.ink}" stroke-width=".8"/>
+ <path d="M50,40 Q67,48 78,62 Q70,68 56,58 Q50,50 50,40Z" fill="${t.tint}" opacity=".55" stroke="${t.ink}" stroke-width=".8"/>
+ <g fill="none" stroke="${t.ink}" stroke-width=".6" opacity=".6">
+ <path d="M50,40 Q35,30 18,33"/>
+ <path d="M50,40 Q65,30 82,33"/>
+ <path d="M50,40 Q40,52 32,60"/>
+ <path d="M50,40 Q60,52 68,60"/>
+ </g>
+ <ellipse cx="50" cy="42" rx="3" ry="10" fill="${t.ink}"/>
+ <path d="M50,32 Q45,22 42,16 M50,32 Q55,22 58,16" stroke="${t.ink}" stroke-width=".9" fill="none"/>
+ </g>
+ `
+ },
+ pagoda:{
+ label:'Pagoda',
+ w:110,h:170,
+ svg:(t)=>`
+ <g stroke="${t.ink}" fill="none" stroke-width="1">
+ <rect x="38" y="140" width="34" height="25" fill="${t.tint}" opacity=".3" stroke="${t.ink}"/>
+ <path d="M38,140 Q55,132 72,140" fill="${t.tint}" opacity=".4"/>
+ <rect x="30" y="108" width="50" height="34" fill="${t.tint}" opacity=".25"/>
+ <path d="M25,108 Q55,96 85,108" fill="${t.tint}" opacity=".35"/>
+ <path d="M22,108 L8,112 M88,108 L102,112" stroke-width=".8"/>
+ <rect x="20" y="72" width="70" height="38" fill="${t.tint}" opacity=".2"/>
+ <path d="M15,72 Q55,56 95,72" fill="${t.tint}" opacity=".3"/>
+ <path d="M12,72 L-2,76 M98,72 L112,76" stroke-width=".8"/>
+ <rect x="28" y="38" width="54" height="36" fill="${t.tint}" opacity=".18"/>
+ <path d="M22,38 Q55,22 88,38" fill="${t.tint}" opacity=".28"/>
+ <path d="M18,38 L5,42 M92,38 L105,42" stroke-width=".8"/>
+ <path d="M55,22 L55,8" stroke-width="1.5"/>
+ <path d="M48,12 Q55,4 62,12" fill="${t.accent}" opacity=".6"/>
+ <line x1="38" y1="148" x2="72" y2="148" stroke-width=".7"/>
+ <line x1="30" y1="122" x2="80" y2="122" stroke-width=".7"/>
+ <line x1="20" y1="90" x2="90" y2="90" stroke-width=".7"/>
+ </g>
+ `
+ },
+ rock:{
+ label:'Garden Rock',
+ w:100,h:90,
+ svg:(t)=>`
+ <g stroke="${t.ink}" stroke-width="1.2" fill="none">
+ <path d="M50,82 Q18,85 10,60 Q6,42 22,28 Q35,16 52,18 Q70,14 82,30 Q95,48 88,65 Q80,80 50,82Z" fill="${t.tint}" opacity=".22"/>
+ <path d="M50,82 Q18,85 10,60 Q6,42 22,28 Q35,16 52,18 Q70,14 82,30 Q95,48 88,65 Q80,80 50,82Z"/>
+ <path d="M22,28 Q32,22 45,25 Q55,20 68,25" stroke-width=".8" opacity=".6"/>
+ <path d="M10,60 Q18,48 15,38" stroke-width=".8" opacity=".5"/>
+ <path d="M88,65 Q84,52 90,42" stroke-width=".8" opacity=".5"/>
+ <path d="M35,82 Q28,70 32,55 Q38,42 50,38 Q62,42 68,55 Q72,70 65,82" stroke-width=".9" fill="${t.tint}" opacity=".18"/>
+ <circle cx="38" cy="48" r="3" fill="${t.tint}" opacity=".4"/>
+ <circle cx="62" cy="42" r="2" fill="${t.tint}" opacity=".4"/>
+ </g>
+ `
+ },
+ peony:{
+ label:'Peony',
+ w:110,h:110,
+ svg:(t)=>`
+ <g fill="${t.accent}" stroke="${t.ink}" stroke-width=".8">
+ ${peonyPetals(t)}
+ </g>
+ <g stroke="${t.ink}" fill="none" stroke-width="1">
+ <path d="M55,90 Q40,100 30,108"/>
+ <path d="M55,90 Q60,102 65,110"/>
+ <path d="M55,90 Q70,98 80,105"/>
+ </g>
+ `
+ },
+ bamboo:{
+ label:'Bamboo',
+ w:80,h:200,
+ svg:(t)=>`
+ <g stroke="${t.ink}" fill="none" stroke-linecap="round">
+ <rect x="28" y="10" width="14" height="42" rx="5" fill="${t.tint}" opacity=".3" stroke="${t.ink}" stroke-width="1"/>
+ <rect x="28" y="54" width="14" height="40" rx="4" fill="${t.tint}" opacity=".25" stroke="${t.ink}" stroke-width="1"/>
+ <rect x="28" y="96" width="14" height="44" rx="4" fill="${t.tint}" opacity=".2" stroke="${t.ink}" stroke-width="1"/>
+ <rect x="28" y="142" width="14" height="42" rx="4" fill="${t.tint}" opacity=".15" stroke="${t.ink}" stroke-width="1"/>
+ <line x1="28" y1="54" x2="42" y2="54" stroke-width="1.5"/>
+ <line x1="28" y1="96" x2="42" y2="96" stroke-width="1.5"/>
+ <line x1="28" y1="142" x2="42" y2="142" stroke-width="1.5"/>
+ <rect x="44" y="30" width="10" height="32" rx="4" fill="${t.tint}" opacity=".2" stroke="${t.ink}" stroke-width=".8"/>
+ <rect x="44" y="64" width="10" height="35" rx="4" fill="${t.tint}" opacity=".18" stroke="${t.ink}" stroke-width=".8"/>
+ <line x1="44" y1="64" x2="54" y2="64" stroke-width="1.2"/>
+ ${bambooLeaf(t,42,52,1)}${bambooLeaf(t,28,94,-1)}${bambooLeaf(t,42,138,1)}${bambooLeaf(t,28,54,-0.5)}
+ </g>
+ `
+ }
+};
+
+// ─── HELPER SVG PRIMITIVES ────────────────────────────────────────────────────
+function blossom(t,cx,cy,r){
+ const c=t.accent;
+ return `<g>
+ ${[0,72,144,216,288].map(a=>{
+ const rx=Math.cos(a*Math.PI/180)*r*1.1,ry=Math.sin(a*Math.PI/180)*r*1.1;
+ return `<ellipse cx="${cx+rx}" cy="${cy+ry}" rx="${r*.7}" ry="${r*.45}" fill="${c}" opacity=".75" stroke="${t.ink}" stroke-width=".6" transform="rotate(${a},${cx+rx},${cy+ry})"/>`;
+ }).join('')}
+ <circle cx="${cx}" cy="${cy}" r="${r*.35}" fill="#f0d070" stroke="${t.ink}" stroke-width=".5"/>
+ </g>`;
+}
+function petals(t,cx,cy,r){
+ return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${t.accent}" opacity=".5" stroke="${t.ink}" stroke-width=".7"/>
+ <circle cx="${cx}" cy="${cy}" r="${r*.4}" fill="#f5d060" stroke="${t.ink}" stroke-width=".4"/>`;
+}
+function plumFlower(t,cx,cy,r){
+ return [0,72,144,216,288].map(a=>{
+ const rx=Math.cos(a*Math.PI/180)*r,ry=Math.sin(a*Math.PI/180)*r;
+ return `<circle cx="${cx+rx}" cy="${cy+ry}" r="${r*.65}" fill="${t.accent}" opacity=".8" stroke="${t.ink}" stroke-width=".6"/>`;
+ }).join('')+`<circle cx="${cx}" cy="${cy}" r="${r*.4}" fill="#f0d880" stroke="${t.ink}" stroke-width=".4"/>`;
+}
+function peonyPetals(t){
+ let out='';
+ const layers=[
+ {count:5,rx:38,ry:24,dist:22,op:.7},
+ {count:7,rx:28,ry:18,dist:30,op:.8},
+ {count:9,rx:20,ry:13,dist:36,op:.85},
+ {count:6,rx:14,ry:10,dist:18,op:.9},
+ ];
+ layers.forEach(l=>{
+ for(let i=0;i<l.count;i++){
+ const a=(i/l.count)*360, rad=a*Math.PI/180;
+ const cx=55+Math.cos(rad)*l.dist, cy=55+Math.sin(rad)*l.dist;
+ out+=`<ellipse cx="${cx}" cy="${cy}" rx="${l.rx}" ry="${l.ry}" fill="${t.accent}" opacity="${l.op}" stroke="${t.ink}" stroke-width=".7" transform="rotate(${a},${cx},${cy})"/>`;
+ }
+ });
+ out+=`<circle cx="55" cy="55" r="12" fill="#f5e08a" opacity=".9" stroke="${t.ink}" stroke-width=".8"/>`;
+ out+=`<circle cx="55" cy="55" r="6" fill="#e8c850" stroke="${t.ink}" stroke-width=".5"/>`;
+ return out;
+}
+function bambooLeaf(t,x,y,side){
+ const w=22,s=side>0?1:-1;
+ return `<path d="M${x},${y} Q${x+s*w*.8},${y-12} ${x+s*w},${y-24} Q${x+s*w*.3},${y-10} ${x},${y}Z" fill="${t.tint}" opacity=".55" stroke="${t.ink}" stroke-width=".8"/>`;
+}
+
+// ─── GROUND TINT RESOLVER ─────────────────────────────────────────────────────
+function tintForGround(gid){
+ const g=GROUNDS.find(x=>x.id===gid)||GROUNDS[0];
+ const dark=g.id==='ink'||g.id==='aubergine';
+ return {
+ ink: dark?'rgba(255,255,255,.85)':'#1c1a17',
+ accent: dark?'#e8c87a':'#8b5c2a',
+ tint: dark?'rgba(255,255,255,.35)':g.tint,
+ silk: dark?'rgba(255,255,255,.9)':'#f9f6ef',
+ };
+}
+
+// ─── STATE ────────────────────────────────────────────────────────────────────
+let groundId='celadon';
+let activeTool=null;
+let items=[]; // {id,type,x,y,w,h,scale,rot,zIndex}
+let selectedId=null;
+let nextId=1;
+let nextZ=1;
+let history=[]; // snapshots of items
+let future=[];
+let svgW=700, svgH=900;
+
+// drag state
+let drag={active:false,id:null,startX:0,startY:0,origX:0,origY:0};
+// rotate state
+let rotating={active:false,id:null,startAngle:0,origRot:0};
+// scale state
+let scaling={active:false,id:null,startDist:0,origScale:1};
+
+// ─── DOM REFS ─────────────────────────────────────────────────────────────────
+const silk=document.getElementById('silk');
+const overlay=document.getElementById('overlay');
+const toast=document.getElementById('toast');
+
+// ─── INIT ─────────────────────────────────────────────────────────────────────
+function init(){
+ buildGrounds();
+ buildTools();
+ sizeSVG();
+ renderGround();
+ window.addEventListener('resize',onResize);
+ silk.addEventListener('click',onCanvasClick);
+ silk.addEventListener('mousedown',onCanvasMD);
+ window.addEventListener('mousemove',onMouseMove);
+ window.addEventListener('mouseup',onMouseUp);
+ window.addEventListener('keydown',onKey);
+ document.getElementById('undoBtn').addEventListener('click',undo);
+ document.getElementById('redoBtn').addEventListener('click',redo);
+ document.getElementById('dlBtn').addEventListener('click',exportPNG);
+ document.getElementById('composeBtn').addEventListener('click',compose);
+ document.getElementById('clearBtn').addEventListener('click',clearAll);
+ document.getElementById('deleteSelBtn').addEventListener('click',deleteSelected);
+ document.getElementById('bringFwdBtn').addEventListener('click',bringForward);
+ document.getElementById('sendBkBtn').addEventListener('click',sendBackward);
+ saveHistory();
+ showToast('Choose a tool, then click the canvas to place elements');
+}
+
+function sizeSVG(){
+ const stage=document.getElementById('stage');
+ const sw=stage.clientWidth-40, sh=stage.clientHeight-40;
+ const ratio=700/900;
+ if(sw/sh>ratio){svgH=Math.min(sh,900);svgW=Math.round(svgH*ratio);}
+ else{svgW=Math.min(sw,700);svgH=Math.round(svgW/ratio);}
+ silk.setAttribute('width',svgW);
+ silk.setAttribute('height',svgH);
+ silk.setAttribute('viewBox',`0 0 700 900`);
+ overlay.style.width=svgW+'px';
+ overlay.style.height=svgH+'px';
+}
+function onResize(){sizeSVG();renderAll();}
+
+// ─── GROUNDS ──────────────────────────────────────────────────────────────────
+function buildGrounds(){
+ const c=document.getElementById('grounds');
+ GROUNDS.forEach(g=>{
+ const d=document.createElement('div');
+ d.className='ground-swatch'+(g.id===groundId?' active':'');
+ d.title=g.label;
+ d.style.background=g.color;
+ d.addEventListener('click',()=>setGround(g.id));
+ c.appendChild(d);
+ });
+}
+function setGround(id){
+ groundId=id;
+ document.querySelectorAll('.ground-swatch').forEach((el,i)=>{
+ el.classList.toggle('active',GROUNDS[i].id===id);
+ });
+ renderAll();
+}
+
+// ─── TOOLS ───────────────────────────────────────────────────────────────────
+const TOOL_ICONS={
+ blossom:`<svg width="36" height="36" viewBox="0 0 36 36"><circle cx="18" cy="18" r="7" fill="#e8c87a" opacity=".7"/><circle cx="18" cy="8" r="4" fill="#e8c87a" opacity=".6"/><circle cx="26" cy="13" r="4" fill="#e8c87a" opacity=".6"/><circle cx="26" cy="23" r="4" fill="#e8c87a" opacity=".6"/><circle cx="18" cy="28" r="4" fill="#e8c87a" opacity=".6"/><circle cx="10" cy="23" r="4" fill="#e8c87a" opacity=".6"/><circle cx="10" cy="13" r="4" fill="#e8c87a" opacity=".6"/></svg>`,
+ plum:`<svg width="36" height="36" viewBox="0 0 36 36"><path d="M18,32 Q14,20 16,8" stroke="#5a4030" fill="none" stroke-width="2"/><circle cx="16" cy="8" r="4" fill="#e8a0a0" opacity=".8"/><circle cx="10" cy="14" r="3.5" fill="#e8a0a0" opacity=".8"/><circle cx="25" cy="12" r="3.5" fill="#e8a0a0" opacity=".8"/></svg>`,
+ songbird:`<svg width="36" height="36" viewBox="0 0 36 36"><ellipse cx="16" cy="22" rx="10" ry="6" fill="#e8c87a" opacity=".8"/><ellipse cx="25" cy="18" rx="7" ry="5" fill="#e8c87a" opacity=".8"/><path d="M25,13 Q29,9 33,9" stroke="#3a3020" fill="none" stroke-width="1.2"/><circle cx="29" cy="15" r="1.5" fill="#1c1a17"/></svg>`,
+ crane:`<svg width="36" height="36" viewBox="0 0 36 36"><ellipse cx="18" cy="22" rx="12" ry="5" fill="white" stroke="#3a3020" stroke-width=".8"/><ellipse cx="18" cy="14" rx="5" ry="4" fill="white" stroke="#3a3020" stroke-width=".8"/><path d="M18,10 L20,7" stroke="#3a3020" stroke-width="1.2"/><rect x="18" y="6" width="5" height="2" rx="1" fill="#c9a961"/><circle cx="18" cy="11" r="1.5" fill="#c0392b"/><path d="M16,27 L14,33 M18,27 L18,33 M20,27 L22,33" stroke="#3a3020" stroke-width="1"/></svg>`,
+ butterfly:`<svg width="36" height="36" viewBox="0 0 36 36"><path d="M18,18 Q8,8 2,12 Q1,20 10,22 Q14,23 18,18Z" fill="#e8c87a" opacity=".75"/><path d="M18,18 Q28,8 34,12 Q35,20 26,22 Q22,23 18,18Z" fill="#e8c87a" opacity=".75"/><path d="M18,18 Q13,22 9,28 Q14,30 18,24Z" fill="#c9a961" opacity=".6"/><path d="M18,18 Q23,22 27,28 Q22,30 18,24Z" fill="#c9a961" opacity=".6"/><ellipse cx="18" cy="20" rx="1.5" ry="5" fill="#1c1a17"/></svg>`,
+ pagoda:`<svg width="36" height="36" viewBox="0 0 36 36"><rect x="12" y="28" width="12" height="7" fill="#c9a961" opacity=".4" stroke="#5a4030" stroke-width=".8"/><path d="M10,28 Q18,22 26,28" fill="#c9a961" opacity=".5"/><rect x="8" y="18" width="20" height="11" fill="#c9a961" opacity=".3" stroke="#5a4030" stroke-width=".8"/><path d="M5,18 Q18,11 31,18" fill="#c9a961" opacity=".4"/><path d="M18,11 L18,5" stroke="#5a4030" stroke-width="1.2"/></svg>`,
+ rock:`<svg width="36" height="36" viewBox="0 0 36 36"><path d="M18,32 Q6,33 4,22 Q3,14 10,8 Q18,4 26,8 Q34,14 33,24 Q31,32 18,32Z" fill="#b0a898" opacity=".5" stroke="#5a4030" stroke-width=".8"/></svg>`,
+ peony:`<svg width="36" height="36" viewBox="0 0 36 36">${[0,60,120,180,240,300].map(a=>`<ellipse cx="${18+Math.cos(a*Math.PI/180)*10}" cy="${18+Math.sin(a*Math.PI/180)*10}" rx="9" ry="5.5" fill="#e8c87a" opacity=".7" stroke="#5a4030" stroke-width=".5" transform="rotate(${a},${18+Math.cos(a*Math.PI/180)*10},${18+Math.sin(a*Math.PI/180)*10})"/>`).join('')}<circle cx="18" cy="18" r="5" fill="#f5e080" stroke="#5a4030" stroke-width=".6"/></svg>`,
+ bamboo:`<svg width="36" height="36" viewBox="0 0 36 36"><rect x="14" y="2" width="7" height="14" rx="3" fill="#8db878" opacity=".6" stroke="#3a5028" stroke-width=".8"/><rect x="14" y="17" width="7" height="14" rx="3" fill="#8db878" opacity=".5" stroke="#3a5028" stroke-width=".8"/><line x1="14" y1="17" x2="21" y2="17" stroke="#3a5028" stroke-width="1.2"/><path d="M21,14 Q30,10 33,6" stroke="#3a5028" fill="none" stroke-width=".9"/></svg>`,
+};
+function buildTools(){
+ const g=document.getElementById('toolGrid');
+ Object.keys(ELEMENTS).forEach(k=>{
+ const el=ELEMENTS[k];
+ const btn=document.createElement('button');
+ btn.className='tool-btn';
+ btn.dataset.tool=k;
+ btn.innerHTML=`${TOOL_ICONS[k]||''}<span class="tool-name">${el.label}</span>`;
+ btn.addEventListener('click',()=>selectTool(k));
+ g.appendChild(btn);
+ });
+}
+function selectTool(k){
+ activeTool=activeTool===k?null:k;
+ document.querySelectorAll('.tool-btn').forEach(b=>{
+ b.classList.toggle('active',b.dataset.tool===activeTool);
+ });
+ silk.style.cursor=activeTool?'crosshair':'default';
+ if(activeTool) selectedId=null, renderSelection();
+}
+
+// ─── CANVAS EVENTS ────────────────────────────────────────────────────────────
+function onCanvasClick(e){
+ if(drag.moved) return;
+ if(!activeTool) return;
+ const pt=svgPoint(e);
+ saveHistory();
+ const el=ELEMENTS[activeTool];
+ const item={
+ id:nextId++,type:activeTool,
+ x:pt.x,y:pt.y,
+ w:el.w,h:el.h,
+ scale:1,rot:0,
+ zIndex:nextZ++
+ };
+ items.push(item);
+ renderAll();
+ animatePop(item.id);
+ future=[];
+}
+function onCanvasMD(e){
+ drag.moved=false;
+ if(activeTool) return;
+ const pt=svgPoint(e);
+ // hit test items (top-most z first)
+ const sorted=[...items].sort((a,b)=>b.zIndex-a.zIndex);
+ for(const item of sorted){
+ if(hitTest(item,pt)){
+ selectedId=item.id;
+ drag={active:true,id:item.id,startX:e.clientX,startY:e.clientY,origX:item.x,origY:item.y,moved:false};
+ renderAll();
+ return;
+ }
+ }
+ selectedId=null;
+ renderAll();
+}
+function onMouseMove(e){
+ if(!drag.active) return;
+ const dx=(e.clientX-drag.startX)*(700/svgW);
+ const dy=(e.clientY-drag.startY)*(900/svgH);
+ if(Math.abs(dx)+Math.abs(dy)>3) drag.moved=true;
+ const item=items.find(i=>i.id===drag.id);
+ if(item){item.x=drag.origX+dx;item.y=drag.origY+dy;renderAll();}
+}
+function onMouseUp(e){
+ if(drag.active && drag.moved) saveHistory();
+ drag.active=false;
+}
+function onKey(e){
+ if(e.key==='Escape'){activeTool=null;document.querySelectorAll('.tool-btn').forEach(b=>b.classList.remove('active'));silk.style.cursor='default';}
+ if((e.ctrlKey||e.metaKey)&&e.key==='z'){e.preventDefault();undo();}
+ if((e.ctrlKey||e.metaKey)&&(e.key==='y'||(e.shiftKey&&e.key==='Z'))){e.preventDefault();redo();}
+ if(e.key==='Delete'||e.key==='Backspace') deleteSelected();
+ if(selectedId&&e.key===']') bringForward();
+ if(selectedId&&e.key==='[') sendBackward();
+ if(selectedId&&(e.key==='+')){ const i=items.find(x=>x.id===selectedId);if(i){saveHistory();i.scale=Math.min(4,i.scale*1.1);renderAll();}}
+ if(selectedId&&e.key==='-'){ const i=items.find(x=>x.id===selectedId);if(i){saveHistory();i.scale=Math.max(.15,i.scale*.9);renderAll();}}
+ if(selectedId&&e.key==='ArrowLeft'){ const i=items.find(x=>x.id===selectedId);if(i){saveHistory();i.rot-=5;renderAll();}}
+ if(selectedId&&e.key==='ArrowRight'){ const i=items.find(x=>x.id===selectedId);if(i){saveHistory();i.rot+=5;renderAll();}}
+}
+
+function svgPoint(e){
+ const r=silk.getBoundingClientRect();
+ return {x:(e.clientX-r.left)*(700/svgW), y:(e.clientY-r.top)*(900/svgH)};
+}
+function hitTest(item,pt){
+ const hw=item.w*item.scale/2, hh=item.h*item.scale/2;
+ return pt.x>=item.x-hw&&pt.x<=item.x+hw&&pt.y>=item.y-hh&&pt.y<=item.y+hh;
+}
+
+// ─── RENDER ───────────────────────────────────────────────────────────────────
+function renderAll(){
+ renderGround();
+ renderItems();
+ renderSelection();
+}
+
+function renderGround(){
+ const g=GROUNDS.find(x=>x.id===groundId)||GROUNDS[0];
+ const dark=groundId==='ink'||groundId==='aubergine';
+
+ // Build SVG defs once
+ silk.innerHTML=`
+ <defs>
+ <filter id="silk-tex" x="0%" y="0%" width="100%" height="100%" color-interpolation-filters="sRGB">
+ <feTurbulence type="fractalNoise" baseFrequency=".65 .3" numOctaves="4" seed="3" result="noise"/>
+ <feColorMatrix type="saturate" values="0" in="noise" result="gray"/>
+ <feBlend in="SourceGraphic" in2="gray" mode="multiply" result="blend"/>
+ <feComponentTransfer in="blend"><feFuncA type="linear" slope="1"/></feComponentTransfer>
+ </filter>
+ <filter id="aged" x="-5%" y="-5%" width="110%" height="110%">
+ <feTurbulence type="turbulence" baseFrequency=".04 .08" numOctaves="3" seed="7" result="t"/>
+ <feDisplacementMap in="SourceGraphic" in2="t" scale="4" xChannelSelector="R" yChannelSelector="G"/>
+ </filter>
+ <pattern id="silk-weave" x="0" y="0" width="6" height="6" patternUnits="userSpaceOnUse">
+ <rect width="6" height="6" fill="${g.color}"/>
+ <line x1="0" y1="0" x2="6" y2="0" stroke="${dark?'rgba(255,255,255,.03)':'rgba(0,0,0,.03)'}" stroke-width="1"/>
+ <line x1="0" y1="3" x2="6" y2="3" stroke="${dark?'rgba(255,255,255,.025)':'rgba(0,0,0,.025)'}" stroke-width=".5"/>
+ <line x1="0" y1="0" x2="0" y2="6" stroke="${dark?'rgba(255,255,255,.02)':'rgba(0,0,0,.02)'}" stroke-width=".5"/>
+ <line x1="3" y1="0" x2="3" y2="6" stroke="${dark?'rgba(255,255,255,.015)':'rgba(0,0,0,.015)'}" stroke-width=".5"/>
+ </pattern>
+ <radialGradient id="vignette" cx="50%" cy="50%" r="70%">
+ <stop offset="0%" stop-color="transparent"/>
+ <stop offset="100%" stop-color="${dark?'rgba(0,0,0,.35)':'rgba(0,0,0,.12)'}"/>
+ </radialGradient>
+ </defs>
+ <!-- Ground -->
+ <rect width="700" height="900" fill="url(#silk-weave)"/>
+ <!-- Aged texture overlay -->
+ <rect width="700" height="900" fill="${dark?'rgba(255,255,255,.03)':'rgba(180,160,120,.08)'}" filter="url(#silk-tex)"/>
+ <!-- Vignette -->
+ <rect width="700" height="900" fill="url(#vignette)"/>
+ <!-- Border -->
+ <rect x="18" y="18" width="664" height="864" fill="none" stroke="${dark?'rgba(255,255,255,.12)':'rgba(28,26,23,.12)'}" stroke-width="1.5"/>
+ <rect x="24" y="24" width="652" height="852" fill="none" stroke="${dark?'rgba(255,255,255,.07)':'rgba(28,26,23,.07)'}" stroke-width=".8"/>
+ <g id="items-group"></g>
+ `;
+
+ // Transition color smoothly via CSS
+ silk.style.transition='background .4s ease';
+ silk.style.background=g.color;
+}
+
+function renderItems(){
+ const g=document.getElementById('items-group');
+ if(!g) return;
+ const tint=tintForGround(groundId);
+ const sorted=[...items].sort((a,b)=>a.zIndex-b.zIndex);
+ g.innerHTML=sorted.map(item=>{
+ const el=ELEMENTS[item.type];
+ const s=item.scale, cx=item.x, cy=item.y;
+ const sel=item.id===selectedId;
+ return `<g id="item-${item.id}" transform="translate(${cx},${cy}) rotate(${item.rot}) scale(${s})"
+ style="cursor:move;outline:none"
+ data-id="${item.id}">
+ ${sel?`<rect x="${-el.w/2-4}" y="${-el.h/2-4}" width="${el.w+8}" height="${el.h+8}" fill="none" stroke="${tint.accent}" stroke-width="${1/s}" stroke-dasharray="${4/s} ${3/s}" rx="2" opacity=".7"/>`:``}
+ <g transform="translate(${-el.w/2},${-el.h/2})">${el.svg(tint)}</g>
+ </g>`;
+ }).join('');
+}
+
+function renderSelection(){
+ overlay.innerHTML='';
+ if(!selectedId) return;
+ const item=items.find(i=>i.id===selectedId);
+ if(!item) return;
+ // Show control handles in overlay space
+ const scaleX=svgW/700, scaleY=svgH/900;
+ const cx=item.x*scaleX, cy=item.y*scaleY;
+ const hw=item.w*item.scale/2*scaleX, hh=item.h*item.scale/2*scaleY;
+
+ // Scale handle (bottom-right)
+ const sh=makeHandle(cx+hw,cy+hh,'nwse-resize');
+ sh.title='Drag to scale';
+ sh.addEventListener('mousedown',e=>{
+ e.stopPropagation();
+ saveHistory();
+ const startDist=Math.hypot(e.clientX-cx*1,e.clientY-cy*1);
+ const origScale=item.scale;
+ const mm=ev=>{
+ const r=silk.getBoundingClientRect();
+ const d=Math.hypot(ev.clientX-r.left-item.x*scaleX, ev.clientY-r.top-item.y*scaleY);
+ item.scale=Math.max(.15,Math.min(4,origScale*(d/(hw/scaleX*origScale||1))));
+ renderAll();
+ };
+ const mu=()=>{window.removeEventListener('mousemove',mm);window.removeEventListener('mouseup',mu);};
+ window.addEventListener('mousemove',mm);
+ window.addEventListener('mouseup',mu);
+ });
+
+ // Rotate handle (top-right)
+ const rh=makeHandle(cx+hw,cy-hh,'grab');
+ rh.title='Drag to rotate';
+ rh.style.background='#a8863e';
+ rh.addEventListener('mousedown',e=>{
+ e.stopPropagation();
+ saveHistory();
+ const origRot=item.rot;
+ const r=silk.getBoundingClientRect();
+ const startAngle=Math.atan2(e.clientY-r.top-item.y*scaleY, e.clientX-r.left-item.x*scaleX)*180/Math.PI;
+ const mm=ev=>{
+ const ang=Math.atan2(ev.clientY-r.top-item.y*scaleY, ev.clientX-r.left-item.x*scaleX)*180/Math.PI;
+ item.rot=origRot+(ang-startAngle);
+ renderAll();
+ };
+ const mu=()=>{window.removeEventListener('mousemove',mm);window.removeEventListener('mouseup',mu);};
+ window.addEventListener('mousemove',mm);
+ window.addEventListener('mouseup',mu);
+ });
+
+ overlay.appendChild(sh);
+ overlay.appendChild(rh);
+
+ // Label
+ const lbl=document.createElement('div');
+ lbl.style.cssText=`position:absolute;left:${cx-hw}px;top:${cy-hh-18}px;font-size:10px;color:var(--accent);letter-spacing:.08em;pointer-events:none;white-space:nowrap`;
+ lbl.textContent=ELEMENTS[item.type]?.label||'';
+ overlay.appendChild(lbl);
+}
+
+function makeHandle(x,y,cursor){
+ const h=document.createElement('div');
+ h.className='sel-handle';
+ h.style.cssText=`left:${x}px;top:${y}px;cursor:${cursor}`;
+ return h;
+}
+
+// ─── POP ANIMATION ───────────────────────────────────────────────────────────
+function animatePop(id){
+ // Quick scale keyframe via JS
+ let t=0;
+ const tick=()=>{
+ t+=.06;
+ if(t>1) return;
+ const scale=1+Math.sin(t*Math.PI)*.18;
+ const item=items.find(i=>i.id===id);
+ if(!item) return;
+ const el=document.getElementById('item-'+id);
+ if(el){const base=item.scale;el.setAttribute('transform',`translate(${item.x},${item.y}) rotate(${item.rot}) scale(${base*scale})`);}
+ requestAnimationFrame(tick);
+ };
+ requestAnimationFrame(tick);
+}
+
+// ─── COMPOSE ──────────────────────────────────────────────────────────────────
+function compose(){
+ saveHistory();
+ items=[];
+ nextZ=1;
+ selectedId=null;
+
+ // Curated balanced scene
+ const scene=[
+ // Background elements (large, lower z)
+ {type:'pagoda', x:540, y:720, scale:.85, rot:-3},
+ {type:'rock', x:120, y:820, scale:1.1, rot:5},
+ {type:'rock', x:580, y:850, scale:.8, rot:-8},
+ // Mid-ground branches
+ {type:'blossom',x:180, y:320, scale:1.1, rot:8},
+ {type:'plum', x:520, y:280, scale:1.0, rot:-12},
+ {type:'bamboo', x:80, y:450, scale:1.0, rot:3},
+ {type:'bamboo', x:630, y:550, scale:.85, rot:-4},
+ // Flowers
+ {type:'peony', x:340, y:580, scale:.9, rot:0},
+ {type:'peony', x:160, y:650, scale:.75, rot:15},
+ // Birds
+ {type:'crane', x:350, y:200, scale:1.0, rot:0},
+ {type:'songbird',x:240, y:390, scale:.9, rot:-5},
+ {type:'songbird',x:470, y:440, scale:.8, rot:10},
+ // Butterflies
+ {type:'butterfly',x:420, y:160, scale:.85, rot:-20},
+ {type:'butterfly',x:130, y:200, scale:.75, rot:15},
+ ];
+
+ scene.forEach(s=>{
+ const el=ELEMENTS[s.type];
+ items.push({
+ id:nextId++,
+ type:s.type,
+ x:s.x,y:s.y,
+ w:el.w,h:el.h,
+ scale:s.scale,
+ rot:s.rot,
+ zIndex:nextZ++
+ });
+ });
+ future=[];
+ renderAll();
+ showToast('Scene composed — click any element to select and adjust');
+}
+
+// ─── HISTORY ─────────────────────────────────────────────────────────────────
+function saveHistory(){
+ history.push(JSON.stringify(items));
+ if(history.length>50) history.shift();
+}
+function undo(){
+ if(!history.length) return;
+ future.push(JSON.stringify(items));
+ items=JSON.parse(history.pop());
+ selectedId=null;
+ renderAll();
+ showToast('Undo');
+}
+function redo(){
+ if(!future.length) return;
+ history.push(JSON.stringify(items));
+ items=JSON.parse(future.pop());
+ selectedId=null;
+ renderAll();
+ showToast('Redo');
+}
+
+// ─── ITEM OPS ─────────────────────────────────────────────────────────────────
+function deleteSelected(){
+ if(!selectedId) return;
+ saveHistory();
+ items=items.filter(i=>i.id!==selectedId);
+ selectedId=null;
+ renderAll();
+ future=[];
+}
+function clearAll(){
+ saveHistory();
+ items=[];selectedId=null;future=[];
+ renderAll();
+ showToast('Canvas cleared');
+}
+function bringForward(){
+ if(!selectedId) return;
+ const item=items.find(i=>i.id===selectedId);
+ if(!item) return;
+ saveHistory();
+ item.zIndex=nextZ++;
+ renderAll();future=[];
+}
+function sendBackward(){
+ if(!selectedId) return;
+ const item=items.find(i=>i.id===selectedId);
+ if(!item) return;
+ saveHistory();
+ const minZ=Math.min(...items.map(i=>i.zIndex));
+ item.zIndex=minZ-1;
+ renderAll();future=[];
+}
+
+// ─── EXPORT PNG ───────────────────────────────────────────────────────────────
+function exportPNG(){
+ selectedId=null;renderAll();
+ // Serialize SVG → canvas → data URI
+ const svgEl=document.getElementById('silk');
+ const serializer=new XMLSerializer();
+ const svgStr=serializer.serializeToString(svgEl);
+ const blob='data:image/svg+xml;charset=utf-8,'+encodeURIComponent(svgStr);
+ const img=new Image();
+ img.onload=()=>{
+ const c=document.createElement('canvas');
+ c.width=700;c.height=900;
+ const ctx=c.getContext('2d');
+ ctx.drawImage(img,0,0,700,900);
+ const url=c.toDataURL('image/png');
+ const a=document.createElement('a');
+ a.href=url;
+ a.download='chinoiserie-mural-dw.png';
+ a.click();
+ showToast('Mural exported as PNG');
+ };
+ img.onerror=()=>showToast('Export error — try a different browser');
+ img.src=blob;
+}
+
+// ─── TOAST ───────────────────────────────────────────────────────────────────
+let toastTimer=null;
+function showToast(msg){
+ toast.textContent=msg;
+ toast.classList.add('show');
+ clearTimeout(toastTimer);
+ toastTimer=setTimeout(()=>toast.classList.remove('show'),3000);
+}
+
+// ─── TOUCH SUPPORT ────────────────────────────────────────────────────────────
+silk.addEventListener('touchstart',e=>{
+ if(e.touches.length===1){
+ const t=e.touches[0];
+ const me=new MouseEvent('mousedown',{clientX:t.clientX,clientY:t.clientY,bubbles:true});
+ silk.dispatchEvent(me);
+ }
+},{passive:true});
+window.addEventListener('touchmove',e=>{
+ if(e.touches.length===1){
+ const t=e.touches[0];
+ onMouseMove({clientX:t.clientX,clientY:t.clientY});
+ }
+},{passive:true});
+window.addEventListener('touchend',()=>onMouseUp({}),{passive:true});
+silk.addEventListener('touchend',e=>{
+ if(!drag.moved){
+ const t=e.changedTouches[0];
+ onCanvasClick({clientX:t.clientX,clientY:t.clientY,target:silk});
+ }
+ drag.active=false;
+},{passive:true});
+
+// ─── START ────────────────────────────────────────────────────────────────────
+init();
+</script>
+</body>
+</html>
diff --git a/public/games/dw-parquet.html b/public/games/dw-parquet.html
new file mode 100644
index 0000000..b3c3088
--- /dev/null
+++ b/public/games/dw-parquet.html
@@ -0,0 +1,733 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Parquet — Designer Wallcoverings</title>
+<style>
+:root{
+ --bg:#0d0b09;
+ --surface:#1a1710;
+ --surface2:#252118;
+ --ink:#f4f1ea;
+ --ink-dim:#9a8f7a;
+ --accent:#c9a961;
+ --accent-dim:#8a6f35;
+ --accent-glow:rgba(201,169,97,.22);
+ --serif:'Cormorant Garamond','Playfair Display','EB Garamond',Georgia,serif;
+ --sans:'Inter','Helvetica Neue',Arial,sans-serif;
+ --ease:cubic-bezier(.4,0,.2,1);
+ --ease-spring:cubic-bezier(.34,1.56,.64,1);
+ --r:10px;
+ --tile-gap:3px;
+}
+*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
+html,body{height:100%;background:var(--bg);color:var(--ink);font-family:var(--sans);overflow-x:hidden}
+
+/* ── HEADER ── */
+header{
+ display:flex;align-items:center;justify-content:space-between;
+ padding:16px 28px 14px;
+ border-bottom:1px solid rgba(201,169,97,.15);
+}
+.wordmark{
+ font-family:var(--serif);
+ font-size:clamp(18px,2.5vw,28px);
+ letter-spacing:.18em;
+ text-transform:uppercase;
+ color:var(--ink);
+ line-height:1;
+}
+.wordmark span{color:var(--accent)}
+.header-sub{
+ font-size:10px;letter-spacing:.25em;text-transform:uppercase;
+ color:var(--ink-dim);margin-top:3px;
+}
+.hdr-controls{display:flex;gap:10px;align-items:center}
+button.ghost{
+ background:none;border:1px solid rgba(201,169,97,.3);
+ color:var(--accent);font-size:11px;letter-spacing:.12em;text-transform:uppercase;
+ font-family:var(--sans);padding:6px 14px;border-radius:4px;cursor:pointer;
+ transition:background .2s,border-color .2s;
+}
+button.ghost:hover{background:var(--accent-glow);border-color:var(--accent)}
+
+/* ── LAYOUT ── */
+main{
+ display:flex;flex-direction:column;align-items:center;
+ padding:28px 16px 40px;min-height:calc(100vh - 65px);gap:20px;
+}
+.game-title{
+ font-family:var(--serif);font-size:clamp(26px,4vw,44px);
+ letter-spacing:.08em;color:var(--accent);text-align:center;line-height:1;
+}
+.game-subtitle{
+ font-size:11px;letter-spacing:.22em;text-transform:uppercase;
+ color:var(--ink-dim);text-align:center;margin-top:4px;
+}
+
+/* ── HUD ── */
+.hud{
+ display:flex;gap:28px;align-items:center;
+ background:var(--surface);border:1px solid rgba(201,169,97,.15);
+ border-radius:var(--r);padding:10px 24px;
+}
+.hud-cell{text-align:center}
+.hud-val{
+ font-family:var(--serif);font-size:28px;color:var(--accent);
+ line-height:1;letter-spacing:.04em;
+}
+.hud-lbl{font-size:9px;letter-spacing:.2em;text-transform:uppercase;color:var(--ink-dim);margin-top:2px}
+.hud-sep{width:1px;height:36px;background:rgba(201,169,97,.18)}
+
+/* ── SIZE PICKER ── */
+.size-row{display:flex;gap:8px}
+.sz-btn{
+ background:none;border:1px solid rgba(201,169,97,.25);
+ color:var(--ink-dim);font-size:11px;letter-spacing:.1em;
+ font-family:var(--sans);padding:5px 14px;border-radius:4px;cursor:pointer;
+ transition:all .2s;
+}
+.sz-btn:hover,.sz-btn.active{
+ background:var(--accent-glow);border-color:var(--accent);color:var(--accent);
+}
+
+/* ── BOARD ── */
+.board-wrap{position:relative}
+#board{
+ display:grid;
+ gap:var(--tile-gap);
+ border:1px solid rgba(201,169,97,.25);
+ border-radius:var(--r);
+ padding:var(--tile-gap);
+ background:rgba(201,169,97,.08);
+ /* set via JS: grid-template-columns */
+ touch-action:none;
+ user-select:none;
+}
+.tile{
+ border-radius:5px;
+ overflow:hidden;
+ cursor:pointer;
+ position:relative;
+ transition:transform .15s var(--ease),box-shadow .15s;
+ will-change:transform;
+}
+.tile:hover{
+ transform:scale(1.025);
+ box-shadow:0 0 0 2px var(--accent);
+ z-index:2;
+}
+.tile.empty{
+ background:rgba(0,0,0,.55);
+ cursor:default;border:1px dashed rgba(201,169,97,.12);
+}
+.tile.empty:hover{transform:none;box-shadow:none}
+.tile svg{display:block;width:100%;height:100%}
+
+/* number badge */
+.tile-num{
+ position:absolute;bottom:3px;right:5px;
+ font-size:9px;color:rgba(255,255,255,.28);font-family:var(--sans);
+ pointer-events:none;display:none;
+}
+.show-nums .tile-num{display:block}
+
+/* ── PEEK PREVIEW ── */
+.peek-wrap{position:relative;cursor:pointer}
+.peek-label{
+ font-size:9px;letter-spacing:.18em;text-transform:uppercase;
+ color:var(--ink-dim);text-align:center;margin-bottom:6px;
+}
+#peek-svg{
+ border:1px solid rgba(201,169,97,.2);border-radius:6px;
+ opacity:.35;transition:opacity .25s;display:block;
+}
+.peek-wrap:hover #peek-svg{opacity:.9}
+.peek-hint{
+ font-size:9px;color:var(--ink-dim);text-align:center;margin-top:4px;letter-spacing:.08em;
+}
+
+/* ── ACTION ROW ── */
+.actions{display:flex;gap:10px;flex-wrap:wrap;justify-content:center}
+button.primary{
+ background:linear-gradient(135deg,#b8922d,#c9a961 60%,#e0c07a);
+ border:none;color:#1c1a17;
+ font-family:var(--serif);font-size:15px;letter-spacing:.12em;
+ padding:11px 30px;border-radius:5px;cursor:pointer;
+ text-transform:uppercase;
+ box-shadow:0 2px 18px rgba(201,169,97,.25);
+ transition:filter .2s,transform .12s;
+}
+button.primary:hover{filter:brightness(1.1);transform:translateY(-1px)}
+button.primary:active{transform:translateY(0)}
+
+/* ── WIN OVERLAY ── */
+#win-overlay{
+ position:fixed;inset:0;background:rgba(13,11,9,.88);
+ display:flex;flex-direction:column;align-items:center;justify-content:center;
+ gap:20px;z-index:100;
+ visibility:hidden;opacity:0;
+ transition:opacity .4s var(--ease),visibility .4s;
+}
+#win-overlay.show{visibility:visible;opacity:1}
+.win-art{border-radius:12px;border:2px solid var(--accent);box-shadow:0 0 60px var(--accent-glow)}
+.win-title{
+ font-family:var(--serif);font-size:clamp(32px,6vw,60px);
+ color:var(--accent);letter-spacing:.12em;text-transform:uppercase;
+ animation:shimmer 2s linear infinite;
+}
+@keyframes shimmer{
+ 0%{text-shadow:0 0 20px rgba(201,169,97,0)}
+ 50%{text-shadow:0 0 40px rgba(201,169,97,.7)}
+ 100%{text-shadow:0 0 20px rgba(201,169,97,0)}
+}
+.win-stats{display:flex;gap:32px;font-family:var(--serif)}
+.win-stat{text-align:center}
+.win-stat-v{font-size:36px;color:var(--accent);line-height:1}
+.win-stat-l{font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-dim)}
+.win-sub{font-size:11px;letter-spacing:.2em;text-transform:uppercase;color:var(--ink-dim);text-align:center}
+.win-buttons{display:flex;gap:12px}
+
+/* ── CONFETTI ── */
+.confetti-piece{
+ position:fixed;pointer-events:none;z-index:200;
+ width:8px;height:8px;border-radius:1px;
+ animation:fall linear forwards;
+}
+@keyframes fall{
+ 0%{transform:translateY(-40px) rotate(0deg);opacity:1}
+ 100%{transform:translateY(110vh) rotate(720deg);opacity:0}
+}
+
+/* ── RESPONSIVE ── */
+@media(max-width:600px){
+ header{padding:12px 16px 10px}
+ .hud{gap:16px;padding:8px 16px}
+ .hud-val{font-size:22px}
+}
+</style>
+</head>
+<body>
+
+<header>
+ <div>
+ <div class="wordmark">Designer <span>Wallcoverings</span></div>
+ <div class="header-sub">Parquet — Sliding Puzzle</div>
+ </div>
+ <div class="hdr-controls">
+ <button class="ghost" id="mute-btn" title="Toggle sound">♪ Sound</button>
+ </div>
+</header>
+
+<main>
+ <div>
+ <div class="game-title">Parquet</div>
+ <div class="game-subtitle">Restore the herringbone panel</div>
+ </div>
+
+ <div class="hud">
+ <div class="hud-cell">
+ <div class="hud-val" id="moves-display">0</div>
+ <div class="hud-lbl">Moves</div>
+ </div>
+ <div class="hud-sep"></div>
+ <div class="hud-cell">
+ <div class="hud-val" id="timer-display">0:00</div>
+ <div class="hud-lbl">Time</div>
+ </div>
+ <div class="hud-sep"></div>
+ <div class="hud-cell">
+ <div class="hud-val" id="best-display">—</div>
+ <div class="hud-lbl">Best (moves)</div>
+ </div>
+ </div>
+
+ <div class="size-row">
+ <button class="sz-btn" data-size="3">3×3</button>
+ <button class="sz-btn active" data-size="4">4×4</button>
+ <button class="sz-btn" data-size="5">5×5</button>
+ </div>
+
+ <div style="display:flex;gap:28px;align-items:flex-start;flex-wrap:wrap;justify-content:center">
+ <div id="board" role="grid" aria-label="Sliding puzzle board"></div>
+ <div class="peek-wrap" title="Hover to peek at solved state">
+ <div class="peek-label">Target Panel</div>
+ <svg id="peek-svg" width="120" height="120" viewBox="0 0 120 120"></svg>
+ <div class="peek-hint">Hover to reveal</div>
+ </div>
+ </div>
+
+ <div class="actions">
+ <button class="primary" id="shuffle-btn">Shuffle</button>
+ <button class="ghost" id="solve-hint-btn">Hint</button>
+ <button class="ghost" id="nums-btn">Toggle #</button>
+ </div>
+</main>
+
+<!-- WIN OVERLAY -->
+<div id="win-overlay" role="dialog" aria-modal="true" aria-label="Puzzle solved">
+ <div class="win-title">Masterpiece</div>
+ <svg class="win-art" id="win-art-svg" width="200" height="200" viewBox="0 0 200 200"></svg>
+ <div class="win-stats">
+ <div class="win-stat">
+ <div class="win-stat-v" id="win-moves">0</div>
+ <div class="win-stat-l">Moves</div>
+ </div>
+ <div class="win-stat">
+ <div class="win-stat-v" id="win-time">0:00</div>
+ <div class="win-stat-l">Time</div>
+ </div>
+ <div class="win-stat">
+ <div class="win-stat-v" id="win-best">—</div>
+ <div class="win-stat-l">Best</div>
+ </div>
+ </div>
+ <div class="win-sub">The herringbone panel is restored</div>
+ <div class="win-buttons">
+ <button class="primary" id="play-again-btn">Play Again</button>
+ <button class="ghost" id="close-win-btn">Close</button>
+ </div>
+</div>
+
+<script>
+// ─── AUDIO ───────────────────────────────────────────────────────────────────
+let muted = false;
+let audioCtx = null;
+function getCtx(){
+ if(!audioCtx) audioCtx = new (window.AudioContext||window.webkitAudioContext)();
+ return audioCtx;
+}
+function playTone(freq,type,dur,vol=0.12,attack=0.005,decay=0.08){
+ if(muted)return;
+ try{
+ const ctx=getCtx();
+ const o=ctx.createOscillator(),g=ctx.createGain();
+ o.connect(g);g.connect(ctx.destination);
+ o.type=type;o.frequency.value=freq;
+ g.gain.setValueAtTime(0,ctx.currentTime);
+ g.gain.linearRampToValueAtTime(vol,ctx.currentTime+attack);
+ g.gain.exponentialRampToValueAtTime(0.001,ctx.currentTime+dur);
+ o.start(ctx.currentTime);o.stop(ctx.currentTime+dur+decay);
+ }catch(e){}
+}
+function sfxSlide(){playTone(300+(Math.random()*200),'sine',.12,.08)}
+function sfxIllegal(){playTone(180,'sawtooth',.08,.05)}
+function sfxWin(){
+ const notes=[523,659,784,1047];
+ notes.forEach((f,i)=>setTimeout(()=>playTone(f,'sine',.35,.1),i*100));
+}
+document.getElementById('mute-btn').addEventListener('click',function(){
+ muted=!muted;this.textContent=muted?'✕ Muted':'♪ Sound';
+});
+
+// ─── HERRINGBONE SVG ART ─────────────────────────────────────────────────────
+// Generates a beautiful marquetry/herringbone SVG for given size (px)
+function buildHerringboneSVG(size){
+ // Colour palette — warm walnut/oak tones
+ const grains=[
+ ['#7B5E3A','#9A7648','#B8934F','#C9A361'], // dark walnut planks
+ ['#A67B4B','#BC9260','#D4AB74','#C8A055'], // medium oak
+ ['#8B6535','#A07840','#BF9A5A','#D4B06A'], // honey oak
+ ['#6B4E2A','#83623A','#9A7648','#B08840'], // ebony
+ ['#C5A55A','#D4B870','#E2CB88','#CAAA5A'], // blonde maple
+ ];
+ // plank dimensions in the herringbone
+ const pw=28,ph=10; // plank width, plank height (landscape orientation)
+ const defs=[];
+ const paths=[];
+
+ // Build a clip path for the viewBox
+ defs.push(`<clipPath id="board-clip"><rect width="${size}" height="${size}"/></clipPath>`);
+
+ // Add subtle wood grain texture filter
+ defs.push(`
+ <filter id="grain" x="0%" y="0%" width="100%" height="100%">
+ <feTurbulence type="fractalNoise" baseFrequency="0.9 0.12" numOctaves="4" seed="3" result="noise"/>
+ <feColorMatrix type="saturate" values="0" in="noise" result="grayNoise"/>
+ <feBlend in="SourceGraphic" in2="grayNoise" mode="multiply" result="blended"/>
+ <feComposite in="blended" in2="SourceGraphic" operator="in"/>
+ </filter>
+ <filter id="shadow" x="-5%" y="-5%" width="110%" height="110%">
+ <feDropShadow dx="0.5" dy="0.5" stdDeviation="0.8" flood-color="rgba(0,0,0,0.45)"/>
+ </filter>
+ `);
+
+ // Herringbone tile pattern — two orientations tiling
+ // Each "unit cell" = two planks: one horizontal, one vertical
+ const uw=ph*2, uh=pw+ph; // unit cell
+ let id=0;
+ const svgPaths=[];
+
+ function plankRect(x,y,w,h,colors,rotation,cx,cy){
+ const [c1,c2,c3,c4]=colors;
+ const pid=`p${id++}`;
+ // gradient along plank length
+ const gid=`g${id++}`;
+ const angle = rotation===0 ? 0 : 90;
+ defs.push(`<linearGradient id="${gid}" x1="0%" y1="0%" x2="100%" y2="0%" gradientTransform="rotate(${angle},0.5,0.5)">
+ <stop offset="0%" stop-color="${c1}"/>
+ <stop offset="30%" stop-color="${c2}"/>
+ <stop offset="65%" stop-color="${c3}"/>
+ <stop offset="100%" stop-color="${c4}"/>
+ </linearGradient>`);
+ const transform = rotation ? `rotate(${rotation},${cx},${cy})` : '';
+ svgPaths.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="url(#${gid})" filter="url(#grain)" ${transform?`transform="${transform}"`:''}/>
+ <rect x="${x}" y="${y}" width="${w}" height="${h}" fill="none" stroke="rgba(0,0,0,0.3)" stroke-width="0.5" ${transform?`transform="${transform}"`:''}/>`);
+ }
+
+ // Tile the herringbone pattern across the SVG
+ const cols=Math.ceil(size/uw)+3;
+ const rows=Math.ceil(size/uh)+3;
+ for(let row=-1;row<rows;row++){
+ for(let col=-1;col<cols;col++){
+ const bx=col*uw - ph;
+ const by=row*uh - ph;
+ // pick color by position for variety
+ const ci=((row*3+col*7)&0xff) % grains.length;
+ const ci2=((row*5+col*3+2)&0xff) % grains.length;
+ // Horizontal plank (top of unit)
+ plankRect(bx, by, pw, ph, grains[ci], 0,0,0);
+ // Vertical plank (rotated 90, right side of unit)
+ plankRect(bx+ph, by, ph, pw, grains[ci2], 0,0,0);
+ // Second row horizontal
+ plankRect(bx, by+ph, pw, ph, grains[(ci+2)%grains.length], 0,0,0);
+ // Second row vertical
+ plankRect(bx+ph, by+ph, ph, pw, grains[(ci2+1)%grains.length], 0,0,0);
+ }
+ }
+
+ // Inlay border frame
+ const fw=4;
+ defs.push(`<linearGradient id="frame-grad" x1="0%" y1="0%" x2="100%" y2="100%">
+ <stop offset="0%" stop-color="#c9a961"/>
+ <stop offset="50%" stop-color="#e0c07a"/>
+ <stop offset="100%" stop-color="#8a6f35"/>
+ </linearGradient>`);
+
+ const svg=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">
+ <defs>${defs.join('')}</defs>
+ <g clip-path="url(#board-clip)">
+ <rect width="${size}" height="${size}" fill="#3a2a14"/>
+ ${svgPaths.join('\n')}
+ <!-- Inner frame inlay -->
+ <rect x="${fw/2}" y="${fw/2}" width="${size-fw}" height="${size-fw}"
+ fill="none" stroke="url(#frame-grad)" stroke-width="${fw}" opacity="0.6"/>
+ <rect x="${fw}" y="${fw}" width="${size-fw*2}" height="${size-fw*2}"
+ fill="none" stroke="rgba(0,0,0,0.4)" stroke-width="0.5"/>
+ </g>
+</svg>`;
+ return svg;
+}
+
+// ─── PUZZLE ENGINE ───────────────────────────────────────────────────────────
+const board=document.getElementById('board');
+const movesEl=document.getElementById('moves-display');
+const timerEl=document.getElementById('timer-display');
+const bestEl=document.getElementById('best-display');
+const winOverlay=document.getElementById('win-overlay');
+const winMovesEl=document.getElementById('win-moves');
+const winTimeEl=document.getElementById('win-time');
+const winBestEl=document.getElementById('win-best');
+const peekSVG=document.getElementById('peek-svg');
+const winArtSVG=document.getElementById('win-art-svg');
+
+let N=4; // grid size
+let tiles=[]; // 0..N*N-1, last = empty
+let emptyIdx=N*N-1;
+let moves=0;
+let startTime=null;
+let timerInterval=null;
+let won=false;
+let showNums=false;
+
+function bestKey(){return`dw-parquet-best-${N}`}
+function getBest(){return parseInt(localStorage.getItem(bestKey())||'0')||null}
+function setBest(m){localStorage.setItem(bestKey(),m)}
+
+function fmt(sec){
+ const m=Math.floor(sec/60),s=sec%60;
+ return `${m}:${s.toString().padStart(2,'0')}`;
+}
+function fmtMs(ms){return fmt(Math.round(ms/1000))}
+
+// Build the full SVG for the panel
+let panelSVG='';
+let tileSize=0;
+let boardPx=0;
+
+function makePanelSVG(n){
+ boardPx=Math.min(window.innerWidth-80,460);
+ boardPx=Math.max(boardPx,260);
+ // snap to nice tile
+ tileSize=Math.floor((boardPx-(n+1)*3)/n);
+ boardPx=tileSize*n+(n+1)*3;
+ return buildHerringboneSVG(boardPx);
+}
+
+function initGame(n){
+ N=n;
+ won=false;
+ moves=0;
+ movesEl.textContent='0';
+ clearInterval(timerInterval);
+ timerEl.textContent='0:00';
+ startTime=null;
+
+ panelSVG=makePanelSVG(N);
+
+ // Update peek
+ peekSVG.setAttribute('width',120);
+ peekSVG.setAttribute('height',120);
+ peekSVG.setAttribute('viewBox',`0 0 ${boardPx} ${boardPx}`);
+ peekSVG.innerHTML=`<image href="data:image/svg+xml;base64,${btoa(panelSVG)}" width="${boardPx}" height="${boardPx}"/>`;
+
+ // Update win art
+ winArtSVG.setAttribute('viewBox',`0 0 ${boardPx} ${boardPx}`);
+ winArtSVG.innerHTML=`<image href="data:image/svg+xml;base64,${btoa(panelSVG)}" width="${boardPx}" height="${boardPx}"/>`;
+
+ // Init solved order then shuffle
+ tiles=Array.from({length:N*N},(_,i)=>i);
+ emptyIdx=N*N-1;
+
+ updateBestDisplay();
+ shuffleTiles();
+ renderBoard();
+}
+
+function shuffleTiles(){
+ // Perform many random legal moves to ensure solvability
+ const moves_count=N*N*60;
+ let ei=N*N-1;
+ let lastMoved=-1;
+ for(let i=0;i<moves_count;i++){
+ const neighbors=getNeighbors(ei);
+ const valid=neighbors.filter(x=>x!==lastMoved);
+ const pick=valid[Math.floor(Math.random()*valid.length)];
+ [tiles[ei],tiles[pick]]=[tiles[pick],tiles[ei]];
+ lastMoved=ei;
+ ei=pick;
+ }
+ emptyIdx=ei;
+}
+
+function getNeighbors(idx){
+ const r=Math.floor(idx/N),c=idx%N;
+ const nb=[];
+ if(r>0)nb.push(idx-N);
+ if(r<N-1)nb.push(idx+N);
+ if(c>0)nb.push(idx-1);
+ if(c<N-1)nb.push(idx+1);
+ return nb;
+}
+
+function canMove(idx){
+ return getNeighbors(emptyIdx).includes(idx);
+}
+
+function moveTile(tileIdx){
+ if(won)return;
+ if(!canMove(tileIdx)){sfxIllegal();return;}
+ if(!startTime){
+ startTime=Date.now();
+ timerInterval=setInterval(()=>{
+ timerEl.textContent=fmtMs(Date.now()-startTime);
+ },500);
+ }
+ [tiles[emptyIdx],tiles[tileIdx]]=[tiles[tileIdx],tiles[emptyIdx]];
+ emptyIdx=tileIdx;
+ moves++;
+ movesEl.textContent=moves;
+ sfxSlide();
+ renderBoard();
+ checkWin();
+}
+
+function checkWin(){
+ if(tiles.every((v,i)=>v===i)){
+ won=true;
+ clearInterval(timerInterval);
+ const elapsed=startTime?fmtMs(Date.now()-startTime):'0:00';
+ const best=getBest();
+ if(!best||moves<best)setBest(moves);
+ updateBestDisplay();
+ setTimeout(()=>showWin(moves,elapsed),350);
+ }
+}
+
+function updateBestDisplay(){
+ const b=getBest();
+ bestEl.textContent=b||'—';
+}
+
+// ─── RENDER ──────────────────────────────────────────────────────────────────
+function renderBoard(){
+ board.style.gridTemplateColumns=`repeat(${N},${tileSize}px)`;
+ board.style.width=boardPx+'px';
+ board.innerHTML='';
+
+ // Parse the panel SVG into per-tile views
+ const svgB64=btoa(panelSVG);
+
+ for(let i=0;i<N*N;i++){
+ const val=tiles[i];
+ const el=document.createElement('div');
+ el.className='tile'+(val===N*N-1?' empty':'');
+ el.style.width=tileSize+'px';
+ el.style.height=tileSize+'px';
+ el.setAttribute('role','gridcell');
+
+ if(val!==N*N-1){
+ const tr=Math.floor(val/N); // source row
+ const tc=val%N; // source col
+ // offset into the full panel image
+ const ox=tc*tileSize;
+ const oy=tr*tileSize;
+
+ el.innerHTML=`<svg width="${tileSize}" height="${tileSize}" viewBox="${ox} ${oy} ${tileSize} ${tileSize}" xmlns="http://www.w3.org/2000/svg">
+ <image href="data:image/svg+xml;base64,${svgB64}" x="0" y="0" width="${boardPx}" height="${boardPx}" transform="translate(-${ox},-${oy})"/>
+ </svg>
+ <div class="tile-num${showNums?'':' hidden'}">${val+1}</div>`;
+
+ el.setAttribute('aria-label',`Tile ${val+1}`);
+ el.setAttribute('tabindex','0');
+ el.addEventListener('click',()=>moveTile(i));
+ el.addEventListener('keydown',e=>{
+ if(e.key==='Enter'||e.key===' ')moveTile(i);
+ });
+ } else {
+ el.setAttribute('aria-label','Empty space');
+ }
+
+ board.appendChild(el);
+ }
+ if(showNums)board.classList.add('show-nums');
+ else board.classList.remove('show-nums');
+}
+
+// ─── WIN OVERLAY ─────────────────────────────────────────────────────────────
+function showWin(m,t){
+ sfxWin();
+ winMovesEl.textContent=m;
+ winTimeEl.textContent=t;
+ const b=getBest();
+ winBestEl.textContent=b||m;
+ winOverlay.classList.add('show');
+ launchConfetti();
+}
+
+document.getElementById('play-again-btn').addEventListener('click',()=>{
+ winOverlay.classList.remove('show');
+ initGame(N);
+});
+document.getElementById('close-win-btn').addEventListener('click',()=>{
+ winOverlay.classList.remove('show');
+});
+
+// ─── CONFETTI ────────────────────────────────────────────────────────────────
+function launchConfetti(){
+ const colors=['#c9a961','#e0c07a','#f4f1ea','#8a6f35','#d4b06a','#c9a961'];
+ for(let i=0;i<50;i++){
+ setTimeout(()=>{
+ const el=document.createElement('div');
+ el.className='confetti-piece';
+ el.style.cssText=`
+ left:${20+Math.random()*60}vw;
+ top:-20px;
+ background:${colors[Math.floor(Math.random()*colors.length)]};
+ animation-duration:${1.5+Math.random()*1.5}s;
+ animation-delay:0s;
+ transform:rotate(${Math.random()*360}deg);
+ width:${6+Math.random()*8}px;
+ height:${6+Math.random()*8}px;
+ border-radius:${Math.random()>.5?'50%':'2px'};
+ `;
+ document.body.appendChild(el);
+ el.addEventListener('animationend',()=>el.remove());
+ },i*40);
+ }
+}
+
+// ─── KEYBOARD NAVIGATION ─────────────────────────────────────────────────────
+// Arrow keys move the tile adjacent to the empty in that direction
+document.addEventListener('keydown',e=>{
+ if(won)return;
+ const arrowMap={ArrowUp:N,ArrowDown:-N,ArrowLeft:1,ArrowRight:-1};
+ if(e.key in arrowMap){
+ e.preventDefault();
+ const offset=arrowMap[e.key];
+ const target=emptyIdx+offset;
+ if(target>=0&&target<N*N){
+ // Check it's not wrapping rows for left/right
+ if(e.key==='ArrowLeft'||e.key==='ArrowRight'){
+ const er=Math.floor(emptyIdx/N),tr2=Math.floor(target/N);
+ if(er!==tr2)return;
+ }
+ moveTile(target);
+ }
+ }
+});
+
+// ─── TOUCH SWIPE ─────────────────────────────────────────────────────────────
+let touchStart=null;
+board.addEventListener('touchstart',e=>{touchStart={x:e.touches[0].clientX,y:e.touches[0].clientY}},{passive:true});
+board.addEventListener('touchend',e=>{
+ if(!touchStart)return;
+ const dx=e.changedTouches[0].clientX-touchStart.x;
+ const dy=e.changedTouches[0].clientY-touchStart.y;
+ touchStart=null;
+ if(Math.abs(dx)<20&&Math.abs(dy)<20)return;
+ // Swipe direction slides tile into empty
+ let target=-1;
+ if(Math.abs(dx)>Math.abs(dy)){
+ target=dx>0?emptyIdx-1:emptyIdx+1;
+ } else {
+ target=dy>0?emptyIdx-N:emptyIdx+N;
+ }
+ if(target>=0&&target<N*N){
+ const er=Math.floor(emptyIdx/N),tr2=Math.floor(target/N);
+ if(Math.abs(dx)>Math.abs(dy)&&er!==tr2)return;
+ moveTile(target);
+ }
+},{passive:true});
+
+// ─── CONTROLS ────────────────────────────────────────────────────────────────
+document.getElementById('shuffle-btn').addEventListener('click',()=>{
+ winOverlay.classList.remove('show');
+ initGame(N);
+});
+
+document.getElementById('nums-btn').addEventListener('click',()=>{
+ showNums=!showNums;
+ renderBoard();
+});
+
+document.getElementById('solve-hint-btn').addEventListener('click',()=>{
+ // Highlight tiles that are adjacent to empty and in their correct position
+ // Simple hint: find first tile not in place and that can move, flash it
+ for(let i=0;i<N*N;i++){
+ if(tiles[i]!==i&&i!==emptyIdx&&canMove(i)){
+ const el=board.children[i];
+ if(el){
+ el.style.boxShadow='0 0 0 2px #c9a961, 0 0 12px rgba(201,169,97,.6)';
+ setTimeout(()=>{el.style.boxShadow=''},900);
+ }
+ break;
+ }
+ }
+});
+
+document.querySelectorAll('.sz-btn').forEach(btn=>{
+ btn.addEventListener('click',function(){
+ document.querySelectorAll('.sz-btn').forEach(b=>b.classList.remove('active'));
+ this.classList.add('active');
+ winOverlay.classList.remove('show');
+ initGame(parseInt(this.dataset.size));
+ });
+});
+
+// ─── INIT ────────────────────────────────────────────────────────────────────
+initGame(4);
+</script>
+</body>
+</html>
← bf43f1b DW Suite index page (/suite, /dw) — categorized landing for
·
back to Model Arena
·
Agent-driven tool: DW Chinoiserie Studio (frontend-developer 4dd0e69 →