[object Object]

← back to CelebritySignatures

Fix Lincoln shop links and launch custom signature wallpaper studio

3d9677dc6adcc75287c6055431f71b9d049d01d9 · 2026-09-09 12:59:20 -0700 · Steve

Files touched

Diff

commit 3d9677dc6adcc75287c6055431f71b9d049d01d9
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Sep 9 12:59:20 2026 -0700

    Fix Lincoln shop links and launch custom signature wallpaper studio
---
 public/assets/wallpaper-pattern.js    |  48 ++++++++++
 public/assets/wallpaper.css           |   5 ++
 public/assets/wallpaper.js            | 163 ++++++++++++++++++++++++++++++++++
 public/game.html                      |   2 +-
 public/index.html                     |  16 ++--
 public/wallpaper.html                 |  49 ++++++++++
 public/wear.html                      |   2 +-
 server.js                             |   8 +-
 test/wallpaper-pattern.test.mjs       |  28 ++++++
 verification/lincoln-campaign.e2e.cjs |  41 +++++++++
 verification/wallpaper.e2e.cjs        |  75 ++++++++++++++++
 11 files changed, 424 insertions(+), 13 deletions(-)

diff --git a/public/assets/wallpaper-pattern.js b/public/assets/wallpaper-pattern.js
new file mode 100644
index 0000000..e84949c
--- /dev/null
+++ b/public/assets/wallpaper-pattern.js
@@ -0,0 +1,48 @@
+export const DEFAULTS = Object.freeze({version:1,qid:'Q91',stripeWidth:4,gapWidth:1.5,rowGap:1.5,scale:100,repeat:'straight',stripeColor:'#e0e6dc',gapColor:'#faf7ef',inkColor:'#244335',zoom:80});
+const number = (value, fallback, min, max) => value !== null && value !== '' && Number.isFinite(Number(value)) ? Math.min(max,Math.max(min,Number(value))) : fallback;
+const color = (value, fallback) => /^#[a-f\d]{6}$/i.test(String(value)) ? value.toLowerCase() : fallback;
+export function normalize(input={}) {
+  if (!input || typeof input !== 'object') input={};
+  return {version:1,qid:/^Q\d+$/.test(input.qid || '') ? input.qid : DEFAULTS.qid,
+    stripeWidth:number(input.stripeWidth,DEFAULTS.stripeWidth,1,10),gapWidth:number(input.gapWidth,DEFAULTS.gapWidth,0,6),
+    rowGap:number(input.rowGap,DEFAULTS.rowGap,0.25,6),scale:number(input.scale,DEFAULTS.scale,25,150),
+    repeat:input.repeat==='half-drop'?'half-drop':'straight',stripeColor:color(input.stripeColor,DEFAULTS.stripeColor),
+    gapColor:color(input.gapColor,DEFAULTS.gapColor),inkColor:color(input.inkColor,DEFAULTS.inkColor),zoom:number(input.zoom,DEFAULTS.zoom,40,150)};
+}
+export function geometry(input, aspect, ppi=96) {
+  const s=normalize(input);
+  const stripe=Math.round(s.stripeWidth*ppi), gap=Math.round(s.gapWidth*ppi);
+  const columns=s.repeat==='half-drop'?2:1;
+  const ratio=number(aspect,4,0.2,30);
+  let motifWidth=Math.round(stripe*0.6*s.scale/100);
+  let motifHeight=Math.max(1,Math.round(motifWidth/ratio));
+  // Keep unusually tall marks proportional and the export canvas bounded.
+  if(motifHeight>5*ppi){motifHeight=5*ppi;motifWidth=Math.max(1,Math.round(motifHeight*ratio));}
+  const height=motifHeight+Math.round(s.rowGap*ppi);
+  return {stripe,gap,columns,columnWidth:stripe+gap,width:(stripe+gap)*columns,height,motifWidth,motifHeight,ppi};
+}
+export function placements(g,repeat) {
+  const output=[];
+  for(let col=0;col<g.columns;col++) {
+    const x=col*g.columnWidth+(g.stripe-g.motifWidth)/2;
+    const drop=repeat==='half-drop' && col%2 ? g.height/2 : 0;
+    for(let row=-1;row<=1;row++) {
+      const y=(g.height-g.motifHeight)/2+drop+row*g.height;
+      if(y<g.height && y+g.motifHeight>0) output.push({x,y,width:g.motifWidth,height:g.motifHeight,column:col});
+    }
+  }
+  return output;
+}
+export function drawTile(canvas,mask,input,ppi=96) {
+  const s=normalize(input),g=geometry(s,mask.width/mask.height,ppi);
+  canvas.width=g.width;canvas.height=g.height;
+  const ctx=canvas.getContext('2d');
+  ctx.fillStyle=s.gapColor;ctx.fillRect(0,0,g.width,g.height);
+  ctx.fillStyle=s.stripeColor;
+  for(let col=0;col<g.columns;col++) ctx.fillRect(col*g.columnWidth,0,g.stripe,g.height);
+  const ink=document.createElement('canvas');ink.width=mask.width;ink.height=mask.height;
+  const brush=ink.getContext('2d');brush.drawImage(mask,0,0);brush.globalCompositeOperation='source-in';
+  brush.fillStyle=s.inkColor;brush.fillRect(0,0,ink.width,ink.height);
+  for(const p of placements(g,s.repeat)) ctx.drawImage(ink,p.x,p.y,p.width,p.height);
+  return g;
+}
diff --git a/public/assets/wallpaper.css b/public/assets/wallpaper.css
new file mode 100644
index 0000000..05e3f41
--- /dev/null
+++ b/public/assets/wallpaper.css
@@ -0,0 +1,5 @@
+:root{--ink:#21382d;--muted:#69736c;--line:#dedfd5;--paper:#f7f5ef;--card:#fffefb;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--ink);background:var(--paper);font-synthesis:none}
+*{box-sizing:border-box}body{margin:0}a{color:inherit}button,input,select{font:inherit}button,a,input,select{touch-action:manipulation}button{cursor:pointer}button:disabled{opacity:.4;cursor:wait}[hidden]{display:none!important}:focus-visible{outline:3px solid #ad7e46;outline-offset:3px}.masthead{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:24px 4vw;border-bottom:1px solid var(--line);background:var(--card)}.brand{font:italic 29px Georgia,serif;text-decoration:none;white-space:nowrap}.masthead nav{display:flex;gap:25px;font-size:14px}.masthead nav a{text-decoration:none;padding:8px 0}.masthead [aria-current]{border-bottom:1px solid var(--ink)}main{max-width:1540px;padding:38px 4vw 50px;margin:auto}.intro{display:flex;justify-content:space-between;align-items:end;gap:20px;margin-bottom:30px}.eyebrow{font-size:10px;text-transform:uppercase;letter-spacing:.2em;margin:0 0 10px;color:var(--muted)}h1{font:400 clamp(27px,3.2vw,43px)/1.1 Georgia,serif;margin:0 0 12px}.intro p:not(.eyebrow){color:var(--muted);margin:0;font-size:15px}.intro>a{font-size:12px;white-space:nowrap;text-underline-offset:5px}.studio{display:grid;grid-template-columns:320px minmax(0,1fr);gap:30px;align-items:start}.settings{border:1px solid var(--line);background:var(--card);border-radius:14px;padding:0 23px 20px}.settings section{padding:24px 0;border-bottom:1px solid var(--line)}h2{font:400 21px Georgia,serif;margin:0 0 18px}.settings h2 span{font:10px sans-serif;color:#999f95;margin-right:9px}.settings label,.settings legend{font-size:12px;display:block;margin:0 0 9px}.settings input[type=search]{border:1px solid var(--line);border-radius:8px;width:100%;padding:11px 12px;background:white;font-size:15px}.settings select{width:100%;margin-top:8px;padding:5px;border:1px solid var(--line);border-radius:8px;background:white;font-size:13px;height:105px}.settings option{padding:5px}.small{font-size:11px;line-height:1.6;color:var(--muted);margin:10px 0 0}.selected{display:flex;align-items:center;gap:12px;margin-top:18px;min-height:44px}.selected img{width:78px;height:38px;object-fit:contain;mix-blend-mode:multiply}.selected strong{display:block;font:17px Georgia,serif}.selected span{display:block;font-size:10px;color:var(--muted);margin-top:4px}.color-pair{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-bottom:20px}input[type=color]{display:block;width:100%;height:36px;padding:2px;border:1px solid var(--line);border-radius:6px;background:#fff;cursor:pointer}.color-pair input{margin-top:9px}output{font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums}.color-pair output{display:block;margin-top:5px}.settings .range-label{display:flex;align-items:center;justify-content:space-between;margin:17px 0 5px}input[type=range]{accent-color:var(--ink);width:100%;cursor:pointer}.ink{display:flex!important;align-items:center;justify-content:space-between;gap:10px}.ink input{width:48px;height:28px}.ink output{margin-left:auto}fieldset{padding:0;margin:24px 0 0;border:0}.repeat-options{display:grid;grid-template-columns:1fr 1fr;gap:9px}.repeat-options label{cursor:pointer;margin:0}.repeat-options input{position:absolute;opacity:0;pointer-events:none}.repeat-options span{display:block;border:1px solid var(--line);border-radius:8px;padding:10px 8px;min-height:74px}.repeat-options b{font-size:11px;font-weight:500;display:block}.repeat-options small{display:block;font-size:10px;line-height:1.5;color:var(--muted);margin-top:6px}.repeat-options input:checked+span{background:#e8eee5;border-color:var(--ink)}.repeat-options input:focus-visible+span{outline:3px solid #ad7e46}.text-button{border:0;background:none;padding:12px 0 0;color:var(--muted);font-size:12px;text-decoration:underline;text-underline-offset:4px}.preview-panel{position:sticky;top:22px;min-width:0}.preview-toolbar{display:flex;align-items:center;justify-content:space-between;gap:15px;margin:0 0 16px}.preview-toolbar h2{margin:5px 0 0;font-size:22px}.preview-toolbar label{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:11px}.preview-toolbar input{width:95px}.preview-toolbar output{width:33px}.preview-frame{position:relative;width:100%;aspect-ratio:1.13;max-height:72vh;min-height:300px;overflow:hidden;border-radius:12px;border:1px solid #d3d8cc;background:#e9e7df;box-shadow:0 15px 45px #26382e0c}.preview-frame canvas{display:block;width:100%;height:100%}.preview-frame>p{position:absolute;inset:0;display:grid;place-items:center;background:#f7f5efe8;margin:0;font-size:14px;color:var(--muted)}.preview-footer{display:flex;align-items:baseline;justify-content:space-between;gap:15px;margin-top:14px;color:var(--muted);font-size:11px;line-height:1.5}.preview-footer p{margin:0}.download-row{display:flex;gap:9px;flex-wrap:wrap;margin-top:20px}.button{border:1px solid var(--ink);background:var(--ink);color:white;border-radius:999px;padding:12px 18px;font-size:12px;text-align:center;cursor:pointer}.button.secondary{background:transparent;color:var(--ink);border-color:#bdc7b8}.export-note{max-width:65ch}.notice{font-size:13px;line-height:1.5;padding:13px 15px;background:#fff4dc;border:1px solid #e0c991;border-radius:8px}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}footer{padding:10px 4vw 32px;font-size:11px;color:var(--muted);text-align:center}footer a{margin-left:12px}
+@media(max-width:950px){.studio{grid-template-columns:285px minmax(0,1fr);gap:20px}.settings{padding:0 16px 15px}.preview-toolbar{align-items:start;flex-direction:column}.intro>a{display:none}.brand{font-size:25px}.masthead nav{gap:16px}}
+@media(max-width:700px){.masthead{padding:17px 20px;flex-wrap:wrap;gap:14px}.masthead nav{font-size:12px;gap:20px}.brand{font-size:26px}main{padding:25px 18px}.intro{margin-bottom:23px}.intro p:not(.eyebrow){font-size:13px}.studio{display:flex;flex-direction:column;gap:22px}.preview-panel{position:static;order:-1;width:100%}.preview-frame{min-height:250px;max-height:54vh;aspect-ratio:1.2}.preview-toolbar{flex-direction:row;align-items:center}.preview-toolbar h2{font-size:19px}.preview-toolbar input{width:67px}.settings{width:100%;padding:0 22px 18px}.download-row{gap:7px}.button{font-size:11px;padding:10px 13px}.preview-footer{font-size:10px}.preview-footer p:first-child{max-width:70%}.settings section{padding:20px 0}h1{font-size:31px}.export-note{font-size:10px}footer{text-align:left;padding:5px 18px 25px}}
+@media(max-width:700px){.preview-panel{position:sticky;top:0;z-index:5;background:var(--paper);padding:10px 0 12px;border-bottom:1px solid var(--line)}.preview-frame{min-height:150px;height:22vh;max-height:190px;aspect-ratio:auto}.preview-toolbar{margin-bottom:10px}.preview-footer{margin-top:8px}.download-row{margin-top:12px}.export-note{font-size:9px;line-height:1.4}.settings input,.settings select,.settings fieldset{scroll-margin-top:400px}}
diff --git a/public/assets/wallpaper.js b/public/assets/wallpaper.js
new file mode 100644
index 0000000..6a63295
--- /dev/null
+++ b/public/assets/wallpaper.js
@@ -0,0 +1,163 @@
+import {DEFAULTS,normalize,drawTile,geometry} from './wallpaper-pattern.js';
+const $=id=>document.getElementById(id), KEY='celebsig-wallpaper-v1';
+let design=normalize(), signatures=[], selected=null, mask=null, loadId=0, frame=0;
+const masks=new Map(), tile=document.createElement('canvas');
+let storageAvailable=true;
+try {design=normalize(JSON.parse(localStorage.getItem(KEY)||'{}'));} catch {storageAvailable=false;}
+const requested=new URLSearchParams(location.search).get('qid');
+if(requested && /^Q\d+$/.test(requested)) design.qid=requested;
+const fields=['stripeWidth','gapWidth','rowGap','scale','stripeColor','gapColor','inkColor','zoom'];
+const inches=n=>`${Number(n.toFixed(2))} in`;
+function notice(message='') {$('notice').textContent=message;$('notice').hidden=!message;}
+function persist() {
+  try {localStorage.setItem(KEY,JSON.stringify(design));storageAvailable=true;}
+  catch {storageAvailable=false;}
+  $('saveStatus').textContent=storageAvailable?'Saved in this browser':'Use “Save design settings” to keep your work';
+}
+function controls() {
+  for(const field of fields) {
+    $(field).value=design[field];
+    $(field+'Value').textContent=field.endsWith('Color')?design[field]:['scale','zoom'].includes(field)?design[field]+'%':inches(design[field]);
+  }
+  document.querySelectorAll('[name=repeat]').forEach(r=>r.checked=r.value===design.repeat);
+}
+function schedule() {if(!frame) frame=requestAnimationFrame(()=>{frame=0;render();});}
+function render() {
+  if(!mask) return;
+  const g=drawTile(tile,mask,design), canvas=$('wallpaperPreview');
+  const {width,height}=$('previewFrame').getBoundingClientRect();
+  const dpr=Math.min(devicePixelRatio||1,2);
+  canvas.width=Math.round(width*dpr);canvas.height=Math.round(height*dpr);
+  const ctx=canvas.getContext('2d');
+  const scale=dpr*design.zoom/100*0.55;
+  ctx.scale(scale,scale);ctx.fillStyle=ctx.createPattern(tile,'repeat');
+  ctx.fillRect(0,0,canvas.width/scale,canvas.height/scale);
+  canvas.dataset.signature=design.qid;canvas.dataset.repeat=design.repeat;
+  canvas.setAttribute('aria-label',`${selected.full_name} wallpaper, ${design.repeat==='half-drop'?'half-drop':'straight'} repeat`);
+  $('tileSummary').textContent=`Repeat tile: ${inches(g.width/g.ppi)} × ${inches(g.height/g.ppi)} · ${design.repeat==='half-drop'?'Half-drop':'Straight'} repeat`;
+  $('previewLoading').hidden=true;$('downloadTile').disabled=false;
+}
+function signatureMask(img) {
+  const ratio=Math.min(1,1600/Math.max(img.naturalWidth,img.naturalHeight));
+  const source=document.createElement('canvas');source.width=Math.max(1,Math.round(img.naturalWidth*ratio));source.height=Math.max(1,Math.round(img.naturalHeight*ratio));
+  const ctx=source.getContext('2d',{willReadFrequently:true});ctx.drawImage(img,0,0,source.width,source.height);
+  const pixels=ctx.getImageData(0,0,source.width,source.height),d=pixels.data;
+  let minX=source.width,minY=source.height,maxX=-1,maxY=-1;
+  for(let y=0;y<source.height;y++) for(let x=0;x<source.width;x++) {
+    const i=(y*source.width+x)*4;
+    // Keep the dark handwriting and remove white paper, including opaque JPG grounds.
+    const alpha=Math.round((255-Math.min(d[i],d[i+1],d[i+2]))*d[i+3]/255);
+    d[i]=d[i+1]=d[i+2]=0;d[i+3]=alpha;
+    if(alpha>20){minX=Math.min(minX,x);minY=Math.min(minY,y);maxX=Math.max(maxX,x);maxY=Math.max(maxY,y);}
+  }
+  if(maxX<minX || maxY<minY) throw new Error('No signature ink found');
+  ctx.putImageData(pixels,0,0);
+  const result=document.createElement('canvas');result.width=maxX-minX+1;result.height=maxY-minY+1;
+  result.getContext('2d').drawImage(source,minX,minY,result.width,result.height,0,0,result.width,result.height);
+  return result;
+}
+function loadImage(url) {
+  return new Promise((resolve,reject)=>{
+    const image=new Image();image.crossOrigin='anonymous';
+    const timer=setTimeout(()=>{image.onload=image.onerror=null;reject(new Error('Image timed out'));},18000);
+    image.onload=()=>{clearTimeout(timer);resolve(image);};image.onerror=()=>{clearTimeout(timer);reject(new Error('Image unavailable'));};image.src=url;
+  });
+}
+function updateURL() {
+  const url=new URL(location.href);url.searchParams.set('qid',design.qid);url.searchParams.delete('name');
+  history.replaceState(null,'',url.pathname+url.search+url.hash);
+}
+async function choose(qid) {
+  const sig=signatures.find(s=>s.qid===qid);if(!sig)return;
+  selected=sig;design.qid=qid;const token=++loadId;mask=null;
+  $('selectedName').textContent=$('previewName').textContent=sig.full_name;
+  $('selectedCategory').textContent=sig.category||'';
+  $('signatureThumb').src=sig.signature_image_url;$('signatureThumb').alt=`Signature of ${sig.full_name}`;$('signatureThumb').hidden=false;
+  $('wearSignature').href='/wear?qid='+encodeURIComponent(qid);
+  $('signatureSelect').value=qid;$('downloadTile').disabled=true;$('downloadDesign').disabled=false;
+  $('previewLoading').textContent='Loading your signature…';$('previewLoading').hidden=false;$('retry').hidden=true;notice();
+  updateURL();persist();
+  try {
+    const source=new URL(sig.signature_image_url,location.href);
+    if(source.protocol!=='https:' && source.origin!==location.origin)throw new Error('Unsupported image URL');
+    const next=masks.get(qid)||signatureMask(await loadImage(source.href));
+    if(token!==loadId)return;
+    masks.set(qid,next);mask=next;render();
+  } catch {
+    if(token!==loadId)return;
+    $('previewLoading').textContent='This signature image could not load.';
+    notice('The archive image is unavailable or cannot be used for a downloadable preview. Try again or choose another name. Your colors and spacing are kept.');
+    $('retry').hidden=false;
+  }
+}
+function filter() {
+  const q=$('signatureSearch').value.trim().toLocaleLowerCase();
+  const matches=signatures.filter(s=>s.full_name.toLocaleLowerCase().includes(q));
+  $('signatureSelect').replaceChildren();
+  for(const sig of matches.slice(0,80)) {
+    const option=document.createElement('option');option.value=sig.qid;option.textContent=sig.full_name;
+    option.selected=sig.qid===design.qid;$('signatureSelect').append(option);
+  }
+  // Searching never silently replaces the signature being designed.
+  if(!matches.slice(0,80).some(s=>s.qid===design.qid)) $('signatureSelect').selectedIndex=-1;
+  $('searchCount').textContent=matches.length?`${matches.length.toLocaleString()} names${matches.length>80?' · type to narrow the list':''}`:'No names match. Try another spelling.';
+}
+for(const field of fields) $(field).addEventListener('input',()=>{
+  design=normalize({...design,[field]:$(field).value});controls();persist();schedule();
+});
+document.querySelectorAll('[name=repeat]').forEach(r=>r.addEventListener('change',()=>{if(r.checked){design.repeat=r.value;persist();schedule();}}));
+$('signatureSearch').addEventListener('input',filter);
+$('signatureSearch').addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();const first=$('signatureSelect').options[0];if(first)choose(first.value);}});
+$('signatureSelect').addEventListener('change',()=>choose($('signatureSelect').value));
+$('retry').addEventListener('click',()=>signatures.length?choose(design.qid):start());
+$('signatureThumb').addEventListener('error',()=>{$('signatureThumb').hidden=true;});
+$('reset').addEventListener('click',()=>{design=normalize({...DEFAULTS,qid:design.qid});controls();persist();schedule();notice();});
+function download(blob,name) {
+  const url=URL.createObjectURL(blob),link=document.createElement('a');link.href=url;link.download=name;document.body.append(link);link.click();link.remove();setTimeout(()=>URL.revokeObjectURL(url),1000);
+}
+const filename=()=>`${(selected?.full_name||'signature').toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'')}-wallpaper`;
+$('downloadTile').addEventListener('click',async()=>{
+  if(!mask)return;
+  try {
+    const name=filename(),out=document.createElement('canvas'),g=drawTile(out,mask,design,150);
+    const blob=await new Promise(resolve=>out.toBlob(resolve,'image/png'));
+    if(!blob)throw new Error('Export unavailable');
+    download(blob,name+'-repeat.png');
+    notice(`Repeat tile downloaded: ${g.width} × ${g.height} pixels. Save the design settings for its intended dimensions.`);
+  } catch {notice('The image could not be exported. Try loading the signature again, then download.');$('retry').hidden=false;}
+});
+$('downloadDesign').addEventListener('click',()=>{
+  const g=mask?geometry(design,mask.width/mask.height,150):null;
+  download(new Blob([JSON.stringify({kind:'celebrity-signature-wallpaper',version:1,design,signatureName:selected?.full_name,source:selected?.signature_image_url,
+    tile:g?{widthPixels:g.width,heightPixels:g.height,widthInches:g.width/g.ppi,heightInches:g.height/g.ppi,pixelsPerInch:g.ppi}:null},null,2)],{type:'application/json'}),filename()+'.json');
+});
+$('importDesign').addEventListener('change',async()=>{
+  try {
+    const file=$('importDesign').files[0];if(!file)return;
+    if(file.size>100000)throw new Error('Too large');
+    const saved=JSON.parse(await file.text());
+    if(saved.kind!=='celebrity-signature-wallpaper'||saved.version!==1||!saved.design||!signatures.some(s=>s.qid===saved.design.qid))throw new Error('Unknown design');
+    design=normalize(saved.design);$('signatureSearch').value='';controls();filter();await choose(design.qid);
+  } catch {notice('That file is not a supported saved wallpaper design. Your current design has been kept.');}
+  finally {$('importDesign').value='';}
+});
+async function start() {
+  $('retry').hidden=true;
+  try {
+    const response=await fetch('/api/wear/signatures',{signal:AbortSignal.timeout(20000)});
+    if(!response.ok)throw new Error('Catalog unavailable');
+    const result=await response.json();
+    signatures=(result.signatures||[]).filter(s=>/^Q\d+$/.test(s.qid)&&s.full_name&&s.signature_image_url).sort((a,b)=>a.full_name.localeCompare(b.full_name));
+    if(!signatures.length)throw new Error('Empty catalog');
+    let missing=false;
+    if(!signatures.some(s=>s.qid===design.qid)){missing=true;design.qid=signatures.some(s=>s.qid==='Q91')?'Q91':signatures[0].qid;}
+    $('signatureSearch').disabled=$('signatureSelect').disabled=false;controls();filter();await choose(design.qid);
+    if(missing)notice('That signature is not available in the wallpaper catalog. A different name is selected; you can search for another.');
+  } catch {
+    $('selectedName').textContent=$('previewName').textContent='Choose a signature';
+    $('searchCount').textContent='The signature catalog is unavailable.';
+    $('previewLoading').textContent='The signature catalog could not load.';
+    notice('Please try loading the catalog again. Your saved design settings are kept.');$('retry').hidden=false;
+  }
+}
+controls();new ResizeObserver(schedule).observe($('previewFrame'));start();
diff --git a/public/game.html b/public/game.html
index aa7e81c..ba1e46e 100644
--- a/public/game.html
+++ b/public/game.html
@@ -129,7 +129,7 @@
   <a class="wordmark" href="/">Celebrity Signatures</a>
   <nav class="gnav">
     <a href="/">Browse signatures</a>
-    <a href="/murals">Order a mural</a>
+    <a href="/wallpaper">Create wallpaper</a>
     <a href="/game" class="on">Play</a>
     <a href="/upload">Upload your signature</a>
   </nav>
diff --git a/public/index.html b/public/index.html
index 30f12eb..e54aa11 100644
--- a/public/index.html
+++ b/public/index.html
@@ -314,7 +314,7 @@
   <a class="logo" href="/"><img src="/assets/logo-celebsig.png" alt="Celebrity Signatures"></a>
   <div class="search topsearch"><input type="search" id="q" placeholder="Search any name — Monet, Lincoln, Kahlo…" aria-label="Search signatures by name"></div>
   <nav class="gnav">
-    <a href="/murals">Murals</a>
+    <a href="/wallpaper">Wallpaper</a>
     <a href="/wear">Clothing</a>
     <a href="/game">Games</a>
     <a href="/upload">Upload yours</a>
@@ -324,12 +324,12 @@
 
 <!-- CAMPAIGN — clearly-labeled artistic rendering (AI), not a photograph -->
 <section class="campaign">
-  <div class="cimg"><img src="/assets/campaign-lincoln.jpg" alt="Editorial artistic rendering of Abraham Lincoln in the Signature Edit" loading="lazy"></div>
+  <div class="cimg"><img src="/assets/campaign-lincoln-products-v2.png" width="1536" height="1024" alt="Editorial artistic rendering of Abraham Lincoln in the Signature Edit" loading="lazy"></div>
   <div class="ctxt">
     <span class="ck">The Campaign · № 001</span>
     <h2 class="ch2">Signed, <em>Mr. Lincoln.</em></h2>
-    <p>Our first icon, styled in the full Signature Edit — the hat, the shirt, the socks. His authentic 1862 autograph runs through every thread.</p>
-    <a class="clink" href="/wear">Shop the Lincoln edit →</a>
+    <p>Our first icon, styled in the full Signature Edit — the hat, the shirt, the socks. His autograph on our Bucket Hat, Classic Tee and Crew Socks.</p>
+    <a class="clink" href="/signature-edit">Shop the Lincoln edit →</a>
     <p class="rendering">Artistic rendering — a stylized depiction of Abraham Lincoln (public domain). Not a photograph.</p>
   </div>
 </section>
@@ -361,8 +361,8 @@
     </div>
     <div class="hero-ctas">
       <a class="hbtn" href="#collection">Browse the collection</a>
-      <a class="hbtn ghost" href="/wear">Shop the Lincoln edit</a>
-      <a class="hbtn ghost" href="/murals">Order a mural</a>
+      <a class="hbtn ghost" href="/signature-edit">Shop the Lincoln edit</a>
+      <a class="hbtn ghost" href="/wallpaper?qid=Q91">Create wallpaper</a>
       <a class="hbtn ghost" href="/game">Play the games</a>
     </div>
   </div>
@@ -379,7 +379,7 @@
       <span class="tab" data-v="collages">Collages</span>
     </div>
     <nav class="d-links">
-      <a href="/murals">Signature murals</a>
+      <a href="/wallpaper">Signature wallpaper</a>
       <a href="/wear">Signature clothing</a>
       <a href="/game">The Signature Games</a>
       <a href="/upload">Upload your signature</a>
@@ -470,7 +470,7 @@ function writeURL(push){ history[push?'pushState':'replaceState']({},'',qstr());
 
 function signatureShopLinks(r, compact=false){
   const params = new URLSearchParams({ qid:qidOf(r), name:r.full_name });
-  return `<a class="shop-mural" href="/murals?${esc(params.toString())}#finder" aria-label="Shop murals for ${esc(r.full_name)}">${compact?'Mural':'Shop murals'}</a>
+  return `<a class="shop-wallpaper" href="/wallpaper?${esc(params.toString())}" aria-label="Design wallpaper with ${esc(r.full_name)}">${compact?'Wallpaper':'Design wallpaper'}</a>
     <a class="shop-clothing" href="/wear?${esc(params.toString())}" aria-label="Shop clothing for ${esc(r.full_name)}">${compact?'Clothing':'Shop clothing'}</a>`;
 }
 
diff --git a/public/wallpaper.html b/public/wallpaper.html
new file mode 100644
index 0000000..de29838
--- /dev/null
+++ b/public/wallpaper.html
@@ -0,0 +1,49 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Signature Wallpaper Studio — Celebrity Signatures</title>
+<meta name="description" content="Create signature stripe wallpaper. Choose your colors, stripe widths, signature scale and straight or half-drop repeat.">
+<link rel="canonical" href="https://celebsignatures.com/wallpaper">
+<link rel="icon" href="/assets/favicon.png">
+<link rel="stylesheet" href="/assets/wallpaper.css">
+<script type="module" src="/assets/wallpaper.js"></script>
+</head>
+<body>
+<header class="masthead"><a class="brand" href="/">Celebrity Signatures</a><nav aria-label="Main navigation"><a href="/">Gallery</a><a href="/wallpaper" aria-current="page">Wallpaper</a><a href="/wear">Clothing</a></nav></header>
+<main>
+  <div class="intro"><div><p class="eyebrow">Make it yours</p><h1>Signature Wallpaper Studio</h1><p>One signature. Your stripes, your colors, your repeat.</p></div><a id="wearSignature" href="/wear?qid=Q91">Wear this signature ↗</a></div>
+  <div class="studio">
+    <aside class="settings" aria-label="Wallpaper design controls">
+      <section><h2><span>01</span> Choose a signature</h2>
+        <label for="signatureSearch">Search the names</label><input id="signatureSearch" type="search" placeholder="Lincoln, Monet, Austen…" autocomplete="off" disabled>
+        <label for="signatureSelect" class="sr-only">Matching signatures</label><select id="signatureSelect" size="4" disabled></select>
+        <p id="searchCount" class="small" aria-live="polite">Loading signatures…</p>
+        <div class="selected"><img id="signatureThumb" alt="" hidden><div><strong id="selectedName">Loading…</strong><span id="selectedCategory"></span></div></div>
+      </section>
+      <section><h2><span>02</span> Set your stripes</h2>
+        <div class="color-pair"><label>Signature stripe<input id="stripeColor" type="color" value="#e0e6dc"><output id="stripeColorValue">#e0e6dc</output></label><label>Between stripes<input id="gapColor" type="color" value="#faf7ef"><output id="gapColorValue">#faf7ef</output></label></div>
+        <label class="range-label" for="stripeWidth">Stripe width <output id="stripeWidthValue"></output></label><input id="stripeWidth" type="range" min="1" max="10" step="0.25">
+        <label class="range-label" for="gapWidth">Width between stripes <output id="gapWidthValue"></output></label><input id="gapWidth" type="range" min="0" max="6" step="0.25">
+        <p class="small">The stripe color fills the band behind each signature. The second color fills the space between bands.</p>
+      </section>
+      <section><h2><span>03</span> Arrange the signature</h2>
+        <label class="ink">Signature color <input id="inkColor" type="color" value="#244335"><output id="inkColorValue">#244335</output></label>
+        <label class="range-label" for="scale">Signature scale <output id="scaleValue"></output></label><input id="scale" type="range" min="25" max="150" step="5">
+        <label class="range-label" for="rowGap">Space between signatures <output id="rowGapValue"></output></label><input id="rowGap" type="range" min="0.25" max="6" step="0.25">
+        <fieldset><legend>Repeat</legend><div class="repeat-options"><label><input type="radio" name="repeat" value="straight" checked><span><b>▦ Straight repeat</b><small>Signatures line up.</small></span></label><label><input type="radio" name="repeat" value="half-drop"><span><b>▥ Half-drop repeat</b><small>Alternate columns shift halfway.</small></span></label></div></fieldset>
+      </section>
+      <button id="reset" class="text-button" type="button">Reset this design</button>
+    </aside>
+    <section class="preview-panel" aria-label="Wallpaper preview">
+      <div class="preview-toolbar"><div><span class="eyebrow">Your wallpaper</span><h2 id="previewName">Loading…</h2></div><label for="zoom">View <input id="zoom" type="range" min="40" max="150" step="5"><output id="zoomValue"></output></label></div>
+      <div id="previewFrame" class="preview-frame"><canvas id="wallpaperPreview" role="img" aria-label="Repeating signature wallpaper preview"></canvas><p id="previewLoading">Loading your signature…</p></div>
+      <div class="preview-footer"><p id="tileSummary">Your repeat dimensions will appear here.</p><p id="saveStatus" role="status" aria-live="polite"></p></div>
+      <p id="notice" class="notice" role="status" hidden></p><button id="retry" class="text-button" type="button" hidden>Try loading again</button>
+      <div class="download-row"><button id="downloadTile" class="button" disabled>Download repeat tile</button><button id="downloadDesign" class="button secondary" disabled>Save design settings</button><label class="button secondary" for="importDesign">Open saved design<input id="importDesign" class="sr-only" type="file" accept="application/json,.json"></label></div>
+      <p class="small export-note">The PNG is a seamless repeat tile. Keep the settings file with it for your colors and dimensions. Preview scale is illustrative; confirm the final print size with your printer.</p>
+    </section>
+  </div>
+</main>
+<footer>Authentic signatures from the Celebrity Signatures archive. <a href="/">Explore all names</a></footer>
+</body></html>
diff --git a/public/wear.html b/public/wear.html
index a3c7488..7b6084d 100644
--- a/public/wear.html
+++ b/public/wear.html
@@ -110,7 +110,7 @@
 <body>
 <header>
   <a class="mast" href="/">Celebrity Signatures</a>
-  <nav><a href="/">Gallery</a><a href="/murals">Murals</a><a href="/wear">Wear</a><a href="/game">Games</a>
+  <nav><a href="/">Gallery</a><a href="/wallpaper">Wallpaper</a><a href="/wear">Wear</a><a href="/game">Games</a>
     <button id="cartBtn" class="cart-btn" aria-label="Cart">🛍 Cart <span id="cartCount" class="cart-badge" hidden>0</span></button>
   </nav>
 </header>
diff --git a/server.js b/server.js
index 30b6b8d..507d327 100644
--- a/server.js
+++ b/server.js
@@ -114,7 +114,7 @@ const FOOTER = '<footer class="site-footer" style="margin:40px 0 0;padding:22px
   + '<div>© 2026 CelebritySignatures · celebsignatures.com</div>'
   + '<nav style="margin-top:6px"><a href="/" style="color:#6b6b6b;text-decoration:none">Home</a> · '
   + '<a href="/game" style="color:#6b6b6b;text-decoration:none">Game</a> · '
-  + '<a href="/murals" style="color:#6b6b6b;text-decoration:none">Murals</a> · '
+  + '<a href="/wallpaper" style="color:#6b6b6b;text-decoration:none">Wallpaper</a> · '
   + '<a href="/privacy" style="color:#6b6b6b;text-decoration:none">Privacy Policy</a> · '
   + '<a href="mailto:info@designerwallcoverings.com" style="color:#6b6b6b;text-decoration:none">Contact</a></nav></footer>';
 const injectFooter = html => (typeof html === 'string' && html.includes('</body>') && !html.includes('site-footer'))
@@ -1059,8 +1059,10 @@ ${paid ? `<div class="ok">✓</div><h1>Order received</h1>
     if (path === '/') path = '/public/index.html';
     if (path === '/murals') path = '/public/murals.html';
     if (path === '/wear') path = '/public/wear.html';
+    if (path === '/wallpaper') path = '/public/wallpaper.html';
     if (path === '/upload') path = '/public/upload-signature.html';
     if (path === '/game') path = '/public/game.html';
+    if (path === '/signature-edit') path = '/public/signature-edit.html';
 
     // ===== SEO: crawlable per-signature page =====
     // The grid is JS-rendered (Google can't index 7,000 cards). Each signature
@@ -1109,7 +1111,7 @@ ${museums.length ? `<div class="sec">In the collections of</div><div class="meta
 <div class="sec">License &amp; source</div><div class="meta">${esc(r.image_license || '')} · <a href="${esc(r.wikidata)}" rel="nofollow noopener" target="_blank">Wikidata</a></div>
 <div class="ad-zone" data-ad-zone="article"></div>
 <p style="margin-top:22px"><a href="/?artist=${am[1]}">See ${esc(r.full_name)} in the full gallery →</a></p>
-<a class="cta" href="/murals?name=${encodeURIComponent(r.full_name)}">Order a mural featuring this signature →</a>
+<a class="cta" href="/wallpaper?qid=${am[1]}&amp;name=${encodeURIComponent(r.full_name)}">Design wallpaper with this signature →</a>
 <p style="margin-top:26px"><a href="/">← Browse all ${(await mergedSignatures()).length.toLocaleString()} signatures</a></p>
 </div></body></html>`;
       res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'public, max-age=3600' });
@@ -1118,7 +1120,7 @@ ${museums.length ? `<div class="sec">In the collections of</div><div class="meta
     if (path === '/sitemap.xml' && M === 'GET') {
       const sigs = await mergedSignatures();
       const B = 'https://celebsignatures.com';
-      const urls = ['/', '/game', '/murals', '/upload'].map(u => `<url><loc>${B}${u}</loc></url>`)
+      const urls = ['/', '/signature-edit', '/game', '/wallpaper', '/wear', '/murals', '/upload'].map(u => `<url><loc>${B}${u}</loc></url>`)
         .concat(sigs.map(r => `<url><loc>${B}/a/${qidOf(r)}</loc></url>`).filter(u => /\/a\/Q\d+/.test(u)));
       res.writeHead(200, { 'Content-Type': 'application/xml' });
       res.end(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join('\n')}\n</urlset>`);
diff --git a/test/wallpaper-pattern.test.mjs b/test/wallpaper-pattern.test.mjs
new file mode 100644
index 0000000..9032cf0
--- /dev/null
+++ b/test/wallpaper-pattern.test.mjs
@@ -0,0 +1,28 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {normalize,geometry,placements} from '../public/assets/wallpaper-pattern.js';
+test('untrusted saved settings are bounded and keep only supported colors and repeat modes',()=>{
+  const s=normalize({qid:'../admin',stripeWidth:Infinity,gapWidth:-500,rowGap:1e99,scale:9999,zoom:null,repeat:'script',stripeColor:'url(secret)',gapColor:'#AB12EF'});
+  assert.equal(s.qid,'Q91');assert.equal(s.gapWidth,0);assert.equal(s.rowGap,6);assert.equal(s.scale,150);
+  assert.equal(s.repeat,'straight');assert.equal(s.stripeColor,'#e0e6dc');assert.equal(s.gapColor,'#ab12ef');
+  for(const input of [null,{},[],{stripeWidth:'NaN',rowGap:''}]) assert.ok(Number.isFinite(geometry(input,4).width));
+});
+test('half-drop doubles horizontal period and wraps both halves of alternate signatures',()=>{
+  const settings={stripeWidth:2,gapWidth:1,rowGap:1,scale:100};
+  const a=geometry(settings,2),b=geometry({...settings,repeat:'half-drop'},2);
+  assert.equal(b.width,a.width*2);assert.equal(b.height,a.height);
+  const p=placements(b,'half-drop'),even=p.filter(x=>x.column===0),odd=p.filter(x=>x.column===1);
+  assert.equal(even.length,1);assert.equal(odd.length,2);
+  assert.ok(odd.some(x=>x.y<0));assert.ok(odd.some(x=>x.y+x.height>b.height));
+  assert.equal(odd[1].y-odd[0].y,b.height);
+  const visible=odd.reduce((n,x)=>n+Math.min(b.height,x.y+x.height)-Math.max(0,x.y),0);
+  assert.equal(visible,b.motifHeight,'whole signature survives the tile boundary');
+});
+test('view zoom does not change export geometry and scale keeps signatures inside each band',()=>{
+  assert.deepEqual(geometry({zoom:40},8,150),geometry({zoom:150},8,150));
+  for(const repeat of ['straight','half-drop']) for(const stripeWidth of [1,10]) for(const gapWidth of [0,6]) for(const scale of [25,150]) {
+    const g=geometry({repeat,stripeWidth,gapWidth,scale},8,150);
+    assert.ok(g.motifWidth<g.stripe);assert.ok(g.width>0&&g.height>0);
+    for(const p of placements(g,repeat)) assert.ok(p.x>=p.column*g.columnWidth && p.x+p.width<=(p.column*g.columnWidth+g.stripe));
+  }
+});
diff --git a/verification/lincoln-campaign.e2e.cjs b/verification/lincoln-campaign.e2e.cjs
new file mode 100644
index 0000000..162a64a
--- /dev/null
+++ b/verification/lincoln-campaign.e2e.cjs
@@ -0,0 +1,41 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const { chromium } = require('/Users/macstudio3/Projects/Designer-Wallcoverings/node_modules/playwright');
+const base = process.env.STORE_PROOF_URL || 'http://127.0.0.1:9956';
+const artifacts = fs.mkdtempSync(path.join(os.tmpdir(), 'tk10286-campaign-'));
+(async () => {
+  const browser = await chromium.launch({ channel:'chrome' });
+  const checks = [];
+  try {
+    for (const [device, viewport] of [['desktop',{width:1280,height:900}],['mobile',{width:390,height:844}]]) {
+      const context = await browser.newContext({viewport});
+      await context.route('**/*', r => ['GET','HEAD'].includes(r.request().method()) ? r.continue() : r.abort());
+      const page = await context.newPage();
+      const errors = []; page.on('pageerror', e => errors.push(e.message));
+      for (const selector of ['.campaign .clink', '.hero-ctas a[href="/wear?qid=Q91"]']) {
+        await page.goto(base, {waitUntil:'domcontentloaded'});
+        const img = page.locator('.campaign img');
+        await img.scrollIntoViewIfNeeded();
+        await page.waitForFunction(()=>{const i=document.querySelector('.campaign img');return i.complete&&i.naturalWidth>0;},{},{timeout:20000});
+        const shape=await img.evaluate(i=>({shown:i.clientWidth/i.clientHeight,natural:i.naturalWidth/i.naturalHeight}));
+        assert.ok(Math.abs(shape.shown-shape.natural)<0.01,'full hat-to-socks image preserves its natural aspect ratio');
+        assert.match(await img.getAttribute('src'), /campaign-lincoln-products-v2\.png$/);
+        assert.ok(await page.locator('.campaign .rendering').isVisible());
+        await page.locator('.campaign').screenshot({path:path.join(artifacts,device+'-campaign.png')});
+        await page.locator(selector).click();
+        await page.waitForURL('**/wear?qid=Q91',{waitUntil:'domcontentloaded'});
+        await page.locator('#scrim.on').waitFor();
+        assert.equal(await page.locator('#pName').textContent(), 'Abraham Lincoln');
+        for (const name of ['Classic Tee','Bucket Hat','Crew Socks']) assert.ok((await page.locator('#garments').textContent()).includes(name));
+      }
+      assert.deepEqual(errors,[]);
+      checks.push({device,verdict:'PASS',paths:['corrected campaign image','disclosure visible','campaign CTA selects Lincoln','hero CTA selects Lincoln'],pageErrors:errors});
+      await context.close();
+    }
+  } finally { await browser.close(); }
+  const result={base,artifacts,checks,externalMutations:0};
+  fs.writeFileSync(path.join(artifacts,'result.json'),JSON.stringify(result,null,2));
+  console.log(JSON.stringify(result,null,2));
+})().catch(e=>{console.error(e);process.exitCode=1;});
diff --git a/verification/wallpaper.e2e.cjs b/verification/wallpaper.e2e.cjs
new file mode 100644
index 0000000..0794ffe
--- /dev/null
+++ b/verification/wallpaper.e2e.cjs
@@ -0,0 +1,75 @@
+const assert=require('node:assert/strict'),fs=require('node:fs'),os=require('node:os'),path=require('node:path');
+const {chromium}=require('/Users/macstudio3/Projects/Designer-Wallcoverings/node_modules/playwright');
+const base=process.env.STORE_PROOF_URL||'http://127.0.0.1:19556';
+const artifacts=fs.mkdtempSync(path.join(os.tmpdir(),'tk10286-wallpaper-'));
+console.log('Artifacts: '+artifacts);
+const checks=[];
+async function ready(page){await page.waitForFunction(()=>document.querySelector('#downloadTile')?.disabled===false);}
+async function set(page,id,value){await page.locator('#'+id).evaluate((el,v)=>{el.value=String(v);el.dispatchEvent(new Event('input',{bubbles:true}));},value);await page.evaluate(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))));}
+async function fingerprint(page){return page.locator('#wallpaperPreview').evaluate(c=>{const d=c.getContext('2d').getImageData(0,0,c.width,c.height).data;let n=0;for(let i=0;i<d.length;i+=64)n=(n*31+d[i]*7+d[i+1]*11+d[i+2])>>>0;return n;});}
+(async()=>{
+ const browser=await chromium.launch({channel:'chrome'});
+ try {
+  for(const [device,viewport] of [['desktop',{width:1280,height:900}],['mobile',{width:390,height:844}]]){
+   const context=await browser.newContext({viewport,acceptDownloads:true});
+   await context.route('**/*',r=>['GET','HEAD'].includes(r.request().method())?r.continue():r.abort());
+   const page=await context.newPage(),errors=[];page.on('pageerror',e=>errors.push(e.message));
+   await page.goto(base+'/?cat=Politics',{waitUntil:'domcontentloaded'});
+   assert.equal(await page.locator('a[href^="/murals"]').count(),0,'no Mural buttons remain on the gallery');
+   await page.locator('.card[data-qid="Q91"] .shop-wallpaper').click();
+   await page.waitForURL('**/wallpaper?**');await ready(page);
+   assert.equal(await page.locator('#selectedName').textContent(),'Abraham Lincoln');
+   assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);
+   let previous=await fingerprint(page);
+   for(const [id,value] of [['stripeColor','#b56e79'],['gapColor','#f6ddb3'],['inkColor','#173d71'],['stripeWidth',3.25],['gapWidth',0.75],['rowGap',1.25],['scale',125]]){
+    await set(page,id,value);const current=await fingerprint(page);assert.notEqual(current,previous,id+' changes the wallpaper pixels');previous=current;
+   }
+   await page.locator('input[name="repeat"][value="half-drop"]').check({force:true});
+   await page.waitForFunction(()=>document.querySelector('#wallpaperPreview').dataset.repeat==='half-drop');
+   assert.notEqual(await fingerprint(page),previous,'half-drop changes actual pattern');
+   const settingsBefore=JSON.parse(await page.evaluate(()=>localStorage.getItem('celebsig-wallpaper-v1')));
+   await page.reload({waitUntil:'domcontentloaded'});await ready(page);
+   assert.deepEqual(JSON.parse(await page.evaluate(()=>localStorage.getItem('celebsig-wallpaper-v1'))),settingsBefore,'all settings survive reload');
+   await page.evaluate(()=>scrollTo(0,0));
+   await page.locator('#wallpaperPreview').screenshot({path:path.join(artifacts,device+'-wallpaper.png')});
+   await page.screenshot({path:path.join(artifacts,device+'-studio.png'),fullPage:true});
+   let dl=page.waitForEvent('download');await page.locator('#downloadTile').click();const tileDownload=await dl;
+   const tileFile=path.join(artifacts,device+'-repeat.png');await tileDownload.saveAs(tileFile);
+   const png=fs.readFileSync(tileFile);assert.equal(png.subarray(1,4).toString(),'PNG');assert.ok(png.length>1500);
+   dl=page.waitForEvent('download');await page.locator('#downloadDesign').click();const designDownload=await dl;
+   const designFile=path.join(artifacts,device+'-design.json');await designDownload.saveAs(designFile);
+   const saved=JSON.parse(fs.readFileSync(designFile,'utf8'));assert.equal(saved.design.repeat,'half-drop');assert.equal(saved.design.qid,'Q91');
+   assert.equal(png.readUInt32BE(16),saved.tile.widthPixels);assert.equal(png.readUInt32BE(20),saved.tile.heightPixels);
+   await page.locator('#reset').click();await page.locator('#importDesign').setInputFiles(designFile);
+   await page.waitForFunction(()=>document.querySelector('#stripeColor').value==='#b56e79');await ready(page);
+   assert.equal(await page.locator('#stripeColor').inputValue(),'#b56e79');assert.equal(await page.locator('#scale').inputValue(),'125');
+   await set(page,'zoom',150);dl=page.waitForEvent('download');await page.locator('#downloadTile').click();const zoomDownload=await dl;
+   const zoomFile=path.join(artifacts,device+'-zoom-repeat.png');await zoomDownload.saveAs(zoomFile);
+   assert.deepEqual(fs.readFileSync(zoomFile),png,'view zoom leaves the exported repeat unchanged');
+   await page.locator('#signatureSearch').fill('zz-no-match-fixture');assert.match(await page.locator('#searchCount').textContent(),/No names match/);
+   assert.equal(await page.locator('#selectedName').textContent(),'Abraham Lincoln','search misses retain the active design');
+   await page.locator('#signatureSearch').fill('Lincoln');await page.locator('#signatureSearch').press('Enter');await ready(page);
+   // Independent pixel checks: solid synthetic ink proves half-drop copies wrap across both boundaries.
+   const seam=await page.evaluate(async()=>{
+    const {drawTile}=await import('/assets/wallpaper-pattern.js');
+    const mask=document.createElement('canvas');mask.width=20;mask.height=10;mask.getContext('2d').fillRect(0,0,20,10);
+    const tile=document.createElement('canvas');const g=drawTile(tile,mask,{stripeWidth:2,gapWidth:1,rowGap:1,scale:100,repeat:'half-drop',stripeColor:'#ffffff',gapColor:'#ff0000',inkColor:'#000000'});
+    const ctx=tile.getContext('2d'),pixel=(x,y)=>Array.from(ctx.getImageData(Math.floor(x),Math.floor(y),1,1).data).slice(0,3);
+    return {firstTop:pixel(g.stripe/2,0),firstMiddle:pixel(g.stripe/2,g.height/2),secondTop:pixel(g.columnWidth+g.stripe/2,0),secondBottom:pixel(g.columnWidth+g.stripe/2,g.height-1),secondMiddle:pixel(g.columnWidth+g.stripe/2,g.height/2),gap:pixel(g.stripe+g.gap/2,0)};
+   });
+   assert.deepEqual(seam,{firstTop:[255,255,255],firstMiddle:[0,0,0],secondTop:[0,0,0],secondBottom:[0,0,0],secondMiddle:[255,255,255],gap:[255,0,0]});
+   assert.deepEqual(errors,[]);
+   checks.push({device,verdict:'PASS',paths:['gallery Wallpaper link retains Lincoln','all color/width/spacing/scale controls change actual pixels','half-drop wrap pixels','reload persistence','PNG and settings downloads','saved-design import','zoom-independent export','search miss recovery'],pageErrors:errors});
+   await context.close();
+  }
+  const context=await browser.newContext(),page=await context.newPage();
+  const blockImages=r=>r.request().resourceType()==='image'?r.abort():r.continue();
+  await context.route('**/*',blockImages);
+  await page.goto(base+'/wallpaper?qid=Q91',{waitUntil:'domcontentloaded'});await page.locator('#retry:not([hidden])').waitFor();
+  assert.equal(await page.locator('#downloadTile').isDisabled(),true);
+  await set(page,'stripeWidth',5.5);await context.unroute('**/*',blockImages);await page.locator('#retry').click();await ready(page);
+  assert.equal(await page.locator('#stripeWidth').inputValue(),'5.5');
+  checks.push({boundary:'image failure and retry',verdict:'PASS',result:'export disabled until the actual image loads; retry keeps settings'});await context.close();
+ }finally{await browser.close();}
+ const result={base,artifacts,checks,externalMutations:0};fs.writeFileSync(path.join(artifacts,'result.json'),JSON.stringify(result,null,2));console.log(JSON.stringify(result,null,2));
+})().catch(e=>{console.error(e);process.exitCode=1;});

← 19dfb15 signature-edit: new SEO lookbook page — 10 icons in the Sign  ·  back to CelebritySignatures  ·  signature-edit: regenerate the 9 figures with distinct poses 936d27e →