Interior — Moodboard
A warm editorial moodboard workspace where you drag material swatches, furniture and finish tiles freely across a paper canvas, layer them front to back, and watch a live palette strip derive its five dominant tones from whatever sits on the board — add, duplicate or remove tiles to compose a room story.
MCP
Code
:root {
--bg: #f6f2ec;
--paper: #fbf9f5;
--ink: #2c2620;
--ink-2: #5a5147;
--muted: #8a8175;
--clay: #b08968;
--clay-d: #8c6a4f;
--walnut: #5c4433;
--sage: #9caf88;
--line: rgba(44, 38, 32, 0.12);
--white: #fff;
--r-sm: 4px;
--r-md: 8px;
--r-lg: 14px;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--ink);
font-family: "Inter", system-ui, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
padding: clamp(18px, 4vw, 48px);
}
.shell {
max-width: 1120px;
margin: 0 auto;
}
/* ---- Masthead ---- */
.masthead {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 24px;
flex-wrap: wrap;
padding-bottom: 22px;
border-bottom: 1px solid var(--line);
}
.eyebrow {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--clay-d);
}
.masthead h1 {
font-family: "Cormorant Garamond", serif;
font-weight: 600;
font-size: clamp(38px, 6vw, 60px);
line-height: 1.02;
margin: 6px 0 8px;
color: var(--walnut);
letter-spacing: -0.01em;
}
.lede {
margin: 0;
max-width: 46ch;
color: var(--ink-2);
font-size: 15px;
}
.mast-meta {
display: flex;
flex-direction: column;
gap: 6px;
text-align: right;
}
.meta-line {
font-size: 13px;
font-weight: 600;
color: var(--ink-2);
display: inline-flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
}
.meta-line.muted { color: var(--muted); font-weight: 500; }
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--sage);
box-shadow: 0 0 0 4px rgba(156, 175, 136, 0.22);
}
/* ---- Workspace ---- */
.workspace {
display: grid;
grid-template-columns: 268px 1fr;
gap: 22px;
margin-top: 26px;
}
.toolbar {
display: flex;
flex-direction: column;
gap: 24px;
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 20px 18px;
align-self: start;
position: sticky;
top: 18px;
}
.tool-group { display: flex; flex-direction: column; gap: 12px; }
.tool-title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--muted);
}
.swatch-tray {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.chip-btn {
display: flex;
align-items: center;
gap: 9px;
padding: 8px 9px;
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-md);
cursor: pointer;
text-align: left;
font: inherit;
color: var(--ink);
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
}
.chip-btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 18px rgba(92, 68, 51, 0.14);
border-color: rgba(176, 137, 104, 0.5);
}
.chip-btn:active { transform: translateY(0); }
.chip-btn:focus-visible {
outline: 2px solid var(--clay);
outline-offset: 2px;
}
.chip-dot {
width: 20px;
height: 20px;
border-radius: 5px;
flex: 0 0 auto;
border: 1px solid rgba(44, 38, 32, 0.16);
background-size: cover;
background-position: center;
}
.chip-name {
font-size: 12px;
font-weight: 600;
line-height: 1.2;
}
.action-col { display: flex; flex-direction: column; gap: 8px; }
.btn {
display: inline-flex;
align-items: center;
gap: 9px;
padding: 10px 12px;
border-radius: var(--r-md);
border: 1px solid var(--line);
background: var(--white);
color: var(--ink);
font: inherit;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease, transform 0.12s ease, border-color 0.15s ease, color 0.15s ease;
}
.btn span[aria-hidden] { color: var(--clay-d); font-size: 14px; }
.btn:hover:not(:disabled) {
background: #f1 e8 df;
background: #f2 eae1;
border-color: rgba(176, 137, 104, 0.5);
}
.btn:active:not(:disabled) { transform: translateY(1px); }
.btn:focus-visible { outline: 2px solid var(--clay); outline-offset: 2px; }
.btn:disabled { opacity: 0.42; cursor: not-allowed; }
.btn-ghost { background: transparent; }
.btn-danger { color: #9a4b3b; }
.btn-danger span[aria-hidden] { color: #9a4b3b; }
.btn-danger:hover:not(:disabled) {
background: rgba(154, 75, 59, 0.09);
border-color: rgba(154, 75, 59, 0.4);
}
.hint {
margin: 0;
font-size: 12px;
color: var(--muted);
line-height: 1.45;
border-top: 1px solid var(--line);
padding-top: 14px;
}
/* ---- Canvas ---- */
.canvas-wrap { min-width: 0; }
.canvas {
position: relative;
min-height: 480px;
height: 62vh;
max-height: 640px;
background:
radial-gradient(circle at 18% 22%, rgba(176, 137, 104, 0.06), transparent 42%),
radial-gradient(circle at 82% 78%, rgba(156, 175, 136, 0.07), transparent 46%),
var(--paper);
border: 1px solid var(--line);
border-radius: var(--r-lg);
overflow: hidden;
touch-action: none;
box-shadow: inset 0 0 0 6px rgba(251, 249, 245, 0.7), 0 20px 44px rgba(92, 68, 51, 0.08);
}
.canvas-hint {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-family: "Cormorant Garamond", serif;
font-size: 22px;
color: var(--muted);
pointer-events: none;
padding: 20px;
text-align: center;
}
.tile {
position: absolute;
width: 150px;
border-radius: var(--r-md);
background: var(--white);
border: 1px solid var(--line);
box-shadow: 0 6px 16px rgba(92, 68, 51, 0.14);
cursor: grab;
user-select: none;
transition: box-shadow 0.18s ease, transform 0.05s ease;
overflow: hidden;
}
.tile:focus-visible {
outline: 2px solid var(--clay);
outline-offset: 3px;
}
.tile.selected {
box-shadow: 0 0 0 2px var(--clay), 0 14px 30px rgba(92, 68, 51, 0.22);
}
.tile.dragging {
cursor: grabbing;
box-shadow: 0 22px 46px rgba(44, 38, 32, 0.3);
transform: scale(1.03) rotate(-0.6deg);
z-index: 999;
}
.tile-swatch {
height: 92px;
background-size: cover;
background-position: center;
border-bottom: 1px solid var(--line);
position: relative;
}
.tile-badge {
position: absolute;
top: 8px;
left: 8px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 3px 7px;
border-radius: 999px;
background: rgba(251, 249, 245, 0.92);
color: var(--walnut);
box-shadow: 0 2px 6px rgba(44, 38, 32, 0.12);
}
.tile-body {
padding: 8px 10px 10px;
display: flex;
flex-direction: column;
gap: 2px;
}
.tile-name {
font-size: 12.5px;
font-weight: 700;
color: var(--ink);
line-height: 1.2;
}
.tile-hex {
font-size: 10.5px;
font-weight: 500;
color: var(--muted);
letter-spacing: 0.04em;
font-variant-numeric: tabular-nums;
}
/* ---- Palette ---- */
.palette-panel {
margin-top: 22px;
background: var(--paper);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 20px 22px 22px;
}
.palette-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 16px;
}
.palette-sub { margin: 6px 0 0; font-size: 13px; color: var(--ink-2); }
.palette-count {
font-family: "Cormorant Garamond", serif;
font-size: 26px;
color: var(--clay-d);
font-weight: 500;
}
.palette-strip {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 10px;
min-height: 84px;
}
.palette-empty {
grid-column: 1 / -1;
display: flex;
align-items: center;
color: var(--muted);
font-size: 13px;
}
.palette-chip {
border: 1px solid var(--line);
border-radius: var(--r-md);
overflow: hidden;
cursor: pointer;
background: var(--white);
padding: 0;
font: inherit;
text-align: left;
transition: transform 0.15s ease, box-shadow 0.15s ease;
animation: rise 0.35s ease both;
}
.palette-chip:hover {
transform: translateY(-3px);
box-shadow: 0 10px 22px rgba(92, 68, 51, 0.16);
}
.palette-chip:focus-visible { outline: 2px solid var(--clay); outline-offset: 2px; }
.palette-color { height: 52px; }
.palette-label {
padding: 6px 8px 8px;
display: flex;
flex-direction: column;
gap: 1px;
}
.palette-hex {
font-size: 11.5px;
font-weight: 700;
color: var(--ink);
letter-spacing: 0.03em;
font-variant-numeric: tabular-nums;
}
.palette-share {
font-size: 10.5px;
color: var(--muted);
font-weight: 500;
}
@keyframes rise {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* ---- Toast ---- */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 20px);
background: var(--walnut);
color: #f6f0e8;
padding: 11px 18px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
box-shadow: 0 14px 30px rgba(44, 38, 32, 0.28);
opacity: 0;
pointer-events: none;
transition: opacity 0.22s ease, transform 0.22s ease;
z-index: 2000;
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }
/* ---- Responsive ---- */
@media (max-width: 820px) {
.workspace { grid-template-columns: 1fr; }
.toolbar { position: static; }
.swatch-tray { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 520px) {
body { padding: 14px; }
.masthead { flex-direction: column; align-items: flex-start; }
.mast-meta { text-align: left; }
.meta-line { justify-content: flex-start; }
.swatch-tray { grid-template-columns: repeat(2, 1fr); }
.canvas { height: 70vh; min-height: 420px; }
.palette-strip { grid-template-columns: repeat(3, 1fr); }
.palette-head { flex-direction: column; }
.tile { width: 128px; }
.tile-swatch { height: 78px; }
}(function () {
"use strict";
/* ---------- Material library ---------- */
var LIBRARY = [
{ key: "oak", name: "White Oak", kind: "Wood", hex: "#c9a97e" },
{ key: "walnut", name: "Walnut", kind: "Wood", hex: "#6b4a33" },
{ key: "plaster", name: "Lime Plaster", kind: "Finish", hex: "#e7ddce" },
{ key: "terracot", name: "Terracotta", kind: "Tile", hex: "#b5654a" },
{ key: "linen", name: "Oat Linen", kind: "Fabric", hex: "#d8cdb8" },
{ key: "sage", name: "Sage Velvet", kind: "Fabric", hex: "#9caf88" },
{ key: "rattan", name: "Rattan", kind: "Weave", hex: "#caa46a" },
{ key: "marble", name: "Pietra Grey", kind: "Stone", hex: "#6f6a63" },
{ key: "brass", name: "Aged Brass", kind: "Metal", hex: "#b08d57" },
{ key: "clay", name: "Clay Wash", kind: "Paint", hex: "#b08968" }
];
/* A few named photographic finishes for variety (gradient-backed, no hotlinking risk) */
function swatchBg(hex) {
var c = shade(hex, -18);
var l = shade(hex, 16);
return "linear-gradient(135deg, " + l + " 0%, " + hex + " 52%, " + c + " 100%)";
}
/* ---------- Color helpers ---------- */
function hexToRgb(hex) {
var h = hex.replace("#", "");
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16) };
}
function rgbToHex(r, g, b) {
function p(v) { v = Math.max(0, Math.min(255, Math.round(v))); return ("0" + v.toString(16)).slice(-2); }
return "#" + p(r) + p(g) + p(b);
}
function shade(hex, amt) {
var c = hexToRgb(hex);
var t = amt < 0 ? 0 : 255;
var p = Math.abs(amt) / 100;
return rgbToHex(c.r + (t - c.r) * p, c.g + (t - c.g) * p, c.b + (t - c.b) * p);
}
function luminance(hex) {
var c = hexToRgb(hex);
return (0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b) / 255;
}
/* ---------- State ---------- */
var canvas = document.getElementById("canvas");
var canvasHint = document.getElementById("canvasHint");
var tray = document.getElementById("swatchTray");
var paletteStrip = document.getElementById("paletteStrip");
var paletteCount = document.getElementById("paletteCount");
var tileCountEl = document.getElementById("tileCount");
var toastEl = document.getElementById("toast");
var dupBtn = document.getElementById("dupBtn");
var backBtn = document.getElementById("backBtn");
var delBtn = document.getElementById("delBtn");
var shuffleBtn = document.getElementById("shuffleBtn");
var clearBtn = document.getElementById("clearBtn");
var tiles = []; // { id, mat, x, y, z, el }
var selectedId = null;
var zTop = 1;
var idSeq = 1;
var toastTimer;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { toastEl.classList.remove("show"); }, 1900);
}
/* ---------- Build material tray ---------- */
LIBRARY.forEach(function (mat) {
var btn = document.createElement("button");
btn.className = "chip-btn";
btn.type = "button";
btn.setAttribute("role", "listitem");
btn.setAttribute("aria-label", "Add " + mat.name + " chip to board");
var dot = document.createElement("span");
dot.className = "chip-dot";
dot.style.background = swatchBg(mat.hex);
var nm = document.createElement("span");
nm.className = "chip-name";
nm.textContent = mat.name;
btn.appendChild(dot);
btn.appendChild(nm);
btn.addEventListener("click", function () { addTile(mat); });
tray.appendChild(btn);
});
/* ---------- Tile creation ---------- */
function addTile(mat, x, y) {
var rect = canvas.getBoundingClientRect();
var tileW = window.innerWidth <= 520 ? 128 : 150;
var tileH = 140;
if (typeof x !== "number") x = 24 + Math.random() * Math.max(20, rect.width - tileW - 48);
if (typeof y !== "number") y = 20 + Math.random() * Math.max(20, rect.height - tileH - 40);
var id = idSeq++;
var el = document.createElement("div");
el.className = "tile";
el.tabIndex = 0;
el.dataset.id = String(id);
el.setAttribute("role", "button");
el.setAttribute("aria-label", mat.name + " tile. Drag or use arrow keys to move.");
var textColor = luminance(mat.hex) > 0.62 ? "#2c2620" : "#fbf9f5";
el.innerHTML =
'<div class="tile-swatch" style="background:' + swatchBg(mat.hex) + '">' +
'<span class="tile-badge">' + mat.kind + "</span>" +
"</div>" +
'<div class="tile-body">' +
'<span class="tile-name">' + mat.name + "</span>" +
'<span class="tile-hex">' + mat.hex.toUpperCase() + "</span>" +
"</div>";
void textColor;
var t = { id: id, mat: mat, x: x, y: y, z: ++zTop, el: el };
el.style.left = x + "px";
el.style.top = y + "px";
el.style.zIndex = t.z;
tiles.push(t);
canvas.appendChild(el);
attachDrag(t);
attachKeys(t);
el.addEventListener("pointerdown", function () { select(id); });
select(id);
sync();
return t;
}
/* ---------- Selection ---------- */
function select(id) {
selectedId = id;
tiles.forEach(function (t) { t.el.classList.toggle("selected", t.id === id); });
var has = id !== null && getTile(id);
dupBtn.disabled = !has;
backBtn.disabled = !has;
delBtn.disabled = !has;
}
function getTile(id) {
for (var i = 0; i < tiles.length; i++) if (tiles[i].id === id) return tiles[i];
return null;
}
function raise(t) {
t.z = ++zTop;
t.el.style.zIndex = t.z;
}
/* ---------- Drag (pointer events, mouse + touch) ---------- */
function attachDrag(t) {
var startX, startY, originX, originY, dragging = false, pid = null;
t.el.addEventListener("pointerdown", function (e) {
if (e.button && e.button !== 0) return;
dragging = true;
pid = e.pointerId;
startX = e.clientX;
startY = e.clientY;
originX = t.x;
originY = t.y;
raise(t);
t.el.classList.add("dragging");
try { t.el.setPointerCapture(pid); } catch (err) { /* noop */ }
e.preventDefault();
});
t.el.addEventListener("pointermove", function (e) {
if (!dragging) return;
var rect = canvas.getBoundingClientRect();
var w = t.el.offsetWidth, h = t.el.offsetHeight;
var nx = originX + (e.clientX - startX);
var ny = originY + (e.clientY - startY);
nx = Math.max(-w * 0.25, Math.min(rect.width - w * 0.75, nx));
ny = Math.max(0, Math.min(rect.height - h * 0.4, ny));
t.x = nx; t.y = ny;
t.el.style.left = nx + "px";
t.el.style.top = ny + "px";
});
function end() {
if (!dragging) return;
dragging = false;
t.el.classList.remove("dragging");
try { t.el.releasePointerCapture(pid); } catch (err) { /* noop */ }
}
t.el.addEventListener("pointerup", end);
t.el.addEventListener("pointercancel", end);
}
/* ---------- Keyboard control ---------- */
function attachKeys(t) {
t.el.addEventListener("keydown", function (e) {
var step = e.shiftKey ? 24 : 8;
var rect = canvas.getBoundingClientRect();
var w = t.el.offsetWidth, h = t.el.offsetHeight;
var moved = false;
if (e.key === "ArrowLeft") { t.x -= step; moved = true; }
else if (e.key === "ArrowRight") { t.x += step; moved = true; }
else if (e.key === "ArrowUp") { t.y -= step; moved = true; }
else if (e.key === "ArrowDown") { t.y += step; moved = true; }
else if (e.key === "Enter") { raise(t); select(t.id); toast("Raised to top"); e.preventDefault(); return; }
else if (e.key === "Delete" || e.key === "Backspace") { removeTile(t.id); e.preventDefault(); return; }
else { return; }
if (moved) {
t.x = Math.max(-w * 0.25, Math.min(rect.width - w * 0.75, t.x));
t.y = Math.max(0, Math.min(rect.height - h * 0.4, t.y));
t.el.style.left = t.x + "px";
t.el.style.top = t.y + "px";
select(t.id);
e.preventDefault();
}
});
t.el.addEventListener("focus", function () { select(t.id); });
}
/* ---------- Remove ---------- */
function removeTile(id) {
var t = getTile(id);
if (!t) return;
var focusNext = null;
var idx = tiles.indexOf(t);
t.el.remove();
tiles.splice(idx, 1);
if (selectedId === id) {
focusNext = tiles.length ? tiles[Math.min(idx, tiles.length - 1)] : null;
select(focusNext ? focusNext.id : null);
if (focusNext) focusNext.el.focus();
}
sync();
toast("Tile removed");
}
/* ---------- Palette derivation ---------- */
function derivePalette() {
if (!tiles.length) return [];
// bucket similar hex values; count frequency weighted by recency (z)
var buckets = {};
tiles.forEach(function (t) {
var c = hexToRgb(t.mat.hex);
// quantize to reduce near-duplicates
var key = [Math.round(c.r / 24), Math.round(c.g / 24), Math.round(c.b / 24)].join("-");
if (!buckets[key]) buckets[key] = { r: 0, g: 0, b: 0, n: 0 };
buckets[key].r += c.r; buckets[key].g += c.g; buckets[key].b += c.b; buckets[key].n += 1;
});
var arr = Object.keys(buckets).map(function (k) {
var b = buckets[k];
return { hex: rgbToHex(b.r / b.n, b.g / b.n, b.b / b.n), n: b.n };
});
arr.sort(function (a, b) { return b.n - a.n || luminance(b.hex) - luminance(a.hex); });
return arr.slice(0, 5);
}
function renderPalette() {
var pal = derivePalette();
paletteStrip.innerHTML = "";
if (!pal.length) {
var empty = document.createElement("span");
empty.className = "palette-empty";
empty.textContent = "Add tiles to compose a palette.";
paletteStrip.appendChild(empty);
paletteCount.textContent = "—";
return;
}
paletteCount.textContent = pal.length + (pal.length === 1 ? " tone" : " tones");
pal.forEach(function (p, i) {
var chip = document.createElement("button");
chip.className = "palette-chip";
chip.type = "button";
chip.setAttribute("role", "listitem");
chip.style.animationDelay = (i * 0.04) + "s";
chip.setAttribute("aria-label", "Copy " + p.hex.toUpperCase());
chip.innerHTML =
'<span class="palette-color" style="background:' + p.hex + '"></span>' +
'<span class="palette-label">' +
'<span class="palette-hex">' + p.hex.toUpperCase() + "</span>" +
'<span class="palette-share">' + Math.round((p.n / tiles.length) * 100) + "% of board</span>" +
"</span>";
chip.addEventListener("click", function () { copyHex(p.hex.toUpperCase()); });
paletteStrip.appendChild(chip);
});
}
function copyHex(hex) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(hex).then(
function () { toast(hex + " copied"); },
function () { toast(hex); }
);
} else {
toast(hex);
}
}
/* ---------- Sync UI state ---------- */
function sync() {
var n = tiles.length;
canvasHint.style.display = n ? "none" : "flex";
tileCountEl.textContent = n + (n === 1 ? " tile on board" : " tiles on board");
renderPalette();
}
/* ---------- Toolbar actions ---------- */
dupBtn.addEventListener("click", function () {
var t = getTile(selectedId);
if (!t) return;
addTile(t.mat, t.x + 22, t.y + 22);
toast("Duplicated");
});
backBtn.addEventListener("click", function () {
var t = getTile(selectedId);
if (!t) return;
var minZ = tiles.reduce(function (m, o) { return Math.min(m, o.z); }, Infinity);
t.z = minZ - 1;
t.el.style.zIndex = t.z;
toast("Sent backward");
});
delBtn.addEventListener("click", function () {
if (selectedId !== null) removeTile(selectedId);
});
shuffleBtn.addEventListener("click", function () {
if (!tiles.length) { toast("Board is empty"); return; }
var rect = canvas.getBoundingClientRect();
tiles.forEach(function (t) {
var w = t.el.offsetWidth, h = t.el.offsetHeight;
t.x = 16 + Math.random() * Math.max(20, rect.width - w - 32);
t.y = 14 + Math.random() * Math.max(20, rect.height - h - 28);
t.el.style.left = t.x + "px";
t.el.style.top = t.y + "px";
raise(t);
});
toast("Scattered");
});
clearBtn.addEventListener("click", function () {
if (!tiles.length) { toast("Already empty"); return; }
tiles.forEach(function (t) { t.el.remove(); });
tiles = [];
select(null);
sync();
toast("Board cleared");
});
/* deselect when clicking empty canvas */
canvas.addEventListener("pointerdown", function (e) {
if (e.target === canvas || e.target === canvasHint) select(null);
});
/* ---------- Seed a starter board ---------- */
function seed() {
var picks = ["oak", "plaster", "terracot", "sage", "rattan", "walnut"];
var rect = canvas.getBoundingClientRect();
var cols = rect.width > 620 ? 3 : 2;
picks.forEach(function (k, i) {
var mat = LIBRARY.filter(function (m) { return m.key === k; })[0];
var col = i % cols, row = Math.floor(i / cols);
var x = 30 + col * (rect.width / cols - 20) * 0.9 + (Math.random() * 24 - 12);
var y = 24 + row * 168 + (Math.random() * 20 - 10);
addTile(mat, Math.max(10, x), Math.max(10, y));
});
select(null);
}
// wait for layout so canvas has real dimensions
if (canvas.getBoundingClientRect().width > 0) seed();
else window.requestAnimationFrame(seed);
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Interior — Moodboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600&family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="shell">
<header class="masthead">
<div class="mast-left">
<span class="eyebrow">Atelier · Studio Board</span>
<h1>The Moodboard</h1>
<p class="lede">Drag finishes, fabrics and forms across the paper. The palette below derives itself from whatever you place.</p>
</div>
<div class="mast-meta">
<span class="meta-line"><span class="dot"></span> Living Room — Draft 03</span>
<span class="meta-line muted" id="tileCount">0 tiles on board</span>
</div>
</header>
<div class="workspace">
<aside class="toolbar" aria-label="Moodboard tools">
<div class="tool-group">
<span class="tool-title">Add a chip</span>
<div class="swatch-tray" id="swatchTray" role="list" aria-label="Material library">
<!-- material buttons injected by script -->
</div>
</div>
<div class="tool-group">
<span class="tool-title">Board actions</span>
<div class="action-col">
<button class="btn" id="dupBtn" type="button" disabled>
<span aria-hidden="true">⧉</span> Duplicate selected
</button>
<button class="btn" id="backBtn" type="button" disabled>
<span aria-hidden="true">⤓</span> Send backward
</button>
<button class="btn" id="delBtn" type="button" disabled>
<span aria-hidden="true">✕</span> Remove selected
</button>
<button class="btn btn-ghost" id="shuffleBtn" type="button">
<span aria-hidden="true">✦</span> Scatter layout
</button>
<button class="btn btn-danger" id="clearBtn" type="button">
<span aria-hidden="true">⌫</span> Clear board
</button>
</div>
</div>
<p class="hint">Tip: focus a tile and use arrow keys to nudge, Enter to raise, Delete to remove.</p>
</aside>
<section class="canvas-wrap">
<div class="canvas" id="canvas" role="application" aria-label="Moodboard canvas. Drag tiles to arrange.">
<span class="canvas-hint" id="canvasHint">Your board is empty — add a chip from the left.</span>
</div>
</section>
</div>
<section class="palette-panel" aria-label="Derived palette">
<div class="palette-head">
<div>
<span class="tool-title">Derived palette</span>
<p class="palette-sub">Five dominant tones, sampled live. Click a chip to copy its hex.</p>
</div>
<span class="palette-count" id="paletteCount">—</span>
</div>
<div class="palette-strip" id="paletteStrip" role="list" aria-label="Palette swatches">
<span class="palette-empty">Add tiles to compose a palette.</span>
</div>
</section>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>The moodboard opens on a soft paper canvas scattered with tactile tiles — oak and walnut finishes, plaster and terracotta swatches, a linen sofa, a rattan pendant. Grab any tile with the mouse or a touch and it lifts, casting a deeper shadow while you slide it into place; the last tile you touch rises to the top of the stack so overlapping compositions stay editable. A trim of tools along the side lets you drop new material chips onto the board, duplicate the selected tile, send it backward, or clear the surface entirely.
Beneath the canvas a live palette strip recomputes on every change, sampling the colour of each tile currently on the board and collapsing it into the five most present tones — the story of your room, at a glance. Each palette chip copies its hex to the clipboard on click, so the board doubles as a spec sheet.
Everything is keyboard reachable: focus a tile and nudge it with the arrow keys, press Delete to remove it, or Enter to raise it. The layout reflows to a single narrow column under 520px, tools wrap, and the canvas keeps its generous editorial whitespace with thin walnut rules and a muted natural palette throughout.