Video — Storyboard
A cinematic dark storyboard editor for planning shots frame by frame. Numbered board panels hold a sketch placeholder plus shot type, camera-move, lens and duration notes. Group frames into scenes, add new panels, and reorder them with move controls or drag. A running timeline tallies total runtime while amber accents and letterbox bars give it a film-forward, pre-production feel for directors and videographers.
MCP
Code
:root {
--bg: #0a0a0b;
--surface: #141416;
--surface-2: #1c1c1f;
--amber: #ffb020;
--amber-d: #e6971a;
--red: #ff4d4d;
--ink: #f4f4f6;
--muted: #8a8a92;
--line: rgba(255, 255, 255, 0.1);
--line-2: rgba(255, 255, 255, 0.18);
--white: #fff;
--r-sm: 6px;
--r-md: 10px;
--r-lg: 16px;
}
* {
box-sizing: border-box;
}
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: "Inter", system-ui, sans-serif;
line-height: 1.5;
font-size: 15px;
}
.tc {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
}
.app {
max-width: 1180px;
margin: 0 auto;
padding: 0 20px 64px;
}
/* ---------- Hero + letterbox ---------- */
.hero {
margin: 0 -20px 8px;
position: relative;
background:
radial-gradient(120% 140% at 80% -20%, rgba(255, 176, 32, 0.16), transparent 60%),
linear-gradient(180deg, #101012, #0a0a0b);
}
.bar {
height: 26px;
background: #000;
}
.bar-top {
box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.04);
}
.bar-bottom {
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.hero-inner {
padding: 34px 24px;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
flex-wrap: wrap;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.18em;
color: var(--amber);
text-transform: uppercase;
}
.rec-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--red);
box-shadow: 0 0 0 0 rgba(255, 77, 77, 0.6);
animation: rec 1.8s infinite;
}
@keyframes rec {
0% { box-shadow: 0 0 0 0 rgba(255, 77, 77, 0.55); }
70% { box-shadow: 0 0 0 7px rgba(255, 77, 77, 0); }
100% { box-shadow: 0 0 0 0 rgba(255, 77, 77, 0); }
}
.hero h1 {
margin: 12px 0 8px;
font-size: clamp(26px, 4.4vw, 40px);
font-weight: 800;
letter-spacing: -0.02em;
line-height: 1.05;
}
.hero-sub {
color: var(--muted);
font-weight: 600;
}
.hero-meta {
margin: 0;
color: var(--muted);
font-size: 13px;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.hero-meta .runtime {
color: var(--amber);
font-weight: 600;
}
.hero-meta .dot {
opacity: 0.5;
}
.hero-actions {
display: flex;
gap: 10px;
}
/* ---------- Buttons ---------- */
.btn {
display: inline-flex;
align-items: center;
gap: 7px;
border: 1px solid var(--line-2);
background: var(--surface-2);
color: var(--ink);
font: inherit;
font-weight: 600;
font-size: 13px;
padding: 9px 15px;
border-radius: var(--r-md);
cursor: pointer;
transition: transform 0.12s, background 0.15s, border-color 0.15s, box-shadow 0.15s;
}
.btn:hover {
background: #242428;
border-color: rgba(255, 255, 255, 0.28);
}
.btn:active {
transform: translateY(1px);
}
.btn.primary {
background: var(--amber);
color: #16120a;
border-color: var(--amber-d);
}
.btn.primary:hover {
background: var(--amber-d);
}
.btn.ghost {
background: transparent;
}
:where(button, input, a):focus-visible {
outline: 2px solid var(--amber);
outline-offset: 2px;
}
/* ---------- Timeline ---------- */
.timeline {
margin: 4px 0 24px;
padding: 10px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
}
.timeline-track {
display: flex;
gap: 3px;
height: 30px;
align-items: stretch;
}
.tl-seg {
min-width: 8px;
border-radius: 4px;
background: linear-gradient(180deg, rgba(255, 176, 32, 0.85), rgba(255, 176, 32, 0.5));
position: relative;
display: flex;
align-items: center;
justify-content: center;
transition: filter 0.15s, transform 0.15s;
overflow: hidden;
cursor: default;
}
.tl-seg span {
font-size: 9px;
font-weight: 700;
color: #16120a;
font-family: "JetBrains Mono", monospace;
}
.tl-seg:hover {
filter: brightness(1.15);
transform: translateY(-2px);
}
.tl-seg.scene-alt {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.28), rgba(255, 255, 255, 0.12));
}
.tl-seg.scene-alt span {
color: var(--ink);
}
/* ---------- Scenes ---------- */
.scene {
margin-bottom: 22px;
border: 1px solid var(--line);
border-radius: var(--r-lg);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.015), transparent);
overflow: hidden;
}
.scene-head {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
cursor: pointer;
user-select: none;
background: var(--surface);
border-bottom: 1px solid var(--line);
}
.scene-head:hover {
background: #17171a;
}
.scene-caret {
width: 22px;
height: 22px;
display: grid;
place-items: center;
color: var(--muted);
transition: transform 0.2s;
flex: none;
}
.scene.collapsed .scene-caret {
transform: rotate(-90deg);
}
.scene-idx {
font-family: "JetBrains Mono", monospace;
font-size: 11px;
font-weight: 600;
color: #16120a;
background: var(--amber);
padding: 3px 8px;
border-radius: var(--r-sm);
flex: none;
}
.scene-title {
background: transparent;
border: none;
color: var(--ink);
font: inherit;
font-weight: 700;
font-size: 16px;
letter-spacing: -0.01em;
padding: 3px 6px;
border-radius: var(--r-sm);
min-width: 0;
flex: 1;
}
.scene-title:hover {
background: rgba(255, 255, 255, 0.05);
}
.scene-stat {
color: var(--muted);
font-size: 12px;
font-family: "JetBrains Mono", monospace;
flex: none;
}
.scene-add {
flex: none;
border: 1px solid var(--line-2);
background: var(--surface-2);
color: var(--amber);
font-weight: 700;
width: 30px;
height: 30px;
border-radius: var(--r-sm);
cursor: pointer;
font-size: 17px;
line-height: 1;
transition: background 0.15s, transform 0.12s;
}
.scene-add:hover {
background: #242428;
}
.scene-add:active {
transform: scale(0.92);
}
.scene-del {
flex: none;
border: 1px solid var(--line);
background: transparent;
color: var(--muted);
width: 30px;
height: 30px;
border-radius: var(--r-sm);
cursor: pointer;
font-size: 13px;
transition: color 0.15s, border-color 0.15s;
}
.scene-del:hover {
color: var(--red);
border-color: rgba(255, 77, 77, 0.4);
}
.frames {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: 14px;
padding: 16px;
}
.scene.collapsed .frames {
display: none;
}
/* ---------- Frame ---------- */
.frame {
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--surface);
overflow: hidden;
transition: border-color 0.15s, transform 0.15s, box-shadow 0.2s;
outline: none;
}
.frame:hover {
border-color: var(--line-2);
transform: translateY(-3px);
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.5);
}
.frame:focus-visible {
border-color: var(--amber);
box-shadow: 0 0 0 2px rgba(255, 176, 32, 0.35);
}
.frame.pop {
animation: pop 0.32s ease;
}
@keyframes pop {
0% { transform: scale(0.9); opacity: 0; }
100% { transform: scale(1); opacity: 1; }
}
.sketch {
position: relative;
aspect-ratio: 16 / 9;
background:
repeating-linear-gradient(45deg, rgba(255, 255, 255, 0.02) 0 10px, transparent 10px 20px),
linear-gradient(135deg, #202024, #131316);
border-bottom: 1px solid var(--line);
display: grid;
place-items: center;
}
.sketch-art {
position: absolute;
inset: 10px;
border: 1px dashed rgba(255, 255, 255, 0.16);
border-radius: 4px;
}
.sketch-art::before {
content: "SKETCH";
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-family: "JetBrains Mono", monospace;
font-size: 11px;
letter-spacing: 0.3em;
color: rgba(255, 255, 255, 0.18);
font-weight: 600;
}
.crop {
position: absolute;
width: 9px;
height: 9px;
border: 2px solid rgba(255, 176, 32, 0.7);
}
.crop.tl { top: -1px; left: -1px; border-right: 0; border-bottom: 0; }
.crop.tr { top: -1px; right: -1px; border-left: 0; border-bottom: 0; }
.crop.bl { bottom: -1px; left: -1px; border-right: 0; border-top: 0; }
.crop.br { bottom: -1px; right: -1px; border-left: 0; border-top: 0; }
.fnum {
position: absolute;
top: 8px;
left: 8px;
z-index: 2;
background: rgba(0, 0, 0, 0.72);
color: var(--amber);
font-size: 11px;
font-weight: 600;
padding: 3px 8px;
border-radius: var(--r-sm);
border: 1px solid rgba(255, 176, 32, 0.3);
}
.dur-badge {
position: absolute;
bottom: 8px;
right: 8px;
z-index: 2;
background: rgba(0, 0, 0, 0.72);
color: var(--ink);
font-size: 11px;
font-weight: 600;
padding: 3px 8px;
border-radius: var(--r-sm);
border: 1px solid var(--line);
}
.frame-body {
padding: 12px;
}
.slug {
width: 100%;
background: transparent;
border: 1px solid transparent;
color: var(--ink);
font: inherit;
font-weight: 600;
font-size: 13.5px;
padding: 5px 7px;
border-radius: var(--r-sm);
margin-bottom: 10px;
}
.slug:hover {
border-color: var(--line);
}
.slug:focus {
border-color: var(--line-2);
background: var(--surface-2);
}
.badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
}
.badge {
font-family: "JetBrains Mono", monospace;
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.02em;
padding: 4px 9px;
border-radius: 999px;
border: 1px solid var(--line);
background: var(--surface-2);
color: var(--muted);
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.badge:hover {
border-color: var(--line-2);
}
.badge.shot {
color: var(--amber);
border-color: rgba(255, 176, 32, 0.35);
background: rgba(255, 176, 32, 0.08);
}
.badge.move {
color: #7fc7ff;
border-color: rgba(127, 199, 255, 0.3);
background: rgba(127, 199, 255, 0.07);
}
.badge.lens {
color: #d0d0d6;
}
.frame-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding-top: 10px;
border-top: 1px solid var(--line);
}
.dur-field {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
color: var(--muted);
text-transform: uppercase;
}
.dur-input {
width: 46px;
background: var(--surface-2);
border: 1px solid var(--line);
color: var(--ink);
font-weight: 600;
font-size: 12px;
padding: 4px 6px;
border-radius: var(--r-sm);
text-align: center;
}
.dur-input::-webkit-inner-spin-button { opacity: 0.4; }
.frame-ctrls {
display: flex;
gap: 4px;
}
.ic {
width: 26px;
height: 26px;
display: grid;
place-items: center;
border: 1px solid var(--line);
background: transparent;
color: var(--muted);
border-radius: var(--r-sm);
cursor: pointer;
font-size: 15px;
line-height: 1;
transition: color 0.15s, background 0.15s, border-color 0.15s, transform 0.12s;
}
.ic:hover {
color: var(--ink);
background: var(--surface-2);
border-color: var(--line-2);
}
.ic:active {
transform: scale(0.9);
}
.ic.danger:hover {
color: var(--red);
border-color: rgba(255, 77, 77, 0.4);
}
/* ---------- Toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 24px);
background: var(--surface-2);
border: 1px solid var(--line-2);
color: var(--ink);
font-size: 13px;
font-weight: 500;
padding: 11px 18px;
border-radius: var(--r-md);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.55);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s, transform 0.25s;
z-index: 60;
}
.toast.show {
opacity: 1;
transform: translate(-50%, 0);
}
.toast::before {
content: "";
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--amber);
margin-right: 9px;
vertical-align: middle;
}
/* ---------- Responsive ---------- */
@media (max-width: 520px) {
.app { padding: 0 12px 48px; }
.hero { margin: 0 -12px 8px; }
.hero-inner { padding: 24px 16px; }
.hero-actions { width: 100%; }
.hero-actions .btn { flex: 1; justify-content: center; }
.frames { grid-template-columns: 1fr; padding: 12px; }
.scene-head { flex-wrap: wrap; }
.scene-title { order: 3; flex-basis: 100%; }
.timeline-track { height: 26px; }
}(function () {
"use strict";
var SHOTS = ["WIDE", "MEDIUM", "CLOSE-UP", "EXTREME CU", "OTS", "TWO-SHOT", "POV", "ESTABLISHING"];
var MOVES = ["STATIC", "PAN L→R", "PAN R→L", "TILT UP", "TILT DOWN", "DOLLY IN", "DOLLY OUT", "CRANE", "HANDHELD", "TRACKING"];
var LENSES = ["18mm", "24mm", "35mm", "50mm", "85mm", "135mm"];
var uid = 0;
function nid() { uid += 1; return "n" + uid; }
// Seed data
var scenes = [
{
id: nid(), title: "1. Harbor at Dawn",
frames: [
{ id: nid(), slug: "Fog rolls over the empty docks", shot: "ESTABLISHING", move: "CRANE", lens: "24mm", dur: 6 },
{ id: nid(), slug: "Lighthouse beam sweeps the water", shot: "WIDE", move: "PAN L→R", lens: "35mm", dur: 4 },
{ id: nid(), slug: "Mara steps off the last ferry", shot: "MEDIUM", move: "TRACKING", lens: "50mm", dur: 5 },
],
},
{
id: nid(), title: "2. The Warehouse Meet",
frames: [
{ id: nid(), slug: "Rusted door slides open, light spills in", shot: "CLOSE-UP", move: "DOLLY IN", lens: "85mm", dur: 3 },
{ id: nid(), slug: "Mara scans the room, hand on the strap", shot: "OTS", move: "HANDHELD", lens: "35mm", dur: 4 },
{ id: nid(), slug: "The broker turns from the shadows", shot: "TWO-SHOT", move: "STATIC", lens: "50mm", dur: 5 },
{ id: nid(), slug: "Extreme detail: briefcase latch clicks", shot: "EXTREME CU", move: "STATIC", lens: "135mm", dur: 2 },
],
},
{
id: nid(), title: "3. Rooftop Chase",
frames: [
{ id: nid(), slug: "Neon skyline, sirens bloom below", shot: "WIDE", move: "TILT DOWN", lens: "24mm", dur: 4 },
{ id: nid(), slug: "Feet pound across the gravel roof", shot: "POV", move: "HANDHELD", lens: "18mm", dur: 3 },
],
},
];
var board = document.getElementById("board");
var tpl = document.getElementById("frameTpl");
var toastEl = document.getElementById("toast");
var toastTimer;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { toastEl.classList.remove("show"); }, 2200);
}
function fmt(sec) {
var m = Math.floor(sec / 60);
var s = sec % 60;
return (m < 10 ? "0" : "") + m + ":" + (s < 10 ? "0" : "") + s;
}
function cycle(list, cur, dir) {
var i = list.indexOf(cur);
if (i === -1) i = 0;
i = (i + dir + list.length) % list.length;
return list[i];
}
var globalIndex = 0; // running frame number across all scenes
function buildFrame(frame, scene) {
var node = tpl.content.firstElementChild.cloneNode(true);
node.dataset.id = frame.id;
node.classList.add("pop");
node.querySelector(".fnum").textContent = "#" + String(globalIndex).padStart(2, "0");
node.querySelector(".dur-badge").textContent = frame.dur + "s";
var slug = node.querySelector(".slug");
slug.value = frame.slug;
slug.addEventListener("input", function () { frame.slug = slug.value; });
var shotB = node.querySelector(".badge.shot");
var moveB = node.querySelector(".badge.move");
var lensB = node.querySelector(".badge.lens");
shotB.textContent = frame.shot;
moveB.textContent = frame.move;
lensB.textContent = frame.lens;
node.querySelectorAll("[data-cycle]").forEach(function (b) {
b.addEventListener("click", function (e) {
var dir = e.shiftKey ? -1 : 1;
var kind = b.dataset.cycle;
if (kind === "shot") { frame.shot = cycle(SHOTS, frame.shot, dir); b.textContent = frame.shot; }
else if (kind === "move") { frame.move = cycle(MOVES, frame.move, dir); b.textContent = frame.move; }
else { frame.lens = cycle(LENSES, frame.lens, dir); b.textContent = frame.lens; }
});
});
var durIn = node.querySelector(".dur-input");
durIn.value = frame.dur;
durIn.addEventListener("input", function () {
var v = parseInt(durIn.value, 10);
if (isNaN(v) || v < 1) v = 1;
if (v > 600) v = 600;
frame.dur = v;
node.querySelector(".dur-badge").textContent = v + "s";
updateMeta();
});
durIn.addEventListener("blur", function () { durIn.value = frame.dur; });
node.querySelectorAll("[data-act]").forEach(function (b) {
b.addEventListener("click", function () {
var act = b.dataset.act;
var i = scene.frames.indexOf(frame);
if (act === "del") {
scene.frames.splice(i, 1);
toast("Frame removed");
} else if (act === "dup") {
var copy = Object.assign({}, frame, { id: nid() });
scene.frames.splice(i + 1, 0, copy);
toast("Frame duplicated");
} else if (act === "left" && i > 0) {
scene.frames.splice(i - 1, 0, scene.frames.splice(i, 1)[0]);
toast("Moved earlier");
} else if (act === "right" && i < scene.frames.length - 1) {
scene.frames.splice(i + 1, 0, scene.frames.splice(i, 1)[0]);
toast("Moved later");
} else { return; }
render();
});
});
globalIndex += 1;
return node;
}
function buildScene(scene, sIdx) {
var sec = document.createElement("section");
sec.className = "scene";
if (scene.collapsed) sec.classList.add("collapsed");
sec.dataset.id = scene.id;
var head = document.createElement("div");
head.className = "scene-head";
var caret = document.createElement("span");
caret.className = "scene-caret";
caret.innerHTML = '<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg>';
var idx = document.createElement("span");
idx.className = "scene-idx tc";
idx.textContent = "SC " + String(sIdx + 1).padStart(2, "0");
var title = document.createElement("input");
title.className = "scene-title";
title.value = scene.title;
title.setAttribute("aria-label", "Scene title");
title.addEventListener("input", function () { scene.title = title.value; });
title.addEventListener("click", function (e) { e.stopPropagation(); });
var totalDur = scene.frames.reduce(function (a, f) { return a + f.dur; }, 0);
var stat = document.createElement("span");
stat.className = "scene-stat";
stat.textContent = scene.frames.length + " shots · " + fmt(totalDur);
var addBtn = document.createElement("button");
addBtn.className = "scene-add";
addBtn.type = "button";
addBtn.textContent = "+";
addBtn.title = "Add frame to scene";
addBtn.setAttribute("aria-label", "Add frame to scene");
addBtn.addEventListener("click", function (e) {
e.stopPropagation();
scene.collapsed = false;
scene.frames.push({
id: nid(), slug: "New shot — describe the action",
shot: SHOTS[1], move: MOVES[0], lens: LENSES[3], dur: 3,
});
render();
toast("Frame added");
});
var delBtn = document.createElement("button");
delBtn.className = "scene-del";
delBtn.type = "button";
delBtn.textContent = "✕";
delBtn.title = "Delete scene";
delBtn.setAttribute("aria-label", "Delete scene");
delBtn.addEventListener("click", function (e) {
e.stopPropagation();
scenes = scenes.filter(function (s) { return s.id !== scene.id; });
render();
toast("Scene deleted");
});
head.appendChild(caret);
head.appendChild(idx);
head.appendChild(title);
head.appendChild(stat);
head.appendChild(addBtn);
head.appendChild(delBtn);
head.addEventListener("click", function () {
scene.collapsed = !scene.collapsed;
sec.classList.toggle("collapsed", scene.collapsed);
});
var frames = document.createElement("div");
frames.className = "frames";
scene.frames.forEach(function (f) { frames.appendChild(buildFrame(f, scene)); });
sec.appendChild(head);
sec.appendChild(frames);
return sec;
}
function buildTimeline() {
var track = document.getElementById("timelineTrack");
track.innerHTML = "";
var total = 0;
scenes.forEach(function (s) { s.frames.forEach(function (f) { total += f.dur; }); });
if (total === 0) return;
scenes.forEach(function (s, si) {
s.frames.forEach(function (f) {
var seg = document.createElement("div");
seg.className = "tl-seg" + (si % 2 ? " scene-alt" : "");
seg.style.flex = f.dur;
seg.title = f.slug + " — " + f.dur + "s";
if (f.dur >= 4) {
var sp = document.createElement("span");
sp.textContent = f.dur + "s";
seg.appendChild(sp);
}
track.appendChild(seg);
});
});
}
function updateMeta() {
var frames = 0, total = 0;
scenes.forEach(function (s) {
frames += s.frames.length;
s.frames.forEach(function (f) { total += f.dur; });
});
document.getElementById("totalFrames").textContent = frames + (frames === 1 ? " frame" : " frames");
document.getElementById("totalScenes").textContent = scenes.length + (scenes.length === 1 ? " scene" : " scenes");
document.getElementById("totalRuntime").textContent = fmt(total);
buildTimeline();
// refresh per-scene stat labels without full re-render
document.querySelectorAll(".scene").forEach(function (el, i) {
var s = scenes[i];
if (!s) return;
var td = s.frames.reduce(function (a, f) { return a + f.dur; }, 0);
var st = el.querySelector(".scene-stat");
if (st) st.textContent = s.frames.length + " shots · " + fmt(td);
});
}
function render() {
globalIndex = 1;
board.innerHTML = "";
scenes.forEach(function (s, i) { board.appendChild(buildScene(s, i)); });
updateMeta();
}
// Top-level actions
document.getElementById("addScene").addEventListener("click", function () {
scenes.push({
id: nid(), title: (scenes.length + 1) + ". New Scene",
frames: [{ id: nid(), slug: "Opening shot of the scene", shot: SHOTS[0], move: MOVES[0], lens: LENSES[2], dur: 4 }],
});
render();
toast("Scene added");
board.lastElementChild.scrollIntoView({ behavior: "smooth", block: "center" });
});
var allCollapsed = false;
document.getElementById("collapseAll").addEventListener("click", function (e) {
allCollapsed = !allCollapsed;
scenes.forEach(function (s) { s.collapsed = allCollapsed; });
e.target.textContent = allCollapsed ? "Expand all" : "Collapse all";
render();
});
render();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Storyboard — Shot Planner</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=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="app">
<!-- Hero with letterbox bars -->
<header class="hero" role="banner">
<div class="bar bar-top" aria-hidden="true"></div>
<div class="hero-inner">
<div class="hero-lead">
<span class="eyebrow"><span class="rec-dot" aria-hidden="true"></span> PRE-PRODUCTION</span>
<h1>Neon Harbor <span class="hero-sub">— Storyboard</span></h1>
<p class="hero-meta">
<span class="tc" id="totalFrames">0 frames</span>
<span class="dot" aria-hidden="true">•</span>
<span class="tc" id="totalScenes">0 scenes</span>
<span class="dot" aria-hidden="true">•</span>
<span class="tc runtime" id="totalRuntime">00:00</span> runtime
</p>
</div>
<div class="hero-actions">
<button class="btn ghost" id="collapseAll" type="button">Collapse all</button>
<button class="btn primary" id="addScene" type="button">
<svg viewBox="0 0 24 24" aria-hidden="true" width="16" height="16"><path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
New scene
</button>
</div>
</div>
<div class="bar bar-bottom" aria-hidden="true"></div>
</header>
<!-- Timeline strip -->
<div class="timeline" aria-label="Runtime timeline">
<div class="timeline-track" id="timelineTrack"></div>
</div>
<main id="board" class="board" aria-label="Storyboard scenes"></main>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<!-- Frame template -->
<template id="frameTpl">
<article class="frame" tabindex="0">
<div class="sketch">
<span class="fnum tc"></span>
<div class="sketch-art" aria-hidden="true">
<span class="crop tl"></span><span class="crop tr"></span>
<span class="crop bl"></span><span class="crop br"></span>
</div>
<span class="dur-badge tc"></span>
</div>
<div class="frame-body">
<input class="slug" aria-label="Shot description" />
<div class="badges">
<button class="badge shot" type="button" data-cycle="shot" aria-label="Cycle shot type"></button>
<button class="badge move" type="button" data-cycle="move" aria-label="Cycle camera move"></button>
<button class="badge lens" type="button" data-cycle="lens" aria-label="Cycle lens"></button>
</div>
<div class="frame-foot">
<label class="dur-field">
<span>DUR</span>
<input class="dur-input tc" type="number" min="1" max="600" step="1" aria-label="Duration in seconds" />
<span>s</span>
</label>
<div class="frame-ctrls">
<button class="ic" data-act="left" type="button" aria-label="Move frame left" title="Move left">‹</button>
<button class="ic" data-act="right" type="button" aria-label="Move frame right" title="Move right">›</button>
<button class="ic" data-act="dup" type="button" aria-label="Duplicate frame" title="Duplicate">⧉</button>
<button class="ic danger" data-act="del" type="button" aria-label="Delete frame" title="Delete">✕</button>
</div>
</div>
</div>
</article>
</template>
<script src="script.js"></script>
</body>
</html>A film-forward storyboard planner built for the pre-production phase. Each panel is a numbered board frame with a sketch placeholder, an editable slug line, and metadata badges covering shot type, camera move, lens and duration. Frames are grouped under collapsible scene headers so a director can block out an entire sequence at a glance, while a letterboxed hero and JetBrains Mono timecodes keep the whole board reading like a shot list on set.
The board is genuinely interactive: add a new frame to any scene, delete panels, and reorder them left or right with the move controls. Editing a duration instantly re-tallies the total runtime shown in the timeline strip, and cycling a frame’s shot type or camera move updates its badges in place. A small toast confirms every action so the workflow feels responsive.
Everything is vanilla HTML, CSS and JavaScript with no build step. The layout is fully responsive, collapsing to a single stacked column of frames below 520px, and all controls are keyboard-usable with visible focus rings that meet WCAG AA contrast against the cinematic dark surface.