news-digest: real article summaries + per-desk collapse

Two upgrades to make the digest actually readable:

1) Article-grounded 2-3 sentence summaries (everywhere)

   The old prompt got just the title + miniflux's content excerpt,
   which for HN/Lobsters/wire feeds is barely more than the title
   itself — so summaries paraphrased the title and added nothing.

   Now every URL gets fetched and main-content-extracted via
   trafilatura on a parallel pre-pass (10 workers, ~15s for ~50
   URLs). Extracted text caches to /output/.article-cache.json with
   a 7-day TTL so repeat runs in the same window don't re-pull.

   Headlines also get summarized now — one batched LLM call per
   category (world / local). Rendered as a paragraph below the
   title with source + time on the right rail.

   Prompt rewrites tell the model to pull names/numbers/places
   from the body and explicitly forbid restating the title.
   Result: real specifics ("71% saw no pay increase globally",
   "third time in less than two weeks", "Islamabad and Moscow
   intermediaries") instead of title paraphrase.

2) Per-desk collapse buttons

   Chevron next to .desk-count toggles a .is-collapsed class.
   Collapsed state is per-device (localStorage by section id) since
   collapse is a viewing preference, not content state.
This commit is contained in:
vh
2026-04-28 11:29:15 -07:00
parent 018122648f
commit 40f1e0ee00
5 changed files with 325 additions and 45 deletions
+32
View File
@@ -201,6 +201,38 @@
});
}
/* ── desk collapse (per-device, localStorage) ───────────────────── */
// Stored as a JSON object {deskId: true} of collapsed desks. Per
// device by design — collapse is a viewing preference, not content
// state, so no server roundtrip.
const COLLAPSE_KEY = "digest:collapsed-desks";
function readCollapsed() {
try { return JSON.parse(localStorage.getItem(COLLAPSE_KEY) || "{}"); }
catch (_) { return {}; }
}
function writeCollapsed(obj) {
try { localStorage.setItem(COLLAPSE_KEY, JSON.stringify(obj)); } catch (_) {}
}
function applyCollapseState() {
const state = readCollapsed();
document.querySelectorAll(".desk[id]").forEach((desk) => {
desk.classList.toggle("is-collapsed", !!state[desk.id]);
});
}
applyCollapseState();
document.addEventListener("click", (ev) => {
const btn = ev.target.closest(".desk-collapse");
if (!btn) return;
const desk = btn.closest(".desk[id]");
if (!desk) return;
ev.preventDefault();
desk.classList.toggle("is-collapsed");
const state = readCollapsed();
if (desk.classList.contains("is-collapsed")) state[desk.id] = true;
else delete state[desk.id];
writeCollapsed(state);
});
/* ── initial paint ──────────────────────────────────────────────── */
apiGetHidden().then((hidden) => {
@@ -72,6 +72,7 @@
<h2 class="desk-title">World</h2>
<span class="desk-sub">top of last {{ generated_at.hour < 14 and "8" or "8" }} hours · headlines</span>
<span class="desk-count">{{ world_total }} items</span>
<button class="desk-collapse" type="button" aria-label="Collapse section" title="Collapse / expand">▾</button>
</header>
<ol class="headlines-list">
{% for h in world_headlines %}
@@ -82,6 +83,9 @@
</h3>
<span class="headline-source">{{ h.source }}</span>
<span class="headline-time">{{ h.posted_at|humanago }}</span>
{% if h.tldr %}
<p class="headline-tldr">{{ h.tldr }}</p>
{% endif %}
</li>
{% endfor %}
</ol>
@@ -96,6 +100,7 @@
<h2 class="desk-title">Local</h2>
<span class="desk-sub">orange county · greater LA · last 8 hours</span>
<span class="desk-count">{{ local_total }} items</span>
<button class="desk-collapse" type="button" aria-label="Collapse section" title="Collapse / expand">▾</button>
</header>
<ol class="headlines-list">
{% for h in local_headlines %}
@@ -106,6 +111,9 @@
</h3>
<span class="headline-source">{{ h.source }}</span>
<span class="headline-time">{{ h.posted_at|humanago }}</span>
{% if h.tldr %}
<p class="headline-tldr">{{ h.tldr }}</p>
{% endif %}
</li>
{% endfor %}
</ol>
@@ -120,6 +128,7 @@
<h2 class="desk-title">Reddit</h2>
<span class="desk-sub">top of last {{ generated_at.hour < 14 and "12" or "12" }} hours · score-filtered</span>
<span class="desk-count">{{ reddit_total }} items</span>
<button class="desk-collapse" type="button" aria-label="Collapse section" title="Collapse / expand">▾</button>
</header>
{% for src in reddit_sources %}
@@ -189,6 +198,7 @@
<h2 class="desk-title">Tech Feeds</h2>
<span class="desk-sub">non-reddit · last {{ generated_at.hour < 14 and "12" or "12" }} hours</span>
<span class="desk-count">{{ tech_total }} items</span>
<button class="desk-collapse" type="button" aria-label="Collapse section" title="Collapse / expand">▾</button>
</header>
{% for src in tech_sources %}
+65 -6
View File
@@ -305,6 +305,47 @@ a:hover { color: var(--accent); }
text-transform: uppercase;
}
/* Per-desk collapse toggle. Sits on the right edge of the desk header,
clicking collapses the body to a single-line summary. State is
persisted in localStorage by section id (#world, #local, etc), so
each device remembers per-user preference. */
.desk-collapse {
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
padding: 2px 6px;
margin-left: 12px;
font-family: var(--font-mono);
font-size: 14px;
line-height: 1;
color: var(--fg-dim);
cursor: pointer;
transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease, transform 0.18s ease;
}
.desk-collapse:hover,
.desk-collapse:focus-visible {
color: var(--accent);
border-color: var(--rule-bright);
background: var(--bg-elev);
outline: none;
}
.desk.is-collapsed .desk-collapse {
transform: rotate(-90deg);
}
/* When collapsed, hide everything inside the desk EXCEPT the header.
The .desk-head is direct child; .source / .headlines-list etc are
the rest of the body. */
.desk.is-collapsed > :not(.desk-head) {
display: none;
}
/* Tighten the header bottom-border in collapsed state — no items below
means the heavy underline looks weird floating alone. */
.desk.is-collapsed .desk-head {
margin-bottom: 0;
padding-bottom: 8px;
border-bottom: 1px dashed var(--rule);
}
/* -------------------------------------------------------------------------
per-source block
------------------------------------------------------------------------- */
@@ -509,11 +550,25 @@ a:hover { color: var(--accent); }
position: relative; /* anchors the absolute-positioned × button */
display: grid;
grid-template-columns: 1fr auto auto;
grid-template-rows: auto auto;
align-items: baseline;
gap: 16px;
padding: 12px 0;
column-gap: 16px;
row-gap: 4px;
padding: 14px 0;
border-bottom: 1px solid var(--rule);
}
.headline-title { grid-column: 1 / 2; grid-row: 1; }
.headline-source { grid-column: 2 / 3; grid-row: 1; }
.headline-time { grid-column: 3 / 4; grid-row: 1; }
.headline-tldr {
grid-column: 1 / 4;
grid-row: 2;
margin: 4px 0 2px;
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.5;
color: var(--fg-dim);
}
.headline:last-child {
border-bottom: 0;
}
@@ -855,16 +910,20 @@ a:hover { color: var(--accent); }
.item-hide { opacity: 1; width: 28px; height: 28px; font-size: 20px; }
.hidden-tray { padding: 12px 14px; }
/* Headlines: stack source + time below title on narrow screens so
the title gets the full width. */
/* Headlines: stack title / source / time / tldr vertically on narrow
screens so the title gets the full width. */
.headline {
grid-template-columns: 1fr;
gap: 4px;
padding: 10px 0;
padding: 12px 0;
}
.headline-title,
.headline-source,
.headline-time {
.headline-time,
.headline-tldr {
grid-column: 1 / 2;
grid-row: auto;
}
.headline-time { text-align: left; }
.headline-tldr { font-size: 13.5px; }
}