News — Front-page Layout Builder
A newsroom-style front-page builder for a fictional broadsheet, pairing a left story queue of draggable cards with a right canvas of slot zones — Lead, two Secondary wells, an analysis Sidebar and an auto-composing Briefs strip. Drop a story into any slot and it renders live as editorial copy, with headline size, drop caps and pull quotes scaling to the slot; a desktop, tablet and mobile width toggle reflows the grid, and a Publish action confirms the edition with a toast. Vanilla JS, no libraries.
MCP
Codice
:root {
--cream: #f4efe4;
--paper: #faf7f0;
--white: #ffffff;
--newsprint: #efe9da;
--ink: #16130f;
--ink-2: #2b2620;
--ink-3: #4a443b;
--muted: #7a7164;
--red: #b4291f;
--red-d: #8f1f17;
--red-50: #f3dcd9;
--rule: rgba(22, 19, 15, 0.16);
--rule-2: rgba(22, 19, 15, 0.30);
--rule-hair: rgba(22, 19, 15, 0.10);
--ok: #2f7d4f;
--warn: #b67a18;
--danger: #b4291f;
--r-sm: 4px;
--r-md: 8px;
--r-lg: 12px;
--serif: "Playfair Display", "Times New Roman", Georgia, serif;
--sans: "Inter", system-ui, -apple-system, sans-serif;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font-family: var(--sans);
line-height: 1.5;
color: var(--ink);
background-color: var(--cream);
background-image:
radial-gradient(circle at 18% 0%, rgba(180, 41, 31, 0.05), transparent 42%),
radial-gradient(circle at 100% 100%, rgba(22, 19, 15, 0.05), transparent 48%);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100vh;
}
.skip-link {
position: absolute;
left: -999px;
top: 8px;
z-index: 100;
background: var(--ink);
color: var(--paper);
padding: 8px 14px;
border-radius: var(--r-sm);
font-size: 13px;
}
.skip-link:focus { left: 12px; }
kbd {
font-family: var(--sans);
font-size: 11px;
font-weight: 600;
background: var(--white);
border: 1px solid var(--rule);
border-bottom-width: 2px;
border-radius: var(--r-sm);
padding: 1px 5px;
}
/* ============ APP BAR ============ */
.appbar {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 22px;
background: var(--ink);
color: var(--cream);
border-bottom: 3px solid var(--red);
position: sticky;
top: 0;
z-index: 30;
}
.appbar__brand { display: flex; flex-direction: column; line-height: 1.1; min-width: 0; }
.appbar__kicker {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.22em;
text-transform: uppercase;
color: #d8b6b1;
}
.appbar__title { font-family: var(--serif); font-weight: 800; font-size: 19px; }
.appbar__center {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
margin-right: auto;
font-size: 12px;
letter-spacing: 0.04em;
color: #cfc6b6;
}
.appbar__edition { text-transform: uppercase; letter-spacing: 0.14em; font-size: 11px; }
.dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--red);
box-shadow: 0 0 0 0 rgba(180, 41, 31, 0.6);
animation: pulse 2.4s infinite;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(180, 41, 31, 0.55); }
70% { box-shadow: 0 0 0 7px rgba(180, 41, 31, 0); }
100% { box-shadow: 0 0 0 0 rgba(180, 41, 31, 0); }
}
.appbar__actions { display: flex; align-items: center; gap: 10px; }
.seg {
display: inline-flex;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: var(--r-sm);
padding: 2px;
}
.seg__btn {
font-family: var(--sans);
font-size: 12px;
font-weight: 500;
color: #cfc6b6;
background: transparent;
border: 0;
padding: 5px 12px;
border-radius: 3px;
cursor: pointer;
}
.seg__btn:hover { color: var(--white); }
.seg__btn.is-active { background: var(--cream); color: var(--ink); font-weight: 600; }
.seg__btn:focus-visible { outline: 2px solid var(--red); outline-offset: 1px; }
.btn {
font-family: var(--sans);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.02em;
padding: 8px 16px;
border-radius: var(--r-sm);
border: 1px solid transparent;
cursor: pointer;
transition: transform 0.08s ease, background 0.15s ease;
}
.btn:active { transform: translateY(1px); }
.btn:focus-visible { outline: 2px solid var(--red); outline-offset: 2px; }
.btn--ghost {
background: transparent;
color: var(--cream);
border-color: rgba(255, 255, 255, 0.28);
}
.btn--ghost:hover { background: rgba(255, 255, 255, 0.1); }
.btn--red { background: var(--red); color: #fff; }
.btn--red:hover { background: var(--red-d); }
/* ============ SHELL ============ */
.shell {
display: grid;
grid-template-columns: 312px 1fr;
gap: 0;
align-items: start;
}
/* ============ PALETTE ============ */
.palette {
border-right: 1px solid var(--rule);
padding: 20px 18px 40px;
position: sticky;
top: 60px;
align-self: start;
height: calc(100vh - 60px);
overflow-y: auto;
background:
linear-gradient(var(--newsprint), var(--newsprint));
}
.palette__head {
display: flex;
align-items: baseline;
justify-content: space-between;
border-bottom: 2px solid var(--ink);
padding-bottom: 8px;
}
.palette__title {
margin: 0;
font-family: var(--serif);
font-weight: 800;
font-size: 22px;
}
.palette__count {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--red);
}
.palette__hint {
font-size: 12px;
color: var(--ink-3);
margin: 10px 0 16px;
line-height: 1.45;
}
.queue { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.card {
background: var(--white);
border: 1px solid var(--rule);
border-left: 3px solid var(--ink);
border-radius: var(--r-sm);
padding: 11px 12px 12px;
cursor: grab;
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease;
position: relative;
}
.card:hover { border-left-color: var(--red); box-shadow: 0 2px 0 var(--rule-hair); }
.card:focus-visible { outline: 2px solid var(--red); outline-offset: 2px; }
.card.is-dragging { opacity: 0.45; }
.card.is-placed { opacity: 0.5; border-left-color: var(--muted); cursor: default; }
.card[data-section="Politics"] { border-left-color: var(--red); }
.card[data-section="Business"] { border-left-color: #1f5d8f; }
.card[data-section="Climate"] { border-left-color: var(--ok); }
.card[data-section="Culture"] { border-left-color: var(--warn); }
.card[data-section="Sport"] { border-left-color: var(--ink-2); }
.card__kicker {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--red);
display: block;
margin-bottom: 4px;
}
.card[data-section="Business"] .card__kicker { color: #1f5d8f; }
.card[data-section="Climate"] .card__kicker { color: var(--ok); }
.card[data-section="Culture"] .card__kicker { color: var(--warn); }
.card[data-section="Sport"] .card__kicker { color: var(--ink-2); }
.card__head {
font-family: var(--serif);
font-weight: 700;
font-size: 16px;
line-height: 1.18;
margin: 0 0 6px;
color: var(--ink);
}
.card__meta {
font-size: 11px;
color: var(--muted);
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.card__meta b { color: var(--ink-3); font-weight: 600; }
.card__placed {
position: absolute;
top: 8px; right: 8px;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
display: none;
}
.card.is-placed .card__placed { display: inline; }
/* ============ STAGE ============ */
.stage { padding: 22px 26px 60px; min-width: 0; }
.stage__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 16px;
gap: 12px;
}
.stage__meta { margin: 0; font-size: 13px; color: var(--ink-3); }
.stage__meta strong { font-family: var(--serif); font-size: 16px; color: var(--ink); }
.stage__note {
margin: 0;
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
}
/* ============ THE PAPER ============ */
.paper {
background: var(--paper);
border: 1px solid var(--rule-2);
box-shadow: 0 1px 0 var(--rule), 0 14px 36px -22px rgba(22, 19, 15, 0.5);
padding: 26px 30px 20px;
margin: 0 auto;
max-width: 1080px;
transition: max-width 0.3s ease;
}
.paper[data-device="tablet"] { max-width: 680px; }
.paper[data-device="mobile"] { max-width: 380px; padding: 18px 18px 14px; }
/* masthead */
.masthead { text-align: center; }
.masthead__rule { border-top: 1px solid var(--ink); }
.masthead__rule--top { border-top-width: 3px; }
.masthead__rule--bottom { border-top-width: 2px; margin-top: 4px; }
.masthead__meta,
.masthead__sub {
display: flex;
justify-content: space-between;
font-size: 10px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--ink-3);
padding: 5px 2px;
}
.masthead__price { color: var(--red); font-weight: 600; }
.masthead__name {
font-family: var(--serif);
font-weight: 900;
font-size: clamp(30px, 6vw, 58px);
line-height: 0.95;
letter-spacing: 0.01em;
margin: 2px 0 4px;
}
.masthead__sub { border-top: 1px solid var(--rule); border-bottom: 1px solid var(--rule); }
.paper[data-device="mobile"] .masthead__sub { font-size: 8px; letter-spacing: 0.08em; }
.paper[data-device="mobile"] .masthead__city { display: none; }
/* layout grid */
.grid {
display: grid;
grid-template-columns: 2fr 1fr;
grid-template-areas:
"lead side"
"sec1 side"
"sec2 side"
"briefs briefs";
gap: 16px;
margin-top: 18px;
}
.slot--lead { grid-area: lead; }
.slot--side { grid-area: side; }
.slot--briefs { grid-area: briefs; }
.slot[data-slot="sec1"] { grid-area: sec1; }
.slot[data-slot="sec2"] { grid-area: sec2; }
.paper[data-device="tablet"] .grid {
grid-template-columns: 1fr 1fr;
grid-template-areas:
"lead lead"
"sec1 sec2"
"side side"
"briefs briefs";
}
.paper[data-device="mobile"] .grid {
grid-template-columns: 1fr;
grid-template-areas:
"lead"
"sec1"
"sec2"
"side"
"briefs";
}
/* slot shell */
.slot {
position: relative;
border: 1px dashed var(--rule-2);
border-radius: var(--r-sm);
min-height: 120px;
padding: 14px;
transition: border-color 0.15s ease, background 0.15s ease;
}
.slot--lead { min-height: 280px; }
.slot--side { min-height: 280px; border-left: 1px solid var(--rule); padding-left: 18px; }
.slot--briefs { min-height: 96px; }
.slot:focus-visible { outline: 2px solid var(--red); outline-offset: 2px; }
.slot.is-over {
border-color: var(--red);
border-style: solid;
background: var(--red-50);
}
.slot.is-filled { border-style: solid; border-color: var(--rule); }
.slot__empty {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
text-align: center;
pointer-events: none;
}
.slot__tag {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--muted);
border: 1px solid var(--rule);
border-radius: 999px;
padding: 3px 11px;
}
.slot__drop { font-size: 12px; color: var(--muted); font-style: italic; }
/* slot remove control */
.slot__remove {
position: absolute;
top: 7px; right: 7px;
z-index: 4;
width: 22px; height: 22px;
border-radius: 50%;
border: 1px solid var(--rule);
background: var(--white);
color: var(--ink-3);
font-size: 14px;
line-height: 1;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
}
.slot.is-filled .slot__remove { display: inline-flex; }
.slot__remove:hover { background: var(--red); color: #fff; border-color: var(--red); }
.slot__remove:focus-visible { outline: 2px solid var(--red); outline-offset: 1px; }
/* ===== rendered article inside a slot ===== */
.art { font-family: var(--serif); }
.art__kicker {
font-family: var(--sans);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--red);
display: block;
margin-bottom: 6px;
}
.art__head {
font-weight: 800;
line-height: 1.04;
margin: 0 0 8px;
color: var(--ink);
}
.art__dek {
font-family: var(--serif);
font-style: italic;
color: var(--ink-3);
margin: 0 0 10px;
line-height: 1.3;
}
.art__byline {
font-family: var(--sans);
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted);
border-top: 1px solid var(--rule);
border-bottom: 1px solid var(--rule);
padding: 5px 0;
margin: 0 0 10px;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.art__byline b { color: var(--ink-2); font-weight: 600; }
.figure { margin: 0 0 10px; }
.figure__img {
aspect-ratio: 16 / 9;
border: 1px solid var(--rule);
background-blend-mode: multiply, normal;
filter: contrast(1.03) saturate(0.85);
}
.figure__cap {
font-family: var(--sans);
font-size: 10px;
font-style: italic;
color: var(--muted);
padding-top: 4px;
line-height: 1.35;
}
.figure__cap b { font-style: normal; font-weight: 600; color: var(--ink-3); }
.art__body {
font-family: var(--serif);
font-size: 13.5px;
line-height: 1.55;
color: var(--ink-2);
text-align: justify;
hyphens: auto;
margin: 0;
}
.art__body p { margin: 0 0 9px; }
/* photographic gradients (duotone-ink / red press feel) */
.img-politics {
background-image:
radial-gradient(120% 80% at 25% 15%, rgba(244,239,228,0.55), transparent 55%),
linear-gradient(150deg, #2b2620 0%, #57473c 45%, #8f1f17 100%);
}
.img-business {
background-image:
radial-gradient(100% 80% at 80% 10%, rgba(244,239,228,0.45), transparent 50%),
linear-gradient(160deg, #14202b 0%, #244a63 55%, #6f8aa0 100%);
}
.img-climate {
background-image:
radial-gradient(110% 90% at 20% 90%, rgba(244,239,228,0.5), transparent 55%),
linear-gradient(135deg, #1c2b22 0%, #2f5d44 50%, #8aa07f 100%);
}
.img-culture {
background-image:
radial-gradient(120% 80% at 70% 20%, rgba(244,239,228,0.5), transparent 52%),
linear-gradient(150deg, #2b2014 0%, #7a4d18 55%, #c79a52 100%);
}
.img-sport {
background-image:
radial-gradient(120% 90% at 30% 20%, rgba(244,239,228,0.5), transparent 55%),
linear-gradient(140deg, #16130f 0%, #4a443b 50%, #9a8f7e 100%);
}
/* === LEAD slot rendered look === */
.slot--lead .art__kicker { font-size: 11px; }
.slot--lead .art__head { font-size: clamp(26px, 3.4vw, 40px); }
.slot--lead .art__dek { font-size: 16px; }
.slot--lead .art__body { columns: 2; column-gap: 22px; column-rule: 1px solid var(--rule-hair); }
.slot--lead .art__body p:first-of-type::first-letter {
font-family: var(--serif);
font-weight: 900;
font-size: 3.4em;
line-height: 0.72;
float: left;
padding: 4px 8px 0 0;
color: var(--red);
}
.paper[data-device="mobile"] .slot--lead .art__body { columns: 1; }
.pull {
break-inside: avoid;
margin: 10px 0;
padding: 10px 0;
border-top: 2px solid var(--ink);
border-bottom: 1px solid var(--rule);
font-family: var(--serif);
font-weight: 600;
font-style: italic;
font-size: 18px;
line-height: 1.22;
color: var(--ink);
}
.pull cite {
display: block;
font-style: normal;
font-family: var(--sans);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--red);
margin-top: 6px;
}
/* === SECONDARY slot rendered look === */
.slot--sec .art__head { font-size: clamp(19px, 2vw, 24px); }
.slot--sec .art__dek { font-size: 13px; }
.slot--sec .art__body { font-size: 12.5px; }
.slot--sec .figure__img { aspect-ratio: 3 / 2; }
/* === SIDEBAR slot rendered look === */
.slot--side .art__head { font-size: 20px; }
.slot--side .art__body { font-size: 12.5px; text-align: left; hyphens: none; }
.slot--side .figure__img { aspect-ratio: 4 / 5; }
.slot--side .art__rail {
font-family: var(--sans);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--ink-2);
border-bottom: 2px solid var(--ink);
padding-bottom: 5px;
margin-bottom: 10px;
}
/* === BRIEFS slot rendered look === */
.briefs__title {
font-family: var(--serif);
font-weight: 800;
font-size: 15px;
border-bottom: 2px solid var(--ink);
padding-bottom: 4px;
margin: 0 0 10px;
display: flex;
align-items: baseline;
gap: 8px;
}
.briefs__title small {
font-family: var(--sans);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--red);
}
.briefs__list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0;
}
.briefs__list li {
font-family: var(--serif);
font-size: 12px;
line-height: 1.35;
color: var(--ink-2);
padding: 0 12px;
border-left: 1px solid var(--rule);
}
.briefs__list li:first-child { border-left: 0; padding-left: 0; }
.briefs__list b {
font-family: var(--sans);
display: block;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--red);
margin-bottom: 3px;
}
.paper[data-device="tablet"] .briefs__list { grid-template-columns: repeat(2, 1fr); gap: 8px 12px; }
.paper[data-device="mobile"] .briefs__list { grid-template-columns: 1fr; }
.paper[data-device="mobile"] .briefs__list li { border-left: 0; padding-left: 0; border-top: 1px solid var(--rule); padding-top: 7px; margin-top: 7px; }
.paper[data-device="mobile"] .briefs__list li:first-child { border-top: 0; padding-top: 0; margin-top: 0; }
/* paper footer */
.paper__foot {
display: flex;
justify-content: space-between;
border-top: 2px solid var(--ink);
margin-top: 18px;
padding-top: 7px;
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
}
/* ============ TOAST ============ */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 24px);
background: var(--ink);
color: var(--cream);
font-size: 13px;
font-weight: 500;
padding: 11px 18px;
border-radius: var(--r-sm);
border-left: 3px solid var(--red);
box-shadow: 0 14px 30px -14px rgba(22, 19, 15, 0.7);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease, transform 0.25s ease;
z-index: 60;
max-width: min(90vw, 420px);
}
.toast.is-on { opacity: 1; transform: translate(-50%, 0); }
.toast strong { color: #fff; }
/* ============ RESPONSIVE ============ */
@media (max-width: 980px) {
.shell { grid-template-columns: 1fr; }
.palette {
position: static;
height: auto;
border-right: 0;
border-bottom: 1px solid var(--rule);
}
.queue { flex-direction: row; flex-wrap: wrap; }
.queue > .card { flex: 1 1 220px; }
}
@media (max-width: 720px) {
.appbar { flex-wrap: wrap; gap: 10px 14px; padding: 10px 14px; }
.appbar__center { order: 3; width: 100%; margin: 0; justify-content: flex-start; }
.appbar__actions { margin-left: auto; flex-wrap: wrap; }
.stage { padding: 16px 14px 50px; }
.paper { padding: 18px 16px 14px; }
/* force single-column paper on small screens regardless of device toggle */
.grid {
grid-template-columns: 1fr !important;
grid-template-areas:
"lead"
"sec1"
"sec2"
"side"
"briefs" !important;
}
.slot--lead .art__body { columns: 1; }
.briefs__list { grid-template-columns: 1fr; }
.briefs__list li { border-left: 0; padding-left: 0; }
}
@media (max-width: 400px) {
.appbar__title { font-size: 16px; }
.seg__btn { padding: 5px 9px; }
.masthead__meta, .masthead__sub { font-size: 8px; letter-spacing: 0.08em; }
}
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
}(function () {
"use strict";
/* ------------------------------------------------------------------ *
* Story data — fictional editorial copy (no lorem ipsum)
* ------------------------------------------------------------------ */
var STORIES = [
{
id: "harbor-vote",
section: "Politics",
img: "img-politics",
kicker: "Harbor City",
head: "Council Clears Waterfront Plan in a Marathon Vote",
dek: "After eleven hours and a packed gallery, the rezoning passes 7–4 — but the financing fight is only beginning.",
byline: { author: "Mara Velasco", role: "City Hall Bureau", read: "8 min" },
caption: "Residents fill the council chamber as the final tally is read aloud.",
credit: "Ledger Photo / D. Okafor",
body: [
"The vote came just after midnight, when a weary council clerk finally read the tally into a chamber that had not emptied since the afternoon. Seven in favor, four opposed, and with that the long-disputed waterfront district moved one step closer to a skyline it has been promised for the better part of a decade.",
"Supporters called it a generational investment in housing and transit. Opponents, many of them lifelong dockworkers, warned that the numbers behind the deal had never been fully explained to the people who would live with them longest.",
"What happens next depends less on the architecture than on the arithmetic. The bond package that underwrites the first phase must still clear a county review, and at least two of the yes votes were cast on the condition that affordability targets be written into the contract rather than the press release."
],
pull: { quote: "We were asked to trust a drawing. Now we want to read the fine print.", cite: "— Councilmember Reyes" }
},
{
id: "rate-decision",
section: "Business",
img: "img-business",
kicker: "Markets",
head: "Central Bank Holds the Line as Traders Brace for Spring",
dek: "Rates stay put for a fourth meeting; the statement’s quiet edits did the talking.",
byline: { author: "Theo Lindqvist", role: "Economics", read: "5 min" },
caption: "The trading floor at Meridian Exchange minutes after the announcement.",
credit: "Ledger Photo / R. Salt",
body: [
"Policymakers left the benchmark rate untouched for a fourth consecutive meeting, a decision that surprised almost no one and unsettled almost everyone. The drama, as is often the case, lived in the footnotes — a single deleted clause about “sustained” cooling that analysts spent the afternoon parsing line by line.",
"Equities drifted, then steadied. The bond market, less forgiving, read the omission as a signal that cuts remain further off than the optimists had penciled in."
]
},
{
id: "delta-flood",
section: "Climate",
img: "img-climate",
kicker: "The Delta",
head: "As the River Rises, an Old Town Rehearses Its Retreat",
dek: "Engineers map a managed flood; the families upstream map a harder set of choices.",
byline: { author: "Anika Brandt", role: "Climate Desk", read: "9 min" },
caption: "Sandbags line the levee road at dawn near the Cedar Bend crossing.",
credit: "Ledger Photo / J. Mwangi",
body: [
"The plan, on paper, is elegant: let the river take the floodplain it has always wanted, and spare the town behind it. In Cedar Bend, where the floodplain is also where people keep their gardens and bury their dead, elegance has been a harder sell.",
"State engineers say the controlled breach could lower peak crests by nearly a meter. Residents say they have heard confident numbers before, and that the water rarely reads the memo."
]
},
{
id: "gallery-heist",
section: "Culture",
img: "img-culture",
kicker: "The Arts",
head: "A Stolen Canvas Returns, and the Museum Won’t Say How",
dek: "Missing for thirty years, the Verecker landscape is back on its hook — minus its frame and its story.",
byline: { author: "Cole Marchetti", role: "Culture", read: "6 min" },
caption: "Conservators examine the recovered Verecker under raking light.",
credit: "Ledger Photo / P. Adeyemi",
body: [
"It reappeared the way it vanished: quietly, overnight, and without a witness willing to go on record. The Verecker landscape, gone since a foggy night in 1996, now hangs again in Gallery Nine, its varnish yellowed and its provenance suddenly very interesting.",
"The museum has confirmed only that the work is authentic and that no ransom was paid. Everything else — who, how, and why now — sits behind a velvet rope of legal caution."
]
},
{
id: "marathon-upset",
section: "Sport",
img: "img-sport",
kicker: "Distance",
head: "An Unseeded Runner Steals the Harbor Marathon",
dek: "Twenty-six miles, one borrowed pair of shoes, and a finish nobody saw coming.",
byline: { author: "Devon Park", role: "Sports", read: "4 min" },
caption: "The leader breaks the tape on the Esplanade as the pack closes in.",
credit: "Ledger Photo / S. Holt",
body: [
"She started in the fourth corral, anonymous behind a field of sponsored favorites. She finished alone, eleven seconds clear, having spent the last mile of the Harbor Marathon doing the one thing the form guides swore was impossible: pulling away.",
"Her shoes, it turned out, were borrowed that morning from a teammate a half-size too big. “I’ll buy her a new pair,” the winner laughed at the line. “These ones are mine now.”"
]
},
{
id: "transit-strike",
section: "Politics",
img: "img-politics",
kicker: "Labor",
head: "Transit Talks Collapse Hours Before the Morning Rush",
dek: "Two unions, one expired contract, and a city wondering how it will get to work.",
byline: { author: "Priya Nandakumar", role: "Transit", read: "7 min" },
caption: "Empty platforms at Central Interchange as the first trains stay parked.",
credit: "Ledger Photo / D. Okafor",
body: [
"Negotiations that had limped along for six weeks finally fell apart at 2 a.m., when the last offer on the table was met with a single word from across the room: no. By sunrise, the consequences belonged to commuters.",
"Both sides claim they want to keep the trains running. Neither, for now, is willing to be the one who pays for it."
]
},
{
id: "vineyard-tech",
section: "Business",
img: "img-business",
kicker: "Enterprise",
head: "The Vineyard That Replaced Its Forecasters With a Model",
dek: "A century-old estate bets the harvest on an algorithm — and a stubborn old foreman.",
byline: { author: "Theo Lindqvist", role: "Economics", read: "6 min" },
caption: "Rows at the Bellhaven estate, sensors blinking between the vines.",
credit: "Ledger Photo / R. Salt",
body: [
"Every morning the model tells the Bellhaven estate when to pick, when to wait, and when to worry. Every morning the foreman, forty harvests deep, walks the rows and decides whether to believe it.",
"So far they have agreed more than they have argued. The year they disagreed, the foreman won — and the vintage, the family insists, was the best in a generation."
]
},
{
id: "coral-lab",
section: "Climate",
img: "img-climate",
kicker: "Science",
head: "In a Cold Tank, Scientists Grow a Warmer Future for Coral",
dek: "A breeding program races bleaching season with reefs born to take the heat.",
byline: { author: "Anika Brandt", role: "Climate Desk", read: "8 min" },
caption: "Juvenile coral colonies under grow-lights at the Marin lab.",
credit: "Ledger Photo / J. Mwangi",
body: [
"In a windowless room that smells of salt and ambition, the next generation of reef is being raised one fragment at a time. The goal is not to save the coral that exists, but to breed the coral that might survive what is coming.",
"It is slow, unglamorous work, measured in millimeters and disappointments. It is also, the researchers say, the only plan they have that does not depend on the ocean cooperating."
]
}
];
/* ------------------------------------------------------------------ */
var queueEl = document.getElementById("queue");
var paperEl = document.getElementById("paper");
var toastEl = document.getElementById("toast");
var queueCountEl = document.getElementById("queueCount");
var layoutPctEl = document.getElementById("layoutPct");
var publishBtn = document.getElementById("publishBtn");
var resetBtn = document.getElementById("resetBtn");
var slots = Array.prototype.slice.call(document.querySelectorAll(".slot"));
var byId = {};
STORIES.forEach(function (s) { byId[s.id] = s; });
// assignment map: slotKey -> storyId
var assignment = {};
var draggingId = null;
/* ---------------- toast helper ---------------- */
var toastTimer = null;
function toast(msg) {
toastEl.innerHTML = msg;
toastEl.classList.add("is-on");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { toastEl.classList.remove("is-on"); }, 3200);
}
function esc(str) {
return String(str).replace(/[&<>"']/g, function (c) {
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
});
}
/* ---------------- palette ---------------- */
function buildQueue() {
queueEl.innerHTML = "";
STORIES.forEach(function (s) {
var li = document.createElement("li");
var placed = isPlaced(s.id);
li.className = "card" + (placed ? " is-placed" : "");
li.setAttribute("draggable", placed ? "false" : "true");
li.setAttribute("data-id", s.id);
li.setAttribute("data-section", s.section);
li.setAttribute("tabindex", "0");
li.setAttribute("role", "button");
li.setAttribute("aria-label", s.head + " — " + s.section + (placed ? ", placed" : ", press Enter to assign"));
li.innerHTML =
'<span class="card__placed">On page</span>' +
'<span class="card__kicker">' + esc(s.section) + " · " + esc(s.kicker) + "</span>" +
'<h3 class="card__head">' + esc(s.head) + "</h3>" +
'<div class="card__meta"><b>' + esc(s.byline.author) + "</b><span>· " +
esc(s.byline.read) + " read</span></div>";
queueEl.appendChild(li);
});
}
function isPlaced(id) {
return Object.keys(assignment).some(function (k) { return assignment[k] === id; });
}
/* ---------------- rendering an article into a slot ---------------- */
function renderArticle(slotKey, story) {
if (slotKey === "briefs") return renderBriefs();
var figure =
'<figure class="figure">' +
'<div class="figure__img ' + story.img + '" role="img" aria-label="' + esc(story.caption) + '"></div>' +
'<figcaption class="figure__cap">' + esc(story.caption) +
" <b>" + esc(story.credit) + "</b></figcaption>" +
"</figure>";
var byline =
'<p class="art__byline">By <b>' + esc(story.byline.author) + "</b><span>· " +
esc(story.byline.role) + "</span><span>· " + esc(story.byline.read) + " read</span></p>";
var bodyParas = story.body.map(function (p) { return "<p>" + esc(p) + "</p>"; });
// pull quote injected into the lead body where the layout allows
if (slotKey === "lead" && story.pull && bodyParas.length > 1) {
var pull =
'<blockquote class="pull">' + esc(story.pull.quote) +
"<cite>" + esc(story.pull.cite) + "</cite></blockquote>";
bodyParas.splice(1, 0, pull);
}
var rail = "";
if (slotKey === "sidebar") {
rail = '<p class="art__rail">Analysis · The Long View</p>';
}
return (
'<button class="slot__remove" type="button" aria-label="Remove story from this slot">×</button>' +
'<article class="art">' +
rail +
'<span class="art__kicker">' + esc(story.section) + " · " + esc(story.kicker) + "</span>" +
'<h2 class="art__head">' + esc(story.head) + "</h2>" +
(slotKey === "lead" || slotKey === "sidebar"
? '<p class="art__dek">' + esc(story.dek) + "</p>"
: "") +
byline +
figure +
'<div class="art__body">' + bodyParas.join("") + "</div>" +
"</article>"
);
}
// The Briefs strip composes from every UNplaced story (max 5)
function renderBriefs() {
var used = Object.keys(assignment)
.filter(function (k) { return k !== "briefs"; })
.map(function (k) { return assignment[k]; });
var pool = STORIES.filter(function (s) { return used.indexOf(s.id) === -1; }).slice(0, 5);
if (pool.length === 0) pool = STORIES.slice(0, 5);
var items = pool
.map(function (s) {
return "<li><b>" + esc(s.section) + "</b>" + esc(s.head) + "</li>";
})
.join("");
return (
'<button class="slot__remove" type="button" aria-label="Clear the briefs strip">×</button>' +
'<h2 class="briefs__title">In Brief <small>From the Wire</small></h2>' +
'<ul class="briefs__list" role="list">' + items + "</ul>"
);
}
/* ---------------- slot state ---------------- */
function fillSlot(slotKey, storyId) {
var slot = slots.filter(function (s) { return s.dataset.slot === slotKey; })[0];
if (!slot) return;
// if this story already sits in another slot, vacate that one (swap-safe)
if (slotKey !== "briefs") {
Object.keys(assignment).forEach(function (k) {
if (k !== slotKey && assignment[k] === storyId) clearSlot(k, true);
});
}
assignment[slotKey] = storyId;
var story = byId[storyId];
slot.innerHTML = renderArticle(slotKey, story || {});
slot.classList.add("is-filled");
slot.setAttribute("aria-label", slot.dataset.label + " slot, " +
(slotKey === "briefs" ? "briefs strip composed" : (story ? story.head : "")));
wireRemove(slot, slotKey);
refresh();
}
function clearSlot(slotKey, silent) {
var slot = slots.filter(function (s) { return s.dataset.slot === slotKey; })[0];
if (!slot) return;
delete assignment[slotKey];
restoreEmpty(slot);
if (!silent) refresh();
}
function restoreEmpty(slot) {
var label = slot.dataset.label;
var drops = {
Lead: "Drop the front-page anchor here",
Secondary: "Above-the-fold support",
Sidebar: "Column rail · analysis",
Briefs: "In Brief · five-line digest strip"
};
slot.classList.remove("is-filled");
slot.setAttribute("aria-label", slot.dataset.label + " slot, empty");
slot.innerHTML =
'<div class="slot__empty">' +
'<span class="slot__tag">' + esc(label) + "</span>" +
'<span class="slot__drop">' + esc(drops[label] || "Drop a story here") + "</span>" +
"</div>";
}
function wireRemove(slot, slotKey) {
var btn = slot.querySelector(".slot__remove");
if (!btn) return;
btn.addEventListener("click", function (e) {
e.stopPropagation();
clearSlot(slotKey);
toast("Removed from <strong>" + slot.dataset.label + "</strong>.");
});
}
/* ---------------- refresh meta + briefs ---------------- */
function refresh() {
// briefs always reflects the current unplaced pool
if (assignment.briefs !== undefined) {
var bslot = slots.filter(function (s) { return s.dataset.slot === "briefs"; })[0];
bslot.innerHTML = renderBriefs();
wireRemove(bslot, "briefs");
}
buildQueue();
wireCards();
var placedCount = STORIES.filter(function (s) { return isPlaced(s.id); }).length;
queueCountEl.textContent = placedCount + " placed";
var filled = slots.filter(function (s) { return s.classList.contains("is-filled"); }).length;
var pct = Math.round((filled / slots.length) * 100);
layoutPctEl.textContent = pct + "%";
}
/* ---------------- drag & drop ---------------- */
function wireCards() {
queueEl.querySelectorAll(".card").forEach(function (card) {
if (card.classList.contains("is-placed")) return;
card.addEventListener("dragstart", function (e) {
draggingId = card.dataset.id;
card.classList.add("is-dragging");
if (e.dataTransfer) {
e.dataTransfer.setData("text/plain", card.dataset.id);
e.dataTransfer.effectAllowed = "move";
}
});
card.addEventListener("dragend", function () {
card.classList.remove("is-dragging");
draggingId = null;
});
// keyboard: assign to first open slot
card.addEventListener("keydown", function (e) {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
assignToNextOpen(card.dataset.id);
}
});
});
}
function assignToNextOpen(storyId) {
var order = ["lead", "sec1", "sec2", "sidebar"];
for (var i = 0; i < order.length; i++) {
if (assignment[order[i]] === undefined) {
fillSlot(order[i], storyId);
var lbl = slots.filter(function (s) { return s.dataset.slot === order[i]; })[0].dataset.label;
toast("Placed in <strong>" + lbl + "</strong>.");
return;
}
}
toast("All story slots are full — remove one first.");
}
slots.forEach(function (slot) {
var key = slot.dataset.slot;
slot.addEventListener("dragover", function (e) {
if (key === "briefs") return; // briefs auto-composes, no manual drop
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
slot.classList.add("is-over");
});
slot.addEventListener("dragleave", function () { slot.classList.remove("is-over"); });
slot.addEventListener("drop", function (e) {
e.preventDefault();
slot.classList.remove("is-over");
if (key === "briefs") return;
var id = (e.dataTransfer && e.dataTransfer.getData("text/plain")) || draggingId;
if (!id || !byId[id]) return;
fillSlot(key, id);
toast("Placed <strong>" + esc(byId[id].byline.author) + "’s</strong> story in " +
"<strong>" + slot.dataset.label + "</strong>.");
});
// keyboard activate empty slot -> nothing; filled handled by remove btn.
// Allow Enter on the briefs slot to compose it.
slot.addEventListener("keydown", function (e) {
if ((e.key === "Enter" || e.key === " ") && e.target === slot) {
e.preventDefault();
if (key === "briefs" && assignment.briefs === undefined) {
fillSlot("briefs", "__briefs__");
toast("Composed the <strong>In Brief</strong> strip.");
}
}
});
});
/* ---------------- device width toggle ---------------- */
document.querySelectorAll(".seg__btn").forEach(function (btn) {
btn.addEventListener("click", function () {
document.querySelectorAll(".seg__btn").forEach(function (b) {
b.classList.remove("is-active");
b.setAttribute("aria-checked", "false");
});
btn.classList.add("is-active");
btn.setAttribute("aria-checked", "true");
paperEl.dataset.device = btn.dataset.device;
});
});
/* ---------------- publish & reset ---------------- */
publishBtn.addEventListener("click", function () {
var storySlots = ["lead", "sec1", "sec2", "sidebar"];
var filled = storySlots.filter(function (k) { return assignment[k] !== undefined; }).length;
if (assignment.lead === undefined) {
toast("A front page needs a <strong>Lead</strong> story before publishing.");
return;
}
var device = paperEl.dataset.device;
toast("Front page published — <strong>" + filled + "</strong> stories live for the " +
device + " edition. ✓");
});
resetBtn.addEventListener("click", function () {
Object.keys(assignment).forEach(function (k) { clearSlot(k, true); });
assignment = {};
slots.forEach(restoreEmpty);
refresh();
toast("Layout cleared. Blank slate.");
});
/* ---------------- seed a starter layout ---------------- */
function seed() {
slots.forEach(restoreEmpty);
fillSlot("lead", "harbor-vote");
fillSlot("sec1", "delta-flood");
fillSlot("sec2", "rate-decision");
fillSlot("sidebar", "gallery-heist");
fillSlot("briefs", "__briefs__");
refresh();
}
buildQueue();
wireCards();
seed();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>The Meridian Ledger — Front-page Layout 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=Inter:wght@400;500;600;700&family=Playfair+Display:ital,wght@0,500;0,600;0,700;0,800;0,900;1,500;1,600&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<a class="skip-link" href="#canvas">Skip to front page</a>
<header class="appbar">
<div class="appbar__brand">
<span class="appbar__kicker">Newsroom</span>
<span class="appbar__title">Front-page Builder</span>
</div>
<div class="appbar__center" role="group" aria-label="Edition status">
<span class="dot" aria-hidden="true"></span>
<span class="appbar__edition">Evening Edition · Draft</span>
</div>
<div class="appbar__actions">
<div class="seg" role="radiogroup" aria-label="Preview width">
<button class="seg__btn is-active" data-device="desktop" role="radio" aria-checked="true">Desktop</button>
<button class="seg__btn" data-device="tablet" role="radio" aria-checked="false">Tablet</button>
<button class="seg__btn" data-device="mobile" role="radio" aria-checked="false">Mobile</button>
</div>
<button class="btn btn--ghost" id="resetBtn" type="button">Reset</button>
<button class="btn btn--red" id="publishBtn" type="button">Publish layout</button>
</div>
</header>
<main class="shell">
<!-- ===== STORY PALETTE ===== -->
<aside class="palette" aria-label="Available stories">
<div class="palette__head">
<h2 class="palette__title">Story Queue</h2>
<span class="palette__count" id="queueCount">0 placed</span>
</div>
<p class="palette__hint">Drag a story onto a slot — or focus a card and press <kbd>Enter</kbd> to assign it to the next open slot.</p>
<ul class="queue" id="queue" role="list">
<!-- story cards injected by script.js -->
</ul>
</aside>
<!-- ===== FRONT-PAGE CANVAS ===== -->
<section class="stage" id="canvas" aria-label="Front page canvas">
<div class="stage__bar">
<p class="stage__meta">Layout · <strong id="layoutPct">0%</strong> filled</p>
<p class="stage__note">Live preview — headline size scales to each slot</p>
</div>
<article class="paper" id="paper" data-device="desktop" aria-label="Front page preview">
<header class="masthead">
<div class="masthead__rule masthead__rule--top"></div>
<div class="masthead__meta">
<span>Vol. CXIV · No. 212</span>
<span class="masthead__price">Two Dollars</span>
</div>
<h1 class="masthead__name">The Meridian Ledger</h1>
<div class="masthead__sub">
<span>Saturday, June 8, 2026</span>
<span class="masthead__city">Harbor City & the Greater Bay</span>
<span>Late Final</span>
</div>
<div class="masthead__rule masthead__rule--bottom"></div>
</header>
<div class="grid">
<!-- LEAD -->
<div class="slot slot--lead" data-slot="lead" data-label="Lead" tabindex="0"
role="button" aria-label="Lead slot, empty">
<div class="slot__empty">
<span class="slot__tag">Lead</span>
<span class="slot__drop">Drop the front-page anchor here</span>
</div>
</div>
<!-- SECONDARY 1 & 2 -->
<div class="slot slot--sec" data-slot="sec1" data-label="Secondary" tabindex="0"
role="button" aria-label="Secondary slot one, empty">
<div class="slot__empty">
<span class="slot__tag">Secondary</span>
<span class="slot__drop">Above-the-fold support</span>
</div>
</div>
<div class="slot slot--sec" data-slot="sec2" data-label="Secondary" tabindex="0"
role="button" aria-label="Secondary slot two, empty">
<div class="slot__empty">
<span class="slot__tag">Secondary</span>
<span class="slot__drop">Above-the-fold support</span>
</div>
</div>
<!-- SIDEBAR -->
<aside class="slot slot--side" data-slot="sidebar" data-label="Sidebar" tabindex="0"
role="button" aria-label="Sidebar slot, empty">
<div class="slot__empty">
<span class="slot__tag">Sidebar</span>
<span class="slot__drop">Column rail · analysis</span>
</div>
</aside>
<!-- BRIEFS STRIP -->
<div class="slot slot--briefs" data-slot="briefs" data-label="Briefs" tabindex="0"
role="button" aria-label="Briefs strip slot, empty">
<div class="slot__empty">
<span class="slot__tag">Briefs</span>
<span class="slot__drop">In Brief · five-line digest strip</span>
</div>
</div>
</div>
<footer class="paper__foot">
<span>Printed on recycled stock · The Meridian Ledger Co.</span>
<span>Continued on A2</span>
</footer>
</article>
</section>
</main>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Front-page Layout Builder
A two-panel newsroom tool for laying out the front page of The Meridian Ledger, a fictional broadsheet. The left rail is the story queue: a column of draggable cards, each colour-keyed by section — Politics, Business, Climate, Culture, Sport — with a serif headline, byline and read-time. The right side is the live front page itself, framed by a real masthead with volume line, dateline and price, and divided into slot zones: a tall Lead, two Secondary wells, an analysis Sidebar rail, and a five-column In Brief strip across the foot.
Drag any card onto a slot and it renders as a fully set article in place — and the typography scales to where it landed. The Lead gets a two-column justified body, a red drop cap and a pull quote; the Secondary wells set tighter; the Sidebar runs a narrow analysis column with its own rule; and the Briefs strip auto-composes a digest from whatever stories are not already on the page. Dropping a story that is already placed swaps it cleanly between slots, and every slot carries a remove control to vacate it. Cards are keyboard-operable too: focus one and press Enter to drop it into the next open slot.
A Desktop / Tablet / Mobile toggle reflows the grid so you can preview each edition without leaving the page, a layout meter tracks how full the front is, and Publish layout validates that a Lead exists before confirming the edition with a toast. Reset wipes back to a blank slate. All headlines, bylines, datelines and body copy are written as real-feeling editorial prose, and every interaction is plain vanilla JavaScript with no external libraries.
Illustrative UI only — masthead, headlines, bylines, and articles are fictional; not a real news publication.