[object Object]

← back to Model Arena

Agent-driven tool: DW Wallpaper Calculator (frontend-developer) — correct roll-yield+waste math, multi-wall, cost breakdown. Registered.

0841216d0c94be6ac38e2f9bdedb23a5d4625c97 · 2026-07-25 11:51:26 -0700 · Steve Abrams

Files touched

Diff

commit 0841216d0c94be6ac38e2f9bdedb23a5d4625c97
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Jul 25 11:51:26 2026 -0700

    Agent-driven tool: DW Wallpaper Calculator (frontend-developer) — correct roll-yield+waste math, multi-wall, cost breakdown. Registered.
---
 data/arcade-games.json                    |   6 +
 public/games/dw-pattern-runner.html       | 851 ++++++++++++++++++++++++++++++
 public/games/dw-wallpaper-calculator.html | 718 +++++++++++++++++++++++++
 3 files changed, 1575 insertions(+)

diff --git a/data/arcade-games.json b/data/arcade-games.json
index e6a5580..2858f37 100644
--- a/data/arcade-games.json
+++ b/data/arcade-games.json
@@ -83,6 +83,12 @@
       "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"
+    },
+    {
+      "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"
     }
   ]
 }
\ No newline at end of file
diff --git a/public/games/dw-pattern-runner.html b/public/games/dw-pattern-runner.html
new file mode 100644
index 0000000..3393d9d
--- /dev/null
+++ b/public/games/dw-pattern-runner.html
@@ -0,0 +1,851 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
+<title>Pattern Runner — Designer Wallcoverings</title>
+<style>
+:root {
+  --bg: #0d0b09;
+  --surface: #1a1713;
+  --surface2: #242018;
+  --ink: #f4f1ea;
+  --ink-dim: #9e9580;
+  --accent: #c9a961;
+  --accent-dim: #8a7240;
+  --red: #c94b4b;
+  --serif: 'Cormorant Garamond','Playfair Display','EB Garamond',Georgia,serif;
+  --sans: 'Gill Sans','Optima',Futura,system-ui,sans-serif;
+  --ease: cubic-bezier(.4,0,.2,1);
+  --radius: 6px;
+}
+*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
+html,body{width:100%;height:100%;background:var(--bg);color:var(--ink);font-family:var(--sans);overflow:hidden;touch-action:none}
+body{display:flex;flex-direction:column;align-items:center}
+
+header{
+  width:100%;padding:10px 20px;
+  display:flex;align-items:center;justify-content:space-between;
+  border-bottom:1px solid var(--accent-dim);
+  background:var(--bg);flex-shrink:0;
+}
+.wordmark{
+  font-family:var(--serif);font-size:clamp(16px,2.5vw,26px);
+  text-transform:uppercase;letter-spacing:.18em;color:var(--accent);
+  line-height:1;user-select:none;
+}
+.wordmark span{display:block;font-size:.5em;letter-spacing:.25em;color:var(--ink-dim);margin-top:2px}
+
+.hud{
+  display:flex;align-items:center;gap:20px;font-family:var(--serif);
+  font-size:clamp(13px,1.8vw,20px);letter-spacing:.06em;
+}
+.hud-val{color:var(--accent);font-size:1.3em;font-weight:700}
+.mute-btn{
+  background:none;border:1px solid var(--accent-dim);color:var(--ink-dim);
+  width:32px;height:32px;border-radius:50%;cursor:pointer;font-size:14px;
+  transition:border-color .2s var(--ease),color .2s var(--ease);
+}
+.mute-btn:hover{border-color:var(--accent);color:var(--accent)}
+
+#wrap{
+  flex:1;width:100%;display:flex;align-items:center;justify-content:center;
+  position:relative;overflow:hidden;
+}
+canvas{
+  display:block;max-width:100%;max-height:100%;
+  image-rendering:pixelated;cursor:pointer;
+}
+
+/* Overlays */
+.overlay{
+  position:absolute;inset:0;display:flex;flex-direction:column;
+  align-items:center;justify-content:center;gap:16px;
+  background:rgba(13,11,9,.88);backdrop-filter:blur(6px);
+  visibility:hidden;opacity:0;transition:opacity .35s var(--ease),visibility .35s;
+  z-index:10;padding:24px;text-align:center;
+}
+.overlay.show{visibility:visible;opacity:1}
+.overlay-title{
+  font-family:var(--serif);font-size:clamp(28px,5vw,52px);
+  text-transform:uppercase;letter-spacing:.14em;color:var(--accent);
+  line-height:1.1;
+}
+.overlay-sub{font-family:var(--serif);font-size:clamp(13px,2vw,20px);color:var(--ink-dim);letter-spacing:.08em}
+.overlay-score{font-family:var(--serif);font-size:clamp(20px,3.5vw,38px);color:var(--ink)}
+.btn{
+  font-family:var(--serif);font-size:clamp(14px,2vw,20px);
+  text-transform:uppercase;letter-spacing:.18em;
+  padding:12px 36px;background:var(--accent);color:var(--bg);
+  border:none;border-radius:var(--radius);cursor:pointer;
+  transition:background .2s var(--ease),transform .1s;
+  margin-top:8px;
+}
+.btn:hover{background:#dbbf7a;transform:translateY(-1px)}
+.btn:active{transform:translateY(0)}
+.best-line{font-size:clamp(11px,1.5vw,15px);color:var(--ink-dim);font-family:var(--sans);letter-spacing:.06em}
+</style>
+</head>
+<body>
+
+<header>
+  <div class="wordmark">Designer Wallcoverings<span>Pattern Runner</span></div>
+  <div class="hud">
+    <div>Score <span class="hud-val" id="hud-score">0</span></div>
+    <div>Best <span class="hud-val" id="hud-best">0</span></div>
+    <div>Dist <span class="hud-val" id="hud-dist">0m</span></div>
+    <button class="mute-btn" id="mute-btn" title="Mute">♪</button>
+  </div>
+</header>
+
+<div id="wrap">
+  <canvas id="c"></canvas>
+
+  <div class="overlay show" id="ov-start">
+    <div class="overlay-title">Pattern Runner</div>
+    <div class="overlay-sub">Collect wallpaper rolls · Dodge obstacles · Paper the wall</div>
+    <button class="btn" id="btn-start">Begin Run</button>
+    <div class="best-line" id="start-best"></div>
+  </div>
+
+  <div class="overlay" id="ov-over">
+    <div class="overlay-title">Run Complete</div>
+    <div class="overlay-score" id="over-score"></div>
+    <div class="overlay-score" id="over-dist"></div>
+    <div class="best-line" id="over-best"></div>
+    <button class="btn" id="btn-again">Run Again</button>
+  </div>
+</div>
+
+<script>
+(function(){
+'use strict';
+
+// ── Audio ─────────────────────────────────────────────────────────────────
+let muted = false;
+let ctx;
+function getCtx(){
+  if(!ctx) ctx=new(window.AudioContext||window.webkitAudioContext)();
+  if(ctx.state==='suspended') ctx.resume();
+  return ctx;
+}
+function beep(freq,type,dur,vol,detune){
+  if(muted) return;
+  try{
+    const c=getCtx(), g=c.createGain(), o=c.createOscillator();
+    o.type=type||'sine'; o.frequency.value=freq; o.detune.value=detune||0;
+    g.gain.setValueAtTime(vol||.18,c.currentTime);
+    g.gain.exponentialRampToValueAtTime(.001,c.currentTime+dur);
+    o.connect(g); g.connect(c.destination);
+    o.start(); o.stop(c.currentTime+dur);
+  }catch(e){}
+}
+function sfxJump(){ beep(320,'sine',.13,.15); setTimeout(()=>beep(480,'sine',.08,.1),60); }
+function sfxCollect(){ beep(660,'sine',.08,.14); setTimeout(()=>beep(880,'sine',.1,.12),55); setTimeout(()=>beep(1100,'triangle',.12,.1),110); }
+function sfxHit(){ beep(120,'sawtooth',.25,.22); setTimeout(()=>beep(80,'square',.3,.15),80); }
+function sfxOver(){ beep(220,'sawtooth',.15,.2); setTimeout(()=>beep(180,'sawtooth',.2,.18),120); setTimeout(()=>beep(140,'sawtooth',.35,.22),260); }
+
+document.getElementById('mute-btn').addEventListener('click',()=>{
+  muted=!muted;
+  document.getElementById('mute-btn').textContent=muted?'✕':'♪';
+});
+
+// ── Canvas + resize ───────────────────────────────────────────────────────
+const canvas=document.getElementById('c');
+const g=canvas.getContext('2d');
+let W=0,H=0;
+const BASE_H=360, BASE_W=640;
+
+function resize(){
+  const wrap=document.getElementById('wrap');
+  const wr=wrap.clientWidth, wh=wrap.clientHeight;
+  const scale=Math.min(wr/BASE_W, wh/BASE_H);
+  W=Math.round(BASE_W*scale); H=Math.round(BASE_H*scale);
+  canvas.width=W; canvas.height=H;
+}
+window.addEventListener('resize',()=>{ resize(); if(state!=='play') drawIdle(); });
+resize();
+
+// ── Game state ────────────────────────────────────────────────────────────
+let state='idle'; // idle|play|over
+let score=0, dist=0, speed=0;
+const BASE_SPEED=3.2;
+const FLOOR_Y_RATIO=0.78;
+let floorY=0;
+let best=parseInt(localStorage.getItem('dw-runner-best')||'0');
+document.getElementById('hud-best').textContent=best;
+const bestEl=document.getElementById('start-best');
+if(best>0) bestEl.textContent='Best: '+best+' pts';
+
+// ── Patterns progression ──────────────────────────────────────────────────
+// 0=grasscloth 1=damask 2=chinoiserie
+let patternPhase=0;
+let patternAlpha=1; // current pattern opacity
+let patternTrans=false;
+let patternTransTimer=0;
+
+// ── Character ─────────────────────────────────────────────────────────────
+const CHAR={
+  w:28,h:48,
+  x:0,y:0,
+  vy:0,
+  jumps:0,maxJumps:2,
+  onGround:false,
+  frame:0,frameTimer:0,
+  dead:false,
+  flashTimer:0,
+};
+
+// ── Particles ─────────────────────────────────────────────────────────────
+const particles=[];
+function spawnParticles(x,y,color){
+  for(let i=0;i<12;i++){
+    const ang=Math.random()*Math.PI*2;
+    const spd=1.5+Math.random()*2.5;
+    particles.push({
+      x,y,vx:Math.cos(ang)*spd,vy:Math.sin(ang)*spd-1.5,
+      life:1,decay:.025+Math.random()*.025,
+      r:3+Math.random()*3,color
+    });
+  }
+}
+
+// ── Scrolling elements ─────────────────────────────────────────────────────
+let obstacles=[];
+let rolls=[];
+let bgClouds=[];
+let wallOffset=0;
+let wallOffset2=0; // second layer parallax
+
+// Obstacle types: 0=paint can, 1=ladder, 2=sample box
+const OBS_DEFS=[
+  {w:24,h:32,type:0},
+  {w:18,h:56,type:1},
+  {w:36,h:28,type:2},
+];
+
+let spawnTimer=0;
+let spawnInterval=140; // frames between spawns
+let rollTimer=0;
+let rollInterval=90;
+let frameCount=0;
+let gameTime=0; // seconds
+
+// ── Scaled helpers ────────────────────────────────────────────────────────
+function sc(v){ return v*(W/BASE_W); }
+function sy(v){ return v*(H/BASE_H); }
+
+// ── Drawing: character (decorator silhouette) ─────────────────────────────
+function drawChar(cx,cy,frame,dead,flashT){
+  const s=sc(1);
+  if(flashT>0 && Math.floor(flashT*8)%2===0) return; // flash on hit
+  const cw=sc(CHAR.w), ch=sy(CHAR.h);
+  const px=cx-cw/2, py=cy-ch;
+
+  // legs animate
+  const legSwing=Math.sin(frame*0.5)*8*s;
+  const leg1=legSwing, leg2=-legSwing;
+
+  g.save();
+  g.translate(cx,cy);
+
+  // body (coat)
+  g.fillStyle=dead?'#666':'#1c1a17';
+  // torso
+  g.fillRect(-cw*.35, -ch*.72, cw*.7, ch*.42);
+  // coat lapel accent
+  g.fillStyle=dead?'#888':'#c9a961';
+  g.fillRect(-cw*.12,-ch*.72,cw*.1,ch*.22);
+  g.fillRect(cw*.02,-ch*.72,cw*.1,ch*.22);
+
+  // legs
+  g.fillStyle=dead?'#555':'#2a2520';
+  // left leg
+  g.save();
+  g.rotate(leg1*Math.PI/180);
+  g.fillRect(-cw*.3,-ch*.3,cw*.25,ch*.38);
+  g.restore();
+  // right leg
+  g.save();
+  g.rotate(leg2*Math.PI/180);
+  g.fillRect(cw*.05,-ch*.3,cw*.25,ch*.38);
+  g.restore();
+
+  // shoes
+  g.fillStyle='#0d0b09';
+  g.fillRect(-cw*.33,-ch*.005,cw*.29,ch*.055);
+  g.fillRect(cw*.04,-ch*.005,cw*.29,ch*.055);
+
+  // head
+  g.fillStyle=dead?'#aaa':'#c9a0a0'; // skin-ish
+  g.beginPath();
+  g.ellipse(0,-ch*.82,cw*.22,ch*.16,0,0,Math.PI*2);
+  g.fill();
+
+  // hat (top hat!)
+  g.fillStyle=dead?'#666':'#1c1a17';
+  g.fillRect(-cw*.25,-ch*1.02,cw*.5,ch*.07); // brim
+  g.fillRect(-cw*.18,-ch*1.24,cw*.36,ch*.24); // crown
+  // hat band
+  g.fillStyle='#c9a961';
+  g.fillRect(-cw*.18,-ch*1.05,cw*.36,ch*.04);
+
+  // arm + roller
+  const armSw=Math.sin(frame*0.5+Math.PI)*6*s;
+  g.save();
+  g.rotate(armSw*Math.PI/180);
+  // arm
+  g.fillStyle=dead?'#555':'#1c1a17';
+  g.fillRect(cw*.3,-ch*.65,cw*.12,ch*.32);
+  // paint roller handle
+  g.fillStyle='#8a7240';
+  g.fillRect(cw*.36,-ch*.73,cw*.06,ch*.12);
+  // roller head
+  g.fillStyle=dead?'#888':'#c9a961';
+  g.beginPath();
+  g.ellipse(cw*.45,-ch*.73,cw*.14,cw*.09,0,0,Math.PI*2);
+  g.fill();
+  g.restore();
+
+  g.restore();
+}
+
+// ── Drawing: paint can obstacle ───────────────────────────────────────────
+function drawPaintCan(x,y,w,h){
+  const s=sc(1);
+  // body
+  g.fillStyle='#c94b4b';
+  g.beginPath();
+  g.roundRect(x,y-h,w,h,sc(3));
+  g.fill();
+  // label
+  g.fillStyle='#f4f1ea';
+  g.fillRect(x+w*.15,y-h*.7,w*.7,h*.3);
+  g.fillStyle='#c94b4b';
+  g.font=`bold ${sc(7)}px sans-serif`;
+  g.textAlign='center';
+  g.fillText('PAINT',x+w/2,y-h*.48);
+  // handle
+  g.strokeStyle='#8a8a8a';
+  g.lineWidth=sc(2);
+  g.beginPath();
+  g.arc(x+w/2,y-h,w*.35,Math.PI,0);
+  g.stroke();
+  // shine
+  g.fillStyle='rgba(255,255,255,.15)';
+  g.fillRect(x+w*.1,y-h+h*.08,w*.15,h*.5);
+}
+
+function drawLadder(x,y,w,h){
+  // two rails
+  g.strokeStyle='#8a7240';
+  g.lineWidth=sc(4);
+  g.beginPath(); g.moveTo(x+w*.15,y); g.lineTo(x+w*.15,y-h); g.stroke();
+  g.beginPath(); g.moveTo(x+w*.85,y); g.lineTo(x+w*.85,y-h); g.stroke();
+  // rungs
+  const rungs=5;
+  g.lineWidth=sc(3);
+  for(let i=0;i<=rungs;i++){
+    const ry=y-h*(i/rungs);
+    g.beginPath(); g.moveTo(x+w*.15,ry); g.lineTo(x+w*.85,ry); g.stroke();
+  }
+}
+
+function drawSampleBox(x,y,w,h){
+  // box
+  g.fillStyle='#e8dfc8';
+  g.beginPath();
+  g.roundRect(x,y-h,w,h,sc(3));
+  g.fill();
+  // swatch patterns on box face
+  const cols=['#c9a961','#8b6f3e','#4a6741','#6b5b8a','#c94b4b'];
+  const sw=w/cols.length;
+  cols.forEach((c,i)=>{
+    g.fillStyle=c;
+    g.fillRect(x+i*sw+1,y-h*.6,sw-1,h*.35);
+  });
+  // label
+  g.fillStyle='#1c1a17';
+  g.font=`${sc(6)}px sans-serif`;
+  g.textAlign='center';
+  g.fillText('SAMPLES',x+w/2,y-h+sc(10));
+  // box edge shadow
+  g.strokeStyle='rgba(0,0,0,.3)';
+  g.lineWidth=sc(1.5);
+  g.beginPath();
+  g.roundRect(x,y-h,w,h,sc(3));
+  g.stroke();
+}
+
+// ── Drawing: wallpaper roll ───────────────────────────────────────────────
+function drawRoll(x,y,bob){
+  const r=sc(14);
+  const cx2=x, cy2=y+Math.sin(bob)*sy(6);
+  // glow
+  const grd=g.createRadialGradient(cx2,cy2,r*.3,cx2,cy2,r*1.6);
+  grd.addColorStop(0,'rgba(201,169,97,.35)');
+  grd.addColorStop(1,'rgba(201,169,97,0)');
+  g.fillStyle=grd;
+  g.beginPath(); g.arc(cx2,cy2,r*1.6,0,Math.PI*2); g.fill();
+  // roll body
+  g.fillStyle='#f4f1ea';
+  g.beginPath(); g.ellipse(cx2,cy2,r,r*.55,0,0,Math.PI*2); g.fill();
+  // pattern strip visible
+  g.fillStyle='#c9a961';
+  g.beginPath(); g.ellipse(cx2,cy2,r*.8,r*.44,0,0,Math.PI*2); g.fill();
+  // inner hole
+  g.fillStyle='#1c1a17';
+  g.beginPath(); g.ellipse(cx2,cy2,r*.25,r*.14,0,0,Math.PI*2); g.fill();
+  // shine
+  g.fillStyle='rgba(255,255,255,.45)';
+  g.beginPath(); g.ellipse(cx2-r*.3,cy2-r*.15,r*.22,r*.12,-.5,0,Math.PI*2); g.fill();
+  // star sparkle
+  g.fillStyle='#fff';
+  const sp=sc(3);
+  g.beginPath();
+  g.moveTo(cx2-r*.55,cy2-r*.28);
+  g.lineTo(cx2-r*.55+sp*.3,cy2-r*.28+sp);
+  g.lineTo(cx2-r*.55+sp,cy2-r*.28);
+  g.lineTo(cx2-r*.55+sp*.3,cy2-r*.28-sp);
+  g.closePath(); g.fill();
+}
+
+// ── Drawing: wall background patterns ─────────────────────────────────────
+function drawWallPattern(phase,alpha,offset,layerH){
+  g.save();
+  g.globalAlpha=alpha;
+  const ww=W, wh=layerH;
+  g.beginPath(); g.rect(0,0,ww,wh); g.clip();
+
+  if(phase===0){ // grasscloth — vertical stripes + fine weave
+    const stripeW=sc(18);
+    const cols=['#c8b98a','#bfaf82','#ccc0a0','#b8ad7e'];
+    const off=((offset*0.4)%stripeW+stripeW)%stripeW;
+    for(let x=-stripeW+off;x<ww+stripeW;x+=stripeW){
+      const ci=Math.floor((x/stripeW+200)%cols.length);
+      g.fillStyle=cols[Math.abs(ci)%cols.length];
+      g.fillRect(x,0,stripeW,wh);
+    }
+    // weave lines
+    g.strokeStyle='rgba(0,0,0,.07)';
+    g.lineWidth=sc(.5);
+    const lineSpacing=sy(5);
+    for(let y=0;y<wh;y+=lineSpacing){
+      g.beginPath(); g.moveTo(0,y); g.lineTo(ww,y); g.stroke();
+    }
+    for(let x=-lineSpacing+((offset*.3)%lineSpacing+lineSpacing)%lineSpacing;x<ww+lineSpacing;x+=lineSpacing){
+      g.beginPath(); g.moveTo(x,0); g.lineTo(x,wh); g.stroke();
+    }
+  } else if(phase===1){ // damask — repeating motifs
+    g.fillStyle='#e8ddc8';
+    g.fillRect(0,0,ww,wh);
+    const tw=sc(60), th=sy(72);
+    const offX=((offset*0.55)%tw+tw)%tw;
+    for(let x=-tw+offX;x<ww+tw;x+=tw){
+      for(let y=-th/2;y<wh+th;y+=th){
+        drawDamaskMotif(x+tw/2,y+th/2,tw*.4,th*.45);
+      }
+    }
+  } else { // chinoiserie — bamboo + branches
+    g.fillStyle='#e0ead8';
+    g.fillRect(0,0,ww,wh);
+    const spaceX=sc(55);
+    const offX=((offset*0.65)%spaceX+spaceX)%spaceX;
+    for(let x=-spaceX+offX;x<ww+spaceX;x+=spaceX){
+      drawBamboo(x, 0, wh, sc(1));
+    }
+    // small branches
+    g.strokeStyle='rgba(60,100,50,.3)';
+    g.lineWidth=sc(1);
+    for(let x=-spaceX+offX+spaceX*.5;x<ww+spaceX;x+=spaceX){
+      for(let y=sy(20);y<wh;y+=sy(40)){
+        g.beginPath(); g.moveTo(x,y); g.quadraticCurveTo(x+sc(15),y-sy(18),x+sc(28),y-sy(10)); g.stroke();
+        g.beginPath(); g.moveTo(x,y); g.quadraticCurveTo(x-sc(12),y-sy(15),x-sc(26),y-sy(8)); g.stroke();
+      }
+    }
+  }
+  g.restore();
+}
+
+function drawDamaskMotif(cx,cy,rw,rh){
+  g.save(); g.translate(cx,cy);
+  const c1='rgba(180,155,100,.35)', c2='rgba(100,80,50,.2)';
+  // main oval
+  g.strokeStyle=c1; g.lineWidth=sc(1.2);
+  g.beginPath(); g.ellipse(0,0,rw*.25,rh*.45,0,0,Math.PI*2); g.stroke();
+  // four petals
+  for(let a=0;a<4;a++){
+    g.save(); g.rotate(a*Math.PI/2);
+    g.strokeStyle=c1; g.lineWidth=sc(1.2);
+    g.beginPath(); g.ellipse(0,-rh*.35,rw*.18,rh*.22,0,0,Math.PI*2); g.stroke();
+    g.restore();
+  }
+  // diamond corners
+  g.strokeStyle=c2; g.lineWidth=sc(.8);
+  for(let a=0;a<4;a++){
+    g.save(); g.rotate(a*Math.PI/2+Math.PI/4);
+    g.beginPath(); g.ellipse(0,-rh*.5,rw*.1,rh*.12,0,0,Math.PI*2); g.stroke();
+    g.restore();
+  }
+  g.restore();
+}
+
+function drawBamboo(x,y,h,s){
+  const segH=sy(28);
+  g.strokeStyle='rgba(80,130,60,.45)'; g.lineWidth=sc(3.5);
+  g.beginPath(); g.moveTo(x,y); g.lineTo(x,y+h); g.stroke();
+  // joints
+  g.strokeStyle='rgba(50,100,40,.6)'; g.lineWidth=sc(2);
+  for(let sy2=y+segH*.5;sy2<y+h;sy2+=segH){
+    g.beginPath(); g.moveTo(x-sc(5),sy2); g.lineTo(x+sc(5),sy2); g.stroke();
+  }
+  // leaves
+  g.fillStyle='rgba(70,120,50,.4)';
+  for(let sy2=y+segH;sy2<y+h;sy2+=segH*1.5){
+    g.save(); g.translate(x,sy2);
+    g.rotate(-.4); g.beginPath();
+    g.ellipse(sc(12),0,sc(16),sc(4),0,0,Math.PI*2); g.fill();
+    g.restore();
+    g.save(); g.translate(x,sy2+segH*.4);
+    g.rotate(.5); g.beginPath();
+    g.ellipse(-sc(10),0,sc(14),sc(3.5),0,0,Math.PI*2); g.fill();
+    g.restore();
+  }
+}
+
+// ── Drawing: floor ────────────────────────────────────────────────────────
+function drawFloor(fy){
+  // baseboard
+  const bh=sy(12);
+  g.fillStyle='#e8dfc8';
+  g.fillRect(0,fy,W,bh);
+  g.fillStyle='#c9a961';
+  g.fillRect(0,fy,W,sy(2.5));
+  g.fillStyle='rgba(0,0,0,.2)';
+  g.fillRect(0,fy+bh-sy(2),W,sy(2));
+  // floor surface
+  const floorH=H-fy-bh;
+  g.fillStyle='#1a1410';
+  g.fillRect(0,fy+bh,W,floorH);
+  // floor tiles/planks
+  const tileW=sc(60);
+  const tileOff=((wallOffset*.7)%tileW+tileW)%tileW;
+  g.strokeStyle='rgba(255,255,255,.05)';
+  g.lineWidth=sc(1);
+  for(let x=-tileW+tileOff;x<W+tileW;x+=tileW){
+    g.beginPath(); g.moveTo(x,fy+bh); g.lineTo(x,H); g.stroke();
+  }
+  // floor sheen
+  const sh=g.createLinearGradient(0,fy+bh,0,H);
+  sh.addColorStop(0,'rgba(201,169,97,.08)');
+  sh.addColorStop(1,'rgba(201,169,97,0)');
+  g.fillStyle=sh;
+  g.fillRect(0,fy+bh,W,floorH);
+}
+
+// ── Drawing: ceiling molding ──────────────────────────────────────────────
+function drawCeiling(){
+  const mh=sy(16);
+  g.fillStyle='#e8dfc8';
+  g.fillRect(0,0,W,mh);
+  // dentil detail
+  g.fillStyle='#c9a961';
+  g.fillRect(0,mh-sy(3),W,sy(1));
+  const dentW=sc(12), dentH=sy(8), dentGap=sc(6);
+  const step=dentW+dentGap;
+  const dOff=((wallOffset*.3)%step+step)%step;
+  g.fillStyle='rgba(0,0,0,.12)';
+  for(let x=-step+dOff;x<W+step;x+=step){
+    g.fillRect(x,mh-dentH-sy(3),dentW,dentH);
+  }
+}
+
+// ── Main draw frame ───────────────────────────────────────────────────────
+function draw(){
+  g.clearRect(0,0,W,H);
+  const fy=H*FLOOR_Y_RATIO;
+  const wallH=fy;
+
+  // wall background
+  drawWallPattern(patternPhase,patternAlpha,wallOffset,wallH);
+  if(patternTrans){
+    const nextPhase=(patternPhase+1)%3;
+    drawWallPattern(nextPhase,1-patternAlpha,wallOffset,wallH);
+  }
+
+  // subtle vignette on wall
+  const vig=g.createLinearGradient(0,0,0,wallH);
+  vig.addColorStop(0,'rgba(13,11,9,.22)');
+  vig.addColorStop(.5,'rgba(13,11,9,0)');
+  vig.addColorStop(1,'rgba(13,11,9,.18)');
+  g.fillStyle=vig;
+  g.fillRect(0,0,W,wallH);
+
+  drawCeiling();
+  drawFloor(fy);
+
+  // particles
+  particles.forEach(p=>{
+    g.globalAlpha=p.life;
+    g.fillStyle=p.color;
+    g.beginPath(); g.arc(p.x,p.y,p.r,0,Math.PI*2); g.fill();
+  });
+  g.globalAlpha=1;
+
+  // obstacles
+  obstacles.forEach(ob=>{
+    const sx2=ob.x, sy2=fy, w2=sc(ob.w), h2=sy(ob.h);
+    if(ob.type===0) drawPaintCan(sx2,sy2,w2,h2);
+    else if(ob.type===1) drawLadder(sx2,sy2,w2,h2);
+    else drawSampleBox(sx2,sy2,w2,h2);
+  });
+
+  // rolls
+  rolls.forEach(r=>{
+    drawRoll(r.x,r.y,r.bob);
+  });
+
+  // character
+  const charX=W*.2, charY=fy;
+  drawChar(charX,CHAR.y,CHAR.frame,CHAR.dead,CHAR.flashTimer);
+}
+
+// ── Physics / update ──────────────────────────────────────────────────────
+let lastT=0;
+function update(dt){
+  const fy=H*FLOOR_Y_RATIO;
+  floorY=fy;
+  frameCount++;
+  gameTime+=dt;
+
+  // speed ramp
+  speed=BASE_SPEED+gameTime*0.08;
+  speed=Math.min(speed, BASE_SPEED*3.2);
+  const px=speed*(W/BASE_W); // pixels per frame at 60fps
+
+  // wall scroll
+  wallOffset-=px*.8;
+  wallOffset2-=px*.4;
+
+  // pattern phase transition
+  const phaseAt=[0,18,40]; // seconds to transition
+  if(!patternTrans){
+    const nextT=phaseAt[patternPhase+1];
+    if(nextT && gameTime>=nextT){
+      patternTrans=true;
+      patternTransTimer=0;
+    }
+  }
+  if(patternTrans){
+    patternTransTimer+=dt;
+    patternAlpha=Math.max(0,1-patternTransTimer/2.5);
+    if(patternAlpha<=0){
+      patternPhase=(patternPhase+1)%3;
+      patternAlpha=1;
+      patternTrans=false;
+    }
+  }
+
+  // distance
+  dist+=px/sc(60);
+
+  // character physics
+  if(!CHAR.dead){
+    CHAR.vy+=sy(.7); // gravity
+    CHAR.y+=CHAR.vy;
+    if(CHAR.y>=fy){
+      CHAR.y=fy;
+      CHAR.vy=0;
+      CHAR.jumps=0;
+      CHAR.onGround=true;
+    } else {
+      CHAR.onGround=false;
+    }
+    // run frame
+    CHAR.frameTimer+=dt*60;
+    if(CHAR.onGround) CHAR.frame=Math.floor(CHAR.frameTimer*.14)%8;
+    else CHAR.frame=4; // airborne pose
+  }
+
+  if(CHAR.flashTimer>0) CHAR.flashTimer-=dt;
+
+  // spawn obstacles
+  spawnTimer++;
+  const curInterval=Math.max(60,spawnInterval-gameTime*1.2);
+  if(spawnTimer>=curInterval){
+    spawnTimer=0;
+    const def=OBS_DEFS[Math.floor(Math.random()*OBS_DEFS.length)];
+    obstacles.push({x:W+sc(20),w:def.w,h:def.h,type:def.type});
+  }
+
+  // spawn rolls
+  rollTimer++;
+  const rollCur=Math.max(50,rollInterval-gameTime*.8);
+  if(rollTimer>=rollCur){
+    rollTimer=0;
+    const rollY=fy-sy(40+Math.random()*60);
+    rolls.push({x:W+sc(10),y:rollY,bob:Math.random()*Math.PI*2,bobSpeed:2+Math.random()});
+  }
+
+  // scroll obstacles
+  obstacles.forEach(ob=>{ ob.x-=px; });
+  obstacles=obstacles.filter(ob=>ob.x>-sc(60));
+
+  // scroll rolls
+  rolls.forEach(r=>{ r.x-=px*.95; r.bob+=dt*r.bobSpeed; });
+  rolls=rolls.filter(r=>r.x>-sc(30));
+
+  // particles
+  particles.forEach(p=>{
+    p.x+=p.vx; p.y+=p.vy; p.vy+=.06; p.life-=p.decay;
+  });
+  particles.splice(0,particles.filter(p=>p.life<=0).length);
+  // clean properly
+  for(let i=particles.length-1;i>=0;i--){
+    if(particles[i].life<=0) particles.splice(i,1);
+  }
+
+  if(CHAR.dead) return;
+
+  const charX=W*.2;
+  const cLeft=charX-sc(CHAR.w*.28), cRight=charX+sc(CHAR.w*.28);
+  const cTop=CHAR.y-sy(CHAR.h*.9), cBot=CHAR.y;
+
+  // collect rolls
+  for(let i=rolls.length-1;i>=0;i--){
+    const r=rolls[i];
+    const dx=r.x-charX, dy=r.y-CHAR.y+sy(CHAR.h*.5);
+    if(Math.abs(dx)<sc(16)&&Math.abs(dy)<sy(20)){
+      rolls.splice(i,1);
+      score+=10;
+      document.getElementById('hud-score').textContent=score;
+      spawnParticles(r.x,r.y,'#c9a961');
+      sfxCollect();
+    }
+  }
+
+  // collide obstacles
+  if(CHAR.flashTimer<=0){
+    for(let i=0;i<obstacles.length;i++){
+      const ob=obstacles[i];
+      const oLeft=ob.x+sc(ob.w*.08), oRight=ob.x+sc(ob.w*.92);
+      const oTop=fy-sy(ob.h*.92), oBot=fy;
+      if(cRight>oLeft&&cLeft<oRight&&cBot>oTop&&cTop<oBot){
+        CHAR.flashTimer=1.5;
+        score=Math.max(0,score-5);
+        document.getElementById('hud-score').textContent=score;
+        sfxHit();
+        // bounce back slightly
+        CHAR.vy=-sy(4);
+        obstacles.splice(i,1);
+        spawnParticles(charX,CHAR.y-sy(24),'#c94b4b');
+        break;
+      }
+    }
+  }
+
+  // score from distance
+  score+=dt*.5|0;
+  document.getElementById('hud-score').textContent=Math.floor(score);
+  document.getElementById('hud-dist').textContent=Math.floor(dist)+'m';
+}
+
+// ── Game loop ─────────────────────────────────────────────────────────────
+let rafId=null;
+function loop(t){
+  if(state!=='play'){ rafId=null; return; }
+  const dt=Math.min((t-lastT)/16.667,3);
+  lastT=t;
+  update(dt);
+  draw();
+  rafId=requestAnimationFrame(loop);
+}
+
+function startGame(){
+  state='play';
+  score=0; dist=0; gameTime=0; frameCount=0;
+  speed=BASE_SPEED;
+  wallOffset=0; wallOffset2=0;
+  obstacles=[]; rolls=[]; particles.splice(0);
+  patternPhase=0; patternAlpha=1; patternTrans=false;
+  CHAR.y=H*FLOOR_Y_RATIO;
+  CHAR.vy=0; CHAR.jumps=0; CHAR.dead=false; CHAR.flashTimer=0; CHAR.frame=0; CHAR.frameTimer=0;
+  document.getElementById('hud-score').textContent='0';
+  document.getElementById('hud-dist').textContent='0m';
+  document.getElementById('ov-start').classList.remove('show');
+  document.getElementById('ov-over').classList.remove('show');
+  if(rafId) cancelAnimationFrame(rafId);
+  lastT=performance.now();
+  rafId=requestAnimationFrame(loop);
+}
+
+function endGame(){
+  state='over';
+  CHAR.dead=true;
+  sfxOver();
+  if(score>best){
+    best=Math.floor(score);
+    localStorage.setItem('dw-runner-best',best);
+    document.getElementById('hud-best').textContent=best;
+  }
+  document.getElementById('over-score').textContent='Score: '+Math.floor(score)+' pts';
+  document.getElementById('over-dist').textContent='Distance: '+Math.floor(dist)+'m';
+  const ob=document.getElementById('over-best');
+  ob.textContent=score>=best?'New best!':'Best: '+best+' pts';
+  document.getElementById('ov-over').classList.add('show');
+}
+
+// ── Input ─────────────────────────────────────────────────────────────────
+function doJump(){
+  if(state!=='play') return;
+  if(CHAR.dead) return;
+  if(CHAR.jumps<CHAR.maxJumps){
+    CHAR.vy=-sy(10.5)*(CHAR.jumps===0?1:.82);
+    CHAR.jumps++;
+    sfxJump();
+  }
+}
+
+window.addEventListener('keydown',e=>{
+  if(e.code==='Space'||e.code==='ArrowUp'||e.code==='KeyW'){
+    e.preventDefault();
+    if(state==='idle'||state==='over') return;
+    doJump();
+  }
+});
+
+canvas.addEventListener('pointerdown',e=>{
+  e.preventDefault();
+  if(state==='play') doJump();
+});
+
+document.getElementById('btn-start').addEventListener('click',startGame);
+document.getElementById('btn-again').addEventListener('click',startGame);
+
+// ── Idle draw (start/over screens) ───────────────────────────────────────
+function drawIdle(){
+  resize();
+  const fy=H*FLOOR_Y_RATIO;
+  g.clearRect(0,0,W,H);
+  drawWallPattern(0,1,0,fy);
+  const vig=g.createLinearGradient(0,0,0,fy);
+  vig.addColorStop(0,'rgba(13,11,9,.25)'); vig.addColorStop(1,'rgba(13,11,9,.15)');
+  g.fillStyle=vig; g.fillRect(0,0,W,fy);
+  drawCeiling();
+  drawFloor(fy);
+  drawChar(W*.2, fy, Math.floor(Date.now()/120)%8, false, 0);
+}
+drawIdle();
+
+// idle animation
+let idleRaf=null;
+function idleLoop(){
+  if(state==='idle'||state==='over'){ drawIdle(); idleRaf=requestAnimationFrame(idleLoop); }
+  else idleRaf=null;
+}
+idleLoop();
+
+})();
+</script>
+</body>
+</html>
diff --git a/public/games/dw-wallpaper-calculator.html b/public/games/dw-wallpaper-calculator.html
new file mode 100644
index 0000000..578466a
--- /dev/null
+++ b/public/games/dw-wallpaper-calculator.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>Wallpaper Calculator — Designer Wallcoverings</title>
+<style>
+:root{
+  --bg:#f4f1ea;--bg2:#eceae1;--bg3:#e5e1d6;--ink:#1c1a17;--ink2:#4a4640;--ink3:#8a7f70;
+  --accent:#c9a961;--accent2:#8b6914;--accent-pale:#f0e8d0;
+  --white:#fdfcf8;--border:rgba(28,26,23,.12);
+  --serif:'Cormorant Garamond','Playfair Display','EB Garamond',Georgia,serif;
+  --sans:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,sans-serif;
+  --ease:cubic-bezier(.4,0,.2,1);--r:10px;
+  --shadow:0 2px 12px rgba(28,26,23,.08);
+  --shadow-lg:0 8px 32px rgba(28,26,23,.14);
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html{font-size:16px;-webkit-font-smoothing:antialiased;scroll-behavior:smooth}
+body{background:var(--bg);color:var(--ink);font-family:var(--sans);min-height:100vh;overflow-x:hidden}
+
+/* ── HEADER ── */
+header{
+  position:sticky;top:0;z-index:100;
+  padding:14px 32px;
+  display:flex;align-items:center;justify-content:space-between;
+  background:var(--bg);border-bottom:1px solid var(--border);
+  backdrop-filter:blur(8px);
+}
+.wordmark{
+  font-family:var(--serif);font-size:clamp(22px,3vw,40px);
+  font-weight:400;letter-spacing:.18em;text-transform:uppercase;
+  color:var(--ink);line-height:1;text-decoration:none;
+}
+.header-tag{font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ink3)}
+
+/* ── LAYOUT ── */
+main{max-width:1100px;margin:0 auto;padding:40px 24px 80px}
+.hero{text-align:center;margin-bottom:48px}
+.hero h1{
+  font-family:var(--serif);font-size:clamp(32px,5vw,56px);font-weight:400;
+  letter-spacing:.06em;line-height:1.15;color:var(--ink);margin-bottom:12px
+}
+.hero p{color:var(--ink2);font-size:15px;letter-spacing:.02em;max-width:480px;margin:0 auto}
+
+.grid{display:grid;grid-template-columns:1fr 420px;gap:32px;align-items:start}
+@media(max-width:860px){.grid{grid-template-columns:1fr}}
+
+/* ── CARD ── */
+.card{
+  background:var(--white);border:1px solid var(--border);
+  border-radius:var(--r);padding:28px 28px 24px;
+  box-shadow:var(--shadow);
+}
+.card+.card{margin-top:20px}
+.card-title{
+  font-family:var(--serif);font-size:18px;letter-spacing:.1em;text-transform:uppercase;
+  color:var(--ink);margin-bottom:20px;padding-bottom:12px;
+  border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px
+}
+.card-title svg{flex-shrink:0}
+
+/* ── UNIT TOGGLE ── */
+.unit-toggle{
+  display:inline-flex;border:1px solid var(--border);border-radius:6px;overflow:hidden;margin-bottom:24px
+}
+.unit-toggle button{
+  padding:6px 16px;background:none;border:none;cursor:pointer;font-size:13px;
+  font-family:var(--sans);letter-spacing:.05em;color:var(--ink2);transition:all .2s var(--ease)
+}
+.unit-toggle button.active{background:var(--accent);color:var(--white);font-weight:600}
+
+/* ── FORM ── */
+.field{margin-bottom:16px}
+.field label{
+  display:block;font-size:11px;letter-spacing:.1em;text-transform:uppercase;
+  color:var(--ink3);margin-bottom:6px
+}
+.field input,.field select{
+  width:100%;padding:10px 14px;
+  background:var(--bg);border:1.5px solid var(--border);border-radius:8px;
+  font-family:var(--sans);font-size:15px;color:var(--ink);
+  transition:border-color .2s var(--ease),box-shadow .2s var(--ease);
+  outline:none;-webkit-appearance:none;appearance:none
+}
+.field input:focus,.field select:focus{
+  border-color:var(--accent);
+  box-shadow:0 0 0 3px rgba(201,169,97,.18)
+}
+.field select{
+  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%238a7f70'/%3E%3C/svg%3E");
+  background-repeat:no-repeat;background-position:right 14px center;padding-right:36px
+}
+.row2{display:grid;grid-template-columns:1fr 1fr;gap:12px}
+.row3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px}
+
+/* ── WALL ENTRIES ── */
+.wall-list{display:flex;flex-direction:column;gap:10px;margin-bottom:14px}
+.wall-entry{
+  display:grid;grid-template-columns:1fr 1fr auto;gap:8px;align-items:center;
+  background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:10px 12px
+}
+.wall-entry label{font-size:11px;letter-spacing:.07em;text-transform:uppercase;color:var(--ink3);margin-bottom:3px;display:block}
+.wall-entry input{
+  width:100%;padding:7px 10px;background:var(--white);border:1.5px solid var(--border);
+  border-radius:6px;font-size:14px;color:var(--ink);outline:none;font-family:var(--sans);
+  transition:border-color .2s var(--ease)
+}
+.wall-entry input:focus{border-color:var(--accent)}
+.btn-rm{
+  width:28px;height:28px;border-radius:50%;border:1px solid var(--border);background:none;
+  cursor:pointer;display:flex;align-items:center;justify-content:center;
+  color:var(--ink3);transition:all .2s var(--ease);flex-shrink:0;align-self:flex-end;margin-bottom:1px
+}
+.btn-rm:hover{background:#fde8e8;border-color:#e09090;color:#c04040}
+.btn-add{
+  display:flex;align-items:center;gap:6px;padding:8px 14px;
+  border:1.5px dashed var(--accent);border-radius:8px;background:none;
+  color:var(--accent2);font-size:13px;letter-spacing:.05em;cursor:pointer;
+  transition:all .2s var(--ease);font-family:var(--sans)
+}
+.btn-add:hover{background:var(--accent-pale)}
+
+/* ── CUSTOM ROLL REVEAL ── */
+#custom-fields{
+  display:none;background:var(--bg2);border-radius:8px;padding:14px;margin-top:8px
+}
+#custom-fields.show{display:block}
+
+/* ── DIAGRAM ── */
+.diagram-wrap{margin-bottom:20px;text-align:center}
+.diagram-label{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--ink3);margin-top:8px}
+
+/* ── RESULT CARD ── */
+.result-card{
+  background:var(--ink);color:var(--white);
+  border-radius:var(--r);padding:28px;text-align:center;
+  box-shadow:var(--shadow-lg);position:relative;overflow:hidden
+}
+.result-card::before{
+  content:'';position:absolute;inset:0;
+  background:radial-gradient(ellipse at 30% 20%,rgba(201,169,97,.15) 0%,transparent 60%);
+  pointer-events:none
+}
+.result-eyebrow{font-size:10px;letter-spacing:.2em;text-transform:uppercase;color:var(--accent);margin-bottom:8px}
+.result-number{
+  font-family:var(--serif);font-size:72px;font-weight:400;
+  color:var(--accent);line-height:1;
+  transition:all .3s var(--ease);
+}
+.result-unit{font-family:var(--serif);font-size:22px;color:rgba(253,252,248,.7);letter-spacing:.05em}
+.result-note{font-size:13px;color:rgba(253,252,248,.6);margin-top:8px;line-height:1.5}
+
+/* ── BREAKDOWN ── */
+.breakdown{margin-top:20px;display:flex;flex-direction:column;gap:8px}
+.brow{
+  display:flex;justify-content:space-between;align-items:baseline;
+  font-size:13px;padding:8px 12px;border-radius:6px;
+  background:rgba(253,252,248,.05);border:1px solid rgba(253,252,248,.08)
+}
+.brow .key{color:rgba(253,252,248,.6);letter-spacing:.03em}
+.brow .val{color:var(--white);font-weight:500;font-variant-numeric:tabular-nums}
+.brow.highlight .val{color:var(--accent)}
+
+/* ── COST ROW ── */
+.cost-row{
+  margin-top:8px;padding:12px;border-radius:8px;
+  background:rgba(201,169,97,.12);border:1px solid rgba(201,169,97,.3);
+  display:flex;justify-content:space-between;align-items:baseline
+}
+.cost-row .key{font-size:13px;color:rgba(253,252,248,.7)}
+.cost-row .val{font-family:var(--serif);font-size:24px;color:var(--accent)}
+
+/* ── ACTIONS ── */
+.actions{display:flex;gap:10px;margin-top:20px;justify-content:center}
+.btn{
+  padding:10px 20px;border-radius:8px;font-size:13px;letter-spacing:.06em;
+  text-transform:uppercase;cursor:pointer;font-family:var(--sans);
+  transition:all .2s var(--ease);border:none;
+}
+.btn-primary{background:var(--accent);color:var(--ink);font-weight:600}
+.btn-primary:hover{background:#d4b26e;transform:translateY(-1px)}
+.btn-ghost{background:rgba(253,252,248,.08);color:rgba(253,252,248,.8);border:1px solid rgba(253,252,248,.15)}
+.btn-ghost:hover{background:rgba(253,252,248,.14)}
+
+/* ── EMPTY STATE ── */
+.empty-state{text-align:center;padding:32px 16px;color:var(--ink3);font-size:14px}
+
+/* ── TIPS ── */
+.tips{margin-top:16px;padding:14px 16px;border-radius:8px;background:var(--accent-pale);border:1px solid rgba(201,169,97,.3)}
+.tips p{font-size:13px;color:var(--ink2);line-height:1.6}
+.tips strong{color:var(--ink)}
+
+/* ── TOAST ── */
+.toast{
+  position:fixed;bottom:24px;left:50%;transform:translateX(-50%) translateY(20px);
+  background:var(--ink);color:var(--white);padding:10px 20px;border-radius:8px;
+  font-size:13px;letter-spacing:.04em;z-index:999;
+  opacity:0;transition:all .3s var(--ease);pointer-events:none;white-space:nowrap
+}
+.toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
+
+/* ── PRINT ── */
+@media print{
+  header{position:static}
+  .actions,.btn-add,.btn-rm,.unit-toggle,.tips{display:none!important}
+  .result-card{background:#1c1a17!important;-webkit-print-color-adjust:exact;print-color-adjust:exact}
+  main{padding:0}
+  .grid{grid-template-columns:1fr}
+}
+</style>
+</head>
+<body>
+
+<header>
+  <a class="wordmark" href="/" tabindex="0">Designer Wallcoverings</a>
+  <span class="header-tag">Wallpaper Calculator</span>
+</header>
+
+<main>
+  <div class="hero">
+    <h1>Wallpaper Calculator</h1>
+    <p>Enter your room dimensions and roll specifications for an accurate estimate — including pattern-repeat waste.</p>
+  </div>
+
+  <div class="grid">
+    <!-- LEFT: inputs -->
+    <div>
+      <!-- Unit toggle -->
+      <div class="card">
+        <div class="card-title">
+          <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><rect x="1" y="1" width="14" height="14" rx="2"/><path d="M1 5h14M5 5v10"/></svg>
+          Room Dimensions
+        </div>
+
+        <div class="unit-toggle" role="group" aria-label="Unit system">
+          <button id="btn-imperial" class="active" onclick="setUnit('imperial')" aria-pressed="true">ft / in</button>
+          <button id="btn-metric" onclick="setUnit('metric')" aria-pressed="false">m / cm</button>
+        </div>
+
+        <div id="wall-list" class="wall-list" aria-label="Wall dimensions"></div>
+
+        <button class="btn-add" onclick="addWall()" aria-label="Add another wall">
+          <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M7 1v12M1 7h12"/></svg>
+          Add another wall
+        </button>
+
+        <div class="field" style="margin-top:20px">
+          <label for="ceiling-h">Ceiling Height <span id="h-unit-label">(feet)</span></label>
+          <input type="number" id="ceiling-h" value="9" min="1" step="0.5" inputmode="decimal" aria-describedby="h-hint">
+          <div id="h-hint" style="font-size:11px;color:var(--ink3);margin-top:4px">Standard ceiling: 8–10 ft</div>
+        </div>
+      </div>
+
+      <!-- Roll config -->
+      <div class="card">
+        <div class="card-title">
+          <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><circle cx="8" cy="8" r="6"/><circle cx="8" cy="8" r="2"/></svg>
+          Roll Specification
+        </div>
+
+        <div class="field">
+          <label for="roll-preset">Roll Size Preset</label>
+          <select id="roll-preset" onchange="handlePreset()">
+            <option value="standard">Standard American — 20.5 in × 33 ft</option>
+            <option value="european">European Double Roll — 21 in × 33 ft</option>
+            <option value="grasscloth">Grasscloth / Natural Fiber — 36 in × 24 ft</option>
+            <option value="commercial">Commercial Type II — 54 in × 30 yd</option>
+            <option value="mural">Mural / Wide Format — 48 in × 12 ft</option>
+            <option value="custom">Custom dimensions…</option>
+          </select>
+        </div>
+
+        <div id="custom-fields" aria-live="polite">
+          <div class="row2">
+            <div class="field">
+              <label for="roll-w">Roll Width <span id="rw-unit">(inches)</span></label>
+              <input type="number" id="roll-w" value="20.5" min="1" step="0.25" inputmode="decimal">
+            </div>
+            <div class="field">
+              <label for="roll-l">Roll Length <span id="rl-unit">(feet)</span></label>
+              <input type="number" id="roll-l" value="33" min="1" step="1" inputmode="decimal">
+            </div>
+          </div>
+        </div>
+
+        <div class="field" style="margin-top:4px">
+          <label for="pattern-rep">Pattern Repeat <span id="pr-unit">(inches)</span></label>
+          <input type="number" id="pattern-rep" value="0" min="0" step="0.5" inputmode="decimal">
+          <div style="font-size:11px;color:var(--ink3);margin-top:4px">Enter 0 for no pattern / straight match</div>
+        </div>
+
+        <div class="field">
+          <label for="price-per">Price per Roll (optional, $)</label>
+          <input type="number" id="price-per" value="" min="0" step="1" inputmode="decimal" placeholder="e.g. 145">
+        </div>
+      </div>
+
+      <div class="tips">
+        <p><strong>Pro tip:</strong> Always order 10–15% extra for waste, cutting, and future repairs. Our calculator already includes a 10% trim allowance. For patterns with repeats over 18 in, consider adding one extra roll.</p>
+      </div>
+    </div>
+
+    <!-- RIGHT: results -->
+    <div>
+      <div class="diagram-wrap" id="diagram-wrap" aria-label="Wall coverage diagram"></div>
+
+      <div class="result-card" role="region" aria-label="Calculation results" aria-live="polite">
+        <div class="result-eyebrow">You'll Need</div>
+        <div class="result-number" id="result-num" aria-label="Rolls needed">—</div>
+        <div class="result-unit">rolls</div>
+        <div class="result-note" id="result-note">Enter your room dimensions to get started</div>
+
+        <div class="breakdown" id="breakdown"></div>
+        <div id="cost-wrap"></div>
+
+        <div class="actions">
+          <button class="btn btn-primary" onclick="copyEstimate()" aria-label="Copy estimate to clipboard">Copy Estimate</button>
+          <button class="btn btn-ghost" onclick="window.print()" aria-label="Print estimate">Print</button>
+        </div>
+      </div>
+    </div>
+  </div>
+</main>
+
+<div class="toast" id="toast" role="status" aria-live="polite"></div>
+
+<script>
+/* ── STATE ── */
+let unit = 'imperial';
+let walls = []; // [{id, w, h}] h is overridden by ceiling-h
+let wallId = 0;
+
+const PRESETS = {
+  standard:   {w:20.5, l:33,  wUnit:'in', lUnit:'ft'},
+  european:   {w:21,   l:33,  wUnit:'in', lUnit:'ft'},
+  grasscloth: {w:36,   l:24,  wUnit:'in', lUnit:'ft'},
+  commercial: {w:54,   l:90,  wUnit:'in', lUnit:'ft'}, // 30 yd = 90 ft
+  mural:      {w:48,   l:12,  wUnit:'in', lUnit:'ft'},
+};
+
+/* ── INIT ── */
+document.addEventListener('DOMContentLoaded', () => {
+  addWall();
+  addWall();
+  addWall();
+  addWall();
+  ['ceiling-h','pattern-rep','price-per'].forEach(id => {
+    document.getElementById(id).addEventListener('input', compute);
+  });
+  compute();
+});
+
+/* ── UNIT SYSTEM ── */
+function setUnit(u){
+  unit = u;
+  document.getElementById('btn-imperial').classList.toggle('active', u==='imperial');
+  document.getElementById('btn-metric').classList.toggle('active', u==='metric');
+  document.getElementById('btn-imperial').setAttribute('aria-pressed', u==='imperial');
+  document.getElementById('btn-metric').setAttribute('aria-pressed', u==='metric');
+
+  const isM = u==='metric';
+  document.getElementById('h-unit-label').textContent = isM ? '(meters)' : '(feet)';
+  document.getElementById('pr-unit').textContent = isM ? '(cm)' : '(inches)';
+  document.getElementById('rw-unit').textContent = isM ? '(cm)' : '(inches)';
+  document.getElementById('rl-unit').textContent = isM ? '(meters)' : '(feet)';
+
+  // Update wall entry placeholders/labels
+  document.querySelectorAll('.wall-unit-label').forEach(el => {
+    el.textContent = isM ? 'm' : 'ft';
+  });
+
+  // Re-set ceiling-h placeholder
+  const ch = document.getElementById('ceiling-h');
+  if(isM){ ch.placeholder='2.7'; ch.step='0.1'; }
+  else { ch.placeholder='9'; ch.step='0.5'; }
+
+  compute();
+}
+
+/* ── WALLS ── */
+function addWall(){
+  const id = ++wallId;
+  walls.push({id, w:'', h:''});
+  renderWalls();
+}
+
+function removeWall(id){
+  if(walls.length <= 1) return;
+  walls = walls.filter(w => w.id !== id);
+  renderWalls();
+}
+
+function renderWalls(){
+  const container = document.getElementById('wall-list');
+  container.innerHTML = '';
+  walls.forEach((wall, i) => {
+    const div = document.createElement('div');
+    div.className = 'wall-entry';
+    div.innerHTML = `
+      <div>
+        <label>Wall ${i+1} Width <span class="wall-unit-label">${unit==='metric'?'m':'ft'}</span></label>
+        <input type="number" min="0" step="${unit==='metric'?'0.1':'0.5'}" inputmode="decimal"
+               value="${wall.w}" placeholder="${unit==='metric'?'3.6':'12'}"
+               aria-label="Wall ${i+1} width"
+               onchange="updateWall(${wall.id},'w',this.value)" oninput="updateWall(${wall.id},'w',this.value)">
+      </div>
+      <div>
+        <label>Openings Width <span class="wall-unit-label">${unit==='metric'?'m':'ft'}</span></label>
+        <input type="number" min="0" step="${unit==='metric'?'0.1':'0.5'}" inputmode="decimal"
+               value="${wall.h}" placeholder="0 (doors/windows)"
+               aria-label="Wall ${i+1} opening deductions"
+               onchange="updateWall(${wall.id},'h',this.value)" oninput="updateWall(${wall.id},'h',this.value)">
+      </div>
+      ${walls.length > 1 ? `<button class="btn-rm" onclick="removeWall(${wall.id})" aria-label="Remove wall ${i+1}">
+        <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M1 1l8 8M9 1L1 9"/></svg>
+      </button>` : '<div></div>'}
+    `;
+    container.appendChild(div);
+  });
+}
+
+function updateWall(id, field, val){
+  const w = walls.find(x => x.id === id);
+  if(w) w[field] = val;
+  compute();
+}
+
+/* ── PRESET HANDLER ── */
+function handlePreset(){
+  const sel = document.getElementById('roll-preset').value;
+  const cf = document.getElementById('custom-fields');
+  if(sel === 'custom'){
+    cf.classList.add('show');
+  } else {
+    cf.classList.remove('show');
+  }
+  compute();
+}
+
+/* ── GET ROLL DIMENSIONS in inches and feet ── */
+function getRollDims(){
+  const sel = document.getElementById('roll-preset').value;
+  if(sel !== 'custom'){
+    const p = PRESETS[sel];
+    return {widthIn: p.w, lengthFt: p.l};
+  }
+  // custom
+  let rw = parseFloat(document.getElementById('roll-w').value)||20.5;
+  let rl = parseFloat(document.getElementById('roll-l').value)||33;
+  if(unit === 'metric'){
+    rw = rw / 2.54;   // cm → in
+    rl = rl * 3.28084; // m → ft
+  }
+  document.getElementById('roll-w').addEventListener('input', compute);
+  document.getElementById('roll-l').addEventListener('input', compute);
+  return {widthIn: rw, lengthFt: rl};
+}
+
+/* ── MAIN COMPUTE ── */
+function compute(){
+  const isM = unit === 'metric';
+
+  // Ceiling height → feet
+  let ceilRaw = parseFloat(document.getElementById('ceiling-h').value);
+  if(!ceilRaw || ceilRaw <= 0) ceilRaw = isM ? 2.74 : 9;
+  const ceilFt = isM ? ceilRaw * 3.28084 : ceilRaw;
+
+  // Pattern repeat → feet
+  let repRaw = parseFloat(document.getElementById('pattern-rep').value)||0;
+  const repFt = isM ? repRaw/100*3.28084 : repRaw/12;
+
+  // Price per roll
+  const pricePerRoll = parseFloat(document.getElementById('price-per').value)||0;
+
+  // Collect wall widths (net = width - openings) → feet
+  let totalNetWidthFt = 0;
+  walls.forEach(w => {
+    const ww = parseFloat(w.w)||0;
+    const wo = parseFloat(w.h)||0; // openings deduction
+    const netW = Math.max(0, ww - wo);
+    totalNetWidthFt += isM ? netW * 3.28084 : netW;
+  });
+
+  if(totalNetWidthFt <= 0 || ceilFt <= 0){
+    renderEmpty();
+    drawDiagram(0,0,0,ceilFt||9,0);
+    return;
+  }
+
+  // Gross wall area (sq ft)
+  const totalAreaSqFt = totalNetWidthFt * ceilFt;
+
+  // Roll dimensions
+  const {widthIn, lengthFt} = getRollDims();
+  const rollWidthFt = widthIn / 12;
+
+  // Strips per roll: how many full-height strips can one roll yield?
+  // Each strip height = ceilFt + one pattern repeat (waste per strip)
+  const stripHeightFt = ceilFt + repFt; // height of one usable cut (with repeat allowance)
+  // Apply 10% trim allowance on the usable roll length
+  const usableLengthFt = lengthFt * 0.90;
+  const stripsPerRoll = Math.floor(usableLengthFt / stripHeightFt);
+
+  if(stripsPerRoll <= 0){
+    // Edge case: roll too short for one strip
+    renderError('Roll length is shorter than wall height. Please use a longer roll.');
+    return;
+  }
+
+  // Coverage per roll in sq ft
+  const sqFtPerRoll = stripsPerRoll * rollWidthFt * ceilFt;
+
+  // Strips needed to cover total width
+  const stripsNeeded = Math.ceil(totalNetWidthFt / rollWidthFt);
+  const rollsNeeded = Math.ceil(stripsNeeded / stripsPerRoll);
+
+  // Total coverage if buying rollsNeeded rolls
+  const totalCoverage = rollsNeeded * sqFtPerRoll;
+  const overage = totalCoverage - totalAreaSqFt;
+
+  // Pattern repeat waste per roll
+  const wastePerRoll = stripsPerRoll * repFt * rollWidthFt;
+  const totalWasteSqFt = wastePerRoll * rollsNeeded;
+
+  // Estimated cost
+  const totalCost = pricePerRoll > 0 ? rollsNeeded * pricePerRoll : null;
+
+  renderResult({
+    rollsNeeded, totalAreaSqFt, sqFtPerRoll, stripsPerRoll,
+    rollWidthFt, totalCoverage, overage, totalWasteSqFt,
+    totalCost, pricePerRoll, ceilFt, widthIn, lengthFt
+  });
+
+  drawDiagram(totalNetWidthFt, ceilFt, stripsNeeded, ceilFt, rollWidthFt);
+}
+
+/* ── RENDER EMPTY ── */
+function renderEmpty(){
+  document.getElementById('result-num').textContent = '—';
+  document.getElementById('result-note').textContent = 'Enter your room dimensions to get started';
+  document.getElementById('breakdown').innerHTML = '';
+  document.getElementById('cost-wrap').innerHTML = '';
+}
+
+function renderError(msg){
+  document.getElementById('result-num').textContent = '!';
+  document.getElementById('result-note').textContent = msg;
+  document.getElementById('breakdown').innerHTML = '';
+  document.getElementById('cost-wrap').innerHTML = '';
+}
+
+/* ── RENDER RESULT ── */
+function renderResult(d){
+  // Animate number
+  const numEl = document.getElementById('result-num');
+  const prev = parseInt(numEl.textContent)||0;
+  if(prev !== d.rollsNeeded){
+    numEl.style.transform = 'scale(1.08)';
+    setTimeout(()=>{ numEl.style.transform = 'scale(1)'; }, 200);
+  }
+  numEl.textContent = d.rollsNeeded;
+  document.getElementById('result-note').textContent =
+    `${d.rollsNeeded} roll${d.rollsNeeded!==1?'s':''} · ${sqFtFmt(d.totalCoverage)} total coverage`;
+
+  const bk = document.getElementById('breakdown');
+  bk.innerHTML = `
+    <div class="brow"><span class="key">Total wall area</span><span class="val">${sqFtFmt(d.totalAreaSqFt)}</span></div>
+    <div class="brow"><span class="key">Roll width</span><span class="val">${d.widthIn} in</span></div>
+    <div class="brow"><span class="key">Roll length</span><span class="val">${d.lengthFt} ft</span></div>
+    <div class="brow"><span class="key">Strips per roll (after 10% trim)</span><span class="val">${d.stripsPerRoll}</span></div>
+    <div class="brow"><span class="key">Coverage per roll</span><span class="val">${sqFtFmt(d.sqFtPerRoll)}</span></div>
+    <div class="brow highlight"><span class="key">Overage / buffer</span><span class="val">${sqFtFmt(d.overage)}</span></div>
+    ${d.totalWasteSqFt > 0.1 ? `<div class="brow"><span class="key">Pattern waste</span><span class="val">${sqFtFmt(d.totalWasteSqFt)}</span></div>` : ''}
+  `;
+
+  const cw = document.getElementById('cost-wrap');
+  if(d.totalCost !== null){
+    cw.innerHTML = `<div class="cost-wrap"><div class="cost-row">
+      <span class="key">Estimated Total (${d.rollsNeeded} × $${d.pricePerRoll})</span>
+      <span class="val">$${d.totalCost.toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2})}</span>
+    </div></div>`;
+  } else {
+    cw.innerHTML = '';
+  }
+}
+
+function sqFtFmt(n){
+  return n.toLocaleString(undefined,{minimumFractionDigits:1,maximumFractionDigits:1}) + ' sq ft';
+}
+
+/* ── SVG DIAGRAM ── */
+function drawDiagram(totalWidthFt, heightFt, stripsNeeded, ceilFt, rollWidthFt){
+  const W = 340, H = 200, pad = 28;
+  const dw = W - pad*2, dh = H - pad*2 - 20;
+
+  // Scale to fit
+  const scaleX = totalWidthFt > 0 ? dw / totalWidthFt : 1;
+  const scaleY = heightFt > 0 ? dh / heightFt : 1;
+  const wallW = totalWidthFt > 0 ? Math.min(dw, totalWidthFt * scaleX) : dw;
+  const wallH = heightFt > 0 ? Math.min(dh, heightFt * scaleY) : dh;
+  const ox = pad + (dw - wallW)/2;
+  const oy = pad;
+
+  // Strip width in svg units
+  const stripSvgW = rollWidthFt > 0 && totalWidthFt > 0 ? (rollWidthFt / totalWidthFt) * wallW : 0;
+
+  // Build strips
+  let strips = '';
+  if(stripsNeeded > 0 && stripSvgW > 0){
+    const colors = ['#d4b870','#c9a961','#b89040','#e0c880','#a07828'];
+    for(let i=0; i<stripsNeeded; i++){
+      const x = ox + i * stripSvgW;
+      const w = Math.min(stripSvgW - 0.5, wallW - i * stripSvgW);
+      if(w <= 0) break;
+      strips += `<rect x="${x.toFixed(1)}" y="${oy}" width="${w.toFixed(1)}" height="${wallH}" fill="${colors[i%colors.length]}" opacity="${0.22 + (i%2)*0.08}"/>`;
+      if(i < stripsNeeded - 1){
+        strips += `<line x1="${(x+w).toFixed(1)}" y1="${oy}" x2="${(x+w).toFixed(1)}" y2="${oy+wallH}" stroke="#c9a961" stroke-width="1" stroke-dasharray="3,2" opacity="0.6"/>`;
+      }
+    }
+  }
+
+  // Dimension arrows
+  const arrowY = oy + wallH + 12;
+  const arrowX = ox + wallW + 10;
+
+  const svg = `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Wall coverage diagram showing ${stripsNeeded} strips">
+  <defs>
+    <marker id="ah" markerWidth="5" markerHeight="5" refX="5" refY="2.5" orient="auto">
+      <path d="M0,0 L5,2.5 L0,5" fill="none" stroke="#8a7f70" stroke-width="1"/>
+    </marker>
+    <marker id="ah2" markerWidth="5" markerHeight="5" refX="0" refY="2.5" orient="auto">
+      <path d="M5,0 L0,2.5 L5,5" fill="none" stroke="#8a7f70" stroke-width="1"/>
+    </marker>
+  </defs>
+
+  <!-- Wall background -->
+  <rect x="${ox}" y="${oy}" width="${wallW}" height="${wallH}"
+        fill="#ede8df" stroke="#c9a961" stroke-width="1.5" rx="2"/>
+
+  <!-- Strip fills -->
+  ${strips}
+
+  <!-- Wall border overlay -->
+  <rect x="${ox}" y="${oy}" width="${wallW}" height="${wallH}"
+        fill="none" stroke="#8b6914" stroke-width="2" rx="2"/>
+
+  <!-- Floor line -->
+  <line x1="${ox-4}" y1="${oy+wallH}" x2="${ox+wallW+4}" y2="${oy+wallH}"
+        stroke="#6b5a3e" stroke-width="2"/>
+
+  <!-- Width arrow -->
+  ${totalWidthFt > 0 ? `
+  <line x1="${ox}" y1="${arrowY}" x2="${ox+wallW}" y2="${arrowY}"
+        stroke="#8a7f70" stroke-width="1" marker-end="url(#ah)" marker-start="url(#ah2)"/>
+  <text x="${ox + wallW/2}" y="${arrowY + 12}" text-anchor="middle"
+        font-size="10" fill="#8a7f70" font-family="system-ui,sans-serif">
+    ${totalWidthFt.toFixed(1)} ft
+  </text>` : ''}
+
+  <!-- Height arrow -->
+  ${heightFt > 0 ? `
+  <line x1="${arrowX}" y1="${oy}" x2="${arrowX}" y2="${oy+wallH}"
+        stroke="#8a7f70" stroke-width="1" marker-end="url(#ah)" marker-start="url(#ah2)"/>
+  <text x="${arrowX+14}" y="${oy + wallH/2}" text-anchor="start" dominant-baseline="middle"
+        font-size="10" fill="#8a7f70" font-family="system-ui,sans-serif">
+    ${heightFt.toFixed(1)} ft
+  </text>` : ''}
+
+  <!-- Strip count badge -->
+  ${stripsNeeded > 0 ? `
+  <rect x="${ox+4}" y="${oy+4}" width="auto" height="16" rx="3" fill="rgba(28,26,23,.55)"/>
+  <text x="${ox+8}" y="${oy+15}" font-size="9" fill="#fdfcf8" font-family="system-ui,sans-serif">
+    ${stripsNeeded} strip${stripsNeeded!==1?'s':''}
+  </text>` : ''}
+</svg>`;
+
+  document.getElementById('diagram-wrap').innerHTML = svg + `<div class="diagram-label">Coverage diagram — each band = one roll width</div>`;
+}
+
+/* ── COPY ESTIMATE ── */
+function copyEstimate(){
+  const rolls = document.getElementById('result-num').textContent;
+  if(rolls === '—' || rolls === '!') return showToast('Enter dimensions first');
+
+  const lines = [];
+  lines.push('WALLPAPER ESTIMATE — Designer Wallcoverings');
+  lines.push('─'.repeat(40));
+  document.querySelectorAll('.brow').forEach(row => {
+    const k = row.querySelector('.key')?.textContent.trim();
+    const v = row.querySelector('.val')?.textContent.trim();
+    if(k && v) lines.push(`${k}: ${v}`);
+  });
+  const costEl = document.querySelector('.cost-row');
+  if(costEl){
+    lines.push(`Total cost: ${costEl.querySelector('.val')?.textContent.trim()}`);
+  }
+  lines.push('─'.repeat(40));
+  lines.push('designerwallcoverings.com');
+
+  navigator.clipboard.writeText(lines.join('\n'))
+    .then(()=>showToast('Estimate copied to clipboard'))
+    .catch(()=>showToast('Copy failed — try printing instead'));
+}
+
+function showToast(msg){
+  const t = document.getElementById('toast');
+  t.textContent = msg;
+  t.classList.add('show');
+  setTimeout(()=>t.classList.remove('show'), 2800);
+}
+
+// Wire up custom roll inputs
+document.getElementById('roll-w').addEventListener('input', compute);
+document.getElementById('roll-l').addEventListener('input', compute);
+</script>
+</body>
+</html>

← b20881d Agent-driven social tool: DW Content Calendar (frontend-deve  ·  back to Model Arena  ·  Agent-driven game: DW Pattern Runner (frontend-developer) — f4f86dc →