← back to Tools Dw Hub

public/mood-board-studio.html

1967 lines

<!--
================================================================================
DESIGNER WALLCOVERINGS — INTERIOR MOOD BOARD STUDIO
Plan Document (v1.0 — Flagship Build)
================================================================================

CONCEPT
-------
A professional creative tool for interior designers and the DW social team to
compose, refine, and export luxury interior mood boards. The studio presents a
draggable-and-arrangeable canvas where designers can assemble wallcovering
texture swatches, color chips, material chips, and a type-pairing sample into
a cohesive visual statement. Five curated style presets instantly repopulate
the canvas with thematically coherent arrangements — from Grandmillennial's
richly layered maximalism to Modern Minimalist's restrained precision.

The deliverable: a 1080×1350 export card (Instagram Portrait ratio) that looks
like it came from a luxury interiors studio — not a web tool.

TARGET USER
-----------
1. Interior designer: assembling a client presentation or concept board, pulling
   DW materials to visually propose a room's direction before spec-ing product.
2. DW social manager: generating shareable content (Instagram, Pinterest boards)
   that visually articulates the brand's sensibility. Needs speed, polish, and
   brand-correct output with zero design skill required.

UX FLOW
-------
[1] LANDING / HEADER
    Full-width header: wordmark "DESIGNER WALLCOVERINGS" in 48px Cormorant Garamond
    uppercase, letter-spacing .18em, darkest ink on brass strip or warm surface.
    Subtitle "Interior Mood Board Studio" in sans. Studio tools in a top rail.

[2] STYLE PRESET SELECTOR (5 pills above canvas)
    • Grandmillennial — chinoiserie, damask, warm golds, antique roses
    • Coastal — linen, raffia, seagrass, celadon, white oak
    • Moody Maximalist — dark silk, jewel tones, rich damask, deep ink
    • Modern Minimalist — clean linen, matte stone, quiet stripe, bone
    • Art Deco — geometric silk, brass chevron, ink, gold, lacquer black
    Clicking a preset triggers a smooth crossfade: swatches morph textures,
    color chips repopulate with named hues, materials update — 400ms ease-out.

[3] MOOD BOARD CANVAS (the central interactive surface)
    A gridded artboard (subtle 20px grid lines) where draggable tiles live:
    • 6 WALLCOVERING SWATCH TILES — each 160×160px with distinct CSS/SVG textures:
        - Grasscloth: woven horizontal fiber lines, warm tan/caramel
        - Damask: intricate mirrored motif in cream on ivory
        - Silk Stripe: thin alternating satin/matte vertical bands
        - Linen: natural woven mesh pattern, oat/flax
        - Raffia: diagonal bast fiber weave, warm straw
        - Chinoiserie: hand-painted branch/blossom on pale background
    • (Earlier versions also had color chips, a type-pairing tile, and material
      chips; the board is now real-products-only — those were removed.)
    Each tile: draggable to anywhere on canvas, z-index promoted on pickup,
    hover lift (translateY -4px, shadow deepen), snaps to 10px grid on drop.

[4] CANVAS CONTROLS (right rail)
    • Shuffle / Recompose button — re-arranges all tiles with easing (stagger 60ms)
    • Clear board — fade tiles to corner
    • Layer controls (Bring to Front / Send to Back)
    • Delete selected tile

[5] EXPORT MOOD BOARD
    Renders the current board as a 1080×1350 Instagram-portrait card on an
    HTML5 Canvas element, then triggers download. The card includes:
    • Dark warm-black (#0d0b09) background with hairline brass frame
    • Board tiles composited in their current positions (scaled)
    • Brass wordmark lockup + style label at bottom
    • Color palette strip with hex names along bottom edge
    • Subtle vignette overlay for print-quality feel

[6] MICRO-INTERACTIONS
    • Tile hover: lift + shadow (200ms ease)
    • Tile drag: cursor 'grabbing', drop shadow deepens, slight scale 1.03
    • Preset switch: stagger-fade swatches out then in (80ms intervals)
    • Shuffle: all tiles simultaneously tween to new positions (spring-ish ease)
    • Export: progress shimmer on button, then download fires

VISUAL SYSTEM (Design Tokens)
------------------------------
--bg:           #0d0b09   warm near-black
--bg-raised:    #161310   lifted panel surface
--bg-canvas:    #f4f1ea   warm ivory artboard
--ink:          #1c1a17   darkest text
--ink-inv:      #e8e3da   primary text on dark
--ink-dim:      #9a9184   muted/secondary
--accent:       #c9a961   DW brass
--accent-dim:   #7a6538   brass hover
--line:         rgba(201,169,97,0.20)
--line-canvas:  rgba(180,170,150,0.25)
--shadow-tile:  0 6px 24px rgba(0,0,0,0.18)
--shadow-drag:  0 18px 48px rgba(0,0,0,0.32)
--serif:        'Cormorant Garamond','Playfair Display','EB Garamond',Georgia,serif
--sans:         -apple-system,'Helvetica Neue',Arial,sans-serif
--ease:         cubic-bezier(0.25,0.46,0.45,0.94)
--ease-spring:  cubic-bezier(0.34,1.56,0.64,1)

TYPE SCALE
----------
Wordmark:    48px serif, uppercase, ls .18em, weight 600
Section:     13px sans, uppercase, ls .14em
Tile label:  11px sans, uppercase, ls .10em
Body:        16px serif

SPACING SCALE
-------------
4 / 8 / 12 / 16 / 24 / 32 / 48 / 64 / 96px (multiples of 4)

MOTION BUDGET
-------------
Hover transitions: 180–220ms ease
Drag feedback: immediate (0ms)
Preset switch: 400ms stagger across N tiles
Shuffle: 500ms spring ease
Export progress: 800ms shimmer
================================================================================
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Designer Wallcoverings — Interior Mood Board Studio</title>
<style>
/* ============================================================
   DESIGN TOKENS
   ============================================================ */
:root {
  --bg:            #0d0b09;
  --bg-raised:     #161310;
  --bg-canvas:     #f4f1ea;
  --ink:           #1c1a17;
  --ink-inv:       #e8e3da;
  --ink-dim:       #9a9184;
  --accent:        #c9a961;
  --accent-dim:    #7a6538;
  --accent-pale:   rgba(201,169,97,0.10);
  --line:          rgba(201,169,97,0.20);
  --line-canvas:   rgba(160,148,128,0.22);
  --shadow-tile:   0 4px 20px rgba(0,0,0,0.14);
  --shadow-hover:  0 10px 32px rgba(0,0,0,0.22);
  --shadow-drag:   0 20px 56px rgba(0,0,0,0.38);
  --serif:         'Cormorant Garamond','Playfair Display','EB Garamond',Georgia,serif;
  --sans:          -apple-system,'Helvetica Neue',Arial,sans-serif;
  --ease:          cubic-bezier(0.25,0.46,0.45,0.94);
  --ease-spring:   cubic-bezier(0.34,1.56,0.64,1);
  --radius:        3px;
}

/* ============================================================
   RESET & BASE
   ============================================================ */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 16px; -webkit-font-smoothing: antialiased; }
body {
  background: var(--bg);
  color: var(--ink-inv);
  font-family: var(--sans);
  min-height: 100vh;
  overflow-x: hidden;
}
button { cursor: pointer; border: none; background: none; font-family: inherit; }
svg { display: block; }

/* ============================================================
   HEADER
   ============================================================ */
header {
  position: relative;
  padding: 12px 48px;
  background: var(--bg-raised);
  border-bottom: 1px solid var(--line);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
  min-height: 80px;
}

.header-lockup {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.wordmark {
  font-family: var(--serif);
  font-size: 42px;
  font-weight: 600;
  letter-spacing: .18em;
  text-transform: uppercase;
  color: var(--ink);
  background: var(--accent);
  padding: 4px 20px 4px 0;
  margin-left: -20px;
  padding-left: 20px;
  line-height: 1.05;
  white-space: nowrap;
}

.header-sub {
  font-family: var(--sans);
  font-size: 10px;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--accent);
  padding-left: 1px;
}

.header-actions {
  display: flex;
  align-items: center;
  gap: 12px;
}

.btn {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  font-family: var(--sans);
  font-size: 11px;
  letter-spacing: .10em;
  text-transform: uppercase;
  padding: 9px 18px;
  border-radius: var(--radius);
  transition: background 180ms var(--ease), color 180ms var(--ease), transform 120ms var(--ease), box-shadow 180ms var(--ease);
  white-space: nowrap;
}
.btn:active { transform: translateY(1px); }

.btn-ghost {
  color: var(--ink-dim);
  border: 1px solid rgba(201,169,97,0.25);
}
.btn-ghost:hover {
  color: var(--ink-inv);
  border-color: rgba(201,169,97,0.50);
  background: rgba(201,169,97,0.06);
}

.btn-accent {
  background: var(--accent);
  color: var(--ink);
  font-weight: 600;
}
.btn-accent:hover {
  background: #d4b872;
  box-shadow: 0 4px 20px rgba(201,169,97,0.35);
  transform: translateY(-1px);
}

.btn-primary {
  background: var(--ink-inv);
  color: var(--ink);
  font-weight: 600;
}
.btn-primary:hover {
  background: #fff;
  box-shadow: 0 4px 20px rgba(255,255,255,0.15);
  transform: translateY(-1px);
}

/* ============================================================
   STYLE PRESET RAIL
   ============================================================ */
.preset-rail {
  padding: 16px 48px;
  background: var(--bg);
  border-bottom: 1px solid var(--line);
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
}

.preset-label {
  font-size: 10px;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--ink-dim);
  margin-right: 8px;
  white-space: nowrap;
}

.preset-pill {
  font-family: var(--sans);
  font-size: 11px;
  letter-spacing: .08em;
  text-transform: uppercase;
  padding: 7px 16px;
  border-radius: 100px;
  border: 1px solid rgba(201,169,97,0.22);
  color: var(--ink-dim);
  transition: all 220ms var(--ease);
  white-space: nowrap;
}
.preset-pill:hover {
  border-color: rgba(201,169,97,0.55);
  color: var(--ink-inv);
  background: rgba(201,169,97,0.07);
}
.preset-pill.active {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--ink);
  font-weight: 600;
}

/* ============================================================
   STUDIO LAYOUT
   ============================================================ */
.studio {
  display: grid;
  grid-template-columns: 1fr 240px;
  height: calc(100vh - var(--chrome-height, 130px));
  min-height: 560px;
}

/* ============================================================
   CANVAS
   ============================================================ */
.canvas-wrap {
  position: relative;
  overflow: hidden;
  background: var(--bg-canvas);
}

/* Subtle grid */
.canvas-wrap::before {
  content: '';
  position: absolute;
  inset: 0;
  background-image:
    linear-gradient(var(--line-canvas) 1px, transparent 1px),
    linear-gradient(90deg, var(--line-canvas) 1px, transparent 1px);
  background-size: 20px 20px;
  pointer-events: none;
  z-index: 0;
}

.canvas-inner {
  position: relative;
  width: 100%;
  height: 100%;
  z-index: 1;
}

/* ============================================================
   DRAGGABLE TILES (shared base)
   ============================================================ */
.tile {
  position: absolute;
  cursor: grab;
  user-select: none;
  border-radius: 2px;
  transition:
    box-shadow 200ms var(--ease),
    transform 200ms var(--ease);
  box-shadow: var(--shadow-tile);
  will-change: transform;
  touch-action: none;
}
.tile:hover {
  transform: translateY(-4px) scale(1.01);
  box-shadow: var(--shadow-hover);
  z-index: 50;
}
.tile.dragging {
  cursor: grabbing;
  transform: scale(1.04) translateY(-6px);
  box-shadow: var(--shadow-drag);
  z-index: 999 !important;
  transition: box-shadow 100ms, transform 100ms;
}
.tile.animating {
  transition: left 500ms var(--ease-spring), top 500ms var(--ease-spring), opacity 400ms var(--ease);
}
.tile.fading-out { opacity: 0; }
.tile.fading-in  { opacity: 1; }

/* Tile label strip */
.tile-label {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  padding: 5px 8px 6px;
  font-family: var(--sans);
  font-size: 9px;
  letter-spacing: .09em;
  text-transform: uppercase;
  font-weight: 600;
  pointer-events: none;
  line-height: 1.2;
}

/* ============================================================
   SWATCH TILES (160×160)
   ============================================================ */
.swatch-tile {
  width: 160px;
  height: 160px;
  overflow: hidden;
}

.swatch-tile .tile-label {
  background: rgba(0,0,0,0.52);
  color: rgba(255,255,255,0.92);
  backdrop-filter: blur(4px);
  display: flex;
  flex-direction: column;
  gap: 1px;
  line-height: 1.18;
}
.swatch-tile .tl-name {
  font-weight: 600;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.swatch-tile .tl-vendor {
  font-size: 8px;
  letter-spacing: .02em;
  opacity: 0.72;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.swatch-svg {
  width: 100%;
  height: 100%;
}
.swatch-img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  background: #1a1712;
}

/* ============================================================
   SIDEBAR
   ============================================================ */
.sidebar {
  background: var(--bg-raised);
  border-left: 1px solid var(--line);
  overflow-y: auto;
  display: flex;
  flex-direction: column;
}

.sidebar-section {
  padding: 20px 20px 0;
  border-bottom: 1px solid var(--line);
  padding-bottom: 20px;
}
.sidebar-section:last-child {
  border-bottom: none;
}

.sidebar-heading {
  font-size: 9px;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--accent);
  margin-bottom: 14px;
  font-weight: 600;
}

.sidebar-btn {
  display: flex;
  align-items: center;
  gap: 9px;
  width: 100%;
  padding: 9px 12px;
  margin-bottom: 6px;
  border-radius: var(--radius);
  font-family: var(--sans);
  font-size: 11px;
  letter-spacing: .07em;
  text-transform: uppercase;
  color: var(--ink-dim);
  border: 1px solid rgba(201,169,97,0.15);
  background: transparent;
  transition: all 180ms var(--ease);
  text-align: left;
}
.sidebar-btn:hover {
  color: var(--ink-inv);
  border-color: rgba(201,169,97,0.40);
  background: rgba(201,169,97,0.06);
}
.sidebar-btn:last-child { margin-bottom: 0; }

.sidebar-btn svg {
  flex-shrink: 0;
  opacity: 0.7;
}

/* Style preview thumbnail */
.style-thumb {
  width: 100%;
  aspect-ratio: 4/3;
  border-radius: 2px;
  margin-bottom: 10px;
  overflow: hidden;
  border: 1px solid var(--line);
}

/* Color palette preview row */
.palette-strip {
  display: flex;
  height: 28px;
  border-radius: 2px;
  overflow: hidden;
  margin-bottom: 6px;
  border: 1px solid var(--line);
}
.palette-strip-swatch {
  flex: 1;
}

.palette-names {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
  margin-bottom: 6px;
}
.palette-name-chip {
  font-size: 8px;
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--ink-dim);
  background: rgba(255,255,255,0.05);
  border-radius: 2px;
  padding: 2px 6px;
}

/* ============================================================
   EXPORT OVERLAY
   ============================================================ */
.overlay {
  display: none;
  position: fixed;
  inset: 0;
  background: rgba(13,11,9,0.92);
  z-index: 10000;
  align-items: center;
  justify-content: center;
  backdrop-filter: blur(8px);
}
.overlay.open { display: flex; }

.overlay-card {
  background: var(--bg-raised);
  border: 1px solid var(--line);
  border-radius: 4px;
  padding: 32px;
  max-width: 560px;
  width: 90%;
  display: flex;
  flex-direction: column;
  gap: 20px;
  box-shadow: 0 32px 96px rgba(0,0,0,0.7);
}

.overlay-title {
  font-family: var(--serif);
  font-size: 24px;
  font-weight: 600;
  color: var(--ink-inv);
  letter-spacing: .04em;
}

.overlay-body {
  font-size: 13px;
  color: var(--ink-dim);
  line-height: 1.6;
}

.overlay-canvas-wrap {
  border: 1px solid var(--line);
  border-radius: 2px;
  overflow: hidden;
  line-height: 0;
}
#exportCanvas {
  width: 100%;
  height: auto;
  display: block;
}

.overlay-actions {
  display: flex;
  gap: 10px;
  justify-content: flex-end;
}

/* ============================================================
   TOAST NOTIFICATION
   ============================================================ */
.toast {
  position: fixed;
  bottom: 32px;
  left: 50%;
  transform: translateX(-50%) translateY(80px);
  background: var(--accent);
  color: var(--ink);
  font-family: var(--sans);
  font-size: 11px;
  letter-spacing: .08em;
  text-transform: uppercase;
  font-weight: 700;
  padding: 10px 22px;
  border-radius: 100px;
  z-index: 99999;
  transition: transform 320ms var(--ease-spring), opacity 320ms var(--ease);
  opacity: 0;
  pointer-events: none;
}
.toast.show {
  transform: translateX(-50%) translateY(0);
  opacity: 1;
}

/* ============================================================
   LOADING SHIMMER on EXPORT BTN
   ============================================================ */
@keyframes shimmer {
  0% { background-position: -200% center; }
  100% { background-position: 200% center; }
}
.btn-shimmer {
  background: linear-gradient(90deg, var(--accent) 25%, #e8c87a 50%, var(--accent) 75%) !important;
  background-size: 200% auto !important;
  animation: shimmer 0.8s linear !important;
}

/* ============================================================
   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 */
  .wordmark { font-size: clamp(18px, 6vw, 34px); letter-spacing: .08em; white-space: normal; }
  .header-actions { width: 100%; justify-content: flex-start; flex-wrap: wrap; }
  /* keep the draggable board's overflow inside the canvas, not the page */
  #canvasWrap, .canvas-wrap { overflow: auto; -webkit-overflow-scrolling: touch; }
}

/* ============================================================
   SCROLLBAR STYLING
   ============================================================ */
.sidebar::-webkit-scrollbar { width: 4px; }
.sidebar::-webkit-scrollbar-track { background: transparent; }
.sidebar::-webkit-scrollbar-thumb { background: var(--line); border-radius: 2px; }

/* ============================================================
   TILE SELECTED STATE
   ============================================================ */
.tile.selected {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

</style>
</head>
<body>

<!-- HEADER -->
<header>
  <div class="header-lockup">
    <div class="wordmark">Designer Wallcoverings</div>
    <div class="header-sub">Interior Mood Board Studio</div>
  </div>
  <div class="header-actions">
    <button class="btn btn-ghost" id="btnShuffle">
      <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
        <path d="M1 4h8.5M9.5 4l-2-2M9.5 4l-2 2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
        <path d="M13 10H4.5M4.5 10l2-2M4.5 10l2 2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
        <path d="M1 10h1.5M11.5 4H13" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
      </svg>
      Shuffle
    </button>
    <button class="btn btn-ghost" id="btnClear">
      <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
        <path d="M2 2l10 10M12 2L2 12" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
      </svg>
      Clear
    </button>
    <button class="btn btn-accent" id="btnExport">
      <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
        <path d="M7 1v8M4 6l3 3 3-3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
        <path d="M2 10v2h10v-2" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
      </svg>
      Export Board
    </button>
  </div>
</header>

<!-- PRESET RAIL -->
<div class="preset-rail">
  <span class="preset-label">Style</span>
  <button class="preset-pill active" data-preset="grandmillennial">Grandmillennial</button>
  <button class="preset-pill" data-preset="coastal">Coastal</button>
  <button class="preset-pill" data-preset="moody">Moody Maximalist</button>
  <button class="preset-pill" data-preset="minimal">Modern Minimalist</button>
  <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 -->
  <div class="canvas-wrap" id="canvasWrap">
    <div class="canvas-inner" id="canvasInner"></div>
  </div>

  <!-- SIDEBAR -->
  <aside class="sidebar">

    <!-- Style Preview -->
    <div class="sidebar-section">
      <div class="sidebar-heading">Active Style</div>
      <div class="style-thumb" id="styleThumb"></div>
      <div class="sidebar-heading" style="margin-bottom:8px;">Style Concept Palette</div>
      <div class="palette-strip" id="paletteSidebarStrip"></div>
      <div class="palette-names" id="paletteSidebarNames"></div>
    </div>

    <!-- Board Actions -->
    <div class="sidebar-section">
      <div class="sidebar-heading">Board Actions</div>
      <button class="sidebar-btn" id="sidebarShuffle">
        <svg width="13" height="13" viewBox="0 0 14 14" fill="none"><path d="M1 4h8.5M9.5 4l-2-2M9.5 4l-2 2M13 10H4.5M4.5 10l2-2M4.5 10l2 2M1 10h1.5M11.5 4H13" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
        Recompose Board
      </button>
      <button class="sidebar-btn" id="sidebarReset">
        <svg width="13" height="13" viewBox="0 0 14 14" fill="none"><path d="M7 2a5 5 0 1 0 4.33 2.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/><path d="M11 1v3.5H7.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
        Reset Layout
      </button>
      <button class="sidebar-btn" id="sidebarFront">
        <svg width="13" height="13" viewBox="0 0 14 14" fill="none"><rect x="4" y="4" width="8" height="8" rx="1" stroke="currentColor" stroke-width="1.2"/><rect x="2" y="2" width="8" height="8" rx="1" stroke="currentColor" stroke-width="1.2"/></svg>
        Bring to Front
      </button>
      <button class="sidebar-btn" id="sidebarBack">
        <svg width="13" height="13" viewBox="0 0 14 14" fill="none"><rect x="2" y="2" width="8" height="8" rx="1" stroke="currentColor" stroke-width="1.2"/><rect x="4" y="4" width="8" height="8" rx="1" stroke="currentColor" stroke-width="1.2"/></svg>
        Send to Back
      </button>
      <button class="sidebar-btn" id="sidebarDelete" style="color:#c1623f;border-color:rgba(193,98,63,0.25);">
        <svg width="13" height="13" viewBox="0 0 14 14" fill="none"><path d="M2 3.5h10M5 3.5V2h4v1.5M5.5 6v4M8.5 6v4M3 3.5l.7 8h6.6l.7-8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/></svg>
        Delete Selected
      </button>
    </div>

    <!-- Tip -->
    <div class="sidebar-section" style="margin-top:auto;padding-bottom:24px;">
      <div class="sidebar-heading">Tips</div>
      <p style="font-size:10px;line-height:1.7;color:var(--ink-dim);">
        Drag swatches to compose.<br>
        Click a tile to select it.<br>
        Switch style presets to<br>
        repopulate the palette.<br>
        Export at 1080&times;1350.
      </p>
    </div>

  </aside>
</div>

<!-- EXPORT OVERLAY -->
<div class="overlay" id="exportOverlay" role="dialog" aria-modal="true" aria-label="Export Mood Board">
  <div class="overlay-card">
    <div style="display:flex;align-items:flex-start;justify-content:space-between;gap:16px;">
      <div class="overlay-title">Export Mood Board</div>
      <button class="btn btn-ghost" id="btnCloseOverlay" style="padding:6px 10px;min-width:0;">&#x2715;</button>
    </div>
    <div class="overlay-body">
      Your board rendered as a 1080&times;1350 portrait card — ready for Instagram, Pinterest, or client decks.
    </div>
    <div class="overlay-canvas-wrap">
      <canvas id="exportCanvas" width="1080" height="1350"></canvas>
    </div>
    <div class="overlay-actions">
      <button class="btn btn-ghost" id="btnCloseOverlay2">Cancel</button>
      <button class="btn btn-accent" id="btnDownload">
        <svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M7 1v8M4 6l3 3 3-3M2 10v2h10v-2" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>
        Download PNG
      </button>
    </div>
  </div>
</div>

<!-- TOAST -->
<div class="toast" id="toast" role="status" aria-live="polite"></div>

<script>
/* ============================================================
   REAL DW PRODUCTS  (live catalog — dw_unified mirror, status=ACTIVE,
   real cdn.shopify.com photos). Keyed by material type; each product:
   { name, vendor, sku, handle, img, price }
   ============================================================ */
const PRODUCTS = {"chinoiserie":[{"name":"Jardin Fleuri - Ivory Multi By Brunschwig & Fils | Summer Palace Wallcoverings |Chinoiserie Botanical & Floral Wallcovering Print","vendor":"Brunschwig & Fils","sku":"DWKK-102285","handle":"dwkk-102285","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/P8019118_192_950782b7-bc9c-4b7a-a0a9-49fae23ee1a0.jpg?v=1725922617","price":1567},{"name":"Tongli - Fern Multi By Brunschwig & Fils | Summer Palace Wallcoverings |Chinoiserie Botanical & Floral Wallcovering Print","vendor":"Brunschwig & Fils","sku":"DWKK-102274","handle":"dwkk-102274","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/P8019115_33_a7361bcf-88e8-40df-b0fa-23c228b55301.jpg?v=1725922594","price":1394},{"name":"Ming Dragon - Indigo Multi By Brunschwig & Fils | Summer Palace Wallcoverings | Chinoiserie Wallcovering Print","vendor":"Brunschwig & Fils","sku":"DWKK-102269","handle":"dwkk-102269","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/P8019113_50_c18abe3c-8f2d-4eda-9618-afb98f493602.jpg?v=1725922585","price":605},{"name":"La Pagode - Teal By Brunschwig & Fils | Les Ensembliers Wallcoverings | Chinoiserie Wallcovering Embroidery","vendor":"Brunschwig & Fils","sku":"DWKK-102331","handle":"dwkk-102331","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/P8020108_35_f3afd4ba-dbe6-4ac9-a195-b930f188a780.jpg?v=1725922727","price":393},{"name":"Visan - Navy/Sky White By Brunschwig & Fils | Cadenet Wallcoverings | Paisley Wallcovering Print","vendor":"Brunschwig & Fils","sku":"DWKK-102427","handle":"dwkk-102427","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/P8023104_550_157e2be2-a7a8-4289-b3e6-e6d1d8ef940e.jpg?v=1725922965","price":null},{"name":"Loire - Emerald Green By Brunschwig & Fils | Louverne Wallcoverings | Botanical & Floral Wallcovering Print","vendor":"Brunschwig & Fils","sku":"DWKK-102349","handle":"dwkk-102349","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/P8020112_53_7a5711f1-5d6e-43f6-b914-f724d2f8b67a.jpg?v=1725922765","price":null},{"name":"Valensole - Leaf/Rose Green By Brunschwig & Fils | Manoir Wallcoverings |Botanical & Floral Toile Wallcovering Print","vendor":"Brunschwig & Fils","sku":"DWKK-102368","handle":"dwkk-102368","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/P8022101_317_fdc6e1bf-dd48-4443-817e-8c4384d964ac.jpg?v=1725922812","price":null},{"name":"Jardin Fleuri - Delft Multi By Brunschwig & Fils | Summer Palace Wallcoverings |Chinoiserie Botanical & Floral Wallcovering Print","vendor":"Brunschwig & Fils","sku":"DWKK-102282","handle":"dwkk-102282","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/P8019118_153_b7b10748-93ce-4c18-9649-24aa6370d0cc.jpg?v=1725922610","price":1567}],"damask":[{"name":"Marguerite Damask 04 - Brown Wallcovering | Nina Campbell","vendor":"Nina Campbell","sku":"EUR-80323","handle":"eur-80323-ncw4304-designer-wallcoverings-los-angeles","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/nina_crop_7513504153651.jpg?v=1775523377","price":107},{"name":"Angelique Damask 05 Blue | Designer's Guild Europe","vendor":"Designers Guild","sku":"EUR-80706","handle":"eur-80706-pdg1036-designer-wallcoverings-los-angeles","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/82097.jpg?v=1782461625","price":null},{"name":"Colonial - 9 Silver | Kravet Design | Lizzo |Damask Metallic Wallcovering Print","vendor":"Kravet","sku":"DWKK-123860","handle":"dwkk-gc8aef5ae","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/LZW-30191_09_b7a2ce2d-5ce4-4d45-ac0f-1c245c57dca5.jpg?v=1753290971","price":null},{"name":"Lido Paper - Fog Grey By Lee Jofa | Westport |Damask  Wallcovering Print","vendor":"Lee Jofa","sku":"DWKK-139823","handle":"dwkk-gf4b29cec","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/P2018104_11_a8350620-d0f1-4139-9d1b-7cbe4af4c42e.jpg?v=1753291202","price":613},{"name":"Lido Paper - Leaf Green By Lee Jofa | Westport |Damask  Wallcovering Print","vendor":"Lee Jofa","sku":"DWKK-139825","handle":"dwkk-g6d4685ef","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/P2018104_3_67c4052d-2912-4eac-b0ae-3247d5c50cc5.jpg?v=1753291197","price":613},{"name":"Arturo Damask, Metallic Silver Wallcoverings | Thibaut","vendor":"Thibaut","sku":"DWTT-73769","handle":"arturo-damask-metallic-silver-dwtt-73769","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T13017_a866ad94-bac1-4783-aae3-261818a808db.jpg?v=1783291218","price":null},{"name":"Canal Damask Durable Vinyl | Hollywood Wallcoverings","vendor":"Hollywood Wallcoverings","sku":"XWA-52078","handle":"canal-damask-durable-vinyl-xwa-52078","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/xwa-52078-sample-canal-damask-durable-vinyl-hollywood-wallcoverings.jpg?v=1775707053","price":4},{"name":"Damasco Damask Stripe Crocus | Designers Guild Europe","vendor":"Designers Guild","sku":"EUR-70081","handle":"eur-70081-designer-wallcoverings-los-angeles","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/47451_5cf553c7-3554-4629-b8e5-3132c3c2f066.webp?v=1782461907","price":null}],"grasscloth":[{"name":"Grasscloth Slubs Natura | Phillipe Romano","vendor":"Phillipe Romano","sku":"GRS-1811","handle":"grasscloth-slubs-natura-grs-1811","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/products/a731e8e333ad16ef3b05543b830e00de_7ec623bc-ba2d-44dd-8884-176af8ed4f39.jpg?v=1745460826","price":23},{"name":"Hula - Black Black By Lee Jofa Modern | Hunt Slonem Ii | Modern Wallcovering Grasscloth","vendor":"Groundworks","sku":"DWKK-139617","handle":"dwkk-139617","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/GWP-3713_84_e688a483-e43f-4d03-b8a3-d9a6741c8a75.jpg?v=1753291483","price":444},{"name":"Diamond Grasscloth Tribal | Phillipe Romano","vendor":"Phillipe Romano","sku":"GRS-1804","handle":"diamond-grasscloth-tribal-grs-1804","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/products/9014b0c5b6e28bcffc717479cca2f5e0.jpg?v=1745460833","price":23},{"name":"Plantation Grasscloth | Phillipe Romano","vendor":"Phillipe Romano","sku":"GRS-1806","handle":"plantation-grasscloth-grs-1806","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/plantation-grasscloth-grs-1806-cropped.jpg?v=1774341392","price":23},{"name":"Pali Basketweave Paperweave Grasscloth | Phillipe Romano","vendor":"Phillipe Romano","sku":"GRA-39052","handle":"pali-basketweave-paperweave-grasscloth-gra-39052","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/products/da6ddb3316744cc200ef1ea31be33850.jpg?v=1745459005","price":5},{"name":"Matera Sisal Pecan Grasscloth Wallcovering | Fentucci","vendor":"Fentucci","sku":"DWFE-1108","handle":"matera-sisal-pecan-grasscloth-wallcovering-fentucci","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/DWNAT-100162.jpg?v=1782321964","price":null},{"name":"Grasscloth Sublimer La Simplicité | Elitis","vendor":"Elitis","sku":"DWEL-142761-Roll","handle":"grasscloth-sublimer-la-simplicite-elitis","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/outdoor-tribus-wall-grasscloth-rm-1083-04-577b2beabe2aef3242534cfa6379.jpg?v=1772170246","price":null},{"name":"Roma Sisal Grasscloth Horizontal Weave Light Brown | Phillipe Romano","vendor":"Phillipe Romano","sku":"GRT-38309","handle":"roma-tight-woven-grasscloth-grt-38309","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/products/f4f84be58dc46b61384a5065ac13eb4f.jpg?v=1745459404","price":4}],"linen":[{"name":"Camellia Garden - Navy and Linen Wallcovering | Anna French","vendor":"Anna French","sku":"DWTA-65014","handle":"camellia-garden-navy-and-linen-anna-french","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/AT24553_c53cd2fa-1662-4475-83c7-95b4bfa6e5e9.jpg?v=1773967647","price":157},{"name":"Annette - Metallic Silver on Linen Wallcovering | Anna French","vendor":"Anna French","sku":"DWTA-65598","handle":"annette-metallic-silver-on-linen-anna-french","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/AT34111.jpg?v=1773963751","price":118},{"name":"Dahlia - Navy on Linen Wallcovering | Anna French","vendor":"Anna French","sku":"DWTA-65019","handle":"dahlia-navy-on-linen-anna-french","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/AT24540.jpg?v=1773967664","price":208},{"name":"Carmel-Misty Blue Linen Wallcovering | Phillipe Romano","vendor":"Phillipe Romano","sku":"LINN-500060","handle":"carmel-misty-blue-linen-wallcovering-phillipe-romano","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/61e4c00a6dee9dc19b41d15900f6ca45.jpg?v=1783879027","price":null},{"name":"Rehoboth-Plain Linen Patterns Hemp Wallcovering | Phillipe Romano","vendor":"Phillipe Romano","sku":"HEMP-500500","handle":"rehoboth-plain-linen-patterns-hemp-wallcovering-phillipe-romano","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/f59b5045838f468cf1ca2c6cb0ec64ed.jpg?v=1783879715","price":null},{"name":"Belgian Linen Wallcovering - Steel Blue Wallcovering | Arte International","vendor":"Arte International","sku":"DWK-31401","handle":"dwk-31401-koroseal-arte-type-2-vinyls-67130-belgian-linen","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/67130_45d13979-c715-4239-bb8c-2d79fc2d701f.jpg?v=1751962288","price":null},{"name":"Athens Katari Linen Wallcovering | Phillipe Romano","vendor":"Phillipe Romano","sku":"DWPP-207945","handle":"athens-katari-linen-wallcovering-phillipe-romano","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/3501KT.jpg?v=1785089900","price":null},{"name":"Summer Wall - Linen wallcovering | Donghia","vendor":"Donghia","sku":"DWDH-188069","handle":"summer-wall-linen-wallcovering-donghia","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/DG-09700_018.jpg?v=1772818831","price":null}],"raffia":[{"name":"Fine Raffia - Natural Beige By Lee Jofa Modern | Elements Ii Naturals |Solid Texture Wallcovering Grasscloth","vendor":"Groundworks","sku":"DWKK-139575","handle":"dwkk-g328a6d2a","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/GWP-3415_16_ae628f14-0e94-479b-bfd8-a11a13484a57.jpg?v=1753291589","price":138},{"name":"Cabo Raffia Wallcovering | Thibaut","vendor":"Thibaut","sku":"DWTT-80391","handle":"dwtt-80391-designer-wallcoverings-los-angeles","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T41160_d90c5f1b-3086-4237-851f-332ec133f1c8.jpg?v=1781202567","price":196},{"name":"Bethany Raffia Off White - Off White Wallcovering | Thibaut","vendor":"Thibaut","sku":"DWTT-80363","handle":"dwtt-80363-designer-wallcoverings-los-angeles","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T19602_05477fe1-50d1-4fa8-b5ec-f23c50e31727.jpg?v=1781202597","price":379},{"name":"Sea Bright Plain Paper Weave Raffia Wallcovering | Phillipe Romano","vendor":"Phillipe Romano","sku":"PWV-500950","handle":"sea-bright-plain-paper-weave-raffia-paper-weave-wallcovering-phillipe-romano","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/baf2884af6422e26083a1b2f6e07e3cd.jpg?v=1783880263","price":null},{"name":"Raffia Decor - Smoke | Scalamandre","vendor":"Scalamandre Wallpaper","sku":"DWA-88550","handle":"wtt661468","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/products/WTT661468.jpg?v=1745346442","price":null},{"name":"Bankun Raffia Antique - Antique Natural Wallcovering | Thibaut","vendor":"Thibaut","sku":"DWTT-72328","handle":"dwtt-72328-designer-wallcoverings-los-angeles","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T6817_89424133-37e8-4acd-9f01-2b49bbf46f15.jpg?v=1781202941","price":4},{"name":"Bankun Raffia, Spa Blue Wallcoverings | Thibaut","vendor":"Thibaut","sku":"DWTT-73839","handle":"bankun-raffia-spa-blue-dwtt-73839","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/T14519.jpg?v=1783471222","price":null},{"name":"Otis Embossed Raffia Blue Yonder - Light Gray Commercial Wallcovering | Koroseal","vendor":"Koroseal","sku":"DWK-29105","handle":"dwk-29105-otis-designer-wallcoverings-los-angeles","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/OTIS_BLUE-YONDER_T521-08__KOROSEAL.jpg?v=1739409499","price":null}],"silk":[{"name":"Watered Silk, Platinum Grey Wallcoverings | Zoffany","vendor":"Zoffany","sku":"DWZF-187032","handle":"watered-silk-platinum-grey-dwzf-187032","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/ZRHW312915_d9df_1d698e8d-35e1-44ae-893d-bcdfa5caff5c.jpg?v=1782315651","price":null},{"name":"Seijun - Wheat By Brunschwig & Fils | |Solid Texture Wallcovering Silk","vendor":"Brunschwig & Fils","sku":"DWKK-102232","handle":"dwkk-102232","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/P8015147_614_c235fc43-02e2-4b31-a124-a591b9091b29.jpg?v=1725922499","price":null},{"name":"Ipswich-Snow Silk Wallcovering | Phillipe Romano","vendor":"Phillipe Romano","sku":"SILK-500850","handle":"ipswich-snow-silk-wallcovering-phillipe-romano","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/034c378753fce9d5ca67c42f155319c8.jpg?v=1783879517","price":null},{"name":"Manchester Silk-Black Ink Durable Vinyl | Phillipe Romano","vendor":"Phillipe Romano","sku":"XXP-969464","handle":"xxp-969464-designerwallcoverings-los-angeles","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/products/T2-LK-08_1fbbb96d-63c4-48f9-a745-e4085ad058a8.jpg?v=1733890863","price":null},{"name":"Milford Silk-Dusk Durable Vinyl | Phillipe Romano","vendor":"Phillipe Romano","sku":"XXP-969682","handle":"xxp-969682-designerwallcoverings-los-angeles","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/products/T2-SL-03_730e8632-1d44-4317-b0f5-79497ea74c9c.jpg?v=1733890246","price":null},{"name":"Portsmouth Silk-Palladium Durable Vinyl | Phillipe Romano","vendor":"Phillipe Romano","sku":"XXP-969547","handle":"xxp-969547-designerwallcoverings-los-angeles","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/products/T2-LX-05_fdec728e-7f70-4452-beca-8066a1c731fc.jpg?v=1733890636","price":null},{"name":"Silkx Wallcovering - Light Gray Wallcovering | Arte International","vendor":"Arte International","sku":"DWK-31451","handle":"dwk-31451-koroseal-arte-type-2-vinyls-67009-silkx","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/67009_1fe3b816-0112-4df5-a70a-968ceab7545b.jpg?v=1751962467","price":null},{"name":"Silken One Palatial - Beige Commercial Wallcovering | Koroseal","vendor":"Koroseal","sku":"DWK-31563","handle":"dwk-31563-koroseal-roger-thomas-s522-10-silken-one-costly","img":"https://cdn.shopify.com/s/files/1/0015/4117/7456/files/SILKEN_ONE-PALATIAL_S522-10_f3e44120-9a03-4bf0-9b08-899d74d13262.jpg?v=1751949423","price":null}]};

/* Rotating picker — each spawn surfaces the next real product for its material,
   so switching / reshuffling presets keeps revealing different live SKUs. */
const _prodIdx = {};
function pickProduct(type) {
  const pool = PRODUCTS[type] || [];
  if (!pool.length) return null;
  const i = (_prodIdx[type] || 0) % pool.length;
  _prodIdx[type] = i + 1;
  return pool[i];
}
/* "Jardin Fleuri - Ivory Multi By Brunschwig ... | Collection | Type" -> "Jardin Fleuri - Ivory Multi" */
function productShortName(name) {
  return (name || '').split(/\s+By\s+|\s*\|\s*/)[0].replace(/\s+/g, ' ').trim();
}
function productHref(handle) {
  return 'https://www.designerwallcoverings.com/products/' + handle;
}

/* ============================================================
   STYLE PRESET DATA
   ============================================================ */
const PRESETS = {
  grandmillennial: {
    label: 'Grandmillennial',
    palette: [
      { name: 'Antique Rose',  hex: '#c9917e' },
      { name: 'Ivory Cream',   hex: '#f0e8d8' },
      { name: 'Dusty Gold',    hex: '#c9a961' },
      { name: 'Sage Velvet',   hex: '#7a9472' },
      { name: 'Blush Linen',   hex: '#e0c8bc' },
      { name: 'Umber',         hex: '#7a5a3c' },
    ],
    swatches: ['chinoiserie','damask','grasscloth','raffia','silk','linen'],
    thumbColors: ['#c9917e','#f0e8d8','#c9a961','#7a9472','#e0c8bc'],
  },
  coastal: {
    label: 'Coastal',
    palette: [
      { name: 'Sea Salt',     hex: '#dde8e5' },
      { name: 'Celadon',      hex: '#a8c4b0' },
      { name: 'White Oak',    hex: '#d4c4a4' },
      { name: 'Dune',         hex: '#d9ccb0' },
      { name: 'Horizon',      hex: '#7fa8b8' },
      { name: 'Oatmeal',      hex: '#e8dccb' },
    ],
    swatches: ['linen','raffia','grasscloth','silk','damask','chinoiserie'],
    thumbColors: ['#dde8e5','#a8c4b0','#d4c4a4','#d9ccb0','#7fa8b8'],
  },
  moody: {
    label: 'Moody Maximalist',
    palette: [
      { name: 'Ink',          hex: '#1c2b3a' },
      { name: 'Jewel Plum',   hex: '#5c3a5e' },
      { name: 'Forest Night', hex: '#2a3d2e' },
      { name: 'Antique Brass',hex: '#c9a961' },
      { name: 'Bordeaux',     hex: '#7a2a2a' },
      { name: 'Midnight',     hex: '#1a1a2e' },
    ],
    swatches: ['damask','silk','chinoiserie','raffia','grasscloth','linen'],
    thumbColors: ['#1c2b3a','#5c3a5e','#2a3d2e','#c9a961','#7a2a2a'],
  },
  minimal: {
    label: 'Modern Minimalist',
    palette: [
      { name: 'Bone',         hex: '#e8e2d8' },
      { name: 'Mist',         hex: '#d4d0c8' },
      { name: 'Warm White',   hex: '#f5f2ec' },
      { name: 'Concrete',     hex: '#b8b4aa' },
      { name: 'Pale Stone',   hex: '#c8c0b0' },
      { name: 'Flax',         hex: '#d8cc9c' },
    ],
    swatches: ['linen','silk','grasscloth','raffia','damask','chinoiserie'],
    thumbColors: ['#e8e2d8','#d4d0c8','#f5f2ec','#b8b4aa','#c8c0b0'],
  },
  deco: {
    label: 'Art Deco',
    palette: [
      { name: 'Lacquer Black',hex: '#0a0a0a' },
      { name: 'Aged Gold',    hex: '#c9a961' },
      { name: 'Ivory',        hex: '#f0ead8' },
      { name: 'Jade',         hex: '#4a7a6a' },
      { name: 'Deep Navy',    hex: '#1a2440' },
      { name: 'Champagne',    hex: '#d8c88a' },
    ],
    swatches: ['silk','damask','chinoiserie','grasscloth','linen','raffia'],
    thumbColors: ['#0a0a0a','#c9a961','#f0ead8','#4a7a6a','#1a2440'],
  },
};

/* ============================================================
   TEXTURE SVG GENERATORS
   Each returns an SVG string for a 160x160 swatch
   ============================================================ */
function svgGrasscloth(palette) {
  const bg  = palette[4]?.hex || '#d4c4a4';
  const fg  = palette[5]?.hex || '#b09a7a';
  const lines = [];
  // Horizontal fiber lines at varying weights
  for (let y = 2; y < 160; y += 4) {
    const op = 0.25 + Math.random() * 0.35;
    const w  = 0.5 + Math.random() * 0.8;
    lines.push(`<line x1="0" y1="${y}" x2="160" y2="${y}" stroke="${fg}" stroke-width="${w.toFixed(1)}" stroke-opacity="${op.toFixed(2)}"/>`);
    if (y % 16 === 2) {
      lines.push(`<line x1="0" y1="${y+1}" x2="160" y2="${y+1}" stroke="${fg}" stroke-width="0.4" stroke-opacity="0.12"/>`);
    }
  }
  // Vertical warp threads
  for (let x = 0; x < 160; x += 6) {
    const op = 0.08 + Math.random() * 0.10;
    lines.push(`<line x1="${x}" y1="0" x2="${x}" y2="160" stroke="${fg}" stroke-width="0.6" stroke-opacity="${op.toFixed(2)}"/>`);
  }
  return `<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160" viewBox="0 0 160 160">
    <rect width="160" height="160" fill="${bg}"/>
    ${lines.join('')}
  </svg>`;
}

function svgDamask(palette) {
  const bg   = palette[1]?.hex || '#f0e8d8';
  const fg   = palette[0]?.hex || '#c9917e';
  const acc  = palette[2]?.hex || '#c9a961';
  return `<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160" viewBox="0 0 160 160">
    <rect width="160" height="160" fill="${bg}"/>
    <defs>
      <pattern id="dmpat" x="0" y="0" width="80" height="80" patternUnits="userSpaceOnUse">
        <!-- Central medallion -->
        <ellipse cx="40" cy="40" rx="18" ry="26" fill="none" stroke="${fg}" stroke-width="0.8" stroke-opacity="0.45"/>
        <ellipse cx="40" cy="40" rx="10" ry="15" fill="none" stroke="${fg}" stroke-width="0.6" stroke-opacity="0.35"/>
        <!-- Four-point petals -->
        <path d="M40 14 C36 20 34 28 40 34 C46 28 44 20 40 14Z" fill="${fg}" fill-opacity="0.20"/>
        <path d="M40 46 C36 52 34 60 40 66 C46 60 44 52 40 46Z" fill="${fg}" fill-opacity="0.20"/>
        <path d="M14 40 C20 36 28 34 34 40 C28 46 20 44 14 40Z" fill="${fg}" fill-opacity="0.20"/>
        <path d="M46 40 C52 36 60 34 66 40 C60 46 52 44 46 40Z" fill="${fg}" fill-opacity="0.20"/>
        <!-- Diagonal acanthus -->
        <path d="M20 20 C22 26 30 30 28 36 C26 30 18 28 20 20Z" fill="${acc}" fill-opacity="0.12"/>
        <path d="M60 20 C58 26 50 30 52 36 C54 30 62 28 60 20Z" fill="${acc}" fill-opacity="0.12"/>
        <path d="M20 60 C22 54 30 50 28 44 C26 50 18 52 20 60Z" fill="${acc}" fill-opacity="0.12"/>
        <path d="M60 60 C58 54 50 50 52 44 C54 50 62 52 60 60Z" fill="${acc}" fill-opacity="0.12"/>
        <!-- Corner dots -->
        <circle cx="0" cy="0" r="3" fill="${fg}" fill-opacity="0.15"/>
        <circle cx="80" cy="0" r="3" fill="${fg}" fill-opacity="0.15"/>
        <circle cx="0" cy="80" r="3" fill="${fg}" fill-opacity="0.15"/>
        <circle cx="80" cy="80" r="3" fill="${fg}" fill-opacity="0.15"/>
        <!-- Hairline grid -->
        <line x1="40" y1="0" x2="40" y2="80" stroke="${fg}" stroke-width="0.3" stroke-opacity="0.15"/>
        <line x1="0" y1="40" x2="80" y2="40" stroke="${fg}" stroke-width="0.3" stroke-opacity="0.15"/>
      </pattern>
    </defs>
    <rect width="160" height="160" fill="url(#dmpat)"/>
  </svg>`;
}

function svgSilkStripe(palette) {
  const cols = [
    palette[0]?.hex || '#c9a961',
    palette[1]?.hex || '#f0e8d8',
    palette[2]?.hex || '#d4c4a4',
    palette[0]?.hex || '#c9a961',
    palette[4]?.hex || '#e0c8bc',
  ];
  const widths = [8, 20, 6, 14, 4, 20, 6, 14, 8];
  const stripes = [];
  let x = 0;
  const total = widths.reduce((a,b) => a+b, 0); // 100
  const reps = Math.ceil(160 / total) + 1;
  for (let r = 0; r < reps; r++) {
    let cx = x + r * total;
    widths.forEach((w, i) => {
      const c = cols[i % cols.length];
      // Alternate satin/matte effect via slight opacity variation
      const op = i % 2 === 0 ? 1 : 0.75;
      stripes.push(`<rect x="${cx}" y="0" width="${w}" height="160" fill="${c}" fill-opacity="${op}"/>`);
      cx += w;
    });
  }
  // Sheen overlay
  const sheen = [];
  for (let s = 0; s < 160; s += 28) {
    sheen.push(`<rect x="${s}" y="0" width="2" height="160" fill="white" fill-opacity="0.06"/>`);
  }
  return `<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160" viewBox="0 0 160 160">
    <rect width="160" height="160" fill="${cols[1]}"/>
    ${stripes.join('')}
    ${sheen.join('')}
  </svg>`;
}

function svgLinen(palette) {
  const bg = palette[1]?.hex || '#e8dccb';
  const fg = palette[5]?.hex || '#c4a878';
  const weave = [];
  // Warp threads
  for (let x = 0; x < 160; x += 5) {
    weave.push(`<line x1="${x}" y1="0" x2="${x}" y2="160" stroke="${fg}" stroke-width="1.2" stroke-opacity="0.28"/>`);
  }
  // Weft threads
  for (let y = 0; y < 160; y += 5) {
    weave.push(`<line x1="0" y1="${y}" x2="160" y2="${y}" stroke="${fg}" stroke-width="1" stroke-opacity="0.22"/>`);
  }
  // Intersection dots (subtle nub)
  for (let x = 0; x < 160; x += 10) {
    for (let y = 0; y < 160; y += 10) {
      weave.push(`<circle cx="${x}" cy="${y}" r="0.7" fill="${fg}" fill-opacity="0.25"/>`);
    }
  }
  return `<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160" viewBox="0 0 160 160">
    <rect width="160" height="160" fill="${bg}"/>
    ${weave.join('')}
  </svg>`;
}

function svgRaffia(palette) {
  const bg  = palette[3]?.hex || '#d9ccb0';
  const fg  = palette[5]?.hex || '#a08060';
  const diag = [];
  // Diagonal bast-fiber weave — NE direction
  for (let d = -160; d < 320; d += 7) {
    const op = 0.18 + (Math.abs(d) % 30) / 120;
    diag.push(`<line x1="${d}" y1="0" x2="${d+160}" y2="160" stroke="${fg}" stroke-width="1.5" stroke-opacity="${op.toFixed(2)}"/>`);
  }
  // SW direction overlay
  for (let d = -160; d < 320; d += 14) {
    diag.push(`<line x1="${d+160}" y1="0" x2="${d}" y2="160" stroke="${fg}" stroke-width="0.6" stroke-opacity="0.12"/>`);
  }
  // Occasional thick bundle
  for (let d = -160; d < 320; d += 42) {
    diag.push(`<line x1="${d}" y1="0" x2="${d+160}" y2="160" stroke="${fg}" stroke-width="2.5" stroke-opacity="0.18"/>`);
  }
  return `<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160" viewBox="0 0 160 160">
    <rect width="160" height="160" fill="${bg}"/>
    ${diag.join('')}
  </svg>`;
}

function svgChinoiserie(palette) {
  const bg    = palette[1]?.hex || '#f4f0e0';
  const blue  = palette[4]?.hex || '#4a7a9e';
  const green = palette[3]?.hex || '#6a9a6a';
  const blush = palette[0]?.hex || '#d4907a';
  return `<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160" viewBox="0 0 160 160">
    <rect width="160" height="160" fill="${bg}"/>
    <!-- Main branch -->
    <path d="M30 140 C40 120 50 100 60 80 C70 60 75 40 80 20" stroke="${blue}" stroke-width="1.8" fill="none" stroke-opacity="0.7"/>
    <path d="M80 20 C85 35 90 50 110 55" stroke="${blue}" stroke-width="1.4" fill="none" stroke-opacity="0.5"/>
    <path d="M60 80 C45 70 35 60 20 55" stroke="${blue}" stroke-width="1.2" fill="none" stroke-opacity="0.45"/>
    <path d="M50 100 C55 90 70 85 85 88" stroke="${blue}" stroke-width="1" fill="none" stroke-opacity="0.4"/>
    <!-- Blossoms -->
    <circle cx="80" cy="20" r="6" fill="${blush}" fill-opacity="0.75"/>
    <circle cx="80" cy="20" r="3" fill="${blush}" fill-opacity="0.9"/>
    <circle cx="79" cy="17" r="2" fill="white" fill-opacity="0.5"/>
    <circle cx="110" cy="55" r="5" fill="${blush}" fill-opacity="0.65"/>
    <circle cx="20" cy="55" r="4" fill="${blush}" fill-opacity="0.60"/>
    <circle cx="85" cy="88" r="4.5" fill="${blush}" fill-opacity="0.70"/>
    <!-- Small buds -->
    <ellipse cx="68" cy="60" rx="3" ry="4.5" fill="${blush}" fill-opacity="0.55" transform="rotate(-20,68,60)"/>
    <ellipse cx="95" cy="40" rx="2.5" ry="4" fill="${blush}" fill-opacity="0.50" transform="rotate(15,95,40)"/>
    <!-- Leaves -->
    <path d="M55 92 C50 85 42 80 38 84 C42 90 50 92 55 92Z" fill="${green}" fill-opacity="0.55"/>
    <path d="M65 72 C60 65 52 62 50 66 C54 72 62 73 65 72Z" fill="${green}" fill-opacity="0.50"/>
    <path d="M88 30 C94 24 102 24 102 28 C96 34 88 32 88 30Z" fill="${green}" fill-opacity="0.48"/>
    <path d="M100 60 C106 54 114 56 112 60 C106 64 100 62 100 60Z" fill="${green}" fill-opacity="0.45"/>
    <!-- Second branch (right side) -->
    <path d="M140 130 C130 110 118 90 115 70 C112 50 118 30 120 15" stroke="${blue}" stroke-width="1.5" fill="none" stroke-opacity="0.55"/>
    <path d="M115 70 C128 65 138 60 148 62" stroke="${blue}" stroke-width="1" fill="none" stroke-opacity="0.40"/>
    <circle cx="120" cy="15" r="5" fill="${blush}" fill-opacity="0.65"/>
    <circle cx="148" cy="62" r="4" fill="${blush}" fill-opacity="0.55"/>
    <path d="M123 50 C130 44 138 46 136 50 C130 54 123 52 123 50Z" fill="${green}" fill-opacity="0.42"/>
    <!-- Small birds (abstract) -->
    <path d="M70 130 C72 128 76 128 78 130 C76 132 72 132 70 130Z" fill="${blue}" fill-opacity="0.35"/>
    <path d="M74 129 L74 126" stroke="${blue}" stroke-width="0.8" stroke-opacity="0.4"/>
    <!-- Decorative dots -->
    <circle cx="40" cy="30" r="2" fill="${blue}" fill-opacity="0.20"/>
    <circle cx="145" cy="100" r="1.5" fill="${blue}" fill-opacity="0.18"/>
    <circle cx="10" cy="100" r="1.5" fill="${blue}" fill-opacity="0.15"/>
  </svg>`;
}

/* ============================================================
   SWATCH TEXTURE ROUTER
   ============================================================ */
const SWATCH_LABELS = {
  grasscloth: 'Grasscloth',
  damask:     'Damask',
  silk:       'Silk Stripe',
  linen:      'Natural Linen',
  raffia:     'Raffia Weave',
  chinoiserie:'Chinoiserie',
};

function buildSwatchSVG(type, palette) {
  switch(type) {
    case 'grasscloth':  return svgGrasscloth(palette);
    case 'damask':      return svgDamask(palette);
    case 'silk':        return svgSilkStripe(palette);
    case 'linen':       return svgLinen(palette);
    case 'raffia':      return svgRaffia(palette);
    case 'chinoiserie': return svgChinoiserie(palette);
    default:            return svgLinen(palette);
  }
}

/* ============================================================
   LAYOUT COMPOSER — determines tile positions
   ============================================================ */
function composeTilePositions(canvasW, canvasH, preset, count) {
  // Real-products-only board: lay out `count` equal 160×160 product tiles in a
  // staggered collage. Positions are deterministic per preset (stable across
  // reshuffle) with a per-preset seed so each style scatters differently.
  count = count || 12;
  const pad = 22, sz = 160, gap = 26;
  const cols = Math.max(3, Math.min(5, Math.floor((900 + gap) / (sz + gap)))); // ~4 @ 900
  const rows = Math.ceil(count / cols);
  const seedMap = { grandmillennial: 0, coastal: 1, moody: 2, minimal: 3, deco: 4 };
  const seed = seedMap[preset] || 0;

  const raw = [];
  for (let i = 0; i < count; i++) {
    const col = i % cols;
    const row = Math.floor(i / cols);
    let x = pad + col * (sz + gap);
    let y = pad + row * (sz + gap);
    // organic, deterministic jitter so it reads as a hand-pinned mood board
    const jx = ((i * 53 + seed * 29) % 15) - 7;  // -7..7
    const jy = ((i * 37 + seed * 13) % 11) - 5;  // -5..5
    x += jx;
    y += (col % 2 === 0 ? 14 : -8) + jy * 2;
    raw.push({ x, y });
  }

  // Scale the base grid (pad + cols/rows of tiles) into the real canvas.
  const baseW = pad * 2 + cols * sz + (cols - 1) * gap;
  const baseH = pad * 2 + rows * sz + (rows - 1) * gap;
  const scaleX = Math.min(1, (canvasW - pad) / baseW);
  const scaleY = Math.min(1, (canvasH - pad) / baseH);

  return raw.map((p, i) => ({
    x: Math.round(p.x * scaleX),
    y: Math.round(p.y * scaleY),
    z: i + 1,
  }));
}

/* ============================================================
   CANVAS ROUNDRECT POLYFILL
   ============================================================ */
if (!CanvasRenderingContext2D.prototype.roundRect) {
  CanvasRenderingContext2D.prototype.roundRect = function(x, y, w, h, r) {
    r = Math.min(r, w/2, h/2);
    this.beginPath();
    this.moveTo(x + r, y);
    this.lineTo(x + w - r, y);
    this.quadraticCurveTo(x + w, y, x + w, y + r);
    this.lineTo(x + w, y + h - r);
    this.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
    this.lineTo(x + r, y + h);
    this.quadraticCurveTo(x, y + h, x, y + h - r);
    this.lineTo(x, y + r);
    this.quadraticCurveTo(x, y, x + r, y);
    this.closePath();
    return this;
  };
}

/* ============================================================
   CHROME HEIGHT — sets CSS var for studio layout
   ============================================================ */
function updateChromeHeight() {
  const header = document.querySelector('header');
  const rail   = document.querySelector('.preset-rail');
  if (header && rail) {
    const h = header.getBoundingClientRect().height + rail.getBoundingClientRect().height;
    document.documentElement.style.setProperty('--chrome-height', Math.round(h) + 'px');
  }
}

/* ============================================================
   STATE
   ============================================================ */
let currentPreset  = 'grandmillennial';
let selectedTileId = null;
let tiles          = [];    // array of { id, el, type, data, x, y, z }
let zCounter       = 100;
let isDragging     = false;

/* ============================================================
   TILE FACTORY
   ============================================================ */
function createSwatchTile(type, palette, x, y, z, id) {
  const el = document.createElement('div');
  el.className = 'tile swatch-tile';
  el.dataset.id = id;
  el.style.left = x + 'px';
  el.style.top  = y + 'px';
  el.style.zIndex = z;

  const prod   = pickProduct(type);
  const svgStr = buildSwatchSVG(type, palette); // procedural fallback texture

  if (!prod) {
    el.innerHTML = `${svgStr}<div class="tile-label">${SWATCH_LABELS[type] || type}</div>`;
    return el;
  }

  // Real DW product photo
  el.dataset.material = type;
  el.dataset.product  = JSON.stringify(prod);
  const short    = productShortName(prod.name);
  const esc = s => String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
  // Price intentionally NOT shown: the dw_unified mirror price column is known to
  // be stale/wrong (memory dw-mirror-price-column-stale) — a wrong number on a
  // 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="${corsImg}">` +
    `<div class="tile-label">` +
      `<span class="tl-name">${esc(short)}</span>` +
      `<span class="tl-vendor">${esc(prod.vendor)}</span>` +
    `</div>`;

  // Degrade gracefully to the procedural texture if a photo ever fails to load
  const img = el.querySelector('.swatch-img');
  img.addEventListener('error', () => {
    el.classList.add('img-failed');
    el.innerHTML = `${svgStr}<div class="tile-label">${SWATCH_LABELS[type] || type}</div>`;
  }, { once: true });
  // Double-click opens the live product (single click stays reserved for drag/select)
  el.addEventListener('dblclick', () => window.open(productHref(prod.handle), '_blank', 'noopener'));

  return el;
}
/* ============================================================
   BOARD POPULATION
   ============================================================ */
function populateBoard(preset, animate) {
  const canvas = document.getElementById('canvasInner');
  const wrap   = document.getElementById('canvasWrap');
  const cW     = wrap.clientWidth;
  const cH     = wrap.clientHeight;

  const data      = PRESETS[preset];
  const prodTypes = buildProductTypes(data);
  const positions = composeTilePositions(cW, cH, preset, prodTypes.length);

  // If animating (preset switch), fade out existing
  if (animate && tiles.length > 0) {
    tiles.forEach((t, i) => {
      t.el.classList.add('animating');
      setTimeout(() => t.el.classList.add('fading-out'), i * 40);
    });
    setTimeout(() => {
      canvas.innerHTML = '';
      tiles = [];
      spawnTiles(prodTypes, data.palette, positions, canvas, true);
    }, tiles.length * 40 + 350);
  } else {
    canvas.innerHTML = '';
    tiles = [];
    spawnTiles(prodTypes, data.palette, positions, canvas, animate);
  }

  updateSidebar(preset);
}

let tileIdCounter = 0;

// Real-products-only board: build the list of material "slots" to fill with
// live products. Two passes over the preset's 6 materials = 12 real SKUs
// (pickProduct rotates, so every tile is a distinct product).
function buildProductTypes(data) {
  const order = data.swatches;      // 6 material types for this style
  const out = [];
  for (let pass = 0; pass < 2; pass++) for (const t of order) out.push(t);
  return out;                        // 12 real product tiles
}

function spawnTiles(prodTypes, palette, positions, canvas, animate) {
  prodTypes.forEach((type, i) => {
    const pos = positions[i] || positions[positions.length - 1];
    const id  = 'tile-' + (++tileIdCounter);
    const el  = createSwatchTile(type, palette, pos.x, pos.y, pos.z, id);
    if (animate) { el.style.opacity = '0'; }
    canvas.appendChild(el);
    attachDragListeners(el);
    el.addEventListener('mousedown', () => selectTile(id), { passive: true });
    el.addEventListener('touchstart', () => selectTile(id), { passive: true });
    tiles.push({ id, el, type: 'swatch', x: pos.x, y: pos.y, z: pos.z });
    if (animate) setTimeout(() => { el.classList.add('animating'); el.style.opacity = '1'; }, i * 45 + 20);
  });
}

/* ============================================================
   SELECTION
   ============================================================ */
function selectTile(id) {
  tiles.forEach(t => t.el.classList.remove('selected'));
  selectedTileId = id;
  const t = tiles.find(t => t.id === id);
  if (t) t.el.classList.add('selected');
}

document.getElementById('canvasWrap').addEventListener('mousedown', (e) => {
  if (e.target === document.getElementById('canvasWrap') ||
      e.target === document.getElementById('canvasInner') ||
      e.target.tagName === 'svg' || e.target.tagName === 'rect' && !e.target.closest('.tile')) {
    tiles.forEach(t => t.el.classList.remove('selected'));
    selectedTileId = null;
  }
});

/* ============================================================
   DRAG & DROP
   ============================================================ */
function attachDragListeners(el) {
  let startX, startY, startLeft, startTop;

  function onStart(cx, cy) {
    isDragging = true;
    startX    = cx;
    startY    = cy;
    startLeft = parseInt(el.style.left) || 0;
    startTop  = parseInt(el.style.top)  || 0;

    zCounter++;
    el.style.zIndex = zCounter;
    const t = tiles.find(t => t.el === el);
    if (t) t.z = zCounter;

    el.classList.add('dragging');
    el.classList.remove('animating');
    el.style.transition = 'none';
  }

  function onMove(cx, cy) {
    if (!isDragging) return;
    const dx = cx - startX;
    const dy = cy - startY;
    const newLeft = Math.max(0, startLeft + dx);
    const newTop  = Math.max(0, startTop  + dy);
    el.style.left = newLeft + 'px';
    el.style.top  = newTop  + 'px';
  }

  function onEnd(cx, cy) {
    if (!isDragging) return;
    isDragging = false;
    el.classList.remove('dragging');

    // Snap to 10px grid
    let left = parseInt(el.style.left) || 0;
    let top  = parseInt(el.style.top)  || 0;
    left = Math.round(left / 10) * 10;
    top  = Math.round(top  / 10) * 10;
    el.style.left = left + 'px';
    el.style.top  = top  + 'px';

    const t = tiles.find(t => t.el === el);
    if (t) { t.x = left; t.y = top; }
  }

  // Mouse
  el.addEventListener('mousedown', (e) => {
    e.preventDefault();
    onStart(e.clientX, e.clientY);
    const moveH = (e) => onMove(e.clientX, e.clientY);
    const upH   = (e) => { onEnd(e.clientX, e.clientY); window.removeEventListener('mousemove', moveH); window.removeEventListener('mouseup', upH); };
    window.addEventListener('mousemove', moveH);
    window.addEventListener('mouseup', upH);
  });

  // Touch
  el.addEventListener('touchstart', (e) => {
    const t = e.touches[0];
    onStart(t.clientX, t.clientY);
  }, { passive: true });

  el.addEventListener('touchmove', (e) => {
    e.preventDefault();
    const t = e.touches[0];
    onMove(t.clientX, t.clientY);
  }, { passive: false });

  el.addEventListener('touchend', (e) => {
    const t = e.changedTouches[0];
    onEnd(t.clientX, t.clientY);
  }, { passive: true });
}

/* ============================================================
   SIDEBAR UPDATER
   ============================================================ */
function updateSidebar(preset) {
  const data = PRESETS[preset];

  // Style thumb
  const thumb = document.getElementById('styleThumb');
  const cols  = data.thumbColors;
  thumb.style.background = `linear-gradient(135deg, ${cols.join(', ')})`;

  // Palette strip
  const strip = document.getElementById('paletteSidebarStrip');
  strip.innerHTML = data.palette.map(c =>
    `<div class="palette-strip-swatch" style="background:${c.hex};" title="${c.name}"></div>`
  ).join('');

  // Palette names
  const names = document.getElementById('paletteSidebarNames');
  names.innerHTML = data.palette.map(c =>
    `<div class="palette-name-chip">${c.name}</div>`
  ).join('');
}

/* ============================================================
   PRESET SWITCHING
   ============================================================ */
document.querySelectorAll('.preset-pill').forEach(pill => {
  pill.addEventListener('click', () => {
    document.querySelectorAll('.preset-pill').forEach(p => p.classList.remove('active'));
    pill.classList.add('active');
    currentPreset = pill.dataset.preset;
    populateBoard(currentPreset, true);
  });
});

/* ============================================================
   SHUFFLE / RECOMPOSE
   ============================================================ */
function shuffleBoard() {
  const wrap = document.getElementById('canvasWrap');
  const cW   = wrap.clientWidth;
  const cH   = wrap.clientHeight;
  const data  = PRESETS[currentPreset];

  // Generate slightly randomized positions within canvas bounds
  tiles.forEach((t, i) => {
    const maxX = Math.max(40, cW - 220);
    const maxY = Math.max(40, cH - 170);
    const newX = Math.round((Math.random() * maxX) / 10) * 10;
    const newY = Math.round((Math.random() * maxY) / 10) * 10;
    t.el.classList.add('animating');
    setTimeout(() => {
      t.el.style.left = newX + 'px';
      t.el.style.top  = newY + 'px';
      t.x = newX;
      t.y = newY;
    }, i * 35);
  });

  showToast('Board recomposed');
}

document.getElementById('btnShuffle').addEventListener('click', shuffleBoard);
document.getElementById('sidebarShuffle').addEventListener('click', shuffleBoard);
document.getElementById('sidebarReset').addEventListener('click', () => { populateBoard(currentPreset, true); showToast('Layout reset'); });

/* ============================================================
   CLEAR
   ============================================================ */
document.getElementById('btnClear').addEventListener('click', () => {
  tiles.forEach((t, i) => {
    t.el.classList.add('animating');
    setTimeout(() => {
      t.el.style.opacity = '0';
      t.el.style.transform = 'scale(0.8)';
    }, i * 30);
  });
  setTimeout(() => {
    document.getElementById('canvasInner').innerHTML = '';
    tiles = [];
    selectedTileId = null;
    showToast('Board cleared');
  }, tiles.length * 30 + 400);
});

/* ============================================================
   LAYER CONTROLS
   ============================================================ */
document.getElementById('sidebarFront').addEventListener('click', () => {
  if (!selectedTileId) return;
  zCounter++;
  const t = tiles.find(t => t.id === selectedTileId);
  if (t) { t.el.style.zIndex = zCounter; t.z = zCounter; }
});

document.getElementById('sidebarBack').addEventListener('click', () => {
  if (!selectedTileId) return;
  const t = tiles.find(t => t.id === selectedTileId);
  if (t) { t.el.style.zIndex = 1; t.z = 1; }
});

document.getElementById('sidebarDelete').addEventListener('click', () => {
  if (!selectedTileId) return;
  const idx = tiles.findIndex(t => t.id === selectedTileId);
  if (idx >= 0) {
    const t = tiles[idx];
    t.el.classList.add('animating');
    t.el.style.opacity = '0';
    t.el.style.transform = 'scale(0.7)';
    setTimeout(() => t.el.remove(), 300);
    tiles.splice(idx, 1);
    selectedTileId = null;
    showToast('Tile removed');
  }
});

/* ============================================================
   KEYBOARD
   ============================================================ */
document.addEventListener('keydown', (e) => {
  if (!selectedTileId) return;
  const t = tiles.find(t => t.id === selectedTileId);
  if (!t) return;
  const step = e.shiftKey ? 10 : 2;
  if (e.key === 'ArrowLeft')  { t.x -= step; t.el.style.left = t.x + 'px'; e.preventDefault(); }
  if (e.key === 'ArrowRight') { t.x += step; t.el.style.left = t.x + 'px'; e.preventDefault(); }
  if (e.key === 'ArrowUp')    { t.y -= step; t.el.style.top  = t.y + 'px'; e.preventDefault(); }
  if (e.key === 'ArrowDown')  { t.y += step; t.el.style.top  = t.y + 'px'; e.preventDefault(); }
  if (e.key === 'Delete' || e.key === 'Backspace') {
    document.getElementById('sidebarDelete').click();
  }
  if (e.key === 'Escape') {
    tiles.forEach(t => t.el.classList.remove('selected'));
    selectedTileId = null;
  }
});

/* ============================================================
   TOAST
   ============================================================ */
let toastTimer;
function showToast(msg) {
  const toast = document.getElementById('toast');
  toast.textContent = msg;
  toast.classList.add('show');
  clearTimeout(toastTimer);
  toastTimer = setTimeout(() => toast.classList.remove('show'), 2200);
}

/* ============================================================
   EXPORT — render board to canvas
   ============================================================ */
document.getElementById('btnExport').addEventListener('click', openExport);

async function openExport() {
  const btn = document.getElementById('btnExport');
  btn.classList.add('btn-shimmer');
  btn.textContent = 'Rendering…';

  await new Promise(r => setTimeout(r, 50));

  // Wait for every product photo to finish loading BEFORE we draw + sample the
  // color story — otherwise unloaded tiles fall through to the procedural SVG
  // fallback and the "sampled from real wallcoverings" strip would be a lie.
  await Promise.allSettled(tiles.flatMap(t => {
    const im = t.el.querySelector('.swatch-img');
    if (!im || im.complete) return [];
    return [new Promise(res => { im.onload = im.onerror = res; })];
  }));

  const exportCanvas = document.getElementById('exportCanvas');
  const ctx   = exportCanvas.getContext('2d', { willReadFrequently: true });
  const W     = 1080;
  const H     = 1350;
  const data  = PRESETS[currentPreset];
  const wrap  = document.getElementById('canvasWrap');
  const cW    = wrap.clientWidth;
  const cH    = wrap.clientHeight;
  const scaleX = W / cW;
  const scaleY = H / cH;

  ctx.clearRect(0, 0, W, H);

  // --- Background ---
  ctx.fillStyle = '#0d0b09';
  ctx.fillRect(0, 0, W, H);

  // --- Subtle warm texture overlay ---
  for (let i = 0; i < 3000; i++) {
    const x  = Math.random() * W;
    const y  = Math.random() * H;
    const op = Math.random() * 0.03;
    ctx.fillStyle = `rgba(201,169,97,${op})`;
    ctx.fillRect(x, y, 1, 1);
  }

  // --- Hairline brass frame ---
  ctx.strokeStyle = 'rgba(201,169,97,0.35)';
  ctx.lineWidth   = 1;
  ctx.strokeRect(28, 28, W - 56, H - 56);
  ctx.strokeStyle = 'rgba(201,169,97,0.15)';
  ctx.lineWidth   = 1;
  ctx.strokeRect(36, 36, W - 72, H - 72);

  // --- Grid lines (subtle) ---
  ctx.strokeStyle = 'rgba(201,169,97,0.07)';
  ctx.lineWidth   = 1;
  for (let x = 60; x < W; x += 60) {
    ctx.beginPath(); ctx.moveTo(x, 60); ctx.lineTo(x, H - 60); ctx.stroke();
  }
  for (let y = 60; y < H; y += 60) {
    ctx.beginPath(); ctx.moveTo(60, y); ctx.lineTo(W - 60, y); ctx.stroke();
  }

  // --- Render each tile as a colored block with label ---
  const sortedTiles = [...tiles].sort((a, b) => (a.z || 0) - (b.z || 0));

  // Board is real-products-only, so every tile is a swatch tile.
  for (const t of sortedTiles) {
    if (!t.el.classList.contains('swatch-tile')) continue;
    drawSwatchOnCanvas(ctx, t, t.x * scaleX, t.y * scaleY, 160 * scaleX, 160 * scaleY, data.palette);
  }

  // --- Color story: sample average colors from the REAL product photos now,
  //     BEFORE the vignette/bar darken the canvas. Truthful "real products only"
  //     palette (replaces the old abstract preset-palette strip). ---
  const _storyColors = (() => {
    const st = sortedTiles.filter(t => t.el.classList.contains('swatch-tile'));
    const out = [];
    const step = Math.max(1, Math.floor(st.length / 6));
    for (let i = 0; i < st.length && out.length < 6; i += step) {
      const t = st[i];
      const cx = Math.round(t.x * scaleX + 80 * scaleX);
      const cy = Math.round(t.y * scaleY + 80 * scaleY);
      try {
        const px = Math.min(W - 16, Math.max(16, cx)) - 8;
        const py = Math.min(H - 16, Math.max(16, cy)) - 8;
        const d = ctx.getImageData(px, py, 16, 16).data;
        let R = 0, G = 0, B = 0, n = 0;
        for (let k = 0; k < d.length; k += 4) { R += d[k]; G += d[k+1]; B += d[k+2]; n++; }
        out.push(`rgb(${Math.round(R/n)},${Math.round(G/n)},${Math.round(B/n)})`);
      } catch (e) { out.push('#7a6a4a'); }
    }
    return out;
  })();

  // --- Vignette ---
  const vig = ctx.createRadialGradient(W/2, H/2, H*0.35, W/2, H/2, H*0.75);
  vig.addColorStop(0, 'rgba(0,0,0,0)');
  vig.addColorStop(1, 'rgba(0,0,0,0.55)');
  ctx.fillStyle = vig;
  ctx.fillRect(0, 0, W, H);

  // --- Bottom bar ---
  const barH = 160;
  const barY = H - barH;
  const barGrad = ctx.createLinearGradient(0, barY, 0, H);
  barGrad.addColorStop(0, 'rgba(13,11,9,0)');
  barGrad.addColorStop(0.3, 'rgba(13,11,9,0.92)');
  barGrad.addColorStop(1,   'rgba(13,11,9,0.98)');
  ctx.fillStyle = barGrad;
  ctx.fillRect(0, barY - 40, W, barH + 40);

  // --- Color-story strip: real colors sampled from the actual wallcoverings ---
  // Only drawn when the board actually has products — an empty board gets no
  // (misleading) strip or caption.
  const stripY  = H - 108;
  const stripH  = 10;
  const stripW  = W - 120;
  const stripX  = 60;
  const hasStory = _storyColors.length > 0;
  if (hasStory) {
    const colW = stripW / _storyColors.length;
    _storyColors.forEach((c, i) => {
      ctx.fillStyle = c;
      ctx.fillRect(stripX + i * colW, stripY, colW, stripH);
    });
  }

  // Caption — truthful about where the palette came from (no abstract color names)
  if (hasStory) {
    ctx.save();
    ctx.font = `500 15px -apple-system,Helvetica,Arial,sans-serif`;
    ctx.fillStyle = 'rgba(232,227,218,0.50)';
    ctx.textAlign = 'center';
    ctx.fillText('COLOR STORY · SAMPLED FROM SELECTED WALLCOVERINGS', W/2, stripY + stripH + 24);
    ctx.restore();
  }

  // --- Brass separator line ---
  ctx.strokeStyle = 'rgba(201,169,97,0.50)';
  ctx.lineWidth   = 1;
  ctx.beginPath();
  ctx.moveTo(60, stripY - 18);
  ctx.lineTo(W - 60, stripY - 18);
  ctx.stroke();

  // --- Style label ---
  ctx.font      = `300 22px -apple-system,Helvetica,Arial,sans-serif`;
  ctx.fillStyle = 'rgba(201,169,97,0.80)';
  ctx.textAlign = 'center';
  ctx.fillText(data.label.toUpperCase(), W/2, stripY - 34);

  // --- Wordmark ---
  const wY = H - 44;
  ctx.font      = `600 40px Georgia,serif`;
  ctx.fillStyle = '#e8e3da';
  ctx.textAlign = 'center';
  ctx.fillText('DESIGNER WALLCOVERINGS', W/2, wY - 8);

  // Underline
  ctx.strokeStyle = 'rgba(201,169,97,0.40)';
  ctx.lineWidth   = 0.8;
  ctx.beginPath();
  ctx.moveTo(W/2 - 220, wY - 2);
  ctx.lineTo(W/2 + 220, wY - 2);
  ctx.stroke();

  // --- Top label ---
  ctx.font      = `400 18px -apple-system,Helvetica,Arial,sans-serif`;
  ctx.fillStyle = 'rgba(201,169,97,0.55)';
  ctx.textAlign = 'center';
  ctx.fillText('INTERIOR MOOD BOARD', W/2, 72);

  btn.classList.remove('btn-shimmer');
  btn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M7 1v8M4 6l3 3 3-3M2 10v2h10v-2" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg> Export Board`;

  document.getElementById('exportOverlay').classList.add('open');
}

/* --- Sub-renderers for canvas export --- */
function drawSwatchOnCanvas(ctx, t, x, y, w, h, palette) {
  // Prefer the REAL product photo. Images load with crossorigin="anonymous" and
  // Shopify CDN sends Access-Control-Allow-Origin:* — so drawing them does NOT
  // taint the export canvas and toBlob() still works.
  const imgEl = t.el.querySelector('.swatch-img');
  if (imgEl && imgEl.complete && imgEl.naturalWidth > 0) {
    const ir = imgEl.naturalWidth / imgEl.naturalHeight;
    const dr = w / h;
    let sx, sy, sw, sh; // object-fit: cover source rect
    if (ir > dr) { sh = imgEl.naturalHeight; sw = sh * dr; sx = (imgEl.naturalWidth - sw) / 2; sy = 0; }
    else         { sw = imgEl.naturalWidth;  sh = sw / dr; sx = 0; sy = (imgEl.naturalHeight - sh) / 2; }
    try {
      ctx.drawImage(imgEl, sx, sy, sw, sh, x, y, w, h);
    } catch (e) {
      drawProceduralSwatch(ctx, t, x, y, w, h, palette);
    }
    drawSwatchLabel(ctx, t, x, y, w, h);
    return;
  }
  drawProceduralSwatch(ctx, t, x, y, w, h, palette);
  drawSwatchLabel(ctx, t, x, y, w, h);
}

function drawSwatchLabel(ctx, t, x, y, w, h) {
  let label = '';
  const pj = t.el.dataset.product;
  if (pj) { try { label = productShortName(JSON.parse(pj).name); } catch (e) {} }
  if (!label) label = t.el.querySelector('.tl-name')?.textContent
                    || t.el.querySelector('.tile-label')?.textContent || '';
  ctx.fillStyle = 'rgba(0,0,0,0.52)';
  ctx.fillRect(x, y + h - h * 0.18, w, h * 0.18);
  ctx.font = `700 ${Math.round(w * 0.062)}px -apple-system,Helvetica,Arial,sans-serif`;
  ctx.fillStyle = 'rgba(255,255,255,0.92)';
  ctx.textAlign = 'left';
  const maxW = w * 0.88;
  let txt = label.toUpperCase();
  if (ctx.measureText(txt).width > maxW) {
    while (txt.length > 4 && ctx.measureText(txt + '…').width > maxW) txt = txt.slice(0, -1);
    txt = txt.replace(/[\s\-]+$/, '') + '…';
  }
  ctx.fillText(txt, x + w * 0.06, y + h - h * 0.06);
}

function drawProceduralSwatch(ctx, t, x, y, w, h, palette) {
  const swatchType = (t.el.dataset.material
                     || t.el.querySelector('.tile-label')?.textContent
                     || '').toLowerCase().replace(/\s+/g,'');

  if (swatchType.includes('grasscloth')) {
    const bg = palette[4]?.hex || '#d4c4a4';
    const fg = palette[5]?.hex || '#b09a7a';
    ctx.fillStyle = bg;
    ctx.fillRect(x, y, w, h);
    ctx.strokeStyle = fg;
    for (let ly = 0; ly < h; ly += h/40) {
      ctx.lineWidth = 0.5 + Math.random() * 0.8;
      ctx.globalAlpha = 0.15 + Math.random() * 0.25;
      ctx.beginPath();
      ctx.moveTo(x, y + ly);
      ctx.lineTo(x + w, y + ly);
      ctx.stroke();
    }
    ctx.globalAlpha = 1;
  } else if (swatchType.includes('damask')) {
    const bg = palette[1]?.hex || '#f0e8d8';
    const fg = palette[0]?.hex || '#c9917e';
    ctx.fillStyle = bg;
    ctx.fillRect(x, y, w, h);
    ctx.fillStyle = fg;
    ctx.globalAlpha = 0.20;
    ctx.beginPath();
    ctx.ellipse(x + w/2, y + h/2, w*0.22, h*0.32, 0, 0, Math.PI*2);
    ctx.fill();
    ctx.globalAlpha = 0.12;
    ctx.beginPath();
    ctx.ellipse(x + w/2, y + h/2, w*0.12, h*0.18, 0, 0, Math.PI*2);
    ctx.fill();
    ctx.globalAlpha = 0.15;
    ctx.fillRect(x + w/2 - w*0.12, y, w*0.24, h*0.18);
    ctx.fillRect(x + w/2 - w*0.12, y + h*0.82, w*0.24, h*0.18);
    ctx.fillRect(x, y + h/2 - h*0.12, w*0.18, h*0.24);
    ctx.fillRect(x + w*0.82, y + h/2 - h*0.12, w*0.18, h*0.24);
    ctx.globalAlpha = 1;
  } else if (swatchType.includes('silk')) {
    const cols = [palette[0]?.hex||'#c9a961', palette[1]?.hex||'#f0e8d8', palette[2]?.hex||'#d4c4a4'];
    const relWidths = [0.08, 0.20, 0.06, 0.14, 0.04, 0.20, 0.06, 0.14, 0.08];
    let cx = x;
    relWidths.forEach((rw, i) => {
      ctx.fillStyle = cols[i % cols.length];
      ctx.globalAlpha = i % 2 === 0 ? 0.9 : 0.6;
      ctx.fillRect(cx, y, w * rw, h);
      cx += w * rw;
    });
    ctx.globalAlpha = 1;
  } else if (swatchType.includes('linen')) {
    const bg = palette[1]?.hex || '#e8dccb';
    const fg = palette[5]?.hex || '#c4a878';
    ctx.fillStyle = bg;
    ctx.fillRect(x, y, w, h);
    ctx.strokeStyle = fg;
    ctx.lineWidth = 1;
    ctx.globalAlpha = 0.20;
    for (let lx = x; lx < x+w; lx += w/32) {
      ctx.beginPath(); ctx.moveTo(lx, y); ctx.lineTo(lx, y+h); ctx.stroke();
    }
    for (let ly = y; ly < y+h; ly += h/32) {
      ctx.beginPath(); ctx.moveTo(x, ly); ctx.lineTo(x+w, ly); ctx.stroke();
    }
    ctx.globalAlpha = 1;
  } else if (swatchType.includes('raffia')) {
    const bg = palette[3]?.hex || '#d9ccb0';
    const fg = palette[5]?.hex || '#a08060';
    ctx.fillStyle = bg;
    ctx.fillRect(x, y, w, h);
    ctx.strokeStyle = fg;
    ctx.lineWidth = 1.5;
    ctx.globalAlpha = 0.22;
    for (let d = -w; d < w*2; d += w/20) {
      ctx.beginPath();
      ctx.moveTo(x + d, y);
      ctx.lineTo(x + d + h, y + h);
      ctx.stroke();
    }
    ctx.globalAlpha = 1;
  } else { // chinoiserie
    const bg = palette[1]?.hex || '#f4f0e0';
    ctx.fillStyle = bg;
    ctx.fillRect(x, y, w, h);
    ctx.strokeStyle = palette[4]?.hex || '#4a7a9e';
    ctx.lineWidth = 1.5;
    ctx.globalAlpha = 0.55;
    ctx.beginPath();
    ctx.moveTo(x + w*0.3, y + h*0.9);
    ctx.bezierCurveTo(x+w*0.38, y+h*0.7, x+w*0.4, y+h*0.5, x+w*0.5, y+h*0.12);
    ctx.stroke();
    ctx.fillStyle = palette[0]?.hex || '#d4907a';
    ctx.globalAlpha = 0.65;
    ctx.beginPath();
    ctx.arc(x + w*0.5, y + h*0.12, w*0.06, 0, Math.PI*2);
    ctx.fill();
    ctx.globalAlpha = 1;
  }
}
/* ============================================================
   EXPORT OVERLAY CONTROLS
   ============================================================ */
function closeOverlay() {
  document.getElementById('exportOverlay').classList.remove('open');
}

document.getElementById('btnCloseOverlay').addEventListener('click', closeOverlay);
document.getElementById('btnCloseOverlay2').addEventListener('click', closeOverlay);

document.getElementById('exportOverlay').addEventListener('click', (e) => {
  if (e.target === document.getElementById('exportOverlay')) closeOverlay();
});

document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') closeOverlay();
});

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 = 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
   ============================================================ */
window.addEventListener('load', () => {
  updateChromeHeight();
  populateBoard('grandmillennial', true);
});

// Handle resize — only re-measure chrome; do NOT repopulate the board (that would
// discard the user's arrangement AND rotate in a different set of products).
let resizeTimer;
window.addEventListener('resize', () => {
  clearTimeout(resizeTimer);
  resizeTimer = setTimeout(updateChromeHeight, 300);
});
</script>
</body>
</html>