Comics — Episode Upload / Panel Sequencer
A comic-creator episode-upload and panel-sequencer studio for the fictional series Neon Ronin, built with inked borders, halftone texture, and bold Bangers lettering. A dashed drop-zone simulates panel uploads, a reorderable vertical list of panel thumbnails supports drag-to-reorder plus up, down, and delete fallbacks, and each row carries its own caption and alt-text fields. Episode title, number, series, and visibility inputs drive a live reading-preview pane that mirrors the current order, while a publish button validates required fields and reports success or problems through a comic-styled toast.
MCP
コード
:root {
--ink: #0e0e12;
--ink-2: #23232b;
--paper: #fdfcf7;
--panel: #ffffff;
--accent: #ff2e4d;
--accent-2: #ffd23f;
--accent-blue: #2e6bff;
--muted: #6b6b78;
--line: rgba(14, 14, 18, 0.14);
--line-2: rgba(14, 14, 18, 0.28);
--halftone: radial-gradient(circle, rgba(14, 14, 18, 0.18) 1px, transparent 1.6px);
--r-sm: 6px;
--r-md: 12px;
--r-lg: 18px;
--shadow: 5px 5px 0 var(--ink);
--shadow-sm: 3px 3px 0 var(--ink);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
min-height: 100vh;
font-family: "Inter", system-ui, sans-serif;
line-height: 1.5;
color: var(--ink);
background-color: var(--paper);
background-image: var(--halftone);
background-size: 6px 6px;
}
.app {
max-width: 1160px;
margin: 0 auto;
padding: clamp(16px, 3vw, 32px);
}
/* ===== Topbar ===== */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
padding: 14px 18px;
background: var(--panel);
border: 3px solid var(--ink);
border-radius: var(--r-lg);
box-shadow: var(--shadow);
margin-bottom: 22px;
}
.brand {
display: flex;
align-items: center;
gap: 14px;
}
.brand__badge {
font-family: "Bangers", system-ui, sans-serif;
font-size: 26px;
letter-spacing: 1px;
color: var(--paper);
background: var(--accent);
border: 3px solid var(--ink);
border-radius: var(--r-sm);
padding: 2px 12px 0;
transform: rotate(-4deg);
box-shadow: var(--shadow-sm);
}
.brand__kicker {
display: block;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--muted);
}
.brand__title {
font-family: "Bangers", system-ui, sans-serif;
font-weight: 400;
font-size: clamp(26px, 4vw, 38px);
letter-spacing: 1.5px;
margin: 0;
line-height: 1;
}
.topbar__meta {
display: flex;
align-items: center;
gap: 14px;
}
.topbar__count {
font-size: 14px;
color: var(--muted);
}
.topbar__count strong {
color: var(--ink);
}
.chip {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 5px 12px;
border: 2px solid var(--ink);
border-radius: 999px;
background: var(--panel);
}
.chip--draft {
background: var(--accent-2);
}
.chip--live {
background: var(--accent-blue);
color: var(--paper);
}
.chip--num {
min-width: 30px;
text-align: center;
background: var(--ink);
color: var(--paper);
}
/* ===== Layout ===== */
.layout {
display: grid;
grid-template-columns: 1.35fr 1fr;
gap: 22px;
align-items: start;
}
.editor {
display: flex;
flex-direction: column;
gap: 22px;
min-width: 0;
}
/* ===== Cards ===== */
.card {
background: var(--panel);
border: 3px solid var(--ink);
border-radius: var(--r-lg);
box-shadow: var(--shadow);
padding: 18px;
}
.card__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.card__title {
font-family: "Bangers", system-ui, sans-serif;
font-weight: 400;
font-size: 22px;
letter-spacing: 1px;
margin: 0 0 14px;
}
.card__head .card__title {
margin: 0;
}
.muted {
font-family: "Inter", sans-serif;
font-size: 12px;
font-weight: 500;
color: var(--muted);
text-transform: none;
letter-spacing: 0;
}
/* ===== Fields ===== */
.grid {
display: grid;
gap: 14px;
}
.grid--2 {
grid-template-columns: 1fr 1fr;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
margin: 0;
padding: 0;
border: 0;
min-width: 0;
}
.field--wide {
grid-column: 1 / -1;
}
.field--mini {
gap: 4px;
}
.field__label {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--ink-2);
}
.req {
color: var(--accent);
}
.input {
width: 100%;
font: inherit;
color: var(--ink);
background: var(--paper);
border: 2px solid var(--ink);
border-radius: var(--r-sm);
padding: 9px 11px;
transition: box-shadow 0.12s ease, transform 0.12s ease;
}
.input--sm {
padding: 7px 9px;
font-size: 13px;
}
.input::placeholder {
color: var(--muted);
}
.input:focus-visible {
outline: none;
box-shadow: var(--shadow-sm);
transform: translate(-1px, -1px);
}
.input.invalid {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(255, 46, 77, 0.18);
}
/* segmented visibility */
.visibility {
gap: 6px;
}
.seg {
display: inline-flex;
border: 2px solid var(--ink);
border-radius: var(--r-sm);
overflow: hidden;
width: fit-content;
}
.seg__opt {
position: relative;
}
.seg__opt input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.seg__opt span {
display: block;
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border-right: 2px solid var(--ink);
background: var(--panel);
}
.seg__opt:last-child span {
border-right: 0;
}
.seg__opt input:checked + span {
background: var(--ink);
color: var(--paper);
}
.seg__opt input:focus-visible + span {
box-shadow: inset 0 0 0 3px var(--accent-2);
}
/* ===== Dropzone ===== */
.dropzone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
width: 100%;
min-height: 130px;
padding: 20px;
cursor: pointer;
font: inherit;
text-align: center;
color: var(--ink);
background-color: var(--paper);
background-image: var(--halftone);
background-size: 6px 6px;
border: 3px dashed var(--ink);
border-radius: var(--r-md);
transition: transform 0.12s ease, background-color 0.12s ease, border-color 0.12s ease;
}
.dropzone:hover {
transform: translate(-2px, -2px);
border-color: var(--accent);
}
.dropzone:focus-visible {
outline: none;
box-shadow: var(--shadow-sm);
}
.dropzone.dragover {
background-color: var(--accent-2);
border-color: var(--accent);
transform: scale(1.01);
}
.dropzone__sfx {
font-family: "Bangers", system-ui, sans-serif;
font-size: 34px;
letter-spacing: 2px;
color: var(--accent);
-webkit-text-stroke: 2px var(--ink);
paint-order: stroke fill;
line-height: 1;
}
.dropzone__main {
font-weight: 800;
font-size: 16px;
}
.dropzone__hint {
font-size: 13px;
color: var(--muted);
}
.seedrow {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 14px;
}
/* ===== Buttons ===== */
.btn {
font: inherit;
font-weight: 700;
cursor: pointer;
border: 2px solid var(--ink);
border-radius: var(--r-sm);
padding: 9px 16px;
background: var(--panel);
color: var(--ink);
box-shadow: var(--shadow-sm);
transition: transform 0.1s ease, box-shadow 0.1s ease, background 0.12s ease;
}
.btn:hover {
transform: translate(-1px, -1px);
box-shadow: 4px 4px 0 var(--ink);
}
.btn:active {
transform: translate(2px, 2px);
box-shadow: 1px 1px 0 var(--ink);
}
.btn:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--accent-2), var(--shadow-sm);
}
.btn--ghost {
font-size: 13px;
padding: 8px 13px;
box-shadow: none;
background: var(--paper);
}
.btn--ghost:hover {
background: var(--accent-2);
box-shadow: var(--shadow-sm);
}
.btn--danger {
border-color: var(--accent);
color: var(--accent);
}
.btn--danger:hover {
background: var(--accent);
color: var(--paper);
}
.btn--publish {
width: 100%;
margin-top: 16px;
padding: 14px;
font-family: "Bangers", system-ui, sans-serif;
font-weight: 400;
font-size: 22px;
letter-spacing: 1.5px;
color: var(--paper);
background: var(--accent);
box-shadow: var(--shadow);
}
.btn--publish:hover {
box-shadow: 6px 6px 0 var(--ink);
}
/* ===== Panel list ===== */
.panellist {
list-style: none;
margin: 14px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.panel {
display: grid;
grid-template-columns: 22px 88px 1fr;
gap: 12px;
align-items: stretch;
padding: 12px;
background: var(--paper);
border: 2px solid var(--ink);
border-radius: var(--r-md);
box-shadow: var(--shadow-sm);
cursor: grab;
transition: transform 0.12s ease, box-shadow 0.12s ease, opacity 0.12s ease;
}
.panel:hover {
transform: translate(-1px, -1px);
box-shadow: 4px 4px 0 var(--ink);
}
.panel.dragging {
opacity: 0.5;
cursor: grabbing;
border-style: dashed;
}
.panel.drop-target {
box-shadow: 0 -4px 0 -1px var(--accent), 4px 4px 0 var(--ink);
}
.panel.flash {
animation: flash 0.4s ease;
}
@keyframes flash {
0% {
background: var(--accent-2);
}
100% {
background: var(--paper);
}
}
.panel__handle {
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
align-items: center;
}
.panel__handle span {
width: 14px;
height: 2px;
background: var(--line-2);
border-radius: 2px;
}
.panel__thumb {
position: relative;
display: flex;
align-items: flex-end;
justify-content: flex-start;
padding: 6px;
border: 2px solid var(--ink);
border-radius: var(--r-sm);
background-color: var(--ink-2);
background-image: var(--halftone);
background-size: 6px 6px;
overflow: hidden;
}
.panel__idx {
position: absolute;
top: 4px;
left: 4px;
font-family: "Bangers", system-ui, sans-serif;
font-size: 18px;
line-height: 1;
color: var(--ink);
background: var(--accent-2);
border: 2px solid var(--ink);
border-radius: 4px;
padding: 1px 6px 0;
}
.panel__sfx {
font-family: "Bangers", system-ui, sans-serif;
font-size: 20px;
letter-spacing: 1px;
color: var(--accent-2);
-webkit-text-stroke: 1px var(--ink);
paint-order: stroke fill;
transform: rotate(-6deg);
}
.panel__body {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.panel__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.panel__name {
font-weight: 700;
font-size: 13px;
}
.panel__moves {
display: flex;
gap: 4px;
}
.iconbtn {
font: inherit;
font-weight: 700;
font-size: 13px;
width: 26px;
height: 26px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
border: 2px solid var(--ink);
border-radius: 5px;
background: var(--panel);
color: var(--ink);
transition: background 0.12s ease, transform 0.1s ease;
}
.iconbtn:hover {
background: var(--accent-2);
}
.iconbtn:active {
transform: scale(0.92);
}
.iconbtn:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--accent-2);
}
.iconbtn--del:hover {
background: var(--accent);
color: var(--paper);
}
.iconbtn:disabled {
opacity: 0.35;
cursor: not-allowed;
background: var(--panel);
}
.empty {
color: var(--muted);
font-size: 14px;
text-align: center;
padding: 18px 8px;
margin: 0;
}
/* ===== Preview ===== */
.preview {
position: sticky;
top: 18px;
display: flex;
flex-direction: column;
background: var(--panel);
border: 3px solid var(--ink);
border-radius: var(--r-lg);
box-shadow: var(--shadow);
padding: 16px;
}
.preview__bar {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 12px;
border-bottom: 2px solid var(--line);
}
.preview__sfx {
font-family: "Bangers", system-ui, sans-serif;
font-size: 22px;
letter-spacing: 1px;
color: var(--paper);
background: var(--accent-blue);
border: 2px solid var(--ink);
border-radius: 5px;
padding: 1px 9px 0;
transform: rotate(-3deg);
}
.preview__titles {
display: flex;
flex-direction: column;
line-height: 1.2;
min-width: 0;
}
.preview__series {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--muted);
}
.preview__ep {
font-family: "Bangers", system-ui, sans-serif;
font-size: 20px;
letter-spacing: 0.5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.reader {
margin-top: 14px;
height: 460px;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 14px;
background-color: var(--ink);
background-image: radial-gradient(circle, rgba(253, 252, 247, 0.08) 1px, transparent 1.6px);
background-size: 6px 6px;
border: 2px solid var(--ink);
border-radius: var(--r-md);
scroll-behavior: smooth;
}
.reader:focus-visible {
outline: 3px solid var(--accent-2);
outline-offset: 2px;
}
.reader__empty {
margin: auto;
text-align: center;
color: rgba(253, 252, 247, 0.6);
font-size: 14px;
}
.rpanel {
position: relative;
min-height: 120px;
display: flex;
align-items: center;
justify-content: center;
padding: 14px;
background-color: var(--paper);
background-image: var(--halftone);
background-size: 6px 6px;
border: 3px solid #fdfcf7;
border-radius: var(--r-sm);
box-shadow: 0 4px 0 rgba(0, 0, 0, 0.5);
}
.rpanel__num {
position: absolute;
top: 6px;
left: 6px;
font-family: "Bangers", system-ui, sans-serif;
font-size: 16px;
line-height: 1;
color: var(--ink);
background: var(--accent-2);
border: 2px solid var(--ink);
border-radius: 4px;
padding: 1px 6px 0;
}
.rpanel__balloon {
position: relative;
max-width: 92%;
background: var(--panel);
border: 2px solid var(--ink);
border-radius: 16px;
padding: 10px 14px;
font-size: 13px;
font-weight: 500;
box-shadow: var(--shadow-sm);
}
.rpanel__balloon::after {
content: "";
position: absolute;
bottom: -10px;
left: 24px;
width: 14px;
height: 14px;
background: var(--panel);
border-right: 2px solid var(--ink);
border-bottom: 2px solid var(--ink);
transform: rotate(45deg);
}
.rpanel__placeholder {
font-family: "Bangers", system-ui, sans-serif;
font-size: 30px;
letter-spacing: 2px;
color: var(--accent);
-webkit-text-stroke: 2px var(--ink);
paint-order: stroke fill;
transform: rotate(-5deg);
}
/* ===== Toast ===== */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 140%);
max-width: min(90vw, 380px);
padding: 12px 18px;
font-weight: 700;
font-size: 14px;
color: var(--paper);
background: var(--ink);
border: 2px solid var(--ink);
border-radius: var(--r-md);
box-shadow: var(--shadow);
opacity: 0;
pointer-events: none;
transition: transform 0.28s cubic-bezier(0.2, 1.2, 0.4, 1), opacity 0.28s ease;
z-index: 50;
}
.toast.show {
transform: translate(-50%, 0);
opacity: 1;
}
.toast--err {
background: var(--accent);
}
.toast--ok {
background: var(--accent-blue);
}
/* ===== Responsive ===== */
@media (max-width: 880px) {
.layout {
grid-template-columns: 1fr;
}
.preview {
position: static;
}
.reader {
height: 380px;
}
}
@media (max-width: 520px) {
.app {
padding: 14px;
}
.grid--2 {
grid-template-columns: 1fr;
}
.topbar {
padding: 12px;
}
.brand__title {
font-size: 26px;
}
.panel {
grid-template-columns: 18px 64px 1fr;
gap: 9px;
padding: 10px;
}
.panel__thumb {
min-height: 78px;
}
.seg {
width: 100%;
}
.seg__opt {
flex: 1;
}
.seg__opt span {
text-align: center;
padding: 8px 6px;
}
.seedrow .btn {
flex: 1;
}
.btn--publish {
font-size: 20px;
}
}(function () {
"use strict";
// ---- fictional flavour pools ----
var SFX = ["POW", "BAM", "WHAM", "ZAP", "KRAK", "BOOM", "SLASH", "FWOOM", "THWIP"];
var SCENES = [
"Rooftop chase under neon rain",
"Close-up: the Ronin's masked eyes",
"Wide shot of the flooded Lowtown market",
"The Iron Vanguard kicks the door in",
"Silhouette draws a humming blade",
"Spark of recognition between rivals",
"Splash page: city skyline at dawn",
"Hand reaching for a dropped data-chip",
];
var CAPTIONS = [
"Rain never stopped in Lowtown.",
"Three years since the Sundering.",
"She knew that mask anywhere.",
"“You shouldn't have come back.”",
"The blade remembered everything.",
"",
];
// ---- state ----
var panels = []; // { id, sfx, scene, caption, alt }
var nextId = 1;
var dragId = null;
// ---- elements ----
var $ = function (id) { return document.getElementById(id); };
var listEl = $("panelList");
var emptyEl = $("emptyState");
var tpl = $("panelTpl");
var readerEl = $("reader");
var readerEmpty = $("readerEmpty");
var toastEl = $("toast");
var statusChip = $("statusChip");
var titleField = $("titleField");
var seriesField = $("seriesField");
var numberField = $("numberField");
// ---- toast helper ----
var toastTimer = null;
function toast(msg, kind) {
toastEl.textContent = msg;
toastEl.className = "toast show" + (kind ? " toast--" + kind : "");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.className = "toast";
}, 2600);
}
function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
// ---- mutations ----
function addPanel(opts) {
opts = opts || {};
panels.push({
id: nextId++,
sfx: opts.sfx || pick(SFX),
scene: opts.scene || pick(SCENES),
caption: opts.caption != null ? opts.caption : pick(CAPTIONS),
alt: opts.alt || "",
});
}
function removePanel(id) {
panels = panels.filter(function (p) { return p.id !== id; });
}
function indexOfId(id) {
for (var i = 0; i < panels.length; i++) if (panels[i].id === id) return i;
return -1;
}
function move(id, dir) {
var i = indexOfId(id);
var j = i + dir;
if (i < 0 || j < 0 || j >= panels.length) return;
var tmp = panels[i];
panels[i] = panels[j];
panels[j] = tmp;
}
function reorder(fromId, toId) {
var from = indexOfId(fromId);
var to = indexOfId(toId);
if (from < 0 || to < 0 || from === to) return;
var moved = panels.splice(from, 1)[0];
panels.splice(to, 0, moved);
}
// ---- rendering ----
function render() {
renderList();
renderPreview();
syncCounts();
}
function renderList() {
listEl.innerHTML = "";
emptyEl.style.display = panels.length ? "none" : "block";
panels.forEach(function (p, i) {
var node = tpl.content.firstElementChild.cloneNode(true);
node.dataset.id = String(p.id);
node.querySelector(".panel__idx").textContent = i + 1;
node.querySelector(".panel__sfx").textContent = p.sfx + "!";
node.querySelector(".panel__name").textContent = "Panel " + (i + 1) + " · " + p.scene;
var capInput = node.querySelector('[data-field="caption"]');
var altInput = node.querySelector('[data-field="alt"]');
capInput.value = p.caption;
altInput.value = p.alt;
capInput.addEventListener("input", function () {
p.caption = capInput.value;
renderPreview();
});
altInput.addEventListener("input", function () {
p.alt = altInput.value;
altInput.classList.remove("invalid");
});
// move / delete buttons
node.querySelectorAll("[data-act]").forEach(function (btn) {
var act = btn.getAttribute("data-act");
if (act === "up") btn.disabled = i === 0;
if (act === "down") btn.disabled = i === panels.length - 1;
btn.addEventListener("click", function () {
if (act === "del") {
removePanel(p.id);
toast("Panel removed.");
render();
} else {
move(p.id, act === "up" ? -1 : 1);
render();
flash(p.id);
}
});
});
// drag to reorder
node.addEventListener("dragstart", function (e) {
dragId = p.id;
node.classList.add("dragging");
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", String(p.id));
}
});
node.addEventListener("dragend", function () {
node.classList.remove("dragging");
dragId = null;
clearDropTargets();
});
node.addEventListener("dragover", function (e) {
e.preventDefault();
if (dragId == null || dragId === p.id) return;
clearDropTargets();
node.classList.add("drop-target");
});
node.addEventListener("dragleave", function () {
node.classList.remove("drop-target");
});
node.addEventListener("drop", function (e) {
e.preventDefault();
node.classList.remove("drop-target");
if (dragId == null) return;
reorder(dragId, p.id);
render();
flash(dragId);
});
listEl.appendChild(node);
});
}
function clearDropTargets() {
var ts = listEl.querySelectorAll(".drop-target");
for (var i = 0; i < ts.length; i++) ts[i].classList.remove("drop-target");
}
function flash(id) {
var el = listEl.querySelector('.panel[data-id="' + id + '"]');
if (!el) return;
el.classList.remove("flash");
void el.offsetWidth; // reflow to restart animation
el.classList.add("flash");
}
function renderPreview() {
$("previewSeries").textContent = seriesField.value.trim() || "Untitled Series";
var num = numberField.value.trim();
var t = titleField.value.trim() || "Untitled episode";
$("previewTitle").textContent = (num ? "#" + num + " · " : "") + t;
if (!panels.length) {
readerEl.innerHTML = "";
readerEl.appendChild(readerEmpty);
readerEmpty.style.display = "block";
return;
}
readerEl.innerHTML = "";
panels.forEach(function (p, i) {
var fig = document.createElement("figure");
fig.className = "rpanel";
var num = document.createElement("span");
num.className = "rpanel__num";
num.textContent = i + 1;
fig.appendChild(num);
if (p.caption && p.caption.trim()) {
var balloon = document.createElement("div");
balloon.className = "rpanel__balloon";
balloon.textContent = p.caption.trim();
fig.appendChild(balloon);
} else {
var ph = document.createElement("span");
ph.className = "rpanel__placeholder";
ph.textContent = p.sfx + "!";
fig.appendChild(ph);
}
var cap = document.createElement("figcaption");
cap.className = "sr-only";
cap.style.position = "absolute";
cap.style.width = "1px";
cap.style.height = "1px";
cap.style.overflow = "hidden";
cap.style.clip = "rect(0 0 0 0)";
cap.textContent = p.alt || p.scene;
fig.setAttribute("role", "img");
fig.setAttribute("aria-label", p.alt || p.scene);
fig.appendChild(cap);
readerEl.appendChild(fig);
});
}
function syncCounts() {
var n = panels.length;
$("panelCountTop").textContent = n;
$("panelCountList").textContent = n;
}
// ---- validation + publish ----
function publish() {
var problems = [];
if (!titleField.value.trim()) {
titleField.classList.add("invalid");
problems.push("an episode title");
} else {
titleField.classList.remove("invalid");
}
if (!panels.length) {
problems.push("at least one panel");
}
var missingAlt = 0;
listEl.querySelectorAll('[data-field="alt"]').forEach(function (inp) {
if (!inp.value.trim()) {
inp.classList.add("invalid");
missingAlt++;
}
});
if (missingAlt) {
problems.push(missingAlt + " panel" + (missingAlt > 1 ? "s" : "") + " missing alt text");
}
if (problems.length) {
toast("Can't publish yet — add " + problems.join(", ") + ".", "err");
return;
}
var vis = document.querySelector('input[name="vis"]:checked');
statusChip.textContent = "Published";
statusChip.className = "chip chip--live";
toast(
"“" + titleField.value.trim() + "” published — " +
panels.length + " panels, " + (vis ? vis.value : "public") + ".",
"ok"
);
}
function markDraft() {
if (statusChip.textContent !== "Draft") {
statusChip.textContent = "Draft";
statusChip.className = "chip chip--draft";
}
}
// ---- wire up controls ----
$("dropzone").addEventListener("click", function () {
addPanel();
render();
flash(panels[panels.length - 1].id);
toast("Panel added.");
markDraft();
});
// simulate real drag-drop file upload
var dz = $("dropzone");
["dragenter", "dragover"].forEach(function (ev) {
dz.addEventListener(ev, function (e) {
e.preventDefault();
dz.classList.add("dragover");
});
});
["dragleave", "drop"].forEach(function (ev) {
dz.addEventListener(ev, function (e) {
e.preventDefault();
dz.classList.remove("dragover");
});
});
dz.addEventListener("drop", function (e) {
var count = e.dataTransfer && e.dataTransfer.files ? e.dataTransfer.files.length : 0;
count = count || 2; // simulate at least a couple
for (var i = 0; i < count; i++) addPanel();
render();
toast(count + " panel" + (count > 1 ? "s" : "") + " added.");
markDraft();
});
$("addOneBtn").addEventListener("click", function () {
addPanel();
render();
flash(panels[panels.length - 1].id);
markDraft();
});
$("addBatchBtn").addEventListener("click", function () {
for (var i = 0; i < 3; i++) addPanel();
render();
toast("3 panels added.");
markDraft();
});
$("clearBtn").addEventListener("click", function () {
if (!panels.length) {
toast("Nothing to clear.");
return;
}
panels = [];
render();
toast("All panels cleared.");
markDraft();
});
$("publishBtn").addEventListener("click", publish);
[titleField, seriesField, numberField].forEach(function (f) {
f.addEventListener("input", function () {
renderPreview();
titleField.classList.remove("invalid");
markDraft();
});
});
document.querySelectorAll('input[name="vis"]').forEach(function (r) {
r.addEventListener("change", markDraft);
});
// ---- seed with a small starter sequence ----
addPanel({ sfx: "KRAK", scene: "Rooftop chase under neon rain", caption: "Rain never stopped in Lowtown.", alt: "A masked figure leaps across wet rooftops under neon signs." });
addPanel({ sfx: "SLASH", scene: "Silhouette draws a humming blade", caption: "", alt: "Close-up of a glowing blade being drawn from its sheath." });
addPanel({ sfx: "BOOM", scene: "The Iron Vanguard kicks the door in", caption: "“You shouldn't have come back.”", alt: "An armored figure smashes through a steel door, sparks flying." });
render();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Neon Ronin — Episode Upload / Panel Sequencer</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=Bangers&family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="app">
<!-- ===== Header ===== -->
<header class="topbar">
<div class="brand">
<span class="brand__badge" aria-hidden="true">INK</span>
<div class="brand__text">
<span class="brand__kicker">Creator Studio</span>
<h1 class="brand__title">Panel Sequencer</h1>
</div>
</div>
<div class="topbar__meta">
<span class="chip chip--draft" id="statusChip">Draft</span>
<span class="topbar__count"><strong id="panelCountTop">0</strong> panels</span>
</div>
</header>
<main class="layout">
<!-- ===== Left: editor ===== -->
<section class="editor" aria-label="Episode editor">
<!-- Episode details -->
<div class="card">
<h2 class="card__title">Episode details</h2>
<div class="grid grid--2">
<label class="field field--wide">
<span class="field__label">Series</span>
<input class="input" id="seriesField" type="text" value="Neon Ronin" />
</label>
<label class="field">
<span class="field__label">Episode #</span>
<input class="input" id="numberField" type="number" min="1" value="7" />
</label>
<label class="field field--wide">
<span class="field__label">Episode title <span class="req">*</span></span>
<input
class="input"
id="titleField"
type="text"
placeholder="e.g. Ghost in the Rainline"
value="Ghost in the Rainline"
/>
</label>
<fieldset class="field field--wide visibility">
<legend class="field__label">Visibility</legend>
<div class="seg" role="radiogroup" aria-label="Episode visibility">
<label class="seg__opt">
<input type="radio" name="vis" value="public" checked />
<span>Public</span>
</label>
<label class="seg__opt">
<input type="radio" name="vis" value="members" />
<span>Members</span>
</label>
<label class="seg__opt">
<input type="radio" name="vis" value="private" />
<span>Private</span>
</label>
</div>
</fieldset>
</div>
</div>
<!-- Drop zone -->
<div class="card">
<h2 class="card__title">Panels</h2>
<button
type="button"
class="dropzone"
id="dropzone"
aria-label="Add panels — click or drop image files"
>
<span class="dropzone__sfx" aria-hidden="true">DROP!</span>
<span class="dropzone__main">Click to add panels</span>
<span class="dropzone__hint">or drop image files here · simulated upload</span>
</button>
<div class="seedrow">
<button type="button" class="btn btn--ghost" id="addOneBtn">+ Add one panel</button>
<button type="button" class="btn btn--ghost" id="addBatchBtn">+ Add 3 panels</button>
<button type="button" class="btn btn--ghost btn--danger" id="clearBtn">
Clear all
</button>
</div>
</div>
<!-- Panel list -->
<div class="card">
<div class="card__head">
<h2 class="card__title">Sequence <span class="muted">(drag to reorder)</span></h2>
<span class="chip chip--num"><span id="panelCountList">0</span></span>
</div>
<ul class="panellist" id="panelList" aria-label="Panel sequence"></ul>
<p class="empty" id="emptyState">
No panels yet — add some above to start sequencing your episode.
</p>
</div>
</section>
<!-- ===== Right: live preview ===== -->
<aside class="preview" aria-label="Reading preview">
<div class="preview__bar">
<span class="preview__sfx" aria-hidden="true">LIVE</span>
<div class="preview__titles">
<span class="preview__series" id="previewSeries">Neon Ronin</span>
<span class="preview__ep" id="previewTitle">Episode</span>
</div>
</div>
<div class="reader" id="reader" tabindex="0" aria-live="polite">
<p class="reader__empty" id="readerEmpty">Your panels will read here, top to bottom.</p>
</div>
<button type="button" class="btn btn--publish" id="publishBtn">
Publish episode
</button>
</aside>
</main>
</div>
<!-- toast -->
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<!-- per-panel row template -->
<template id="panelTpl">
<li class="panel" draggable="true">
<div class="panel__handle" aria-hidden="true">
<span></span><span></span><span></span>
</div>
<div class="panel__thumb">
<span class="panel__idx">1</span>
<span class="panel__sfx"></span>
</div>
<div class="panel__body">
<div class="panel__row">
<span class="panel__name"></span>
<div class="panel__moves">
<button type="button" class="iconbtn" data-act="up" aria-label="Move panel up">↑</button>
<button type="button" class="iconbtn" data-act="down" aria-label="Move panel down">↓</button>
<button type="button" class="iconbtn iconbtn--del" data-act="del" aria-label="Remove panel">✕</button>
</div>
</div>
<label class="field field--mini">
<span class="field__label">Caption</span>
<input class="input input--sm" data-field="caption" type="text" placeholder="On-panel caption…" />
</label>
<label class="field field--mini">
<span class="field__label">Alt text <span class="req">*</span></span>
<input class="input input--sm" data-field="alt" type="text" placeholder="Describe the art for screen readers…" />
</label>
</div>
</li>
</template>
<script src="script.js"></script>
</body>
</html>Episode Upload / Panel Sequencer
A creator-side studio for assembling an episode of the fictional manga-noir series Neon Ronin. Episode details sit up top — series name, episode number, a required title, and a segmented Public / Members / Private visibility control. A thick dashed drop-zone with a bold DROP! SFX simulates uploading art: click it (or actually drag image files over it) to push placeholder panels into the sequence, and quick-add buttons drop one or three panels at a time.
The sequence is a vertical list of inked panel cards, each with a halftone thumbnail, an index badge, an auto-generated scene name, and its own caption and alt-text fields. Reorder panels by dragging — a drop indicator shows where they’ll land — or use the ↑ / ↓ buttons as a keyboard-friendly fallback, with ✕ to remove. Every edit re-flows the panel numbers and flashes the moved card so the change is obvious.
On the right, a sticky reading-preview pane renders the panels top-to-bottom exactly as a reader would scroll them: captions become tailed speech balloons, empty panels show their SFX placeholder, and the preview header tracks the live series, number, and title. Each preview panel exposes its alt text as an aria-label. The Publish button validates that there’s a title, at least one panel, and alt text on every panel — flagging missing fields inline and surfacing the result (success or what’s still needed) through a comic-styled toast().
Illustrative UI only — fictional series, characters, and data.