[object Object]

← back to Tools Dw Hub

mood-board-studio: pre-deploy hardening (closes DTD contrarian's export/tablet dissent)

e4ef8df07da4a73dfef68a196c02ba6e123168f5 · 2026-07-30 18:19:11 -0700 · Steve

The /dtd panel voted A (deploy) but the contrarian reviewer's FIX-FIRST dissent
flagged real, localhost-unverifiable risks on the public origin. Closed all three:

1. Export no longer fails silently: canvas.toDataURL() is wrapped in try/catch —
   on a SecurityError (canvas tainted) it shows a 'right-click the preview → Save
   image as…' toast instead of a dead button.
2. CORS taint PREVENTED at the source: product-image src now carries a
   tool-specific cache-bust param (&mbc=1) so the crossorigin=anonymous request
   gets a distinct cache key and can't collide with a credentialed cdn.shopify.com
   entry warmed by the storefront (the taint vector). Verified CDN still 200+ACAO
   with the param; export stays untainted.
3. Tablet controls: sidebar hides <=900px (iPad portrait = 820px lost all layer
   controls + Tips). Added a mobile control bar (Tap a swatch, then: To Front /
   To Back / Delete / Reset) that delegates to the existing handlers.

Verified: desktop 1440 (bar hidden, 12/12 load w/ cache-bust, export OK 1.9MB);
iPad 820 (bar visible + functional — To Front changed z 1→102, no h-scroll);
0 console errors. Deploy itself remains Steve-gated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit e4ef8df07da4a73dfef68a196c02ba6e123168f5
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jul 30 18:19:11 2026 -0700

    mood-board-studio: pre-deploy hardening (closes DTD contrarian's export/tablet dissent)
    
    The /dtd panel voted A (deploy) but the contrarian reviewer's FIX-FIRST dissent
    flagged real, localhost-unverifiable risks on the public origin. Closed all three:
    
    1. Export no longer fails silently: canvas.toDataURL() is wrapped in try/catch —
       on a SecurityError (canvas tainted) it shows a 'right-click the preview → Save
       image as…' toast instead of a dead button.
    2. CORS taint PREVENTED at the source: product-image src now carries a
       tool-specific cache-bust param (&mbc=1) so the crossorigin=anonymous request
       gets a distinct cache key and can't collide with a credentialed cdn.shopify.com
       entry warmed by the storefront (the taint vector). Verified CDN still 200+ACAO
       with the param; export stays untainted.
    3. Tablet controls: sidebar hides <=900px (iPad portrait = 820px lost all layer
       controls + Tips). Added a mobile control bar (Tap a swatch, then: To Front /
       To Back / Delete / Reset) that delegates to the existing handlers.
    
    Verified: desktop 1440 (bar hidden, 12/12 load w/ cache-bust, export OK 1.9MB);
    iPad 820 (bar visible + functional — To Front changed z 1→102, no h-scroll);
    0 console errors. Deploy itself remains Steve-gated.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/mood-board-studio.html | 53 +++++++++++++++++++++++++++++++++++++++++--
 1 file changed, 51 insertions(+), 2 deletions(-)

diff --git a/public/mood-board-studio.html b/public/mood-board-studio.html
index d4b8565..7641847 100644
--- a/public/mood-board-studio.html
+++ b/public/mood-board-studio.html
@@ -641,9 +641,27 @@ header {
 /* ============================================================
    RESPONSIVE ADJUSTMENTS
    ============================================================ */
+/* Mobile control bar — hidden on desktop (sidebar carries these); shown <=900px */
+.mobile-controls { display: none; }
+.mobile-controls .mc-hint {
+  font-family: var(--sans); font-size: 10px; letter-spacing: .06em;
+  text-transform: uppercase; color: var(--accent); white-space: nowrap; align-self: center;
+}
+.mc-btn {
+  font-family: var(--sans); font-size: 11px; font-weight: 600; letter-spacing: .04em;
+  color: var(--ink); background: var(--bg-raised); border: 1px solid var(--line);
+  border-radius: 6px; padding: 8px 12px; white-space: nowrap; cursor: pointer;
+}
+.mc-btn:active { background: var(--accent); }
+
 @media (max-width: 900px) {
   .studio { grid-template-columns: 1fr; }
   .sidebar { display: none; }
+  .mobile-controls {
+    display: flex; gap: 8px; align-items: center; overflow-x: auto;
+    padding: 8px 20px; background: var(--bg-raised); border-bottom: 1px solid var(--line);
+    -webkit-overflow-scrolling: touch;
+  }
   header { padding: 10px 20px; flex-wrap: wrap; gap: 10px; min-height: 0; }
   .preset-rail { padding: 12px 20px; }
   /* wordmark must shrink + wrap so the page never scrolls horizontally on phones */
@@ -713,6 +731,15 @@ header {
   <button class="preset-pill" data-preset="deco">Art Deco</button>
 </div>
 
+<!-- MOBILE CONTROL BAR (only shown <=900px, where the sidebar is hidden) -->
+<div class="mobile-controls" id="mobileControls">
+  <span class="mc-hint">Tap a swatch, then:</span>
+  <button class="mc-btn" data-act="sidebarFront">To Front</button>
+  <button class="mc-btn" data-act="sidebarBack">To Back</button>
+  <button class="mc-btn" data-act="sidebarDelete">Delete</button>
+  <button class="mc-btn" data-act="sidebarReset">Reset</button>
+</div>
+
 <!-- STUDIO BODY -->
 <div class="studio">
   <!-- CANVAS -->
@@ -1219,8 +1246,12 @@ function createSwatchTile(type, palette, x, y, z, id) {
   // client-facing export is worse than none. Name + vendor only; SKU in tooltip.
   el.title = `${prod.name}  —  ${prod.vendor}${prod.sku ? '  ·  ' + prod.sku : ''}\n(double-click to open on designerwallcoverings.com)`;
 
+  // Append a tool-specific cache-bust param so the CORS (crossorigin=anonymous)
+  // request gets a DISTINCT cache key and can never collide with a credentialed
+  // cdn.shopify.com entry warmed by the storefront — which would taint the export.
+  const corsImg = esc(prod.img) + (prod.img.includes('?') ? '&' : '?') + 'mbc=1';
   el.innerHTML =
-    `<img class="swatch-img" crossorigin="anonymous" loading="lazy" alt="${esc(short)}" src="${esc(prod.img)}">` +
+    `<img class="swatch-img" crossorigin="anonymous" loading="lazy" alt="${esc(short)}" src="${corsImg}">` +
     `<div class="tile-label">` +
       `<span class="tl-name">${esc(short)}</span>` +
       `<span class="tl-vendor">${esc(prod.vendor)}</span>` +
@@ -1890,13 +1921,31 @@ document.addEventListener('keydown', (e) => {
 document.getElementById('btnDownload').addEventListener('click', () => {
   const canvas = document.getElementById('exportCanvas');
   const preset = PRESETS[currentPreset].label.toLowerCase().replace(/\s+/g, '-');
+  // toDataURL throws SecurityError if any product image tainted the canvas
+  // (can happen if a CDN image was served from a credentialed cache). Never fail
+  // silently on a public tool — tell the user + offer the right-click fallback.
+  let url;
+  try {
+    url = canvas.toDataURL('image/png');
+  } catch (e) {
+    showToast('Export blocked by the browser — right-click the preview above and "Save image as…"');
+    return;
+  }
   const a = document.createElement('a');
   a.download = `dw-mood-board-${preset}.png`;
-  a.href = canvas.toDataURL('image/png');
+  a.href = url;
   a.click();
   showToast('Board downloaded');
 });
 
+// Mobile control bar → delegate to the (hidden-on-mobile) sidebar buttons, reusing all their logic
+document.querySelectorAll('.mobile-controls .mc-btn').forEach(b => {
+  b.addEventListener('click', () => {
+    const target = document.getElementById(b.dataset.act);
+    if (target) target.click();
+  });
+});
+
 /* ============================================================
    INIT
    ============================================================ */

← 0a30fa2 start-fleet-persistent.sh: accept optional slug args to (re)  ·  back to Tools Dw Hub  ·  tools-dw-hub deploy: INSTALL_CMD=true (no npm deps → npm ci fda8808 →