[object Object]

← back to Crazy News Channel

Add admin panel: create/delete/reset controls for breaking stories

c180c52f2bd4c582701beb5c7d3e487f82c6e0a4 · 2026-09-23 14:58:48 -0700 · Steve Abrams

Adds a backstage "control room" admin panel (toggled via a distinctly
styled Admin button, never themed by the broadcast mood system) that
lets Steve create and delete the 3 escalating breaking stories:

- Create form: headline + category (reuses the 6 existing mood categories
  plus a new 7th "Uncategorized" category/mood, added to both the admin
  select and the viewer's category filter so no admin-assigned category is
  an orphan). Empty-headline submission shows a real inline error state
  (no alert()); valid submissions synthesize a full 5-stage escalation
  ladder by layering escalating bulletin framing onto the admin's headline,
  so arbitrary input still plugs into the same DEVELOPING->TRANSCENDENT
  timer system, ticker, and completion logic as the seeded stories.
- Delete: per-story Delete buttons in the admin list, working on seeded
  and admin-created stories alike. Deleting the last story in a filtered
  category correctly falls back to the existing per-category empty state;
  deleting every story on the network triggers a new top-level "no stories
  currently on the network" state (distinct from the per-category one,
  since the page previously assumed >=1 story always existed somewhere).
- Persistence: localStorage-backed (with shape validation + fallback to
  defaults on missing/corrupt data), plus a "Reset to Defaults" control.

Bugs found and fixed via the Playwright verification pass (47/47 new
checks + 19/19 original regression checks, zero console errors):
- Admin story row was an implicit-min-width CSS Grid item, pushing its
  Delete button off-screen at 390px -- same bug class as the ticker/fieldset
  overflow fixed in the initial build; added min-width:0.
- STORIES.every() is vacuously true on an empty array, which would have
  falsely fired the "NETWORK OVERLOAD" meltdown banner the instant every
  story was deleted -- added an explicit length guard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011iuURMrsPBwAqMc5LgvFLH

Files touched

Diff

commit c180c52f2bd4c582701beb5c7d3e487f82c6e0a4
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Sep 23 14:58:48 2026 -0700

    Add admin panel: create/delete/reset controls for breaking stories
    
    Adds a backstage "control room" admin panel (toggled via a distinctly
    styled Admin button, never themed by the broadcast mood system) that
    lets Steve create and delete the 3 escalating breaking stories:
    
    - Create form: headline + category (reuses the 6 existing mood categories
      plus a new 7th "Uncategorized" category/mood, added to both the admin
      select and the viewer's category filter so no admin-assigned category is
      an orphan). Empty-headline submission shows a real inline error state
      (no alert()); valid submissions synthesize a full 5-stage escalation
      ladder by layering escalating bulletin framing onto the admin's headline,
      so arbitrary input still plugs into the same DEVELOPING->TRANSCENDENT
      timer system, ticker, and completion logic as the seeded stories.
    - Delete: per-story Delete buttons in the admin list, working on seeded
      and admin-created stories alike. Deleting the last story in a filtered
      category correctly falls back to the existing per-category empty state;
      deleting every story on the network triggers a new top-level "no stories
      currently on the network" state (distinct from the per-category one,
      since the page previously assumed >=1 story always existed somewhere).
    - Persistence: localStorage-backed (with shape validation + fallback to
      defaults on missing/corrupt data), plus a "Reset to Defaults" control.
    
    Bugs found and fixed via the Playwright verification pass (47/47 new
    checks + 19/19 original regression checks, zero console errors):
    - Admin story row was an implicit-min-width CSS Grid item, pushing its
      Delete button off-screen at 390px -- same bug class as the ticker/fieldset
      overflow fixed in the initial build; added min-width:0.
    - STORIES.every() is vacuously true on an empty array, which would have
      falsely fired the "NETWORK OVERLOAD" meltdown banner the instant every
      story was deleted -- added an explicit length guard.
    
    Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_011iuURMrsPBwAqMc5LgvFLH
---
 index.html | 424 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 421 insertions(+), 3 deletions(-)

diff --git a/index.html b/index.html
index 46c86cf..445df7a 100644
--- a/index.html
+++ b/index.html
@@ -95,6 +95,11 @@ body[data-mood="scitech"] {
   --accent: #00f0ff; --accent2: #ff00e6; --text: #eafcff; --text-dim: #c9f3ffaa;
   --chrome: #08081ccc; --focus: #ff00e6;
 }
+body[data-mood="uncategorized"] {
+  --bg: #e9edf0; --bg2: #ffffff; --panel: #ffffff; --panel-border: #54657833;
+  --accent: #546578; --accent2: #2f3b47; --text: #20272d; --text-dim: #20272d99;
+  --chrome: #ffffffcc; --focus: #0b3d91;
+}
 
 /* ---------- 3. Chrome (header / nav) ---------- */
 .chrome {
@@ -159,6 +164,20 @@ body[data-mood="scitech"] {
 .sound-toggle input { width: 1rem; height: 1rem; accent-color: var(--accent); cursor: pointer; }
 .audio-note { font-size: .7rem; color: var(--text-dim); max-width: 16rem; }
 
+/* Admin toggle — deliberately NOT themed by mood vars: fixed slate/mono
+   "control room" look so it never reads as part of the in-universe broadcast. */
+.admin-toggle {
+  cursor: pointer;
+  border: 1.5px dashed #6b7280;
+  background: #20242b; color: #d7dee5;
+  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+  font-size: .72rem; font-weight: 700; letter-spacing: .3px;
+  padding: .5rem .75rem; border-radius: .4rem;
+  transition: background-color .15s ease, border-color .15s ease, color .15s ease;
+}
+.admin-toggle:hover { background: #2a2f37; }
+.admin-toggle[aria-expanded="true"] { background: #343a40; border-style: solid; border-color: #7dd3fc; color: #7dd3fc; }
+
 /* ---------- category filter (nav) ---------- */
 .category-filter { padding: .6rem 1rem; border-bottom: 1px solid var(--panel-border); }
 .category-filter fieldset { border: 0; margin: 0; padding: 0; min-width: 0; }
@@ -332,6 +351,80 @@ body.breaking-mode .ticker-track { color: #ff5959; font-weight: 800; }
   margin-top: 1rem; border-color: var(--accent); border-style: solid;
   background: color-mix(in srgb, var(--accent) 12%, var(--panel));
 }
+.story-card.just-added { outline: 3px dashed var(--accent); outline-offset: 2px; }
+
+/* ---------- 7b. Admin panel (backstage / control-room, not on-air) ----------
+   Deliberately fixed slate/mono palette independent of --mood vars so it
+   never gets confused with the in-universe broadcast chrome. Renders in
+   normal document flow (an expand/collapse section, not an overlay) so it
+   can never overlap content or fight the ticker/vignette z-index stack. */
+.admin-panel {
+  max-width: 1200px;
+  margin: 1rem auto 0;
+  padding: 1rem 1.25rem 1.25rem;
+  border-radius: .6rem;
+  background: #1b1f24;
+  color: #e8eaed;
+  border: 2px dashed #4a5057;
+  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+}
+.admin-panel h2 { font-size: .95rem; letter-spacing: .5px; text-transform: uppercase; color: #7dd3fc; margin-bottom: .15rem; }
+.admin-panel h3 { font-size: .78rem; letter-spacing: .4px; text-transform: uppercase; color: #b8c0c8; margin-bottom: .5rem; }
+.admin-hint { font-size: .72rem; color: #9aa4ad; margin-bottom: 1rem; font-style: italic; }
+.admin-panel hr { border: none; border-top: 1px dashed #3a4048; margin: 1.1rem 0; }
+
+.admin-form { display: grid; gap: .5rem; }
+.admin-form label { font-size: .7rem; text-transform: uppercase; letter-spacing: .4px; color: #b8c0c8; }
+.admin-form input[type="text"], .admin-form select {
+  width: 100%; padding: .5rem .6rem; border-radius: .35rem;
+  border: 1.5px solid #4a5057; background: #262b31; color: #f2f4f6;
+  font-family: inherit; font-size: .85rem;
+}
+.admin-form input[aria-invalid="true"] { border-color: #ff6b6b; }
+.admin-form input[type="text"]:focus-visible,
+.admin-form select:focus-visible,
+.admin-panel button:focus-visible {
+  outline: 3px solid #7dd3fc; outline-offset: 2px;
+}
+.admin-error { font-size: .78rem; color: #ff8b8b; font-weight: 700; }
+.admin-success { font-size: .78rem; color: #7dffb0; font-weight: 700; }
+.admin-submit-btn {
+  justify-self: start; margin-top: .2rem;
+  cursor: pointer; border: none; border-radius: .35rem;
+  padding: .55rem 1.1rem; font-weight: 800; font-size: .82rem;
+  background: #7dd3fc; color: #0b1a24;
+}
+.admin-reset-btn {
+  justify-self: start; margin-top: 1rem;
+  cursor: pointer; border: 1.5px solid #4a5057; border-radius: .35rem;
+  padding: .5rem 1rem; font-weight: 700; font-size: .78rem;
+  background: transparent; color: #e8eaed;
+}
+.admin-reset-btn:hover { background: #262b31; }
+
+.admin-story-list { list-style: none; margin: 0 0 .5rem; padding: 0; display: grid; gap: .5rem; max-height: 16rem; overflow-y: auto; }
+.admin-story-row {
+  /* min-width: 0 overrides the browser's implicit min-width:auto on grid
+     items — without it this row (and its nowrap/ellipsis child text) won't
+     shrink below its content's min-content width, pushing the Delete
+     button off-screen at narrow viewports (the same class of bug as the
+     ticker/fieldset overflow fixed earlier in this project). */
+  min-width: 0;
+  display: flex; align-items: center; justify-content: space-between; gap: .75rem;
+  padding: .5rem .6rem; border: 1px solid #343a40; border-radius: .4rem; background: #22262b;
+}
+.admin-story-info { flex: 1 1 auto; display: flex; flex-direction: column; gap: .15rem; min-width: 0; }
+.admin-story-cat { font-size: .62rem; text-transform: uppercase; letter-spacing: .4px; color: #7dd3fc; }
+.admin-story-headline { font-size: .8rem; color: #e8eaed; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
+.admin-delete-btn {
+  flex: 0 0 auto; cursor: pointer; border: 1.5px solid #ff6b6b; background: transparent;
+  color: #ff8b8b; border-radius: .35rem; padding: .35rem .6rem; font-weight: 700; font-size: .72rem;
+}
+.admin-delete-btn:hover { background: #ff6b6b22; }
+.admin-empty { font-size: .8rem; color: #9aa4ad; padding: .5rem 0; }
+@media (min-width: 768px) {
+  .admin-panel-body { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start; }
+}
 
 /* ---------- 8. Focus & a11y helpers ---------- */
 a, button, input, [tabindex] { outline-offset: 2px; }
@@ -350,6 +443,7 @@ a, button, input, [tabindex] { outline-offset: 2px; }
   .chrome { padding: 1rem 1.5rem; }
   .category-filter { padding: .75rem 1.5rem; }
   main#channelContainer { padding: 1.25rem 1.5rem 1.75rem; }
+  .admin-panel { padding: 1.25rem 1.5rem 1.75rem; }
   .story-grid { grid-template-columns: repeat(2, 1fr); }
   .story-card.lead { grid-column: 1 / -1; }
 }
@@ -357,6 +451,7 @@ a, button, input, [tabindex] { outline-offset: 2px; }
   .chrome { padding: 1rem 2rem; }
   .category-filter { padding: .75rem 2rem; }
   main#channelContainer { padding: 1.5rem 2rem 2rem; }
+  .admin-panel { padding: 1.5rem 2rem 2rem; }
   .story-grid {
     grid-template-columns: 2fr 1fr;
     grid-template-rows: auto auto;
@@ -409,6 +504,10 @@ a, button, input, [tabindex] { outline-offset: 2px; }
       Klaxon sound (muted by default)
     </label>
     <span id="audioNote" class="audio-note" role="status" hidden></span>
+    <button id="adminToggleBtn" class="admin-toggle" type="button"
+            aria-expanded="false" aria-controls="adminPanel">
+      ⚙ Admin
+    </button>
   </div>
 </header>
 
@@ -447,10 +546,50 @@ a, button, input, [tabindex] { outline-offset: 2px; }
         <input type="radio" name="category" id="cat-business" value="business" />
         <label for="cat-business">Business</label>
       </span>
+      <span class="chip-item">
+        <input type="radio" name="category" id="cat-uncategorized" value="uncategorized" />
+        <label for="cat-uncategorized">Uncategorized</label>
+      </span>
     </div>
   </fieldset>
 </nav>
 
+<!-- ============================================================
+     ADMIN PANEL — backstage / control-room tooling for managing
+     the breaking stories. Renders in normal document flow (not an
+     overlay) so it can never overlap broadcast content, and is
+     styled with a fixed slate/mono palette independent of the
+     on-air mood theming so it's unmistakably operator tooling.
+     ============================================================ -->
+<section id="adminPanel" class="admin-panel" hidden aria-label="Admin: manage breaking stories">
+  <h2>Admin — Breaking Stories</h2>
+  <p class="admin-hint">Backstage controls. Not part of the broadcast.</p>
+
+  <div class="admin-panel-body">
+    <div>
+      <h3>Create a story</h3>
+      <form id="adminCreateForm" class="admin-form" novalidate>
+        <label for="adminHeadline">Headline</label>
+        <input type="text" id="adminHeadline" name="headline" autocomplete="off" />
+        <div id="adminHeadlineError" class="admin-error" role="alert" hidden></div>
+
+        <label for="adminCategory">Category</label>
+        <select id="adminCategory" name="category"></select>
+
+        <button type="submit" class="admin-submit-btn">+ Add Story</button>
+        <div id="adminCreateSuccess" class="admin-success" role="status" hidden></div>
+      </form>
+    </div>
+
+    <div>
+      <h3>Current stories (<span id="adminStoryCount">0</span>)</h3>
+      <ul id="adminStoryList" class="admin-story-list"></ul>
+      <p id="adminNoStories" class="admin-empty" hidden>No stories exist. Create one, or reset to defaults.</p>
+      <button id="adminResetBtn" type="button" class="admin-reset-btn">↺ Reset to Defaults</button>
+    </div>
+  </div>
+</section>
+
 <!-- BREAKING banner (hidden by default, shown when klaxon toggled ON) -->
 <div id="breakingBanner" class="breaking-banner" hidden aria-live="assertive">
   <span class="bb-icon" aria-hidden="true">🚨</span>
@@ -469,6 +608,13 @@ a, button, input, [tabindex] { outline-offset: 2px; }
     <span class="mood-readout" id="moodReadout">Channel mood: Calm-ish (Default)</span>
   </div>
 
+  <div id="noStoriesState" class="empty-state" hidden>
+    <span class="es-emoji" aria-hidden="true">📡</span>
+    <h3>No stories currently on the network.</h3>
+    <p>P24 has nothing to report right now, which is itself deeply suspicious.<br />
+       Use the admin panel to add a story, or hit <strong>Reset to Defaults</strong>.</p>
+  </div>
+
   <div id="storyGrid" class="story-grid"></div>
 
   <div id="emptyState" class="empty-state" hidden>
@@ -519,12 +665,28 @@ const MOODS = {
   sports:        { label: "Hype Orange" },
   entertainment: { label: "Glam Panic" },
   business:      { label: "Money Green" },
+  uncategorized: { label: "Slate Neutral" },
+};
+
+// Human-readable badge text per category (used for both seeded and
+// admin-created stories, and to populate the admin "category" select).
+const CATEGORY_LABELS = {
+  politics: "Politics",
+  weather: "Weather",
+  scitech: "Sci-Tech",
+  sports: "Sports",
+  entertainment: "Entertainment",
+  business: "Business",
+  uncategorized: "Uncategorized",
 };
 
 // Escalation ladder shared by every story.
 const LEVEL_LABELS = ["DEVELOPING", "ESCALATING", "SPIRALING", "CRITICAL", "TRANSCENDENT"];
 
-const STORIES = [
+// The original 3 seeded stories — kept as an immutable template so the
+// admin panel's "Reset to Defaults" can always restore them exactly,
+// regardless of what's been created/deleted/escalated since.
+const DEFAULT_STORIES = [
   {
     id: "tuesdays",
     category: "politics",
@@ -615,6 +777,47 @@ const FILLERS = [
   "P24 exclusive: our interns have unionized. We fully support it. Please send snacks.",
 ];
 
+/* ---------- A2. Persistence (admin create/delete survive a reload) ---------- */
+const STORAGE_KEY = "pandemonium24.breakingStories.v1";
+
+function cloneStories(list) {
+  return JSON.parse(JSON.stringify(list));
+}
+
+function loadStories() {
+  try {
+    const raw = localStorage.getItem(STORAGE_KEY);
+    if (!raw) return cloneStories(DEFAULT_STORIES);
+    const parsed = JSON.parse(raw);
+    // Basic shape validation guards against corrupt/foreign localStorage
+    // data — falls back to the defaults rather than crashing the page.
+    // An empty array is valid (it's the legitimate "deleted everything"
+    // state) and must be preserved, not treated as corrupt.
+    const isValidShape = Array.isArray(parsed) && parsed.every((s) =>
+      s && typeof s.id === "string" && typeof s.category === "string" &&
+      Array.isArray(s.stages) && s.stages.length > 0 &&
+      typeof s.stageIndex === "number"
+    );
+    return isValidShape ? parsed : cloneStories(DEFAULT_STORIES);
+  } catch (err) {
+    return cloneStories(DEFAULT_STORIES);
+  }
+}
+
+function persistStories() {
+  try {
+    localStorage.setItem(STORAGE_KEY, JSON.stringify(STORIES));
+  } catch (err) {
+    // Storage unavailable/full — non-fatal; admin changes just won't
+    // survive a reload this session. Everything else keeps working.
+  }
+}
+
+// `let`, not `const` — deleteStory()/resetToDefaults() reassign the whole
+// array (filter / clone). All readers (tick, renderStories, buildTickerItems)
+// reference the outer STORIES binding at call-time, so this is safe.
+let STORIES = loadStories();
+
 /* ---------- B. State ---------- */
 const state = {
   category: "all",
@@ -627,6 +830,7 @@ const state = {
   meltdownShown: false,
   audioCtx: null,
   klaxonAudioTimer: null,
+  justCreatedId: null,
 };
 
 /* ---------- C. Rendering ---------- */
@@ -645,6 +849,18 @@ const els = {
   tickerScroll: document.getElementById("tickerScroll"),
   tickerTrack: document.getElementById("tickerTrack"),
   tickerStatic: document.getElementById("tickerStatic"),
+  noStoriesState: document.getElementById("noStoriesState"),
+  adminToggleBtn: document.getElementById("adminToggleBtn"),
+  adminPanel: document.getElementById("adminPanel"),
+  adminCreateForm: document.getElementById("adminCreateForm"),
+  adminHeadline: document.getElementById("adminHeadline"),
+  adminHeadlineError: document.getElementById("adminHeadlineError"),
+  adminCategory: document.getElementById("adminCategory"),
+  adminCreateSuccess: document.getElementById("adminCreateSuccess"),
+  adminStoryList: document.getElementById("adminStoryList"),
+  adminStoryCount: document.getElementById("adminStoryCount"),
+  adminNoStories: document.getElementById("adminNoStories"),
+  adminResetBtn: document.getElementById("adminResetBtn"),
 };
 
 function announce(text) {
@@ -672,8 +888,10 @@ function storyCardHTML(story, isLead) {
   ).join("");
   const ariaLevel = `Escalation level ${story.stageIndex + 1} of ${story.stages.length}: ${levelLabel}`;
 
+  const justAdded = story.id === state.justCreatedId;
   return `
-    <article class="story-card${isLead ? " lead" : ""}${concluded ? " concluded" : ""}"
+    <article class="story-card${isLead ? " lead" : ""}${concluded ? " concluded" : ""}${justAdded ? " just-added" : ""}"
+              data-id="${escapeHTML(story.id)}"
               aria-label="${isLead ? "Lead story" : "Story"}: ${escapeHTML(story.categoryLabel)}">
       <div class="kicker">
         <span class="badge-cat">${escapeHTML(story.categoryLabel)}</span>
@@ -703,11 +921,24 @@ function escapeHTML(str) {
 }
 
 function renderStories() {
+  // Top-level completion state: every story has been deleted (a state that
+  // didn't exist before the admin panel could delete the seeded 3) — this
+  // is distinct from the per-category empty state below.
+  if (STORIES.length === 0) {
+    els.storyGrid.hidden = true;
+    els.emptyState.hidden = true;
+    els.meltdownBanner.hidden = true;
+    els.noStoriesState.hidden = false;
+    return;
+  }
+  els.noStoriesState.hidden = true;
+
   const list = visibleStories();
 
   if (!list.length) {
     els.storyGrid.hidden = true;
     els.emptyState.hidden = false;
+    els.meltdownBanner.hidden = true;
     return;
   }
   els.storyGrid.hidden = false;
@@ -718,7 +949,8 @@ function renderStories() {
     .map((s) => storyCardHTML(s, s === lead))
     .join("");
 
-  // completion / meltdown state: every story has reached its final stage
+  // completion / meltdown state: every story has reached its final stage.
+  // (STORIES.length is guaranteed > 0 here, so .every() can't vacuously fire.)
   const allConcluded = STORIES.every((s) => s.stageIndex === s.stages.length - 1);
   els.meltdownBanner.hidden = !allConcluded;
   if (allConcluded && !state.meltdownShown) {
@@ -860,6 +1092,7 @@ function tick() {
   });
 
   if (anyStoryAdvanced) {
+    persistStories();
     renderStories();
     refreshTicker();
   } else if (state.elapsedSec % 5 === 0 && !state.reducedMotion) {
@@ -873,6 +1106,176 @@ function tick() {
   }
 }
 
+/* ---------- I. Admin panel (create / delete / reset breaking stories) ----------
+   Backstage tooling for Steve, not a broadcast feature. Manages the SAME
+   STORIES array + escalation-stage system the seeded stories use, so
+   admin-created stories fully participate in the ticker, the lead/secondary
+   hierarchy, and the completion/meltdown logic. */
+
+// Layers escalating satirical framing onto an arbitrary admin headline so a
+// freeform story still progresses through the same 5-stage ladder in-voice,
+// without hand-authored per-story copy (which isn't possible for arbitrary
+// input). Each stage keeps the admin's original headline as the anchor and
+// wraps it in progressively more unhinged bulletin language.
+function synthesizeStages(headline) {
+  const frames = [
+    {
+      headline: (h) => h,
+      detail: () =>
+        "P24 has confirmed initial reports. Our sources describe the situation as \"developing, technically.\"",
+    },
+    {
+      headline: (h) => `${h} — And It's Already Getting Worse`,
+      detail: () =>
+        "Multiple unnamed officials now confirm what one napkin-based memo predicted hours ago.",
+    },
+    {
+      headline: (h) => `Sources: "${h}" Is Now Somehow Everyone's Problem`,
+      detail: () =>
+        "A task force has been formed to form a task force. Neither has met yet.",
+    },
+    {
+      headline: (h) => `BULLETIN: ${h} — Has Escalated Beyond Any Reasonable Framework`,
+      detail: () =>
+        "Analysts are reaching for words that don't normally appear in a weekday broadcast.",
+    },
+    {
+      headline: (h) => `${h}: Nation Agrees This Is Somehow Fine Now`,
+      detail: () =>
+        "Historians say this will be, at best, a footnote in a much stranger book.",
+      conclusion: "This story has concluded. Reality has not yet followed up.",
+    },
+  ];
+  return frames.map((f) => {
+    const stage = { headline: f.headline(headline), detail: f.detail(headline) };
+    if (f.conclusion) stage.conclusion = f.conclusion;
+    return stage;
+  });
+}
+
+function createStory(headline, categoryKey) {
+  const categoryLabel = CATEGORY_LABELS[categoryKey] || CATEGORY_LABELS.uncategorized;
+  return {
+    id: `admin-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
+    category: categoryKey,
+    categoryLabel,
+    location: "Location Pending Confirmation",
+    byline: "P24 Assignment Desk",
+    intervalSec: 6,
+    // Phase the new story's ladder off the CURRENT elapsed time so it gets
+    // its own ~6s cadence starting now, rather than colliding with the
+    // seeded stories' schedule.
+    offsetSec: state.elapsedSec,
+    stageIndex: 0,
+    lastUpdated: new Date().toLocaleTimeString(),
+    stages: synthesizeStages(headline),
+  };
+}
+
+function renderAdminStoryList() {
+  els.adminStoryCount.textContent = String(STORIES.length);
+  if (!STORIES.length) {
+    els.adminStoryList.innerHTML = "";
+    els.adminNoStories.hidden = false;
+    return;
+  }
+  els.adminNoStories.hidden = true;
+  els.adminStoryList.innerHTML = STORIES.map((s) => {
+    const headline = s.stages[s.stageIndex].headline;
+    return `
+      <li class="admin-story-row" data-id="${escapeHTML(s.id)}">
+        <span class="admin-story-info">
+          <span class="admin-story-cat">${escapeHTML(s.categoryLabel)}</span>
+          <span class="admin-story-headline" title="${escapeHTML(headline)}">${escapeHTML(headline)}</span>
+        </span>
+        <button type="button" class="admin-delete-btn" data-delete-id="${escapeHTML(s.id)}"
+                aria-label="Delete story: ${escapeHTML(headline)}">Delete</button>
+      </li>
+    `;
+  }).join("");
+}
+
+function populateAdminCategorySelect() {
+  els.adminCategory.innerHTML = Object.entries(CATEGORY_LABELS)
+    .map(([key, label]) => `<option value="${key}">${escapeHTML(label)}</option>`)
+    .join("");
+}
+
+function refreshAfterStoriesChanged() {
+  persistStories();
+  renderStories();
+  refreshTicker();
+  renderAdminStoryList();
+}
+
+function deleteStory(id) {
+  if (!STORIES.some((s) => s.id === id)) return;
+  const deleted = STORIES.find((s) => s.id === id);
+  STORIES = STORIES.filter((s) => s.id !== id);
+  refreshAfterStoriesChanged();
+  announce(`Story deleted: ${deleted.stages[deleted.stageIndex].headline}`);
+}
+
+function resetToDefaults() {
+  STORIES = cloneStories(DEFAULT_STORIES);
+  state.meltdownShown = false;
+  state.justCreatedId = null;
+  refreshAfterStoriesChanged();
+  announce("All stories reset to defaults.");
+}
+
+function handleAdminCreateSubmit(e) {
+  e.preventDefault();
+  const headline = els.adminHeadline.value.trim();
+
+  // Error state: empty headline. Real inline feedback, not alert()/silent no-op.
+  if (!headline) {
+    els.adminHeadlineError.hidden = false;
+    els.adminHeadlineError.textContent =
+      "A headline's required — even P24 can't run a story about nothing (yet).";
+    els.adminHeadline.setAttribute("aria-invalid", "true");
+    els.adminCreateSuccess.hidden = true;
+    els.adminHeadline.focus();
+    announce("Error: a headline is required to create a story.");
+    return;
+  }
+
+  els.adminHeadlineError.hidden = true;
+  els.adminHeadline.removeAttribute("aria-invalid");
+
+  const categoryKey = els.adminCategory.value || "uncategorized";
+  const newStory = createStory(headline, categoryKey);
+  STORIES.push(newStory);
+  state.justCreatedId = newStory.id;
+  refreshAfterStoriesChanged();
+  announce(`New story created: ${newStory.stages[0].headline}`);
+
+  els.adminCreateSuccess.hidden = false;
+  els.adminCreateSuccess.textContent = `Story added to the ${newStory.categoryLabel} desk.`;
+  els.adminHeadline.value = "";
+  els.adminHeadline.focus();
+
+  // Clear the "just added" highlight after a few seconds (static outline,
+  // no animation — safe under reduced-motion since nothing moves/flashes).
+  setTimeout(() => {
+    if (state.justCreatedId === newStory.id) {
+      state.justCreatedId = null;
+      renderStories();
+    }
+  }, 4000);
+}
+
+function setAdminOpen(open) {
+  els.adminPanel.hidden = !open;
+  els.adminToggleBtn.setAttribute("aria-expanded", String(open));
+  els.adminToggleBtn.textContent = open ? "⚙ Admin (close)" : "⚙ Admin";
+  if (open) {
+    els.adminHeadline.focus();
+  } else {
+    els.adminToggleBtn.focus();
+  }
+}
+
 /* ---------- H. Wiring / init ---------- */
 function init() {
   // category radios
@@ -910,6 +1313,21 @@ function init() {
     ? mq.addEventListener("change", (e) => applyMotionPref(e.matches))
     : mq.addListener((e) => applyMotionPref(e.matches));
 
+  // admin panel: toggle, create form, delete delegation, reset
+  els.adminToggleBtn.addEventListener("click", () => setAdminOpen(els.adminPanel.hidden));
+  document.addEventListener("keydown", (e) => {
+    if (e.key === "Escape" && !els.adminPanel.hidden) setAdminOpen(false);
+  });
+  populateAdminCategorySelect();
+  els.adminCreateForm.addEventListener("submit", handleAdminCreateSubmit);
+  els.adminStoryList.addEventListener("click", (e) => {
+    const btn = e.target.closest("[data-delete-id]");
+    if (!btn) return;
+    deleteStory(btn.dataset.deleteId);
+  });
+  els.adminResetBtn.addEventListener("click", resetToDefaults);
+  renderAdminStoryList();
+
   updateMoodUI();
   setTickerMode(state.reducedMotion);
   refreshTicker();

← cff03a3 Build PANDEMONIUM-24: satirical 24hr news channel (Crazy New  ·  back to Crazy News Channel  ·  Re-anchor saved story offsets on load so admin stories keep 4459222 →