← 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
M public/mood-board-studio.html
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 →