▍ humdrum codex / soft

Add News Briefs source; weather day-parts; paper layout for both

1d3c8f7f0f36ff08bfabdac57cc7082aa102b1da
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-06-09 10:47

parent cff9c267

Add News Briefs source; weather day-parts; paper layout for both

- New keyless "briefs" source: pulls Screamer category briefs from
  screamer.humdrum.one (politics/tech/sports/culture), walking back
  through dates per category. Wired through schema, module, barrel,
  seed (enabled), CardBodies, and SOURCE_KINDS.
- Weather: fetch hourly and derive four day-parts (morning/noon/
  evening/night) for a printed weather hero strip; WeatherPart schema.
- /print: briefs render as the trailing full-page spread (replaces raw
  headlines in print order); globals.css + page.tsx layout updates.
- Fix stale absolute path in settings.local.json after repo move to
  ~/Developer/Personal-Dashboard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

12 files changed

.claude/settings.local.json +27 −1
@@ -53,7 +53,33 @@       "Bash(ls .next.nosync/server/app/print/page.js)",
       "Bash(ls -ld data editions)",
       "Bash(file data *)",
       "Bash(ls -la data/*.db)",
-      "Bash(git commit -q -m ' *)"
+      "Bash(git commit -q -m ' *)",
+      "Bash(ls ~/Library/LaunchAgents/)",
+      "Bash(ls \"/Users/kortum/Documents/30-39 Interwebs/37 Apps/Scripts/\")",
+      "Bash(ls ~/bin ~/.local/bin)",
+      "Bash(curl -s -o /dev/null -w \"http://192.168.1.94 → %{http_code}\\\\n\" --max-time 6 http://192.168.1.94/)",
+      "Bash(curl -s --max-time 6 http://192.168.1.94/)",
+      "Bash(curl -s -o /dev/null -w \"ipp 631 → %{http_code}\\\\n\" --max-time 6 http://192.168.1.94:631/)",
+      "Bash(lpr -P Brother_HL_L2350DW_series_2 '/Users/kortum/Documents/10-19 Personal/10 Personal Info/10.06 Morning-Paper/2026/06/2026-06-08.pdf')",
+      "Bash(lpstat -W not-completed -o Brother_HL_L2350DW_series_2)",
+      "Bash(lpstat -p Brother_HL_L2350DW_series_2)",
+      "Bash(lpoptions -p Brother_HL_L2350DW_series_2 -l)",
+      "Bash(lpoptions -p Brother_HL_L2350DW_series_2)",
+      "Bash(pdfinfo paper-mock-A.pdf)",
+      "Bash(echo \"A pages: $\\(pdfinfo paper-mock-A.pdf)",
+      "Bash(break)",
+      "Bash(lpr -P Brother_HL_L2350DW_series_2 -o sides=two-sided-long-edge -o media=Letter '/Users/kortum/Documents/10-19 Personal/10 Personal Info/10.05 Daily-Notes/2026/06/2026-06-08.pdf')",
+      "Bash(lpr -P Brother_HL_L2350DW_series_2 -o sides=two-sided-long-edge -o media=Letter '/Users/kortum/Developer/Personal-Dashboard/editions.nosync/edition-2026-06-09-13-00-13.pdf')",
+      "Bash(lpstat -o)",
+      "Bash(killall bird *)",
+      "Bash(lsof -t -iTCP:4317 -sTCP:LISTEN)",
+      "Bash(xargs -r kill)",
+      "WebFetch(domain:github.com)",
+      "Bash(brew uninstall *)",
+      "Bash(command -v zplug)",
+      "Read(//opt/homebrew/Cellar/zplug/2.4.2/**)",
+      "Bash(zsh -i -c 'whence -v zplug; echo \"exit=$?\"')",
+      "Bash(zsh -i -c 'print -l $fpath | grep -i zplug || echo \"no zplug in fpath\"')"
     ]
   }
 }
app/globals.css +74 −30
@@ -193,43 +193,87 @@   border-bottom: 1px solid #111;
   padding: 4px 0;
   margin-top: var(--space-2);
 }
-.paper-lead {
-  text-align: center;
-  font-family: var(--font-display);
-  font-size: 18px;
-  margin: 0 0 var(--space-5);
+/* ── Weather hero ── full width: current block + today's day-part strip ── */
+.paper-wx { border: 1.5px solid #111; display: flex; margin-bottom: var(--space-4); }
+.paper-wx-now {
+  display: flex; align-items: center; gap: 16px;
+  padding: 12px 18px; border-right: 1.5px solid #111; min-width: 300px;
 }
-.paper-body { column-count: 2; column-gap: var(--space-6); }
-@media (max-width: 640px) { .paper-body { column-count: 1; } }
-/* Sections flow and balance across columns/pages instead of jumping whole —
-   that's what left big empty gaps. Headings stay glued to their first lines,
-   and individual items never split mid-entry. */
-.paper-section { margin-bottom: var(--space-5); }
-/* Short sections (e.g. From the Vault) stay intact in a single column instead
-   of splitting; long sections still flow column-to-column. */
-.paper-section--whole { break-inside: avoid; }
-.paper-section > h2 {
-  font-family: var(--font-display);
-  font-size: 20px;
-  border-bottom: 2px solid #111;
-  margin: 0 0 var(--space-2);
-  padding-bottom: 2px;
+.paper-wx-temp { font-family: var(--font-display); font-weight: 700; font-size: 56px; line-height: 0.86; }
+.paper-wx-loc {
+  font-family: var(--font-mono); font-weight: 700; font-size: 11px;
+  letter-spacing: 0.1em; text-transform: uppercase; margin-bottom: 2px;
+}
+.paper-wx-cond { font-family: var(--font-display); font-size: 19px; }
+.paper-wx-hl { font-family: var(--font-mono); font-size: 10px; color: #555; margin-top: 4px; }
+.paper-wx-strip { flex: 1; display: flex; }
+.paper-wx-seg { flex: 1; text-align: center; padding: 10px 4px; border-left: 1px solid #ddd; }
+.paper-wx-seg:first-child { border-left: none; }
+.paper-wx-seg-t {
+  font-family: var(--font-mono); font-weight: 700; font-size: 9px;
+  letter-spacing: 0.12em; text-transform: uppercase; color: #555;
+}
+.paper-wx-seg-g { font-size: 20px; line-height: 1.3; margin: 3px 0; }
+.paper-wx-seg-d { font-family: var(--font-display); font-weight: 700; font-size: 21px; line-height: 1; }
+.paper-wx-seg-p { font-family: var(--font-mono); font-size: 9px; color: #555; margin-top: 3px; }
+
+/* ── Personal bento grid ── 6 cols, dynamic spans, dense flow ── */
+.paper-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 11px; grid-auto-flow: row dense; }
+.paper-card {
+  border: 1px solid #ddd; border-left: 4px solid #111; border-radius: 7px;
+  padding: 9px 12px 10px; break-inside: avoid;
+}
+.paper-card > h2 {
+  font-family: var(--font-display); font-weight: 700; font-size: 16px;
+  display: flex; align-items: baseline; gap: 7px;
+  border-bottom: 1.5px solid #111; padding-bottom: 4px; margin: 0 0 6px;
   break-after: avoid;
 }
-.paper-section h3 {
-  font-family: var(--font-mono);
-  font-size: 10px;
-  text-transform: uppercase;
-  letter-spacing: 0.1em;
-  margin: var(--space-3) 0 var(--space-1);
+.paper-card > h2 .kick {
+  margin-left: auto; font-family: var(--font-mono); font-weight: 500; font-size: 8px;
+  letter-spacing: 0.13em; text-transform: uppercase; color: #777;
 }
-.paper-item { margin-bottom: var(--space-2); font-size: 13px; line-height: 1.45; break-inside: avoid; }
-.paper-item .src { color: #555; font-size: 11px; }
+.paper-card h3 {
+  font-family: var(--font-mono); font-size: 9px; text-transform: uppercase;
+  letter-spacing: 0.1em; margin: 6px 0 3px; color: #555;
+}
+.paper-item { margin-bottom: 4px; font-size: 12px; line-height: 1.4; break-inside: avoid; }
+.paper-item .src { color: #555; font-size: 10px; }
 .paper-empty { color: #777; font-style: italic; }
 .paper-item .yr { font-family: var(--font-mono); font-weight: 700; }
-.paper-weather { font-size: 14px; }
-.paper-todo { font-size: 13px; }
 .paper-todo.done { text-decoration: line-through; color: #777; }
+
+/* ── News briefs spread ── trailing full page, 2-column magazine flow ── */
+.paper-news { break-before: page; }
+.paper-news-band {
+  border-top: 2px solid #111; border-bottom: 2px solid #111;
+  padding: 5px 2px; margin-bottom: var(--space-3);
+  display: flex; justify-content: space-between; align-items: baseline;
+}
+.paper-news-band h1 { font-family: var(--font-display); font-weight: 700; font-size: 26px; margin: 0; }
+.paper-news-band .meta {
+  font-family: var(--font-mono); font-size: 10px; text-transform: uppercase;
+  letter-spacing: 0.14em; color: #777;
+}
+.paper-news-flow { column-count: 2; column-gap: 22px; }
+.paper-brief { break-inside: avoid-column; margin-bottom: 15px; }
+.paper-brief > h2 {
+  font-family: var(--font-display); font-weight: 700; font-size: 19px;
+  display: flex; align-items: baseline; gap: 7px;
+  border-bottom: 2px solid #111; padding-bottom: 4px; margin: 0 0 8px;
+  break-after: avoid;
+}
+.paper-brief > h2 .kick {
+  margin-left: auto; font-family: var(--font-mono); font-weight: 500; font-size: 8px;
+  letter-spacing: 0.13em; text-transform: uppercase; color: #777;
+}
+.paper-story { margin-bottom: 10px; break-inside: avoid; }
+.paper-story .t { font-weight: 700; font-size: 12.5px; line-height: 1.28; margin-bottom: 3px; }
+.paper-story .s { font-size: 11px; line-height: 1.4; color: #222; margin-bottom: 3px; }
+.paper-story .src {
+  font-family: var(--font-mono); font-weight: 500; font-size: 8.5px;
+  letter-spacing: 0.04em; text-transform: uppercase; color: #777;
+}
 
 @media print {
   nav, [role="status"], nextjs-portal { display: none !important; }
app/print/page.tsx +132 −30
@@ -1,12 +1,16 @@
 // /print — the newspaper edition. Server-rendered from current cached snapshots.
 // Puppeteer loads this route to produce the PDF (see /api/edition).
 //
-// Sections are driven by enabled sources in the user's order (see edition-data).
-// Each kind has a small paper renderer below; add a case to support a new kind.
+// Layout: masthead → full-width weather hero (with today's day-part strip) →
+// a dynamic bento grid of personal sections → a trailing full-page news spread
+// (the four Screamer briefs). Each kind has a small paper renderer below; add a
+// case to support a new kind.
 
 import { gatherEdition, type EditionSection } from "@/lib/edition-data";
 import type { SourceKind } from "@/lib/schemas/source";
+import type { WeatherPayload } from "@/lib/schemas/sources/weather";
 import type { NewsPayload } from "@/lib/schemas/sources/news";
+import type { BriefsPayload } from "@/lib/schemas/sources/briefs";
 import type { OnThisDayPayload } from "@/lib/schemas/sources/onthisday";
 import type { ObsidianPayload } from "@/lib/schemas/sources/obsidian";
 import type { CalendarPayload } from "@/lib/schemas/sources/calendar";
@@ -22,6 +26,7 @@
 // Classic newspaper heading per kind; falls back to the source's own label.
 const SECTION_TITLE: Partial<Record<SourceKind, string>> = {
   news: "Headlines",
+  briefs: "News Briefs",
   feeds: "My Feeds",
   onthisday: "On This Day",
   obsidian: "From the Vault",
@@ -35,6 +40,26 @@   bluesky: "Bluesky",
   todos: "Agenda",
 };
 
+// Small mono kicker shown at the right of each card header.
+const SECTION_KICK: Partial<Record<SourceKind, string>> = {
+  calendar: "Today",
+  todos: "Things",
+  sports: "Scores",
+  feeds: "RSS",
+  onthisday: "History",
+  obsidian: "Obsidian",
+};
+
+// Bento spans on a 6-column grid — content weight, not source order.
+const SECTION_SPAN: Partial<Record<SourceKind, number>> = {
+  calendar: 2,
+  sports: 2,
+  feeds: 2,
+  obsidian: 2,
+  todos: 3,
+  onthisday: 3,
+};
+
 function shortDate(iso: string) {
   return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric" });
 }
@@ -52,6 +77,61 @@ function leagueName(path: string) {
   return (path.split("/").pop() ?? path).toUpperCase();
 }
 
+// WMO weather code → a grayscale-safe glyph for the weather strip.
+function wmoGlyph(code: number): string {
+  if (code === 0) return "☀";
+  if (code <= 2) return "⛅";
+  if (code === 3) return "☁";
+  if (code <= 48) return "🌫";
+  if (code <= 67) return "🌧";
+  if (code <= 77) return "❄";
+  if (code <= 82) return "🌦";
+  if (code <= 86) return "🌨";
+  return "⛈";
+}
+
+function WeatherHero({ w }: { w: WeatherPayload }) {
+  const deg = w.units === "metric" ? "°C" : "°F";
+  const wind = w.units === "metric" ? "km/h" : "mph";
+  const today = w.daily[0];
+  return (
+    <div className="paper-wx">
+      <div className="paper-wx-now">
+        <div className="paper-wx-temp">
+          {w.current.temp}
+          {deg}
+        </div>
+        <div>
+          <div className="paper-wx-loc">{w.location}</div>
+          <div className="paper-wx-cond">{w.current.text}</div>
+          {today && (
+            <div className="paper-wx-hl">
+              H {today.max}
+              {deg} · L {today.min}
+              {deg} · Wind {w.current.wind} {wind}
+            </div>
+          )}
+        </div>
+      </div>
+      {w.parts.length > 0 && (
+        <div className="paper-wx-strip">
+          {w.parts.map((p) => (
+            <div className="paper-wx-seg" key={p.label}>
+              <div className="paper-wx-seg-t">{p.label}</div>
+              <div className="paper-wx-seg-g">{wmoGlyph(p.code)}</div>
+              <div className="paper-wx-seg-d">
+                {p.temp}
+                {deg}
+              </div>
+              <div className="paper-wx-seg-p">{p.precipProb != null ? `${p.precipProb}%` : "—"}</div>
+            </div>
+          ))}
+        </div>
+      )}
+    </div>
+  );
+}
+
 // Render a section's body for its kind. Returns null when there's nothing to show.
 function SectionBody({ kind, payload }: { kind: SourceKind; payload: unknown }) {
   switch (kind) {
@@ -59,8 +139,6 @@     case "news":
     case "feeds": {
       const p = payload as NewsPayload;
       if (p.items.length === 0) return null;
-      // Cap headlines so they don't crowd out higher-priority sections; feeds
-      // ("My Feeds") run a touch longer since they're hand-picked.
       const cap = kind === "news" ? 9 : 12;
       return (
         <>
@@ -225,25 +303,60 @@       return null;
   }
 }
 
-// Short trailing/list sections that look broken when split mid-section across
-// columns. Long sections (headlines, on this day) still flow column-to-column.
-const KEEP_WHOLE: Partial<Record<SourceKind, true>> = { obsidian: true };
-
-function PaperSection({ section }: { section: EditionSection }) {
+// A personal-section bento card on page one.
+function PaperCard({ section }: { section: EditionSection }) {
   const body = SectionBody({ kind: section.kind, payload: section.payload });
   if (!body) return null;
-  const cls = KEEP_WHOLE[section.kind] ? "paper-section paper-section--whole" : "paper-section";
+  const span = SECTION_SPAN[section.kind] ?? 2;
+  const kick = SECTION_KICK[section.kind];
   return (
-    <section className={cls}>
-      <h2>{SECTION_TITLE[section.kind] ?? section.label}</h2>
+    <section className="paper-card" style={{ gridColumn: `span ${span}` }}>
+      <h2>
+        {SECTION_TITLE[section.kind] ?? section.label}
+        {kick && <span className="kick">{kick}</span>}
+      </h2>
       {body}
     </section>
   );
 }
 
+// The trailing full-page news spread: the four Screamer briefs in a 2-column
+// magazine flow. Title (bold) → summary → source publications.
+function BriefsSpread({ section }: { section: EditionSection }) {
+  const p = section.payload as BriefsPayload;
+  const live = p.categories.filter((c) => c.stories.length > 0);
+  if (live.length === 0) return null;
+  return (
+    <div className="paper-news">
+      <div className="paper-news-band">
+        <h1>News Briefs</h1>
+        <span className="meta">Screamer</span>
+      </div>
+      <div className="paper-news-flow">
+        {live.map((c) => (
+          <div className="paper-brief" key={c.key}>
+            <h2>
+              {c.name}
+              <span className="kick">Screamer</span>
+            </h2>
+            {c.stories.map((s, i) => (
+              <div className="paper-story" key={i}>
+                <div className="t">{s.title}</div>
+                {s.summary && <div className="s">{s.summary}</div>}
+                {s.sources.length > 0 && <div className="src">{s.sources.join(", ")}</div>}
+              </div>
+            ))}
+          </div>
+        ))}
+      </div>
+    </div>
+  );
+}
+
 export default function PrintPage() {
   const d = gatherEdition();
-  const deg = d.weather?.units === "metric" ? "°C" : "°F";
+  const briefs = d.sections.find((s) => s.kind === "briefs");
+  const grid = d.sections.filter((s) => s.kind !== "briefs");
 
   return (
     <div className="paper">
@@ -255,26 +368,15 @@           <span>Personal Edition</span>
         </div>
       </div>
 
-      {d.weather && (
-        <p className="paper-lead">
-          {d.weather.location}: {d.weather.current.temp}
-          {deg}, {d.weather.current.text.toLowerCase()}.{" "}
-          {d.weather.daily[0] && (
-            <>
-              High {d.weather.daily[0].max}
-              {deg} · Low {d.weather.daily[0].min}
-              {deg}
-              {d.weather.daily[0].precipProb ? `, ${d.weather.daily[0].precipProb}% precip` : ""}.
-            </>
-          )}
-        </p>
-      )}
+      {d.weather && <WeatherHero w={d.weather} />}
 
-      <div className="paper-body">
-        {d.sections.map((s) => (
-          <PaperSection key={s.id} section={s} />
+      <div className="paper-grid">
+        {grid.map((s) => (
+          <PaperCard key={s.id} section={s} />
         ))}
       </div>
+
+      {briefs && <BriefsSpread section={briefs} />}
     </div>
   );
 }
components/cards/CardBodies.tsx +29 −0
@@ -5,6 +5,7 @@ // and renders a compact card body. Unknown kinds fall back to JSON.
 
 import type { WeatherPayload } from "@/lib/schemas/sources/weather";
 import type { NewsPayload } from "@/lib/schemas/sources/news";
+import type { BriefsPayload } from "@/lib/schemas/sources/briefs";
 import type { OnThisDayPayload } from "@/lib/schemas/sources/onthisday";
 import type { ObsidianPayload } from "@/lib/schemas/sources/obsidian";
 import type { GitHubPayload } from "@/lib/schemas/sources/github";
@@ -82,6 +83,32 @@           <span className="ml-1 text-xs" style={faint}>· {it.source}</span>
         </li>
       ))}
     </ul>
+  );
+}
+
+function BriefsBody({ p }: { p: BriefsPayload }) {
+  const live = p.categories.filter((c) => c.stories.length > 0);
+  if (live.length === 0) return <p className="text-sm" style={faint}>No briefs yet.</p>;
+  return (
+    <div className="space-y-3">
+      {live.map((c) => (
+        <div key={c.key}>
+          <h4 className="text-xs uppercase tracking-widest mb-1" style={muted}>
+            {c.name}
+          </h4>
+          <ul className="space-y-1">
+            {c.stories.map((s, i) => (
+              <li key={i} className="text-sm leading-snug">
+                <span className="font-medium">{s.title}</span>
+                {s.sources.length > 0 && (
+                  <span className="ml-1 text-xs" style={faint}>· {s.sources[0]}</span>
+                )}
+              </li>
+            ))}
+          </ul>
+        </div>
+      ))}
+    </div>
   );
 }
 
@@ -309,6 +336,8 @@       return <WeatherBody p={payload as WeatherPayload} row={shortTile} />;
     case "news":
     case "feeds":
       return <NewsBody p={payload as NewsPayload} />;
+    case "briefs":
+      return <BriefsBody p={payload as BriefsPayload} />;
     case "onthisday":
       return <OnThisDayBody p={payload as OnThisDayPayload} />;
     case "obsidian":
lib/edition-data.ts +1 −1
@@ -109,9 +109,9 @@   "calendar", // today's events
   "todos", // agenda / tasks
   "sports", // scores
   "feeds", // my feeds
-  "news", // headlines
   "onthisday",
   "obsidian", // from the vault
+  "briefs", // news briefs — rendered as the trailing full-page spread
 ];
 
 function printRank(kind: SourceKind): number {
lib/schemas/source.ts +1 −0
@@ -5,6 +5,7 @@ // "todos" is local data (no fetcher) — the others pull from the network/disk.
 export const SOURCE_KINDS = [
   "weather",
   "news",
+  "briefs",
   "feeds",
   "onthisday",
   "sports",
lib/schemas/sources/briefs.ts +40 −0
@@ -0,0 +1,40 @@
+import { z } from "zod";
+
+// Screamer news briefs (screamer.humdrum.one). Each category is an AI-written
+// digest published as JSON at `${baseUrl}/data/${date}-${category}.json`.
+
+export const BriefCategory = z.object({
+  key: z.string(), // "politics" | "tech" | "sports" | "culture" | …
+  name: z.string(), // display name
+});
+
+export const BriefsConfig = z.object({
+  baseUrl: z.string().url().default("https://screamer.humdrum.one"),
+  categories: z.array(BriefCategory).default([
+    { key: "politics", name: "Politics" },
+    { key: "tech", name: "Technology" },
+    { key: "sports", name: "Sports" },
+    { key: "culture", name: "Culture" },
+  ]),
+  // How many past dates to walk back when today's brief isn't published yet.
+  lookbackDays: z.number().int().min(1).max(14).default(7),
+});
+export type BriefsConfig = z.infer<typeof BriefsConfig>;
+
+export const BriefStory = z.object({
+  title: z.string(),
+  summary: z.string(),
+  sources: z.array(z.string()).default([]),
+});
+
+export const BriefSection = z.object({
+  key: z.string(),
+  name: z.string(),
+  date: z.string().nullable(), // the date actually resolved for this category
+  stories: z.array(BriefStory),
+});
+
+export const BriefsPayload = z.object({
+  categories: z.array(BriefSection),
+});
+export type BriefsPayload = z.infer<typeof BriefsPayload>;
lib/schemas/sources/weather.ts +11 −0
@@ -16,6 +16,16 @@   text: z.string(),
   precipProb: z.number().nullable(),
 });
 
+// One of today's four day-parts (morning/noon/evening/night) — drives the
+// printed weather hero strip. Empty array when hourly data is unavailable.
+export const WeatherPart = z.object({
+  label: z.string(), // "Morning" | "Noon" | "Evening" | "Night"
+  temp: z.number(),
+  code: z.number(),
+  text: z.string(),
+  precipProb: z.number().nullable(),
+});
+
 export const WeatherPayload = z.object({
   location: z.string(),
   units: z.enum(["metric", "imperial"]),
@@ -27,5 +37,6 @@     wind: z.number(),
     humidity: z.number().nullable(),
   }),
   daily: z.array(WeatherDay),
+  parts: z.array(WeatherPart).default([]),
 });
 export type WeatherPayload = z.infer<typeof WeatherPayload>;
lib/sources/modules/briefs.ts +86 −0
@@ -0,0 +1,86 @@
+import type { SourceModule } from "../registry";
+import {
+  BriefsConfig,
+  BriefsPayload,
+  type BriefsConfig as Config,
+  type BriefsPayload as Payload,
+} from "@/lib/schemas/sources/briefs";
+
+// Shape of a single category file on screamer.humdrum.one.
+interface RawBrief {
+  date?: string;
+  category?: string;
+  stories?: Array<{
+    title?: string;
+    summary?: string;
+    articles?: Array<{ publication?: string }>;
+  }>;
+}
+
+// Newest-first list of dates that have at least one published brief.
+async function fetchDates(baseUrl: string, signal?: AbortSignal): Promise<string[]> {
+  try {
+    const res = await fetch(`${baseUrl}/data/index.json`, { signal });
+    if (!res.ok) return [];
+    const j = (await res.json()) as { dates?: string[] };
+    return Array.isArray(j.dates) ? j.dates : [];
+  } catch {
+    return [];
+  }
+}
+
+// Fetch the newest available brief for one category, walking back through dates
+// until one resolves (briefs publish at staggered times overnight, so today's
+// may not exist yet at 6 AM). Returns null if none found in the window.
+async function fetchCategory(
+  baseUrl: string,
+  key: string,
+  dates: string[],
+  signal?: AbortSignal,
+): Promise<{ date: string; raw: RawBrief } | null> {
+  for (const date of dates) {
+    try {
+      const res = await fetch(`${baseUrl}/data/${date}-${key}.json`, { signal });
+      if (!res.ok) continue;
+      const raw = (await res.json()) as RawBrief;
+      if (raw.stories && raw.stories.length > 0) return { date, raw };
+    } catch {
+      // try the next date
+    }
+  }
+  return null;
+}
+
+export const briefsModule: SourceModule<Config, Payload> = {
+  kind: "briefs",
+  label: "News Briefs",
+  keyless: true,
+  defaultRefreshSeconds: 3600,
+  configSchema: BriefsConfig,
+  payloadSchema: BriefsPayload,
+
+  isConfigured: (config) => config.categories.length > 0,
+
+  async fetch({ config, signal }) {
+    const baseUrl = config.baseUrl.replace(/\/$/, "");
+    const allDates = await fetchDates(baseUrl, signal);
+    // index.json is newest-first; cap how far back we'll look per category.
+    const dates = (allDates.length ? allDates : []).slice(0, config.lookbackDays);
+
+    const categories = await Promise.all(
+      config.categories.map(async ({ key, name }) => {
+        const hit = await fetchCategory(baseUrl, key, dates, signal);
+        const stories = (hit?.raw.stories ?? []).map((s) => ({
+          title: s.title ?? "",
+          summary: s.summary ?? "",
+          sources: [
+            ...new Set((s.articles ?? []).map((a) => a.publication).filter((p): p is string => !!p)),
+          ],
+        }));
+        return { key, name, date: hit?.date ?? null, stories };
+      }),
+    );
+
+    return { categories };
+  },
+};
lib/sources/modules/index.ts +2 −0
@@ -2,6 +2,7 @@ import type { SourceModule } from "../registry";
 import { weatherModule } from "./weather";
 import { onThisDayModule } from "./onthisday";
 import { newsModule } from "./news";
+import { briefsModule } from "./briefs";
 import { feedsModule } from "./feeds";
 import { obsidianModule } from "./obsidian";
 import { githubModule } from "./github";
@@ -20,6 +21,7 @@ export const sourceModules: SourceModule[] = [
   weatherModule as SourceModule,
   onThisDayModule as SourceModule,
   newsModule as SourceModule,
+  briefsModule as SourceModule,
   feedsModule as SourceModule,
   obsidianModule as SourceModule,
   githubModule as SourceModule,
lib/sources/modules/weather.ts +40 −0
@@ -61,6 +61,10 @@     url.searchParams.set(
       "daily",
       "temperature_2m_max,temperature_2m_min,weather_code,precipitation_probability_max",
     );
+    url.searchParams.set(
+      "hourly",
+      "temperature_2m,weather_code,precipitation_probability",
+    );
     url.searchParams.set("temperature_unit", tempUnit);
     url.searchParams.set("wind_speed_unit", windUnit);
     url.searchParams.set("timezone", "auto");
@@ -81,6 +85,12 @@         temperature_2m_max: number[];
         temperature_2m_min: number[];
         weather_code: number[];
         precipitation_probability_max: (number | null)[];
+      };
+      hourly?: {
+        time: string[];
+        temperature_2m: number[];
+        weather_code: number[];
+        precipitation_probability: (number | null)[];
       };
     };
 
@@ -93,6 +103,35 @@       text: codeText(j.daily.weather_code[i]),
       precipProb: j.daily.precipitation_probability_max[i] ?? null,
     }));
 
+    // Today split into four day-parts for the printed weather strip. Open-Meteo
+    // hourly times are local ("auto" tz), formatted "YYYY-MM-DDTHH:00". Pick the
+    // hour nearest each anchor (8am / 12pm / 6pm / 10pm) within today only.
+    const today = j.daily.time[0];
+    const parts: Payload["parts"] = [];
+    if (j.hourly && today) {
+      const anchors: Array<[string, number]> = [
+        ["Morning", 8], ["Noon", 12], ["Evening", 18], ["Night", 22],
+      ];
+      const hours = j.hourly.time
+        .map((t, i) => ({ t, i, hr: Number(t.slice(11, 13)), day: t.slice(0, 10) }))
+        .filter((h) => h.day === today && Number.isFinite(h.hr));
+      for (const [label, anchor] of anchors) {
+        let best: (typeof hours)[number] | null = null;
+        for (const h of hours) {
+          if (!best || Math.abs(h.hr - anchor) < Math.abs(best.hr - anchor)) best = h;
+        }
+        if (!best) continue;
+        const code = j.hourly.weather_code[best.i];
+        parts.push({
+          label,
+          temp: Math.round(j.hourly.temperature_2m[best.i]),
+          code,
+          text: codeText(code),
+          precipProb: j.hourly.precipitation_probability[best.i] ?? null,
+        });
+      }
+    }
+
     return {
       location: place.label,
       units,
@@ -104,6 +143,7 @@         wind: Math.round(j.current.wind_speed_10m),
         humidity: j.current.relative_humidity_2m ?? null,
       },
       daily,
+      parts,
     };
   },
 };
scripts/seed.ts +18 −0
@@ -43,6 +43,24 @@       ],
       limit: 14,
     },
   },
+  // Screamer news briefs — replaces raw headlines in the paper. Keyless.
+  {
+    kind: "briefs",
+    label: "News Briefs",
+    enabled: true,
+    refreshSeconds: 3600,
+    size: "tall",
+    config: {
+      baseUrl: "https://screamer.humdrum.one",
+      categories: [
+        { key: "politics", name: "Politics" },
+        { key: "tech", name: "Technology" },
+        { key: "sports", name: "Sports" },
+        { key: "culture", name: "Culture" },
+      ],
+      lookbackDays: 7,
+    },
+  },
   // Personal RSS — starts empty; add your own feed URLs in Settings → My Feeds.
   { kind: "feeds", label: "My Feeds", enabled: true, refreshSeconds: 1800, size: "tall", config: { feeds: [], limit: 14 } },
   { kind: "sports", label: "Scores", enabled: false, refreshSeconds: 600, size: "md", config: { leagues: ["basketball/nba"], teams: [] } },