News — Sticky Article TOC + Progress
A long-read editorial layout for the fictional Harbor Dispatch that pairs a justified, multi-column feature with a sticky table-of-contents rail. The TOC lists every section with a numbered index, highlights the active section as you scroll via IntersectionObserver, and carries its own reading meter and minutes-left note. A thin red progress bar tracks the whole page, TOC clicks smooth-scroll and move focus for accessibility, and a back-to-top button appears once you are well into the story. Built with Playfair Display, Inter and vanilla JS only.
MCP
Code
: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.3);
--rule-hair: rgba(22, 19, 15, 0.1);
--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;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
background: var(--cream);
color: var(--ink);
font-family: var(--sans);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
a {
color: inherit;
}
::selection {
background: var(--red-50);
}
/* ---------- Reading progress ---------- */
.progress {
position: fixed;
inset: 0 0 auto 0;
height: 3px;
background: transparent;
z-index: 60;
}
.progress__bar {
height: 100%;
width: 0;
background: var(--red);
transition: width 0.08s linear;
}
/* ---------- Masthead ---------- */
.masthead {
background: var(--paper);
border-bottom: 1.5px solid var(--ink);
padding-top: 12px;
}
.masthead__inner {
max-width: 1180px;
margin: 0 auto;
padding: 4px 24px 12px;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 16px;
}
.masthead__rail {
margin: 0;
font-size: 0.66rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
font-weight: 600;
}
.masthead__rail--r {
text-align: right;
}
.masthead__title {
font-family: var(--serif);
font-weight: 900;
font-size: clamp(1.9rem, 5.5vw, 3.4rem);
letter-spacing: 0.01em;
text-align: center;
text-decoration: none;
color: var(--ink);
white-space: nowrap;
line-height: 1;
}
.subnav {
border-top: 1px solid var(--rule);
border-bottom: 1px solid var(--rule);
background: var(--paper);
}
.subnav ul {
max-width: 1180px;
margin: 0 auto;
padding: 9px 24px;
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 22px;
justify-content: center;
}
.subnav a {
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
font-weight: 600;
color: var(--ink-3);
text-decoration: none;
padding-bottom: 2px;
border-bottom: 2px solid transparent;
}
.subnav a:hover {
color: var(--ink);
}
.subnav a.is-active {
color: var(--red);
border-bottom-color: var(--red);
}
/* ---------- Layout ---------- */
.wrap {
max-width: 1180px;
margin: 0 auto;
padding: 0 24px;
}
.layout {
display: grid;
grid-template-columns: 232px minmax(0, 1fr);
gap: 48px;
padding: 40px 0 64px;
}
/* ---------- TOC rail ---------- */
.toc__sticky {
position: sticky;
top: 26px;
}
.toc__kicker {
margin: 0 0 12px;
font-size: 0.64rem;
letter-spacing: 0.16em;
text-transform: uppercase;
font-weight: 700;
color: var(--muted);
padding-bottom: 8px;
border-bottom: 1.5px solid var(--ink);
}
.toc__nav ol {
list-style: none;
margin: 0;
padding: 0;
counter-reset: toc;
}
.toc__nav li {
counter-increment: toc;
border-bottom: 1px solid var(--rule-hair);
}
.toc__nav a {
display: grid;
grid-template-columns: 22px 1fr;
gap: 8px;
align-items: baseline;
padding: 9px 0 9px 10px;
text-decoration: none;
font-size: 0.84rem;
line-height: 1.35;
color: var(--ink-3);
border-left: 2px solid transparent;
transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
}
.toc__nav a::before {
content: counter(toc, decimal-leading-zero);
font-family: var(--serif);
font-size: 0.78rem;
font-weight: 600;
color: var(--muted);
}
.toc__nav a:hover {
color: var(--ink);
background: rgba(22, 19, 15, 0.03);
}
.toc__nav a.is-active {
color: var(--ink);
font-weight: 600;
border-left-color: var(--red);
background: rgba(180, 41, 31, 0.05);
}
.toc__nav a.is-active::before {
color: var(--red);
}
.toc__nav a:focus-visible {
outline: 2px solid var(--red);
outline-offset: 2px;
}
.toc__meter {
margin: 16px 0 8px;
height: 4px;
background: var(--newsprint);
border-radius: 99px;
overflow: hidden;
}
.toc__meterFill {
height: 100%;
width: 0;
background: linear-gradient(90deg, var(--red-d), var(--red));
transition: width 0.12s linear;
}
.toc__read {
margin: 0;
font-size: 0.68rem;
letter-spacing: 0.04em;
color: var(--muted);
font-weight: 500;
}
/* ---------- Article ---------- */
.article {
min-width: 0;
}
.article__head {
border-bottom: 1px solid var(--rule);
padding-bottom: 22px;
margin-bottom: 26px;
}
.kicker {
margin: 0 0 12px;
font-size: 0.72rem;
letter-spacing: 0.18em;
text-transform: uppercase;
font-weight: 700;
color: var(--red);
}
.headline {
font-family: var(--serif);
font-weight: 800;
font-size: clamp(2.1rem, 5.2vw, 3.6rem);
line-height: 1.04;
letter-spacing: -0.01em;
margin: 0 0 16px;
color: var(--ink);
}
.deck {
font-family: var(--serif);
font-style: italic;
font-weight: 500;
font-size: clamp(1.05rem, 2.4vw, 1.4rem);
line-height: 1.4;
color: var(--ink-2);
margin: 0 0 20px;
max-width: 42ch;
}
.byline {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
font-size: 0.82rem;
color: var(--muted);
}
.byline strong {
color: var(--ink-2);
font-weight: 600;
}
.byline__by {
letter-spacing: 0.01em;
}
.byline__dot {
color: var(--rule-2);
}
/* ---------- Figures ---------- */
.figure {
margin: 0 0 28px;
}
.figure figcaption {
font-family: var(--serif);
font-style: italic;
font-size: 0.86rem;
line-height: 1.4;
color: var(--ink-3);
padding-top: 8px;
border-top: 1px solid var(--rule-hair);
margin-top: 8px;
}
.credit {
font-style: normal;
font-family: var(--sans);
font-size: 0.66rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
display: inline-block;
margin-left: 6px;
}
.ph {
border-radius: var(--r-sm);
position: relative;
overflow: hidden;
}
.ph::after {
content: "";
position: absolute;
inset: 0;
background-image: repeating-linear-gradient(
0deg,
rgba(22, 19, 15, 0.05) 0 1px,
transparent 1px 3px
);
mix-blend-mode: multiply;
opacity: 0.5;
}
.ph--hero {
aspect-ratio: 16 / 9;
background:
radial-gradient(120% 130% at 18% 12%, rgba(244, 239, 228, 0.85) 0%, transparent 42%),
radial-gradient(90% 120% at 88% 90%, rgba(180, 41, 31, 0.32) 0%, transparent 55%),
linear-gradient(150deg, #3a352c 0%, #5a5347 30%, #837a68 55%, #43403a 100%);
}
.ph--chart {
aspect-ratio: 4 / 3;
background:
radial-gradient(80% 90% at 30% 20%, rgba(250, 247, 240, 0.7) 0%, transparent 50%),
linear-gradient(160deg, #2f2b24 0%, #6d6452 45%, #8f8772 70%, #3c3830 100%);
}
/* ---------- Body ---------- */
.body {
font-size: 1.04rem;
line-height: 1.66;
color: var(--ink-2);
}
.sec {
scroll-margin-top: 88px;
outline: none;
}
.sec + .sec {
margin-top: 8px;
}
.sec__h {
font-family: var(--serif);
font-weight: 700;
font-size: clamp(1.35rem, 3vw, 1.9rem);
line-height: 1.15;
color: var(--ink);
margin: 36px 0 14px;
padding-top: 18px;
border-top: 1px solid var(--rule);
}
.sec:first-child .sec__h {
display: none;
}
.body p {
margin: 0 0 1.05em;
text-align: justify;
hyphens: auto;
}
.body p + p {
text-indent: 1.4em;
}
.lead {
text-indent: 0 !important;
}
.dropcap {
float: left;
font-family: var(--serif);
font-weight: 800;
font-size: 4.2rem;
line-height: 0.78;
padding: 6px 12px 0 0;
color: var(--red);
}
/* Inline figure within text */
.figure--inline {
float: right;
width: 46%;
margin: 6px 0 14px 26px;
}
.figure--inline .ph {
width: 100%;
}
/* Pull quote */
.pull {
margin: 30px 0;
padding: 22px 0 6px;
border-top: 2px solid var(--ink);
border-bottom: 1px solid var(--rule);
text-align: center;
}
.pull p {
font-family: var(--serif);
font-weight: 600;
font-style: italic;
font-size: clamp(1.5rem, 3.6vw, 2.1rem);
line-height: 1.25;
color: var(--ink);
margin: 0 0 12px;
text-align: center;
text-indent: 0;
}
.pull cite {
font-style: normal;
font-size: 0.74rem;
letter-spacing: 0.12em;
text-transform: uppercase;
font-weight: 600;
color: var(--red);
}
/* Article footer */
.article__foot {
margin-top: 40px;
padding-top: 18px;
border-top: 1.5px solid var(--ink);
}
.article__tags {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
font-size: 0.74rem;
letter-spacing: 0.06em;
margin: 0 0 10px;
}
.article__tags span {
text-transform: uppercase;
font-weight: 700;
color: var(--muted);
letter-spacing: 0.12em;
}
.article__tags a {
text-decoration: none;
color: var(--red);
border-bottom: 1px solid var(--red-50);
padding-bottom: 1px;
}
.article__tags a:hover {
border-bottom-color: var(--red);
}
.article__pub {
margin: 0;
font-size: 0.78rem;
font-style: italic;
color: var(--muted);
font-family: var(--serif);
}
/* ---------- Page footer ---------- */
.page-foot {
border-top: 1.5px solid var(--ink);
background: var(--paper);
text-align: center;
padding: 22px 24px;
}
.page-foot p {
margin: 0;
font-size: 0.7rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted);
font-weight: 600;
}
/* ---------- Back to top ---------- */
.totop {
position: fixed;
right: 22px;
bottom: 22px;
z-index: 55;
background: var(--ink);
color: var(--paper);
border: none;
border-radius: 99px;
padding: 10px 16px;
font-family: var(--sans);
font-size: 0.74rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: pointer;
box-shadow: 0 4px 14px rgba(22, 19, 15, 0.22);
opacity: 0;
transform: translateY(8px);
transition: opacity 0.2s ease, transform 0.2s ease, background 0.15s ease;
}
.totop:not([hidden]) {
opacity: 1;
transform: translateY(0);
}
.totop:hover {
background: var(--red-d);
}
.totop:focus-visible {
outline: 2px solid var(--red);
outline-offset: 3px;
}
/* ---------- Toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 16px);
background: var(--ink);
color: var(--paper);
font-size: 0.82rem;
font-weight: 500;
padding: 11px 18px;
border-radius: var(--r-md);
box-shadow: 0 6px 20px rgba(22, 19, 15, 0.28);
opacity: 0;
pointer-events: none;
transition: opacity 0.22s ease, transform 0.22s ease;
z-index: 70;
max-width: 86vw;
}
.toast.is-on {
opacity: 1;
transform: translate(-50%, 0);
}
/* ---------- Responsive ---------- */
@media (max-width: 900px) {
.layout {
grid-template-columns: 1fr;
gap: 0;
}
.toc {
margin-bottom: 30px;
}
.toc__sticky {
position: static;
}
.toc__nav ol {
columns: 2;
column-gap: 24px;
}
.toc__nav li {
break-inside: avoid;
}
}
@media (max-width: 720px) {
.figure--inline {
float: none;
width: 100%;
margin: 6px 0 18px;
}
.body p {
text-align: left;
hyphens: manual;
}
.body p + p {
text-indent: 0;
}
}
@media (max-width: 480px) {
.wrap {
padding: 0 16px;
}
.masthead__inner {
grid-template-columns: 1fr;
text-align: center;
gap: 6px;
padding: 4px 16px 12px;
}
.masthead__rail,
.masthead__rail--r {
text-align: center;
}
.subnav ul {
gap: 14px;
padding: 9px 16px;
justify-content: flex-start;
overflow-x: auto;
flex-wrap: nowrap;
}
.toc__nav ol {
columns: 1;
}
.dropcap {
font-size: 3.4rem;
}
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
* {
transition-duration: 0.01ms !important;
}
}(function () {
"use strict";
var doc = document;
var sections = Array.prototype.slice.call(doc.querySelectorAll(".sec"));
var tocLinks = Array.prototype.slice.call(doc.querySelectorAll("[data-toc]"));
var progressBar = doc.getElementById("progressBar");
var tocMeter = doc.getElementById("tocMeter");
var readNote = doc.getElementById("readNote");
var toTop = doc.getElementById("toTop");
var toastEl = doc.getElementById("toast");
var article = doc.querySelector(".article");
/* ---- map section id -> toc link ---- */
var linkById = {};
tocLinks.forEach(function (a) {
var id = a.getAttribute("href").slice(1);
linkById[id] = a;
a.addEventListener("click", function (e) {
var target = doc.getElementById(id);
if (!target) return;
e.preventDefault();
target.scrollIntoView({ behavior: "smooth", block: "start" });
// move focus for accessibility without scroll-jumping
window.setTimeout(function () {
target.focus({ preventScroll: true });
}, 420);
history.replaceState(null, "", "#" + id);
});
});
/* ---- toast helper ---- */
var toastTimer;
function toast(msg) {
if (!toastEl) return;
toastEl.textContent = msg;
toastEl.classList.add("is-on");
window.clearTimeout(toastTimer);
toastTimer = window.setTimeout(function () {
toastEl.classList.remove("is-on");
}, 2200);
}
/* ---- active-section tracking via IntersectionObserver ---- */
var visible = {};
var titles = {};
sections.forEach(function (s) {
var link = linkById[s.id];
titles[s.id] = link ? link.textContent.trim() : "";
});
function setActive(id) {
tocLinks.forEach(function (a) {
a.classList.toggle(
"is-active",
a.getAttribute("href") === "#" + id
);
if (a.getAttribute("href") === "#" + id) {
a.setAttribute("aria-current", "true");
} else {
a.removeAttribute("aria-current");
}
});
}
function pickActive() {
// choose the topmost section currently intersecting; fall back to last passed
var current = null;
var best = Infinity;
sections.forEach(function (s) {
if (!visible[s.id]) return;
var top = s.getBoundingClientRect().top;
if (top < best) {
best = top;
current = s.id;
}
});
if (!current) {
// none intersecting: pick last section above the fold
for (var i = sections.length - 1; i >= 0; i--) {
if (sections[i].getBoundingClientRect().top < 120) {
current = sections[i].id;
break;
}
}
}
if (current) setActive(current);
}
if ("IntersectionObserver" in window) {
var io = new IntersectionObserver(
function (entries) {
entries.forEach(function (en) {
visible[en.target.id] = en.isIntersecting;
});
pickActive();
},
{ rootMargin: "-15% 0px -70% 0px", threshold: 0 }
);
sections.forEach(function (s) {
io.observe(s);
});
}
/* ---- reading progress + meter + back-to-top ---- */
var ticking = false;
function update() {
ticking = false;
var docEl = doc.documentElement;
var scrollTop = window.pageYOffset || docEl.scrollTop;
var max = (docEl.scrollHeight || doc.body.scrollHeight) - window.innerHeight;
var pct = max > 0 ? Math.min(1, Math.max(0, scrollTop / max)) : 0;
if (progressBar) progressBar.style.width = (pct * 100).toFixed(2) + "%";
// article-scoped meter: how far through the article body
if (tocMeter && article) {
var rect = article.getBoundingClientRect();
var total = rect.height - window.innerHeight * 0.4;
var passed = -rect.top + window.innerHeight * 0.4;
var aPct = total > 0 ? Math.min(1, Math.max(0, passed / total)) : 0;
tocMeter.style.width = (aPct * 100).toFixed(2) + "%";
if (readNote) {
if (aPct <= 0.01) readNote.textContent = "Start of article · 14 min read";
else if (aPct >= 0.99) readNote.textContent = "Article complete";
else
readNote.textContent =
Math.round(aPct * 100) + "% read · ~" +
Math.max(1, Math.round((1 - aPct) * 14)) +
" min left";
}
}
if (toTop) {
if (scrollTop > 600) toTop.hidden = false;
else toTop.hidden = true;
}
}
function onScroll() {
if (!ticking) {
window.requestAnimationFrame(update);
ticking = true;
}
}
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll, { passive: true });
/* ---- back to top ---- */
if (toTop) {
toTop.addEventListener("click", function () {
window.scrollTo({ top: 0, behavior: "smooth" });
var first = doc.getElementById("top");
if (first) {
window.setTimeout(function () {
toast("Back to the top of the page");
}, 200);
}
});
}
/* ---- deep-link on load ---- */
if (location.hash && linkById[location.hash.slice(1)]) {
var t = doc.getElementById(location.hash.slice(1));
if (t) {
window.setTimeout(function () {
t.scrollIntoView({ behavior: "auto", block: "start" });
}, 0);
}
}
update();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>The Harbor Dispatch — The City That Learned to Read the Tides</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>
<!-- Reading progress bar -->
<div class="progress" aria-hidden="true">
<div class="progress__bar" id="progressBar"></div>
</div>
<header class="masthead" role="banner">
<div class="masthead__inner">
<p class="masthead__rail">Vol. CXIV · No. 212 · Late Edition</p>
<a class="masthead__title" href="#top">The Harbor Dispatch</a>
<p class="masthead__rail masthead__rail--r">Tuesday, June 8 · $3.50</p>
</div>
<nav class="subnav" aria-label="Sections">
<ul>
<li><a href="#top" class="is-active">Front</a></li>
<li><a href="#top">City</a></li>
<li><a href="#top">Water & Climate</a></li>
<li><a href="#top">Business</a></li>
<li><a href="#top">Culture</a></li>
<li><a href="#top">Opinion</a></li>
</ul>
</nav>
</header>
<main class="wrap" id="top">
<div class="layout">
<!-- Sticky TOC rail -->
<aside class="toc" aria-label="In this article">
<div class="toc__sticky">
<p class="toc__kicker">In this article</p>
<nav id="toc" class="toc__nav" aria-label="Table of contents">
<ol>
<li><a href="#lede" data-toc>The morning the gauges agreed</a></li>
<li><a href="#origins" data-toc>A century of guesswork</a></li>
<li><a href="#network" data-toc>Building the sensor network</a></li>
<li><a href="#economy" data-toc>What a tide is worth</a></li>
<li><a href="#dissent" data-toc>The skeptics on Pier 9</a></li>
<li><a href="#future" data-toc>Reading the next century</a></li>
</ol>
</nav>
<div class="toc__meter" aria-hidden="true">
<div class="toc__meterFill" id="tocMeter"></div>
</div>
<p class="toc__read" id="readNote">Start of article · 14 min read</p>
</div>
</aside>
<!-- Article -->
<article class="article">
<header class="article__head">
<p class="kicker">Water & Climate</p>
<h1 class="headline">The City That Learned to Read the Tides</h1>
<p class="deck">
For a century, Marrow Bay drowned its own forecasts in guesswork. A web of
cheap sensors — and a stubborn hydrologist — taught it to listen instead.
</p>
<div class="byline">
<span class="byline__by">By <strong>Eleanor Vance</strong> and <strong>Tomas Reyes</strong></span>
<span class="byline__dot" aria-hidden="true">·</span>
<span class="byline__line">Marrow Bay</span>
<span class="byline__dot" aria-hidden="true">·</span>
<span class="byline__line">14 min read</span>
</div>
</header>
<figure class="figure figure--hero">
<div class="ph ph--hero" role="img" aria-label="A row of weathered tide gauges along a misty harbor pier at dawn."></div>
<figcaption>
Tide gauges along the old fish pier at first light, their dials repainted by a
volunteer crew. <span class="credit">Pell Hartmann / The Harbor Dispatch</span>
</figcaption>
</figure>
<div class="body">
<section id="lede" class="sec" tabindex="-1">
<h2 class="sec__h">The morning the gauges agreed</h2>
<p class="lead">
<span class="dropcap">F</span>or the first time in living memory, every gauge in
Marrow Bay told the same story on the same morning. At 5:41, as the fog peeled
off the breakwater, eleven sensors strung along the harbor reported the tide to
within two centimeters of one another — a quiet agreement that, a decade ago,
would have read like a clerical error.
</p>
<p>
The harbor has always kept secrets. Currents fold around the old coal jetty;
the river dumps its silt unevenly; a stiff easterly can shove water into the
shallows and make a nonsense of any chart drawn in calmer weather. Captains
learned the bay the slow way, by losing keels to it. The city, for its part,
learned almost nothing at all.
</p>
<p>
That changed when a hydrologist named Dr. Ana Okwu decided the problem was not
the water but the listening. “We had one good gauge and a hundred bad
habits,” she said, standing on the pier where her experiment began.
“You can’t forecast a system you only measure in one place.”
</p>
</section>
<section id="origins" class="sec" tabindex="-1">
<h2 class="sec__h">A century of guesswork</h2>
<p>
Marrow Bay’s first tide table, printed in 1911, was a single column of
numbers copied from a port four hundred miles south and adjusted by what the
almanac called “local judgement.” The judgement was a harbormaster
named Cribb, who set his watch by the church bell and his expectations by his
knees.
</p>
<figure class="figure figure--inline">
<div class="ph ph--chart" role="img" aria-label="A faded duotone reproduction of an antique tide-prediction chart."></div>
<figcaption>
The 1911 table, reprinted from the city archive. Errors of an hour were common.
<span class="credit">City of Marrow Bay Archive</span>
</figcaption>
</figure>
<p>
For decades the errors were a nuisance rather than a danger. Then the storms
changed character. The bay began flooding on clear days, at the top of ordinary
spring tides, in places the old table swore were safe. Insurers noticed before
the council did.
</p>
<p>
By the time Okwu arrived, the city was paying for its ignorance in basements and
brine-rotted wiring. What it lacked was not concern but resolution — a way to see
the bay as the network of moods it actually was, rather than a single averaged
guess.
</p>
</section>
<section id="network" class="sec" tabindex="-1">
<h2 class="sec__h">Building the sensor network</h2>
<p>
The breakthrough was deliberately unglamorous. Instead of one precise, expensive
instrument, Okwu’s team scattered dozens of cheap pressure sensors — each
little more than a sealed tube, a battery, and a radio — and let the swarm do
the work that money used to do alone.
</p>
<blockquote class="pull">
<p>“Accuracy isn’t a place you stand. It’s a chorus you build.”</p>
<cite>— Dr. Ana Okwu, harbor hydrologist</cite>
</blockquote>
<p>
Each sensor is wrong in its own small way; pooled together and corrected against
the one survey-grade gauge on the coal jetty, they describe the whole harbor with
a fidelity no single instrument could match. The data flows to a shed behind the
ferry terminal, where a secondhand server turns it into a live map updated every
ninety seconds.
</p>
<p>
Volunteers — fishermen, kayakers, a retired electrician who calls himself the
network’s “tide janitor” — keep the units clean and the radios
fed. The whole apparatus cost less than the city once spent each year drying out
the public library’s lower stacks.
</p>
</section>
<section id="economy" class="sec" tabindex="-1">
<h2 class="sec__h">What a tide is worth</h2>
<p>
The forecasts turned out to be quietly lucrative. Ferries now sail closer to the
schedule and farther from the sandbars. The fish market times its deliveries to
the water rather than the clock. A small fleet of oyster growers credits the map
with a tenth of their season.
</p>
<p>
The council, which had treated the project as a science-fair indulgence, began
folding the data into its flood warnings and, eventually, its zoning. A
waterfront lot that floods on every third spring tide now carries that fact on
its title, plain as a price.
</p>
<p>
“People assume the value is in the disasters you avoid,” said Reyes
Mbeki, who runs the ferry line. “Most of it is in the ordinary days you stop
wasting. We were running scared of a bay we could have just looked at.”
</p>
</section>
<section id="dissent" class="sec" tabindex="-1">
<h2 class="sec__h">The skeptics on Pier 9</h2>
<p>
Not everyone trusts the chorus. On Pier 9, a knot of older captains still reads
the water by eye and the sky by instinct, and regards the live map with the
suspicion reserved for any machine that claims to know the harbor better than a
man who has worked it for forty years.
</p>
<p>
“The bay doesn’t care about your average,” said one, a
trawlerman named Doss, watching the fog. He is not entirely wrong: the network
still misses the freak surges that the old hands feel in their boats before any
gauge registers them. Okwu, to her credit, keeps a column on the map for exactly
those reports.
</p>
<p>
The friction has been productive. Several sensors now sit where they do because a
skeptic insisted the model was blind to a particular eddy. The chorus, it turns
out, sings better with a few critics in it.
</p>
</section>
<section id="future" class="sec" tabindex="-1">
<h2 class="sec__h">Reading the next century</h2>
<p>
The plan now is to teach the network to anticipate rather than merely report — to
fold in weather, river flow, and a decade of its own memory and forecast the bay
days out instead of minutes. Three other harbors have asked for the blueprint,
which Okwu is publishing for nothing.
</p>
<p>
Standing on the pier as the last fog burned off, she seemed less interested in
the technology than in the habit it had created. “A city that measures
itself honestly behaves differently,” she said. “It stops being
surprised by its own water.”
</p>
<p>
At 5:41 the next morning, the gauges agreed again. By now, no one in the ferry
shed found it remarkable. That, Okwu allowed, was the whole point.
</p>
</section>
<footer class="article__foot">
<p class="article__tags">
<span>Filed under:</span>
<a href="#top">Marrow Bay</a>
<a href="#top">Tides</a>
<a href="#top">Infrastructure</a>
<a href="#top">Climate</a>
</p>
<p class="article__pub">Published in the Late Edition · Corrections to the desk.</p>
</footer>
</div>
</article>
</div>
</main>
<footer class="page-foot">
<p>The Harbor Dispatch · An independent fictional broadsheet · Established MCMXI</p>
</footer>
<!-- Back to top -->
<button class="totop" id="toTop" type="button" aria-label="Back to top" hidden>
<span aria-hidden="true">↑</span> Top
</button>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Sticky Article TOC + Progress
A complete long-form reading view for The Harbor Dispatch, a fictional broadsheet, art-directed in a warm newsprint palette with hairline rules and a single red accent. The main column carries an oversized Playfair Display headline, an italic deck, a byline row and a justified, hyphenated body broken into six labelled sections, complete with a red drop cap on the lead, captioned duotone figures and an oversized serif pull quote.
To the left sits a sticky table-of-contents rail. Each section is listed with a numbered index, and the
entry for the section you are currently reading is highlighted with a red marker — driven by an
IntersectionObserver that tracks which heading owns the viewport. The rail also holds a slim reading
meter scoped to the article and a live note that counts down the minutes left.
Clicking any TOC entry smooth-scrolls to its section and moves keyboard focus there for accessibility, while a thin red progress bar across the top of the page tracks overall scroll depth. A back-to-top button fades in once you are well into the story, and a small toast helper confirms the jump. Everything runs on vanilla JS — no frameworks, no build step, and no network requests beyond the two Google Fonts.
Illustrative UI only — masthead, headlines, bylines, and articles are fictional; not a real news publication.