Museum — Exhibition Builder
A curatorial studio screen for planning a gallery show: browse a searchable pool of collection objects on the left, then build an ordered exhibition sequence on the right. Add works, reorder them up or down across rooms, group them into named sections with editable wall text, and watch a live object count and estimated wall-space meter respond as you go. Save a draft and a toast confirms the room and object totals.
MCP
Code
:root {
--paper: #f6f4ef;
--wall: #ffffff;
--charcoal: #1c1b19;
--ink: #2a2825;
--ink-2: #4a4640;
--muted: #8c857a;
--gold: #a98140;
--gold-d: #876631;
--gold-50: #f3ecdd;
--line: rgba(28, 27, 25, 0.12);
--line-2: rgba(28, 27, 25, 0.2);
--ok: #3f7d56;
--warn: #b8842c;
--danger: #b4493a;
--r-sm: 6px;
--r-md: 12px;
--r-lg: 18px;
--serif: "Cormorant Garamond", Georgia, serif;
--sans: "Inter", system-ui, -apple-system, sans-serif;
--shadow-1: 0 1px 2px rgba(28, 27, 25, 0.05), 0 4px 14px rgba(28, 27, 25, 0.05);
--shadow-2: 0 8px 30px rgba(28, 27, 25, 0.1);
}
* { box-sizing: border-box; }
html, body { margin: 0; }
body {
font-family: var(--sans);
background: var(--paper);
color: var(--ink);
line-height: 1.55;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
button, input, textarea { font-family: inherit; }
:focus-visible {
outline: 2px solid var(--gold);
outline-offset: 2px;
border-radius: var(--r-sm);
}
/* ---------- Top bar ---------- */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
flex-wrap: wrap;
padding: 20px 28px;
background: var(--wall);
border-bottom: 1px solid var(--line);
position: sticky;
top: 0;
z-index: 20;
}
.brand { display: flex; align-items: center; gap: 16px; }
.brand__mark {
width: 44px; height: 44px;
display: grid; place-items: center;
border: 1px solid var(--line-2);
border-radius: 50%;
color: var(--gold-d);
font-size: 22px;
flex: none;
}
.brand__eyebrow {
display: block;
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--muted);
}
.brand__title {
font-family: var(--serif);
font-weight: 600;
font-size: 28px;
line-height: 1.1;
margin: 2px 0 0;
color: var(--charcoal);
}
.topbar__actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.draft-stamp {
font-size: 12px;
color: var(--muted);
letter-spacing: 0.04em;
padding: 5px 12px;
border: 1px dashed var(--line-2);
border-radius: 999px;
}
.draft-stamp.is-saved {
color: var(--ok);
border-style: solid;
border-color: rgba(63, 125, 86, 0.4);
background: rgba(63, 125, 86, 0.07);
}
/* ---------- Buttons ---------- */
.btn {
border: 1px solid transparent;
border-radius: var(--r-sm);
padding: 9px 16px;
font-size: 13.5px;
font-weight: 500;
cursor: pointer;
transition: background 0.18s, border-color 0.18s, transform 0.06s, color 0.18s;
}
.btn:active { transform: translateY(1px); }
.btn--sm { padding: 6px 12px; font-size: 12.5px; }
.btn--solid { background: var(--charcoal); color: #f7f5f0; }
.btn--solid:hover { background: #000; }
.btn--ghost {
background: transparent;
color: var(--ink);
border-color: var(--line-2);
}
.btn--ghost:hover { background: var(--gold-50); border-color: var(--gold); color: var(--gold-d); }
/* ---------- Studio layout ---------- */
.studio {
display: grid;
grid-template-columns: minmax(0, 360px) minmax(0, 1fr);
gap: 24px;
padding: 24px 28px 56px;
max-width: 1320px;
margin: 0 auto;
align-items: start;
}
.panel {
background: var(--wall);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--shadow-1);
overflow: hidden;
}
.pool { position: sticky; top: 96px; }
.panel__head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 14px;
padding: 18px 20px 14px;
border-bottom: 1px solid var(--line);
}
.panel__title {
font-family: var(--serif);
font-weight: 600;
font-size: 21px;
margin: 0;
color: var(--charcoal);
}
.panel__sub {
margin: 2px 0 0;
font-size: 12.5px;
color: var(--muted);
}
/* ---------- Pool ---------- */
.pool__search { padding: 14px 20px; border-bottom: 1px solid var(--line); }
.field {
width: 100%;
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
padding: 9px 11px;
font-size: 13.5px;
color: var(--ink);
background: var(--paper);
transition: border-color 0.18s, background 0.18s;
}
.field:focus { outline: none; border-color: var(--gold); background: var(--wall); }
.field--inline { background: transparent; border-color: transparent; padding: 4px 6px; }
.field--inline:hover { border-color: var(--line); }
.field--inline:focus { background: var(--wall); border-color: var(--gold); }
.chips { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 11px; }
.chip {
border: 1px solid var(--line-2);
background: var(--wall);
color: var(--ink-2);
border-radius: 999px;
padding: 5px 12px;
font-size: 12px;
cursor: pointer;
transition: all 0.16s;
}
.chip:hover { border-color: var(--gold); color: var(--gold-d); }
.chip.is-active { background: var(--charcoal); color: #f7f5f0; border-color: var(--charcoal); }
.pool__list {
list-style: none;
margin: 0;
padding: 10px;
max-height: min(64vh, 640px);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
}
.pcard {
display: grid;
grid-template-columns: 52px 1fr auto;
gap: 12px;
align-items: center;
padding: 10px;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--wall);
transition: border-color 0.16s, box-shadow 0.16s, transform 0.1s;
}
.pcard:hover { border-color: var(--line-2); box-shadow: var(--shadow-1); }
.pcard.is-used { opacity: 0.45; }
.pcard.is-used .pcard__add { visibility: hidden; }
.pcard__thumb, .srow__thumb {
width: 52px; height: 52px;
border-radius: var(--r-sm);
border: 1px solid var(--line-2);
flex: none;
box-shadow: inset 0 0 0 4px var(--wall);
}
.srow__thumb { width: 42px; height: 42px; }
.pcard__body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.pcard__title {
font-family: var(--serif);
font-weight: 600;
font-size: 16px;
color: var(--charcoal);
line-height: 1.2;
}
.pcard__meta { font-size: 11.5px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pcard__foot { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
.pcard__cat { font-size: 10.5px; color: var(--muted); letter-spacing: 0.04em; }
.badge {
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--gold-d);
background: var(--gold-50);
border-radius: 999px;
padding: 2px 8px;
white-space: nowrap;
}
.pcard__add {
border: 1px solid var(--line-2);
background: var(--wall);
color: var(--ink);
border-radius: var(--r-sm);
padding: 7px 10px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.16s;
white-space: nowrap;
}
.pcard__add:hover { background: var(--charcoal); color: #f7f5f0; border-color: var(--charcoal); }
.pool__empty {
text-align: center;
color: var(--muted);
font-size: 13px;
padding: 28px 12px;
}
/* ---------- Sequence meters ---------- */
.meters {
display: grid;
grid-template-columns: auto 1fr;
gap: 20px;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--line);
background: linear-gradient(0deg, var(--paper), var(--wall));
}
.meter__row { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
.meter__label { font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted); }
.meter__val { font-family: var(--serif); font-weight: 600; font-size: 22px; color: var(--charcoal); }
.bar {
margin-top: 8px;
height: 9px;
border-radius: 999px;
background: var(--gold-50);
border: 1px solid var(--line);
overflow: hidden;
}
.bar__fill {
display: block;
height: 100%;
width: 0%;
background: linear-gradient(90deg, var(--gold), var(--gold-d));
transition: width 0.35s ease, background 0.35s;
border-radius: 999px;
}
.bar.is-over .bar__fill { background: linear-gradient(90deg, var(--warn), var(--danger)); }
.meter__hint { margin: 7px 0 0; font-size: 11.5px; color: var(--muted); }
.meter__hint.is-warn { color: var(--danger); }
/* ---------- Rooms ---------- */
.rooms { padding: 16px 20px 22px; display: flex; flex-direction: column; gap: 18px; }
.room {
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--paper);
overflow: hidden;
}
.room__head {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 13px;
background: var(--wall);
border-bottom: 1px solid var(--line);
}
.room__no {
font-family: var(--serif);
font-weight: 700;
font-size: 15px;
color: var(--gold-d);
width: 30px; height: 30px;
display: grid; place-items: center;
border: 1px solid var(--line-2);
border-radius: 50%;
flex: none;
}
.room__name {
flex: 1 1 auto;
font-family: var(--serif);
font-weight: 600;
font-size: 18px;
color: var(--charcoal);
min-width: 0;
}
.room__stat { font-size: 11.5px; color: var(--muted); white-space: nowrap; }
.room__walltext {
width: calc(100% - 26px);
margin: 13px 13px 6px;
resize: vertical;
font-size: 13px;
color: var(--ink-2);
background: var(--wall);
min-height: 48px;
}
.room__objs { list-style: none; margin: 0; padding: 8px 13px 13px; display: flex; flex-direction: column; gap: 8px; }
.room__empty { margin: 0 13px 14px; font-size: 12.5px; color: var(--muted); font-style: italic; }
.srow {
display: grid;
grid-template-columns: 42px 1fr auto;
gap: 11px;
align-items: center;
padding: 8px 10px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
background: var(--wall);
transition: box-shadow 0.16s, border-color 0.16s;
}
.srow:hover { box-shadow: var(--shadow-1); border-color: var(--line-2); }
.srow__body { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.srow__title { font-family: var(--serif); font-weight: 600; font-size: 15.5px; color: var(--charcoal); line-height: 1.2; }
.srow__meta { font-size: 11px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.srow__ctrls { display: flex; gap: 4px; }
.iconbtn {
width: 28px; height: 28px;
display: grid; place-items: center;
border: 1px solid var(--line-2);
background: var(--wall);
color: var(--ink-2);
border-radius: var(--r-sm);
font-size: 14px;
cursor: pointer;
transition: all 0.14s;
}
.iconbtn:hover { background: var(--gold-50); color: var(--gold-d); border-color: var(--gold); }
.iconbtn:disabled { opacity: 0.3; cursor: not-allowed; }
.iconbtn:disabled:hover { background: var(--wall); color: var(--ink-2); border-color: var(--line-2); }
.iconbtn--del:hover { background: rgba(180, 73, 58, 0.1); color: var(--danger); border-color: rgba(180, 73, 58, 0.4); }
/* ---------- Toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 20px);
background: var(--charcoal);
color: #f7f5f0;
padding: 11px 20px;
border-radius: var(--r-sm);
font-size: 13.5px;
box-shadow: var(--shadow-2);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s, transform 0.25s;
z-index: 50;
max-width: 88vw;
}
.toast.is-show { opacity: 1; transform: translate(-50%, 0); }
/* ---------- Responsive ---------- */
@media (max-width: 900px) {
.studio { grid-template-columns: 1fr; }
.pool { position: static; }
.pool__list { max-height: 360px; }
}
@media (max-width: 520px) {
.topbar { padding: 16px; }
.brand__title { font-size: 23px; }
.brand__mark { width: 38px; height: 38px; font-size: 18px; }
.topbar__actions { width: 100%; justify-content: space-between; }
.draft-stamp { order: 3; }
.studio { padding: 16px 14px 44px; gap: 18px; }
.meters { grid-template-columns: 1fr; gap: 12px; }
.pcard { grid-template-columns: 44px 1fr; }
.pcard__add { grid-column: 1 / -1; width: 100%; }
.pcard__thumb { width: 44px; height: 44px; }
.panel__head { flex-wrap: wrap; }
}(function () {
"use strict";
/* ---------- Demo data: fictional collection ---------- */
var OBJECTS = [
{ id: "o1", title: "Composition in Slate", artist: "Margit Halász", date: 1931, medium: "Painting", cat: "AMA.1931.0142", width: 1.6, c1: "#3a3f4a", c2: "#6b7280" },
{ id: "o2", title: "Reclining Form, No. 4", artist: "Edouard Brun", date: 1948, medium: "Sculpture", cat: "AMA.1948.0061", width: 2.4, c1: "#b08a5a", c2: "#7c5f38" },
{ id: "o3", title: "Untitled (Lattice)", artist: "Yara Osei", date: 1969, medium: "Painting", cat: "AMA.1969.0307", width: 1.9, c1: "#c0492f", c2: "#7c2d1c" },
{ id: "o4", title: "Salt Flats at Dawn", artist: "Ren Takeda", date: 1972, medium: "Photograph", cat: "AMA.1972.0028", width: 0.9, c1: "#dcd2c0", c2: "#9aa3ad" },
{ id: "o5", title: "Woven Field I", artist: "Astrid Lindqvist", date: 1965, medium: "Textile", cat: "AMA.1965.0190", width: 2.1, c1: "#8a6f3c", c2: "#c9a96a" },
{ id: "o6", title: "Black Square Study", artist: "Margit Halász", date: 1928, medium: "Painting", cat: "AMA.1928.0099", width: 1.2, c1: "#1f1d1a", c2: "#3a342c" },
{ id: "o7", title: "Cantilever", artist: "Edouard Brun", date: 1955, medium: "Sculpture", cat: "AMA.1955.0211", width: 1.8, c1: "#9ca3ab", c2: "#5b626a" },
{ id: "o8", title: "Two Apertures", artist: "Yara Osei", date: 1974, medium: "Painting", cat: "AMA.1974.0140", width: 2.0, c1: "#2e5a44", c2: "#1c3a2b" },
{ id: "o9", title: "Quay, Long Exposure", artist: "Ren Takeda", date: 1970, medium: "Photograph", cat: "AMA.1970.0066", width: 1.0, c1: "#cbd2d9", c2: "#7d8893" },
{ id: "o10", title: "Threshold (Indigo)", artist: "Astrid Lindqvist", date: 1968, medium: "Textile", cat: "AMA.1968.0233", width: 1.7, c1: "#3b4a78", c2: "#23315a" },
{ id: "o11", title: "Plumb Line", artist: "Margit Halász", date: 1940, medium: "Painting", cat: "AMA.1940.0177", width: 1.4, c1: "#a98140", c2: "#876631" },
{ id: "o12", title: "Folded Plane", artist: "Edouard Brun", date: 1961, medium: "Sculpture", cat: "AMA.1961.0058", width: 2.2, c1: "#7a7468", c2: "#4a4640" },
{ id: "o13", title: "Grid, Reduced", artist: "Yara Osei", date: 1967, medium: "Painting", cat: "AMA.1967.0301", width: 1.5, c1: "#b4493a", c2: "#7c2d24" },
{ id: "o14", title: "Tide Marks", artist: "Ren Takeda", date: 1975, medium: "Photograph", cat: "AMA.1975.0012", width: 0.8, c1: "#e0d8c8", c2: "#a8b0b8" }
];
var WALL_TOTAL = 62.0; // metres of available wall
/* ---------- State ---------- */
var state = {
placed: {}, // id -> true
rooms: [
{ id: "r1", name: "Room 1 · Beginnings", walltext: "The exhibition opens with the earliest works, where pictorial space is pared back to its essential structure.", objs: ["o6", "o1"] },
{ id: "r2", name: "Room 2 · The Built Object", walltext: "", objs: ["o2"] }
],
filter: "all",
query: "",
dirty: false
};
state.rooms.forEach(function (r) { r.objs.forEach(function (id) { state.placed[id] = true; }); });
var roomSeq = 3;
/* ---------- Helpers ---------- */
function $(sel, ctx) { return (ctx || document).querySelector(sel); }
function byId(id) { return OBJECTS.filter(function (o) { return o.id === id; })[0]; }
var toastEl = $("#toast");
var toastTimer;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("is-show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { toastEl.classList.remove("is-show"); }, 2400);
}
function thumbStyle(o) {
return "background:linear-gradient(135deg," + o.c1 + "," + o.c2 + ");";
}
function markDirty() {
if (!state.dirty) {
state.dirty = true;
var s = $("#draftStamp");
s.classList.remove("is-saved");
s.textContent = "Draft · unsaved";
}
}
/* ---------- Pool rendering ---------- */
var poolList = $("#poolList");
var poolTpl = $("#poolCardTpl");
function renderPool() {
poolList.innerHTML = "";
var q = state.query.trim().toLowerCase();
var matches = OBJECTS.filter(function (o) {
if (state.filter !== "all" && o.medium !== state.filter) return false;
if (!q) return true;
return (o.title + " " + o.artist + " " + o.cat).toLowerCase().indexOf(q) !== -1;
});
if (!matches.length) {
var p = document.createElement("p");
p.className = "pool__empty";
p.textContent = "No objects match your search.";
poolList.appendChild(p);
}
matches.forEach(function (o) {
var node = poolTpl.content.firstElementChild.cloneNode(true);
node.dataset.id = o.id;
$("[data-thumb]", node).setAttribute("style", thumbStyle(o));
$("[data-title]", node).textContent = o.title;
$("[data-artist]", node).textContent = o.artist;
$("[data-date]", node).textContent = o.date;
$("[data-medium]", node).textContent = o.medium;
$("[data-cat]", node).textContent = o.cat;
if (state.placed[o.id]) node.classList.add("is-used");
$("[data-add]", node).addEventListener("click", function () { addToSequence(o.id); });
node.addEventListener("keydown", function (e) {
if ((e.key === "Enter" || e.key === " ") && !state.placed[o.id]) {
e.preventDefault();
addToSequence(o.id);
}
});
poolList.appendChild(node);
});
var placedCount = Object.keys(state.placed).length;
$("#poolCount").textContent = OBJECTS.length + " objects in store · " + placedCount + " placed";
}
/* ---------- Sequence / rooms rendering ---------- */
var roomsEl = $("#rooms");
var roomTpl = $("#roomTpl");
var seqRowTpl = $("#seqRowTpl");
function renderRooms() {
roomsEl.innerHTML = "";
state.rooms.forEach(function (room, ri) {
var node = roomTpl.content.firstElementChild.cloneNode(true);
node.dataset.id = room.id;
$("[data-roomno]", node).textContent = ri + 1;
var nameInput = $("[data-name]", node);
nameInput.value = room.name;
nameInput.addEventListener("input", function () { room.name = nameInput.value; markDirty(); });
var wallText = $("[data-walltext]", node);
wallText.value = room.walltext;
wallText.addEventListener("input", function () { room.walltext = wallText.value; markDirty(); });
var roomWall = room.objs.reduce(function (sum, id) {
var o = byId(id); return sum + (o ? o.width : 0);
}, 0);
$("[data-stat]", node).textContent = room.objs.length + " obj · " + roomWall.toFixed(1) + " m";
var delRoom = $("[data-delroom]", node);
delRoom.disabled = state.rooms.length <= 1;
delRoom.addEventListener("click", function () { removeRoom(ri); });
var objsUl = $("[data-objs]", node);
var emptyP = $("[data-empty]", node);
emptyP.style.display = room.objs.length ? "none" : "";
room.objs.forEach(function (id, oi) {
var o = byId(id);
if (!o) return;
var row = seqRowTpl.content.firstElementChild.cloneNode(true);
$("[data-thumb]", row).setAttribute("style", thumbStyle(o));
$("[data-title]", row).textContent = o.title;
$("[data-artist]", row).textContent = o.artist;
$("[data-date]", row).textContent = o.date;
$("[data-cat]", row).textContent = o.cat;
var up = $("[data-up]", row);
var down = $("[data-down]", row);
up.disabled = (ri === 0 && oi === 0);
down.disabled = (ri === state.rooms.length - 1 && oi === room.objs.length - 1);
up.addEventListener("click", function () { moveObject(ri, oi, -1); });
down.addEventListener("click", function () { moveObject(ri, oi, 1); });
$("[data-remove]", row).addEventListener("click", function () { removeFromSequence(ri, oi); });
objsUl.appendChild(row);
});
roomsEl.appendChild(node);
});
updateMeters();
}
/* ---------- Meters ---------- */
function updateMeters() {
var count = state.rooms.reduce(function (n, r) { return n + r.objs.length; }, 0);
var wall = 0;
state.rooms.forEach(function (r) {
r.objs.forEach(function (id) { var o = byId(id); if (o) wall += o.width; });
});
$("#objCount").textContent = count;
$("#wallUsed").textContent = wall.toFixed(1);
$("#wallTotal").textContent = WALL_TOTAL.toFixed(1);
var pct = Math.min(100, (wall / WALL_TOTAL) * 100);
var fill = $("#wallFill");
fill.style.width = pct + "%";
var bar = $("#wallBar");
bar.setAttribute("aria-valuenow", wall.toFixed(1));
var hint = $("#wallHint");
if (wall > WALL_TOTAL) {
bar.classList.add("is-over");
hint.classList.add("is-warn");
hint.textContent = "Over capacity by " + (wall - WALL_TOTAL).toFixed(1) + " m — remove or relocate objects.";
} else {
bar.classList.remove("is-over");
hint.classList.remove("is-warn");
var rem = WALL_TOTAL - wall;
hint.textContent = rem > 15 ? "Plenty of wall space remaining." : rem.toFixed(1) + " m of wall space remaining.";
}
}
/* ---------- Mutations ---------- */
function addToSequence(id) {
if (state.placed[id]) return;
var last = state.rooms[state.rooms.length - 1];
last.objs.push(id);
state.placed[id] = true;
markDirty();
var o = byId(id);
toast("Added “" + o.title + "” to " + last.name.replace(/^Room \d+ · /, "") || "the sequence");
renderPool();
renderRooms();
}
function removeFromSequence(ri, oi) {
var id = state.rooms[ri].objs[oi];
state.rooms[ri].objs.splice(oi, 1);
delete state.placed[id];
markDirty();
toast("Removed from sequence.");
renderPool();
renderRooms();
}
function moveObject(ri, oi, dir) {
var room = state.rooms[ri];
var target = oi + dir;
if (target >= 0 && target < room.objs.length) {
// swap within room
var tmp = room.objs[oi];
room.objs[oi] = room.objs[target];
room.objs[target] = tmp;
} else {
// move across room boundary
var nextRi = ri + dir;
if (nextRi < 0 || nextRi >= state.rooms.length) return;
var moved = room.objs.splice(oi, 1)[0];
if (dir === 1) {
state.rooms[nextRi].objs.unshift(moved);
} else {
state.rooms[nextRi].objs.push(moved);
}
}
markDirty();
renderRooms();
}
function addRoom() {
state.rooms.push({ id: "r" + roomSeq++, name: "Room " + (state.rooms.length + 1) + " · Untitled", walltext: "", objs: [] });
markDirty();
toast("Room added.");
renderRooms();
}
function removeRoom(ri) {
if (state.rooms.length <= 1) return;
var room = state.rooms[ri];
room.objs.forEach(function (id) { delete state.placed[id]; });
var freed = room.objs.length;
state.rooms.splice(ri, 1);
// renumber default names
state.rooms.forEach(function (r, i) {
r.name = r.name.replace(/^Room \d+/, "Room " + (i + 1));
});
markDirty();
toast(freed ? "Room removed · " + freed + " object(s) returned to store." : "Room removed.");
renderPool();
renderRooms();
}
/* ---------- Wiring ---------- */
$("#poolSearch").addEventListener("input", function (e) { state.query = e.target.value; renderPool(); });
$("#poolFilters").addEventListener("click", function (e) {
var btn = e.target.closest(".chip");
if (!btn) return;
state.filter = btn.dataset.medium;
Array.prototype.forEach.call(this.querySelectorAll(".chip"), function (c) { c.classList.remove("is-active"); });
btn.classList.add("is-active");
renderPool();
});
$("#addSectionBtn").addEventListener("click", addRoom);
$("#saveBtn").addEventListener("click", function () {
state.dirty = false;
var s = $("#draftStamp");
s.classList.add("is-saved");
s.textContent = "Draft saved · " + new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
var count = state.rooms.reduce(function (n, r) { return n + r.objs.length; }, 0);
toast("Draft saved — " + state.rooms.length + " rooms, " + count + " objects.");
});
$("#previewBtn").addEventListener("click", function () {
var count = state.rooms.reduce(function (n, r) { return n + r.objs.length; }, 0);
toast("Walkthrough: " + state.rooms.length + " rooms · " + count + " objects in sequence.");
});
/* ---------- Init ---------- */
renderPool();
renderRooms();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Museum — Exhibition Builder</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@500;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="brand__mark" aria-hidden="true">◷</span>
<div class="brand__txt">
<span class="brand__eyebrow">Atherton Museum of Art · Curatorial Studio</span>
<h1 class="brand__title">Exhibition Builder</h1>
</div>
</div>
<div class="topbar__actions">
<span class="draft-stamp" id="draftStamp">Draft · unsaved</span>
<button class="btn btn--ghost" id="previewBtn" type="button">Preview walkthrough</button>
<button class="btn btn--solid" id="saveBtn" type="button">Save draft</button>
</div>
</header>
<main class="studio">
<!-- POOL -->
<section class="panel pool" aria-labelledby="poolHeading">
<div class="panel__head">
<div>
<h2 class="panel__title" id="poolHeading">Available objects</h2>
<p class="panel__sub" id="poolCount">— objects in store</p>
</div>
</div>
<div class="pool__search">
<input type="search" id="poolSearch" class="field" placeholder="Search by title, artist, catalog no.…" aria-label="Search available objects" />
<div class="chips" id="poolFilters" role="group" aria-label="Filter by medium">
<button class="chip is-active" data-medium="all" type="button">All</button>
<button class="chip" data-medium="Painting" type="button">Painting</button>
<button class="chip" data-medium="Sculpture" type="button">Sculpture</button>
<button class="chip" data-medium="Photograph" type="button">Photograph</button>
<button class="chip" data-medium="Textile" type="button">Textile</button>
</div>
</div>
<ul class="pool__list" id="poolList" aria-label="Objects available to add"></ul>
</section>
<!-- SEQUENCE -->
<section class="panel seq" aria-labelledby="seqHeading">
<div class="panel__head">
<div>
<h2 class="panel__title" id="seqHeading">Exhibition sequence</h2>
<p class="panel__sub"><span id="seqTitleEcho">Quiet Geometries: Form & Restraint, 1920–1975</span></p>
</div>
<button class="btn btn--ghost btn--sm" id="addSectionBtn" type="button">+ Add room</button>
</div>
<div class="meters" aria-live="polite">
<div class="meter">
<div class="meter__row">
<span class="meter__label">Objects placed</span>
<span class="meter__val" id="objCount">0</span>
</div>
</div>
<div class="meter">
<div class="meter__row">
<span class="meter__label">Estimated wall space</span>
<span class="meter__val"><span id="wallUsed">0.0</span> / <span id="wallTotal">62.0</span> m</span>
</div>
<div class="bar" role="progressbar" aria-label="Wall space used" aria-valuemin="0" aria-valuemax="62" aria-valuenow="0" id="wallBar">
<span class="bar__fill" id="wallFill"></span>
</div>
<p class="meter__hint" id="wallHint">Plenty of wall space remaining.</p>
</div>
</div>
<div class="rooms" id="rooms"></div>
</section>
</main>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<!-- object pool card template -->
<template id="poolCardTpl">
<li class="pcard" tabindex="0">
<span class="pcard__thumb" data-thumb aria-hidden="true"></span>
<span class="pcard__body">
<span class="pcard__title" data-title></span>
<span class="pcard__meta"><span data-artist></span> · <span data-date></span></span>
<span class="pcard__foot">
<span class="badge" data-medium></span>
<span class="pcard__cat" data-cat></span>
</span>
</span>
<button class="pcard__add" data-add type="button" aria-label="Add to sequence">Add →</button>
</li>
</template>
<!-- room/section template -->
<template id="roomTpl">
<article class="room">
<header class="room__head">
<span class="room__no" data-roomno></span>
<input class="room__name field field--inline" data-name aria-label="Room name" />
<span class="room__stat" data-stat></span>
<button class="iconbtn iconbtn--del" data-delroom type="button" aria-label="Remove room">✕</button>
</header>
<textarea class="room__walltext field" data-walltext rows="2" aria-label="Wall text for this room" placeholder="Wall text — a short curatorial note shown at the room entrance…"></textarea>
<ul class="room__objs" data-objs aria-label="Objects in this room"></ul>
<p class="room__empty" data-empty>No objects yet — add from the pool on the left.</p>
</article>
</template>
<!-- sequence object row template -->
<template id="seqRowTpl">
<li class="srow" tabindex="0">
<span class="srow__thumb" data-thumb aria-hidden="true"></span>
<span class="srow__body">
<span class="srow__title" data-title></span>
<span class="srow__meta"><span data-artist></span> · <span data-date></span> · <span data-cat></span></span>
</span>
<span class="srow__ctrls">
<button class="iconbtn" data-up type="button" aria-label="Move up">↑</button>
<button class="iconbtn" data-down type="button" aria-label="Move down">↓</button>
<button class="iconbtn iconbtn--del" data-remove type="button" aria-label="Remove from sequence">✕</button>
</span>
</li>
</template>
<script src="script.js"></script>
</body>
</html>Exhibition Builder
A two-panel curatorial workspace for assembling a gallery show. The left panel is the collection store: a searchable, filterable pool of fictional objects — paintings, sculptures, photographs and textiles by invented artists, each with a title, date, medium and catalog number. Filter by medium or type a query, then add any object to the sequence with a click or the keyboard.
The right panel is the exhibition sequence, organised into rooms. Each room has an editable name, a wall-text note shown at the room entrance, and an ordered list of objects. Reorder works with the up and down buttons — movement carries across room boundaries — remove a piece back to the store, or add and delete whole rooms. A live header tracks the number of objects placed and an estimated wall-space meter that warns when the show exceeds the gallery’s available metres.
Everything is vanilla JavaScript with a small toast() helper for confirmations. Saving the
draft stamps the time and reports the room and object totals. The layout collapses to a single
column on narrow screens and stays keyboard-accessible throughout.
Illustrative UI only — demo data; not a real museum system.