← 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
M data/arcade-games.jsonA public/games/dw-pattern-runner.htmlA public/games/dw-wallpaper-calculator.html
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 →