News — Article Editor
A newsroom article editor built as a two-pane CMS surface: an editing column with an inline headline, italic standfirst, byline and a formatting toolbar that runs bold, italic, subheads, pull quotes, links and captioned press figures, beside a publishing sidebar for section, tags, Draft to Published status, SEO slug, cover treatment and live word count plus read time. A reader-style preview renders the finished article in justified editorial columns with a drop cap.
MCP
代码
: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: var(--cream);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.visually-hidden {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0);
white-space: nowrap; border: 0;
}
.skip-link {
position: absolute;
left: 12px; top: -48px;
background: var(--ink);
color: var(--paper);
padding: 8px 14px;
border-radius: var(--r-sm);
z-index: 100;
transition: top .15s ease;
font-size: 13px;
}
.skip-link:focus { top: 12px; }
:focus-visible {
outline: 2px solid var(--red);
outline-offset: 2px;
}
/* ============ TOP BAR ============ */
.topbar {
position: sticky;
top: 0;
z-index: 40;
display: flex;
align-items: center;
gap: 18px;
padding: 12px 22px;
background: var(--paper);
border-bottom: 1px solid var(--rule-2);
}
.topbar__brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
.topbar__mark {
display: grid;
place-items: center;
width: 38px; height: 38px;
background: var(--ink);
color: var(--paper);
font-family: var(--serif);
font-weight: 800;
font-size: 22px;
border-radius: var(--r-sm);
flex: none;
}
.topbar__id { display: flex; flex-direction: column; line-height: 1.15; min-width: 0; }
.topbar__paper { font-family: var(--serif); font-size: 18px; font-weight: 800; letter-spacing: .2px; }
.topbar__sub {
font-size: 11px;
text-transform: uppercase;
letter-spacing: .14em;
color: var(--muted);
font-weight: 600;
}
.topbar__status {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
font-size: 12.5px;
color: var(--ink-3);
white-space: nowrap;
}
.dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
.dot--saved { background: var(--ok); }
.dot--dirty { background: var(--warn); }
.topbar__actions { display: flex; gap: 8px; }
/* ============ BUTTONS ============ */
.btn {
font-family: var(--sans);
font-size: 13px;
font-weight: 600;
padding: 9px 15px;
border-radius: var(--r-sm);
border: 1px solid var(--rule-2);
background: var(--white);
color: var(--ink);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 7px;
transition: background .14s ease, color .14s ease, border-color .14s ease, transform .05s ease;
}
.btn:hover { background: var(--newsprint); }
.btn:active { transform: translateY(1px); }
.btn__ico { font-size: 13px; line-height: 1; }
.btn--ghost { background: transparent; }
.btn--ghost[aria-pressed="true"] {
background: var(--ink);
color: var(--paper);
border-color: var(--ink);
}
.btn--primary {
background: var(--red);
color: var(--white);
border-color: var(--red-d);
}
.btn--primary:hover { background: var(--red-d); }
.btn--tiny { padding: 7px 11px; font-size: 12px; }
/* ============ WORKSPACE LAYOUT ============ */
.workspace {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 0;
max-width: 1320px;
margin: 0 auto;
}
.editor, .reader { padding: 34px clamp(20px, 4vw, 56px) 80px; min-width: 0; }
.editor__inner { max-width: 720px; margin: 0 auto; }
.sidebar {
border-left: 1px solid var(--rule);
background: var(--paper);
padding: 8px 0 60px;
}
/* ============ EDITOR FIELDS ============ */
.field-label {
display: block;
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: .16em;
font-weight: 700;
color: var(--muted);
margin: 22px 0 6px;
}
.kicker {
font-size: 11.5px;
text-transform: uppercase;
letter-spacing: .2em;
font-weight: 700;
color: var(--red);
margin: 0 0 4px;
}
[contenteditable] { outline: none; }
[contenteditable]:focus-visible {
outline: 2px solid var(--red);
outline-offset: 4px;
border-radius: var(--r-sm);
}
[contenteditable][data-placeholder]:empty::before {
content: attr(data-placeholder);
color: var(--muted);
opacity: .7;
}
.headline-input {
font-family: var(--serif);
font-weight: 800;
font-size: clamp(30px, 5vw, 46px);
line-height: 1.06;
letter-spacing: -0.01em;
color: var(--ink);
margin: 0;
}
.deck-input {
font-family: var(--serif);
font-weight: 500;
font-style: italic;
font-size: clamp(17px, 2.4vw, 21px);
line-height: 1.42;
color: var(--ink-3);
margin: 12px 0 0;
}
.byline-row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 7px;
margin-top: 16px;
padding: 12px 0;
border-top: 1px solid var(--rule-hair);
border-bottom: 1px solid var(--rule-hair);
font-size: 12.5px;
}
.byline-row__by { color: var(--muted); }
.byline-input {
font-weight: 700;
color: var(--ink);
border-bottom: 1px dashed transparent;
}
.byline-input:hover { border-bottom-color: var(--rule); }
.byline-row__sep { color: var(--rule-2); }
.dateline {
text-transform: uppercase;
letter-spacing: .12em;
font-weight: 700;
font-size: 11.5px;
color: var(--ink-2);
}
.readtime { color: var(--muted); font-style: italic; }
/* ============ TOOLBAR ============ */
.toolbar {
position: sticky;
top: 64px;
z-index: 20;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin: 22px 0 18px;
padding: 7px 9px;
background: var(--paper);
border: 1px solid var(--rule);
border-radius: var(--r-md);
}
.toolbar__group { display: flex; gap: 4px; }
.toolbar__rule { width: 1px; align-self: stretch; background: var(--rule); margin: 2px 4px; }
.tbtn {
font-family: var(--sans);
font-size: 13px;
min-width: 32px;
height: 32px;
padding: 0 9px;
border: 1px solid transparent;
background: transparent;
color: var(--ink-2);
border-radius: var(--r-sm);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 600;
transition: background .12s ease, color .12s ease;
}
.tbtn--wide { font-size: 12px; }
.tbtn:hover { background: var(--newsprint); }
.tbtn.is-active {
background: var(--ink);
color: var(--paper);
}
.tbtn b, .tbtn i { font-style: normal; }
.tbtn i { font-style: italic; font-family: var(--serif); }
.tbtn b { font-family: var(--serif); }
/* ============ BODY (edit) ============ */
.body {
font-family: "Georgia", "Times New Roman", serif;
font-size: 18px;
line-height: 1.66;
color: var(--ink-2);
}
.body p { margin: 0 0 1.15em; text-align: justify; hyphens: auto; }
.body > p:first-of-type::first-letter {
font-family: var(--serif);
font-weight: 800;
float: left;
font-size: 3.6em;
line-height: .76;
padding: 6px 10px 0 0;
color: var(--red);
}
.body h2 {
font-family: var(--serif);
font-weight: 700;
font-size: 23px;
line-height: 1.2;
color: var(--ink);
margin: 1.5em 0 .5em;
padding-top: .6em;
border-top: 1px solid var(--rule-hair);
}
.body blockquote {
font-family: var(--serif);
font-weight: 600;
font-style: italic;
font-size: 24px;
line-height: 1.32;
color: var(--ink);
margin: 1.1em 0;
padding: 4px 0 4px 22px;
border-left: 3px solid var(--red);
}
.body a { color: var(--red-d); text-decoration: underline; text-underline-offset: 2px; }
.body strong { font-weight: 700; color: var(--ink); }
.body em { font-style: italic; }
/* inserted figure inside editor body */
.body figure, .reader__body figure {
margin: 1.4em 0;
}
.body figure figcaption, .reader__body figure figcaption {
font-family: var(--sans);
font-size: 12.5px;
font-style: italic;
color: var(--muted);
margin-top: 7px;
}
/* ============ PRESS PHOTOS (simulated) ============ */
.press-photo {
display: block;
width: 100%;
aspect-ratio: 16 / 10;
border-radius: var(--r-sm);
background-color: #5a4a3a;
background-image:
radial-gradient(120% 80% at 18% 14%, rgba(244, 239, 228, 0.55), transparent 46%),
radial-gradient(90% 70% at 86% 8%, rgba(180, 41, 31, 0.30), transparent 55%),
linear-gradient(150deg, #2b2620 0%, #4a443b 38%, #8f1f17 72%, #1a1712 100%);
position: relative;
overflow: hidden;
box-shadow: inset 0 0 0 1px var(--rule);
}
.press-photo::after {
content: "";
position: absolute;
inset: 0;
background:
repeating-linear-gradient(0deg, rgba(0,0,0,0.05) 0 2px, transparent 2px 4px),
radial-gradient(140% 120% at 70% 90%, rgba(22,19,15,0.5), transparent 60%);
mix-blend-mode: multiply;
}
.press-photo--hero { aspect-ratio: 16 / 9; }
.press-photo--cover {
aspect-ratio: 16 / 10;
background-image:
radial-gradient(110% 90% at 12% 16%, rgba(244, 239, 228, 0.45), transparent 48%),
linear-gradient(140deg, #1f3a4a 0%, #2b3b3a 40%, #4a443b 70%, #16130f 100%);
}
/* alternate cover treatments toggled by JS */
.press-photo--t2 {
background-image:
radial-gradient(100% 80% at 80% 18%, rgba(180,41,31,.5), transparent 50%),
linear-gradient(120deg, #3a2418 0%, #6b3a1a 45%, #b4291f 80%, #1a1712 100%);
}
.press-photo--t3 {
background-image:
radial-gradient(120% 90% at 22% 20%, rgba(244,239,228,.5), transparent 52%),
linear-gradient(160deg, #14202b 0%, #2b3b4a 42%, #4a443b 74%, #0f1216 100%);
}
/* ============ SIDEBAR PANELS ============ */
.panel {
padding: 18px 20px;
border-bottom: 1px solid var(--rule-hair);
}
.panel--stats { border-bottom: none; }
.panel__title {
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: .16em;
font-weight: 700;
color: var(--muted);
margin: 0 0 12px;
}
.status-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.status-chip {
font-family: var(--sans);
font-size: 12.5px;
font-weight: 600;
padding: 9px 8px;
border: 1px solid var(--rule);
background: var(--white);
color: var(--ink-3);
border-radius: var(--r-sm);
cursor: pointer;
transition: all .12s ease;
}
.status-chip:hover { border-color: var(--rule-2); }
.status-chip.is-active {
background: var(--ink);
color: var(--paper);
border-color: var(--ink);
}
.status-chip[data-status="published"].is-active {
background: var(--ok);
border-color: var(--ok);
}
.status-note {
margin: 12px 0 0;
font-size: 12.5px;
font-style: italic;
color: var(--muted);
line-height: 1.45;
}
/* cover */
.cover { margin: 0; }
.cover__well {
width: 100%;
padding: 0;
border: 1px solid var(--rule);
background: none;
border-radius: var(--r-sm);
cursor: pointer;
display: block;
position: relative;
overflow: hidden;
}
.cover__hint {
position: absolute;
inset-inline: 0;
bottom: 0;
padding: 6px;
font-size: 11px;
font-weight: 600;
letter-spacing: .04em;
color: var(--paper);
background: linear-gradient(transparent, rgba(22,19,15,.7));
text-align: center;
opacity: 0;
transition: opacity .15s ease;
}
.cover__well:hover .cover__hint,
.cover__well:focus-visible .cover__hint { opacity: 1; }
.cover__cap { margin: 9px 0 0; }
.cover__caption-input {
width: 100%;
font-family: var(--sans);
font-size: 12.5px;
font-style: italic;
color: var(--ink-3);
border: none;
border-bottom: 1px dashed var(--rule);
background: transparent;
padding: 2px 0;
}
.cover__caption-input:focus-visible { outline: none; border-bottom-color: var(--red); }
.credit {
display: block;
margin-top: 4px;
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: .1em;
color: var(--muted);
font-weight: 600;
}
/* select */
.select {
width: 100%;
font-family: var(--sans);
font-size: 13.5px;
padding: 9px 11px;
border: 1px solid var(--rule);
border-radius: var(--r-sm);
background: var(--white);
color: var(--ink);
cursor: pointer;
}
/* tags */
.tags { display: flex; flex-wrap: wrap; gap: 6px; }
.tag {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 600;
padding: 4px 4px 4px 9px;
background: var(--newsprint);
border: 1px solid var(--rule);
border-radius: 999px;
color: var(--ink-2);
}
.tag button {
border: none;
background: none;
cursor: pointer;
color: var(--muted);
font-size: 15px;
line-height: 1;
width: 18px; height: 18px;
border-radius: 50%;
}
.tag button:hover { background: var(--red-50); color: var(--red-d); }
.tag-add { display: flex; gap: 6px; margin-top: 10px; }
.tag-add__input {
flex: 1;
min-width: 0;
font-family: var(--sans);
font-size: 13px;
padding: 8px 10px;
border: 1px solid var(--rule);
border-radius: var(--r-sm);
background: var(--white);
}
/* slug */
.slug {
display: flex;
align-items: center;
border: 1px solid var(--rule);
border-radius: var(--r-sm);
background: var(--white);
overflow: hidden;
}
.slug__base {
font-size: 12px;
color: var(--muted);
padding: 8px 0 8px 10px;
white-space: nowrap;
}
.slug__input {
flex: 1;
min-width: 0;
font-family: var(--sans);
font-size: 12.5px;
font-weight: 600;
color: var(--red-d);
border: none;
padding: 8px 10px 8px 2px;
background: transparent;
}
.slug__input:focus-visible { outline: none; }
.link-btn {
margin-top: 8px;
font-family: var(--sans);
font-size: 12px;
font-weight: 600;
color: var(--red-d);
background: none;
border: none;
padding: 0;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
/* stats */
.stats { margin: 0; display: grid; gap: 10px; }
.stat {
display: flex;
align-items: baseline;
justify-content: space-between;
border-bottom: 1px dotted var(--rule);
padding-bottom: 8px;
}
.stat dt {
font-size: 12px;
text-transform: uppercase;
letter-spacing: .1em;
color: var(--muted);
font-weight: 600;
}
.stat dd {
margin: 0;
font-family: var(--serif);
font-weight: 700;
font-size: 22px;
color: var(--ink);
}
/* ============ READER PREVIEW ============ */
.reader { background: var(--paper); }
.reader__article { max-width: 680px; margin: 0 auto; }
.reader__kicker {
font-size: 11.5px;
text-transform: uppercase;
letter-spacing: .2em;
font-weight: 700;
color: var(--red);
margin: 0 0 8px;
}
.reader__headline {
font-family: var(--serif);
font-weight: 900;
font-size: clamp(32px, 5.4vw, 52px);
line-height: 1.04;
letter-spacing: -0.015em;
margin: 0;
}
.reader__deck {
font-family: var(--serif);
font-style: italic;
font-size: clamp(18px, 2.6vw, 22px);
line-height: 1.42;
color: var(--ink-3);
margin: 14px 0 18px;
}
.reader__meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 12.5px;
color: var(--ink-2);
padding: 12px 0;
border-top: 1px solid var(--rule);
border-bottom: 1px solid var(--rule);
}
.reader__meta span:first-child { font-weight: 700; }
.reader__sep { color: var(--rule-2); }
.reader__hero { margin: 24px 0; }
.reader__hero figcaption {
font-size: 12.5px;
font-style: italic;
color: var(--muted);
margin-top: 8px;
}
.reader__body {
column-count: 2;
column-gap: 36px;
column-rule: 1px solid var(--rule-hair);
font-family: "Georgia", "Times New Roman", serif;
font-size: 16px;
line-height: 1.68;
color: var(--ink-2);
}
.reader__body p { margin: 0 0 1.1em; text-align: justify; hyphens: auto; }
.reader__body > p:first-of-type::first-letter {
font-family: var(--serif);
font-weight: 800;
float: left;
font-size: 3.4em;
line-height: .76;
padding: 6px 10px 0 0;
color: var(--red);
}
.reader__body h2 {
font-family: var(--serif);
font-weight: 700;
font-size: 20px;
margin: 1.2em 0 .4em;
color: var(--ink);
column-span: all;
padding-top: .5em;
border-top: 1px solid var(--rule-hair);
}
.reader__body blockquote {
font-family: var(--serif);
font-style: italic;
font-weight: 600;
font-size: 22px;
line-height: 1.3;
color: var(--ink);
margin: .6em 0;
padding-left: 18px;
border-left: 3px solid var(--red);
column-span: all;
}
.reader__body figure { column-span: all; }
.reader__body strong { color: var(--ink); }
/* state: previewing */
body.is-preview .editor { display: none; }
body.is-preview .sidebar { display: none; }
body.is-preview .workspace { grid-template-columns: 1fr; }
/* ============ TOAST ============ */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 16px);
background: var(--ink);
color: var(--paper);
font-size: 13.5px;
font-weight: 500;
padding: 11px 18px;
border-radius: var(--r-sm);
box-shadow: 0 6px 22px rgba(22,19,15,.28);
opacity: 0;
pointer-events: none;
transition: opacity .2s ease, transform .2s ease;
z-index: 90;
max-width: 90vw;
}
.toast.is-show { opacity: 1; transform: translate(-50%, 0); }
.toast--ok { border-left: 3px solid var(--ok); }
.toast--accent { border-left: 3px solid var(--red); }
/* ============ RESPONSIVE ============ */
@media (max-width: 980px) {
.workspace { grid-template-columns: 1fr; }
.sidebar { border-left: none; border-top: 1px solid var(--rule); }
.reader__body { column-count: 1; }
}
@media (max-width: 720px) {
.topbar { flex-wrap: wrap; gap: 12px; }
.topbar__status { order: 3; margin-left: 0; }
.topbar__actions { margin-left: auto; }
.editor, .reader { padding: 24px 18px 64px; }
.toolbar { top: 0; position: static; }
}
@media (max-width: 420px) {
.topbar__actions { width: 100%; }
.topbar__actions .btn { flex: 1; justify-content: center; }
.status-grid { grid-template-columns: 1fr; }
.headline-input { font-size: 28px; }
}(function () {
"use strict";
var $ = function (sel, ctx) { return (ctx || document).querySelector(sel); };
var $$ = function (sel, ctx) { return Array.prototype.slice.call((ctx || document).querySelectorAll(sel)); };
/* ---------- Toast helper ---------- */
var toastEl = $("#toast");
var toastTimer = null;
function toast(msg, kind) {
toastEl.textContent = msg;
toastEl.className = "toast is-show" + (kind ? " toast--" + kind : "");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.className = "toast" + (kind ? " toast--" + kind : "");
}, 2600);
}
/* ---------- Elements ---------- */
var body = $("#articleBody");
var headline = $("#headlineInput");
var deck = $("#deckInput");
var byline = $("#bylineInput");
var slugInput = $("#slugInput");
var saveDot = $("#saveDot");
var saveLabel = $("#saveLabel");
/* ---------- Dirty / save state ---------- */
var dirty = false;
function markDirty() {
if (dirty) return;
dirty = true;
saveDot.className = "dot dot--dirty";
saveLabel.textContent = "Unsaved changes";
}
function markSaved() {
dirty = false;
saveDot.className = "dot dot--saved";
saveLabel.textContent = "All changes saved";
}
/* ---------- Word count & read time ---------- */
var wordCountEl = $("#wordCount");
var readTimeEl = $("#readTime");
var charCountEl = $("#charCount");
var readTimeMeta = $("#readTimeMeta");
function plainText(el) {
return (el.innerText || el.textContent || "").replace(/ /g, " ");
}
function updateStats() {
var text = (plainText(headline) + " " + plainText(deck) + " " + plainText(body)).trim();
var words = text.length ? text.split(/\s+/).filter(Boolean).length : 0;
var chars = plainText(body).length;
var minutes = Math.max(1, Math.round(words / 220));
wordCountEl.textContent = words.toLocaleString();
charCountEl.textContent = chars.toLocaleString();
readTimeEl.textContent = minutes + " min";
readTimeMeta.textContent = minutes + " min read";
}
/* ---------- Kicker / dateline echoes ---------- */
var sectionSelect = $("#sectionSelect");
var kickerEcho = $("#kickerEcho");
var datelineEcho = $("#datelineEcho");
function syncKicker() {
kickerEcho.textContent = sectionSelect.value + " · The Meridian Dispatch";
}
sectionSelect.addEventListener("change", function () {
syncKicker();
markDirty();
});
/* ---------- Slugify ---------- */
function slugify(s) {
return s.toLowerCase()
.replace(/['"’]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60);
}
$("#slugSync").addEventListener("click", function () {
slugInput.value = slugify(plainText(headline)) || "untitled-story";
markDirty();
toast("Slug synced from headline", "ok");
});
slugInput.addEventListener("input", markDirty);
/* ---------- Toolbar formatting ---------- */
var toolbar = $("#toolbar");
function focusBody() {
if (document.activeElement !== body && !body.contains(document.activeElement)) {
body.focus();
}
}
function exec(cmd, value) {
focusBody();
try { document.execCommand(cmd, false, value || null); } catch (e) {}
markDirty();
updateStats();
updateToolbarState();
}
$$(".tbtn[data-cmd]").forEach(function (btn) {
btn.addEventListener("mousedown", function (e) { e.preventDefault(); });
btn.addEventListener("click", function () {
var cmd = btn.getAttribute("data-cmd");
var val = btn.getAttribute("data-value");
if (cmd === "formatBlock") {
exec("formatBlock", val);
} else {
exec(cmd);
}
});
});
function updateToolbarState() {
try {
$$(".tbtn[data-cmd='bold']").forEach(function (b) {
b.classList.toggle("is-active", document.queryCommandState("bold"));
});
$$(".tbtn[data-cmd='italic']").forEach(function (b) {
b.classList.toggle("is-active", document.queryCommandState("italic"));
});
} catch (e) {}
}
document.addEventListener("selectionchange", function () {
if (body.contains(document.getSelection().anchorNode)) updateToolbarState();
});
/* ---------- Link insert ---------- */
$("#linkBtn").addEventListener("mousedown", function (e) { e.preventDefault(); });
$("#linkBtn").addEventListener("click", function () {
focusBody();
var sel = document.getSelection();
var hasText = sel && sel.toString().length > 0;
var url = window.prompt("Link URL", "https://");
if (!url) return;
if (hasText) {
exec("createLink", url);
} else {
var label = window.prompt("Link text", url) || url;
document.execCommand("insertHTML", false,
'<a href="' + escapeAttr(url) + '">' + escapeHtml(label) + "</a>");
markDirty();
updateStats();
}
toast("Link inserted", "ok");
});
/* ---------- Figure insert ---------- */
var coverTreatments = ["", "press-photo--t2", "press-photo--t3"];
var figCounter = 0;
$("#figureBtn").addEventListener("mousedown", function (e) { e.preventDefault(); });
$("#figureBtn").addEventListener("click", function () {
focusBody();
var cap = window.prompt("Figure caption", "Front Street after the January king tide.");
if (cap === null) return;
var treat = coverTreatments[figCounter % coverTreatments.length];
figCounter++;
var html =
'<figure contenteditable="false">' +
'<span class="press-photo ' + treat + '"></span>' +
'<figcaption><em>' + escapeHtml(cap || "Untitled figure.") +
'</em> <span class="credit">Meridian Dispatch / Picture Desk</span></figcaption>' +
'</figure><p><br></p>';
document.execCommand("insertHTML", false, html);
markDirty();
updateStats();
toast("Figure inserted", "ok");
});
function escapeHtml(s) {
return String(s).replace(/[&<>]/g, function (c) {
return ({ "&": "&", "<": "<", ">": ">" })[c];
});
}
function escapeAttr(s) {
return escapeHtml(s).replace(/"/g, """);
}
/* ---------- Status chips ---------- */
var statusNotes = {
draft: "A draft is visible only to the newsroom.",
review: "Sent to the desk editor for review before publication.",
scheduled: "Queued to go live automatically at the scheduled time.",
published: "Live on the wire and visible to all readers."
};
var statusNote = $("#statusNote");
$$(".status-chip").forEach(function (chip) {
chip.addEventListener("click", function () {
$$(".status-chip").forEach(function (c) {
c.classList.remove("is-active");
c.setAttribute("aria-checked", "false");
});
chip.classList.add("is-active");
chip.setAttribute("aria-checked", "true");
var st = chip.getAttribute("data-status");
statusNote.textContent = statusNotes[st] || "";
markDirty();
toast("Status set to " + chip.textContent.trim(), st === "published" ? "ok" : "accent");
});
});
/* ---------- Tags ---------- */
var tagList = $("#tagList");
function bindTagRemove(btn) {
btn.addEventListener("click", function () {
btn.parentNode.remove();
markDirty();
});
}
$$("#tagList .tag button").forEach(bindTagRemove);
$("#tagForm").addEventListener("submit", function (e) {
e.preventDefault();
var input = $("#tagInput");
var raw = input.value.trim().toLowerCase().replace(/[^a-z0-9\- ]/g, "");
if (!raw) return;
var exists = $$("#tagList .tag").some(function (t) {
return t.textContent.replace("×", "").trim() === raw;
});
if (exists) { toast("Tag already added", "accent"); input.value = ""; return; }
var span = document.createElement("span");
span.className = "tag";
span.innerHTML = escapeHtml(raw) +
' <button type="button" aria-label="Remove ' + escapeAttr(raw) + '">×</button>';
tagList.appendChild(span);
bindTagRemove(span.querySelector("button"));
input.value = "";
markDirty();
});
/* ---------- Cover treatment cycle ---------- */
var coverArt = $("#coverArt");
var coverIdx = 0;
$("#coverBtn").addEventListener("click", function () {
coverIdx = (coverIdx + 1) % coverTreatments.length;
coverArt.className = "press-photo press-photo--cover " + coverTreatments[coverIdx];
markDirty();
toast("Cover treatment updated");
});
$("#coverCaption").addEventListener("input", markDirty);
/* ---------- Preview toggle ---------- */
var previewToggle = $("#previewToggle");
var reader = $("#reader");
var previewing = false;
function buildPreview() {
$("#rdKicker").textContent = sectionSelect.value;
$("#rdHeadline").textContent = plainText(headline);
$("#rdDeck").textContent = plainText(deck);
$("#rdByline").textContent = "By " + plainText(byline);
$("#rdDateline").textContent = datelineEcho.textContent;
$("#rdReadtime").textContent = readTimeMeta.textContent;
$("#rdCoverCap").textContent = $("#coverCaption").value;
$("#rdHero").className = "press-photo press-photo--hero " + coverTreatments[coverIdx];
$("#rdBody").innerHTML = body.innerHTML;
}
function setPreview(on) {
previewing = on;
document.body.classList.toggle("is-preview", on);
reader.hidden = !on;
previewToggle.setAttribute("aria-pressed", String(on));
previewToggle.lastChild.textContent = on ? " Edit" : " Preview";
if (on) {
buildPreview();
window.scrollTo({ top: 0, behavior: "smooth" });
}
}
previewToggle.addEventListener("click", function () { setPreview(!previewing); });
/* ---------- Save / publish ---------- */
$("#saveDraft").addEventListener("click", function () {
markSaved();
toast("Draft saved · " + new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }), "ok");
});
$("#publishBtn").addEventListener("click", function () {
var pub = $(".status-chip[data-status='published']");
$$(".status-chip").forEach(function (c) {
c.classList.remove("is-active"); c.setAttribute("aria-checked", "false");
});
pub.classList.add("is-active");
pub.setAttribute("aria-checked", "true");
statusNote.textContent = statusNotes.published;
markSaved();
toast("Published to the wire — “" + plainText(headline).slice(0, 42) + "…”", "ok");
});
/* ---------- Keyboard shortcuts ---------- */
document.addEventListener("keydown", function (e) {
var mod = e.metaKey || e.ctrlKey;
if (mod && e.key.toLowerCase() === "s") {
e.preventDefault();
$("#saveDraft").click();
}
if (mod && e.key.toLowerCase() === "b" && body.contains(document.activeElement)) {
updateToolbarState();
markDirty();
}
});
/* ---------- Wire up dirty + stats on editing ---------- */
[headline, deck, byline, body].forEach(function (el) {
el.addEventListener("input", function () { markDirty(); updateStats(); });
});
body.addEventListener("keyup", updateToolbarState);
body.addEventListener("mouseup", updateToolbarState);
/* ---------- Init ---------- */
syncKicker();
updateStats();
markSaved();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>The Meridian Dispatch — Article Editor</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:wght@500;600;700;800;900&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<a class="skip-link" href="#editor-surface">Skip to editor</a>
<header class="topbar" role="banner">
<div class="topbar__brand">
<span class="topbar__mark" aria-hidden="true">M</span>
<div class="topbar__id">
<strong class="topbar__paper">The Meridian Dispatch</strong>
<span class="topbar__sub">Newsroom CMS · Article Editor</span>
</div>
</div>
<div class="topbar__status" role="group" aria-label="Save state">
<span class="dot dot--saved" id="saveDot" aria-hidden="true"></span>
<span id="saveLabel">All changes saved</span>
</div>
<div class="topbar__actions">
<button class="btn btn--ghost" id="previewToggle" type="button" aria-pressed="false">
<span class="btn__ico" aria-hidden="true">◍</span> Preview
</button>
<button class="btn btn--ghost" id="saveDraft" type="button">Save draft</button>
<button class="btn btn--primary" id="publishBtn" type="button">Publish</button>
</div>
</header>
<main class="workspace" id="workspace">
<!-- ================= EDITING SURFACE ================= -->
<section class="editor" id="editor-surface" aria-label="Article editing surface">
<div class="editor__inner">
<p class="kicker" id="kickerEcho">Investigations · The Meridian Dispatch</p>
<label class="field-label" for="headlineInput">Headline</label>
<h1 class="headline-input" id="headlineInput" contenteditable="true"
spellcheck="true" role="textbox" aria-multiline="true"
data-placeholder="Write the headline…">The Tide Line Moves Inland, and a Harbor Town Rewrites Its Map</h1>
<label class="field-label" for="deckInput">Standfirst / Deck</label>
<p class="deck-input" id="deckInput" contenteditable="true" spellcheck="true"
role="textbox" aria-multiline="true"
data-placeholder="Write the standfirst…">For three generations the Calder family fished from the same slip. This winter the water reached the bakery on Front Street — and the council finally agreed it could no longer wait.</p>
<div class="byline-row">
<span class="byline-row__by">By</span>
<span class="byline-input" id="bylineInput" contenteditable="true" spellcheck="false"
role="textbox" data-placeholder="Reporter name">Eleanor Voss</span>
<span class="byline-row__sep" aria-hidden="true">·</span>
<span class="dateline" id="datelineEcho">PORTHALLOW</span>
<span class="byline-row__sep" aria-hidden="true">·</span>
<span class="readtime" id="readTimeMeta">4 min read</span>
</div>
<!-- Formatting toolbar -->
<div class="toolbar" role="toolbar" aria-label="Text formatting" id="toolbar">
<div class="toolbar__group">
<button class="tbtn" type="button" data-cmd="bold" aria-label="Bold" title="Bold (Ctrl/Cmd+B)"><b>B</b></button>
<button class="tbtn" type="button" data-cmd="italic" aria-label="Italic" title="Italic (Ctrl/Cmd+I)"><i>I</i></button>
</div>
<span class="toolbar__rule" aria-hidden="true"></span>
<div class="toolbar__group">
<button class="tbtn tbtn--wide" type="button" data-cmd="formatBlock" data-value="h2" title="Section subhead">H2</button>
<button class="tbtn tbtn--wide" type="button" data-cmd="formatBlock" data-value="blockquote" title="Pull quote">❝ Quote</button>
<button class="tbtn tbtn--wide" type="button" data-cmd="formatBlock" data-value="p" title="Normal paragraph">¶ Body</button>
</div>
<span class="toolbar__rule" aria-hidden="true"></span>
<div class="toolbar__group">
<button class="tbtn tbtn--wide" type="button" id="linkBtn" title="Insert link">🔗 Link</button>
<button class="tbtn tbtn--wide" type="button" id="figureBtn" title="Insert figure">▣ Figure</button>
</div>
</div>
<!-- The article body -->
<article class="body" id="articleBody" contenteditable="true" spellcheck="true"
role="textbox" aria-multiline="true" aria-label="Article body">
<p>The first warning came not from a tide gauge but from a bakery. On the morning of the king tide in January, Marta Calder opened the door of <strong>Front Street Bake House</strong> to find seawater pooling against the ovens, an inch of cold brine where there had only ever been flour dust. By noon it had drained back to the harbor, but the line it left on the skirting board did not wash away — and neither did the question it raised.</p>
<p>Porthallow has flooded before. Old photographs in the parish hall show the quay underwater in 1953, men in waders ferrying crates of mackerel above the swell. What is different now, residents say, is the frequency. The harbor master logged eleven tidal incursions last year, against an average of two a decade ago. The sea is not arriving in a single dramatic storm. It is arriving on calm, bright days, on the ordinary turn of the moon.</p>
<h2>A council that waited, and then could not</h2>
<p>For most of the past decade the borough council deferred. A managed-realignment plan drafted in 2017 was shelved as too expensive and too politically costly: no councillor wished to be the one who told a fishing family the slip their grandfather built would be surrendered to the water. The plan sat in a drawer while the estimates for a sea wall climbed past nine million pounds.</p>
<blockquote>You can argue with a flood map for twenty years. You cannot argue with water in the bread oven. That is the moment the debate ended.</blockquote>
<p>The vote, when it finally came in March, was not close. Seven to two, the council adopted a phased retreat: the lowest row of harborside cottages will be bought out over five years, the quay rebuilt eighty meters inland, and the original slip given back to the estuary as salt marsh — a sponge to soak the surge rather than a wall to refuse it.</p>
<h2>What is gained by giving ground</h2>
<p>Coastal engineers call it <em>working with the water</em>, and the phrase carries an unfamiliar humility for a town that has spent four centuries holding the sea at arm's length. Salt marsh absorbs wave energy that concrete merely reflects. It returns, in time, the oystercatchers and the samphire that the harbor wall had long ago pushed out. It is cheaper, and it is slower, and it asks the town to grieve a shape of itself before it can imagine another.</p>
<p>Marta Calder has not decided whether she will take the buyout. The bake house has been in the family since 1908; the recipe for the saffron loaf is older than the harbor wall. But she has started keeping the flour on the high shelf, and she no longer argues, as her father did, that the water will hold to its old line. "The map we grew up with," she said, wiping down the counter where the salt had reached, "was only ever borrowed."</p>
</article>
</div>
</section>
<!-- ================= READER PREVIEW (hidden by default) ================= -->
<section class="reader" id="reader" aria-label="Reader preview" hidden>
<article class="reader__article">
<p class="reader__kicker" id="rdKicker">Investigations</p>
<h1 class="reader__headline" id="rdHeadline"></h1>
<p class="reader__deck" id="rdDeck"></p>
<div class="reader__meta">
<span id="rdByline"></span>
<span class="reader__sep" aria-hidden="true">·</span>
<span id="rdDateline"></span>
<span class="reader__sep" aria-hidden="true">·</span>
<span id="rdReadtime"></span>
</div>
<figure class="reader__hero">
<div class="press-photo press-photo--hero" id="rdHero" aria-hidden="true"></div>
<figcaption><em id="rdCoverCap">The harbor at Porthallow on a January king tide.</em> <span class="credit">Meridian Dispatch / Picture Desk</span></figcaption>
</figure>
<div class="reader__body" id="rdBody"></div>
</article>
</section>
<!-- ================= META / PUBLISH SIDEBAR ================= -->
<aside class="sidebar" aria-label="Publishing settings">
<div class="panel">
<h2 class="panel__title">Status</h2>
<div class="status-grid" role="radiogroup" aria-label="Publication status">
<button class="status-chip is-active" type="button" role="radio" aria-checked="true" data-status="draft">Draft</button>
<button class="status-chip" type="button" role="radio" aria-checked="false" data-status="review">In review</button>
<button class="status-chip" type="button" role="radio" aria-checked="false" data-status="scheduled">Scheduled</button>
<button class="status-chip" type="button" role="radio" aria-checked="false" data-status="published">Published</button>
</div>
<p class="status-note" id="statusNote">A draft is visible only to the newsroom.</p>
</div>
<div class="panel">
<h2 class="panel__title">Cover image</h2>
<figure class="cover">
<button class="cover__well" type="button" id="coverBtn" aria-label="Cycle cover treatment">
<span class="press-photo press-photo--cover" id="coverArt" aria-hidden="true"></span>
<span class="cover__hint">Tap to change treatment</span>
</button>
<figcaption class="cover__cap">
<input class="cover__caption-input" id="coverCaption" value="The harbor at Porthallow on a January king tide." aria-label="Cover caption" />
<span class="credit">Meridian Dispatch / Picture Desk</span>
</figcaption>
</figure>
</div>
<div class="panel">
<h2 class="panel__title">Section</h2>
<label class="visually-hidden" for="sectionSelect">Section</label>
<select class="select" id="sectionSelect">
<option>Investigations</option>
<option>Climate & Coast</option>
<option>Local</option>
<option>Business</option>
<option>Culture</option>
<option>Opinion</option>
</select>
</div>
<div class="panel">
<h2 class="panel__title">Tags</h2>
<div class="tags" id="tagList" aria-live="polite">
<span class="tag">climate <button type="button" aria-label="Remove climate" data-tag="climate">×</button></span>
<span class="tag">coastal <button type="button" aria-label="Remove coastal" data-tag="coastal">×</button></span>
<span class="tag">porthallow <button type="button" aria-label="Remove porthallow" data-tag="porthallow">×</button></span>
</div>
<form class="tag-add" id="tagForm">
<input class="tag-add__input" id="tagInput" placeholder="Add a tag…" aria-label="Add a tag" />
<button class="btn btn--tiny" type="submit">Add</button>
</form>
</div>
<div class="panel">
<h2 class="panel__title">SEO slug</h2>
<div class="slug">
<span class="slug__base">meridian.example/</span>
<input class="slug__input" id="slugInput" value="tide-line-moves-inland" aria-label="URL slug" />
</div>
<button class="link-btn" type="button" id="slugSync">Sync slug from headline</button>
</div>
<div class="panel panel--stats">
<h2 class="panel__title">Length</h2>
<dl class="stats">
<div class="stat"><dt>Words</dt><dd id="wordCount">0</dd></div>
<div class="stat"><dt>Read time</dt><dd id="readTime">0 min</dd></div>
<div class="stat"><dt>Characters</dt><dd id="charCount">0</dd></div>
</dl>
</div>
</aside>
</main>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Article Editor
A full newsroom writing surface for The Meridian Dispatch, split into two panes. On the left, the editing column holds an inline-editable headline set in a Playfair display serif, an italic standfirst, an editable byline with a live dateline and read-time meta, and a contenteditable body wearing a sticky formatting toolbar. The toolbar runs bold, italic, H2 subheads, oversized pull quotes, normal-paragraph reset, link insertion, and a figure tool that drops a simulated press photo with an italic caption and credit line straight into the copy. The lead paragraph carries a red drop cap and the columns are justified with hyphenation for a true printed feel.
On the right, a publishing sidebar handles everything around the words: a four-state status switch (Draft, In review, Scheduled, Published) with contextual notes, a removable tag list with an add field, a section select that re-flows the kicker, an SEO slug that can be synced from the headline, a cover image with cycle-able duotone treatments and an editable caption, and a length panel that recomputes word count, character count and read time as you type. A Preview toggle hides the chrome and re-renders the story in reader layout — multi-column, drop-capped, with a hero figure — and Save and Publish fire toast confirmations.
Everything is vanilla JavaScript over contenteditable and document.execCommand, with keyboard shortcuts (Cmd/Ctrl+S to save, Cmd/Ctrl+B/I for formatting), a dirty-state indicator in the top bar, and ARIA roles on the toolbar, status radiogroup and live regions. No frameworks, no network, no images — the photography is built entirely from CSS gradients.
Illustrative UI only — masthead, headlines, bylines, and articles are fictional; not a real news publication.