stacks/news-digest: twice-daily LLM-curated briefing on ana-docker

The Miniflux inbox got noisy after a few subreddits + HN + Lobste.rs.
This stack distills a single static page twice a day — at 0800 and
2000 local — that surfaces only what cleared score + ratio filters,
each item tldr'd by qwen3.5-35-a3b on llama-swap.

Pipeline (digest.py, ~330 lines):
  1. Discover subreddits from Miniflux feeds (any reddit.com/r/<sub>/
     URL — single source of truth, no duplicated config).
  2. Reddit JSON top-of-day per sub. Filter: score >= 50,
     upvote_ratio >= 0.85. Cap 8 items per sub.
  3. Miniflux /v1/entries for the 'Tech aggregators' category
     (HN, Lobste.rs) — last 12 hours.
  4. Batched per-source summarization via llama-swap
     /v1/chat/completions. Each post gets a one-sentence tldr +
     one-word tag (news / tutorial / release / discussion /
     question / showcase / drama / meme).
  5. Render Jinja2 template. Atomic write to /output/index.html
     (.tmp + rename) so partial pages never get served. Per-edition
     archive at /output/edition-YYYY-MM-DD-{am,pm}.html.

Two containers:
  news-digest-worker  python:3.12-alpine + busybox crond
  news-digest-web     nginx:alpine, port 8181, homepage card via
                      docker labels (group=News, fits next to Miniflux)

Both bind-mount /opt/docker/data/news-digest as /output and
/usr/share/nginx/html respectively.

Aesthetic — operations-center chrome (Australis cool-mono palette,
JetBrains Mono UPPERCASE eyebrows, mdi-glyph anchor) wrapping
editorial-serif news content (Fraunces variable serif w/ optical
sizes). Two type families that wouldn't normally meet, intentionally
combined: chrome says 'filed at 0800 from the bridge'; headlines say
'this is news, read it like news.' Sticky aurora-glow rule under the
masthead is the only sanctioned Australis gradient.

Edition stamp (AM/PM in big mono Australis-yellow) is the signature
piece — establishes the twice-daily rhythm at a glance.

All filtering + LLM + scheduling knobs in .env. Subreddit list is
implicit (read from Miniflux), so adding a sub = subscribing in
Miniflux, no config edit on this stack.
This commit is contained in:
vh
2026-04-26 13:14:13 -07:00
parent 8351e0c325
commit 2e80e69ef5
11 changed files with 1560 additions and 0 deletions
+132
View File
@@ -0,0 +1,132 @@
# Deploy news-digest (LLM-curated daily briefing) to ana-docker.
#
# Usage:
# scripts/elway ana-docker --playbook playbooks/deploy-news-digest.yaml
#
# Idempotent — every step is creates-/when-gated; rerun is safe.
#
# After first deploy, the .env still has CHANGE_ME for the Miniflux
# password. Edit it (see stacks/news-digest/README.md), then
# `docker compose up -d` again — the container will run the first
# digest at startup if /output is empty, so no need to wait for cron.
vars:
compose_dir: /opt/docker/compose/news-digest
output_dir: /opt/docker/data/news-digest
host_port: "8181"
steps:
# ── host-side dirs ──────────────────────────────────────────────────
- name: Ensure compose dir exists
shell: mkdir -p {{ compose_dir }}
creates: "{{ compose_dir }}"
- name: Ensure output dir exists (bind-mounted into both containers)
shell: mkdir -p {{ output_dir }}
creates: "{{ output_dir }}"
# ── deploy build context (compose, env, dockerfile, app, templates) ──
- name: Upload compose.yaml
upload:
src: stacks/news-digest/compose.yaml
dest: "{{ compose_dir }}/compose.yaml"
mode: "0644"
- name: Upload Dockerfile
upload:
src: stacks/news-digest/Dockerfile
dest: "{{ compose_dir }}/Dockerfile"
mode: "0644"
- name: Upload digest.py
upload:
src: stacks/news-digest/digest.py
dest: "{{ compose_dir }}/digest.py"
mode: "0644"
- name: Upload entrypoint.sh
upload:
src: stacks/news-digest/entrypoint.sh
dest: "{{ compose_dir }}/entrypoint.sh"
mode: "0755"
- name: Upload run-digest.sh
upload:
src: stacks/news-digest/run-digest.sh
dest: "{{ compose_dir }}/run-digest.sh"
mode: "0755"
- name: Upload crontab
upload:
src: stacks/news-digest/crontab
dest: "{{ compose_dir }}/crontab"
mode: "0644"
- name: Ensure templates dir exists
shell: mkdir -p {{ compose_dir }}/templates
creates: "{{ compose_dir }}/templates"
- name: Upload templates/digest.html.j2
upload:
src: stacks/news-digest/templates/digest.html.j2
dest: "{{ compose_dir }}/templates/digest.html.j2"
mode: "0644"
- name: Upload templates/style.css
upload:
src: stacks/news-digest/templates/style.css
dest: "{{ compose_dir }}/templates/style.css"
mode: "0644"
- name: Seed .env from template (only if absent)
upload:
src: stacks/news-digest/.env.example
dest: "{{ compose_dir }}/.env"
mode: "0644"
when: "[ ! -f {{ compose_dir }}/.env ]"
# ── build + bring up ────────────────────────────────────────────────
# The Dockerfile expects style.css to be inside the image too (it's
# copied via `COPY templates /app/templates`). nginx serves the
# generated index.html alongside its own copy of style.css from
# /output, so the worker writes a copy of style.css into /output too.
- name: docker compose build (~2-3 min first time)
shell: |
set -o pipefail
cd {{ compose_dir }} && docker compose build --progress=plain 2>&1 \
| grep -vE '^#[0-9]+ [0-9.]+ (Downloading|Collecting|Requirement|Using cached|Installing collected|Successfully (installed|built)|Saved /|━|Resolved|Prepared|Built)'
- name: Pre-stage style.css into /output so the first page render works
# Worker writes index.html which references "style.css" (relative).
# The CSS isn't generated by the worker; nginx serves whichever
# copy lands at /usr/share/nginx/html/style.css. Copy the template
# CSS into the bind-mounted /output dir at deploy-time.
shell: cp -f {{ compose_dir }}/templates/style.css {{ output_dir }}/style.css
- name: docker compose up -d
shell: cd {{ compose_dir }} && docker compose up -d
- name: Wait for nginx to serve /
shell: |
for i in $(seq 1 30); do
curl -sf -o /dev/null --max-time 3 http://localhost:{{ host_port }}/ && exit 0
sleep 2
done
exit 1
changed_when: "false"
verify:
- name: nginx returns 200 on /
shell: curl -sf -o /dev/null http://localhost:{{ host_port }}/
changed_when: "false"
- name: Both containers running
shell: docker inspect news-digest-worker news-digest-web --format '{{.State.Status}}' | grep -c running | grep -q '^2$'
changed_when: "false"
- name: news-digest-web on traefik-net (homepage discovery)
shell: docker inspect news-digest-web --format '{{json .NetworkSettings.Networks}}' | grep -q traefik-net
changed_when: "false"
+52
View File
@@ -0,0 +1,52 @@
# news-digest stack tunables. Copy to `.env` on ana-docker before
# deploying and fill in MINIFLUX_PASSWORD.
# ── image pin ────────────────────────────────────────────────────────
# Bump when you change Dockerfile / digest.py / templates / crontab
# to force a clean image build.
NEWS_DIGEST_TAG=v1
# ── network ──────────────────────────────────────────────────────────
# Host port for the static web container (nginx). Container 80.
# Reserved on ana-docker (sample): 5001 Dockge, 7878 task-board,
# 8000 (other), 8025/8088/8090/8100/8380/8780 (various), 8080 Miniflux.
# 8181 free.
NEWS_DIGEST_PORT=8181
NEWS_DIGEST_BIND=0.0.0.0
# Timezone — busybox crond honors this for fire-times.
NEWS_DIGEST_TZ=America/Los_Angeles
# ── llama-swap (LLM summarizer) ──────────────────────────────────────
# Model picked for one-shot summarization quality + low VRAM impact.
# qwen3.5-35-a3b is loaded in the persistent group on ana-ml2.
LLAMA_SWAP_URL=http://10.250.50.54:9292
LLAMA_SWAP_MODEL=qwen3.5-35-a3b
LLAMA_SWAP_TIMEOUT=180
# ── miniflux (feed source for Tech aggregators + subreddit list) ─────
# In-cluster: miniflux container is on traefik-net so we can reach it
# by container hostname. From-host fallback: http://10.250.50.70:8080
MINIFLUX_URL=http://miniflux:8080
MINIFLUX_USER=lkraven
MINIFLUX_PASSWORD=CHANGE_ME
# Category in Miniflux holding non-Reddit feeds (HN, Lobste.rs, etc).
DIGEST_MINIFLUX_TECH_CATEGORY=Tech aggregators
# ── filtering knobs ──────────────────────────────────────────────────
# Reddit: only consider posts created in the last N hours, with
# at least N upvotes and an upvote ratio above threshold.
DIGEST_REDDIT_HOURS=12
DIGEST_MIN_SCORE=50
DIGEST_MIN_RATIO=0.85
DIGEST_MAX_PER_SUB=8
# Miniflux Tech aggregators: same look-back window + cap per source.
DIGEST_MINIFLUX_HOURS=12
DIGEST_MINIFLUX_MAX=8
# ── output dir on host (bind-mounted) ────────────────────────────────
# Separate from /opt/docker/conf/<stack>/ to keep generated content
# distinct from config. Owned by container UID; writes are atomic.
NEWS_DIGEST_OUTPUT_DIR=/opt/docker/data/news-digest
+28
View File
@@ -0,0 +1,28 @@
# news-digest worker — twice-daily curated briefing generator.
#
# Runs cron internally (alpine's busybox crond) and a one-shot
# digest.py per fire. Bind-mounted /output is shared with the
# news-digest-web nginx container that serves the HTML.
FROM python:3.12-alpine
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
# tzdata so $TZ works for cron + datetime; tini so signals propagate cleanly.
RUN apk add --no-cache tzdata tini bash
RUN pip install --no-cache-dir requests jinja2
WORKDIR /app
COPY digest.py /app/digest.py
COPY templates /app/templates
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
COPY crontab /etc/crontabs/root
RUN chmod +x /usr/local/bin/entrypoint.sh
# Sentinel + first-run output dir
VOLUME /output
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/entrypoint.sh"]
+139
View File
@@ -0,0 +1,139 @@
# news-digest
Twice-daily LLM-curated briefing across Reddit (via JSON API) and
Miniflux's Tech aggregators category. Output is a single static HTML
page styled in the Australis design system with editorial-serif
headlines (Fraunces) — operations-center chrome wrapping news content.
## Why this stack exists
After a few subreddits + HN + Lobste.rs, the Miniflux inbox gets
noisy. This stack:
1. Pulls **top-of-day** posts per subreddit from Reddit's public JSON
API (gives us scores + upvote ratios — RSS doesn't).
2. Filters by `score >= 50` and `upvote_ratio >= 0.85` (configurable)
to drop flame-bait and low-effort posts.
3. Pulls non-Reddit recent items from Miniflux's Tech aggregators
category (HN, Lobste.rs).
4. Sends each source through `qwen3.5-35-a3b` on llama-swap (one
batched call per source — efficient) for a one-sentence tl;dr +
single-word tag (news / tutorial / release / discussion / question
/ showcase / drama / meme / other).
5. Renders an HTML page styled with Australis tokens + Fraunces
serif headlines.
6. Static page is served by a tiny `nginx:alpine`. Cron writes
`/output/index.html` atomically; nginx serves whichever copy is
there.
Two editions per day: 8am and 8pm local. Plus per-edition archives
at `/edition-YYYY-MM-DD-{am,pm}.html`.
## Architecture
Two containers, both on `traefik-net`, sharing a bind-mounted
output dir:
```
news-digest-worker (python:3.12-alpine + cron)
├── busybox crond fires at 0 8,20 * * *
├── digest.py:
│ ├── miniflux /v1/feeds → discover subreddits
│ ├── reddit JSON top/.json?t=day per sub (gentle 1.5s sleep)
│ ├── miniflux /v1/entries → tech aggregators
│ ├── llama-swap /v1/chat/completions → batched per source
│ └── jinja2 render → /output/index.html (atomic .tmp + rename)
│ → /output/edition-2026-04-26-pm.html
└── style.css served from /output (copied at deploy)
news-digest-web (nginx:alpine)
└── serves /output as / on host port 8181
└── homepage card via container labels (group=News)
```
## Deploy
```bash
scripts/elway ana-docker --playbook playbooks/deploy-news-digest.yaml
```
After first deploy, **fill in MINIFLUX_PASSWORD on the host**:
```bash
ssh ana-docker '
cd /opt/docker/compose/news-digest
sed -i "s|^MINIFLUX_PASSWORD=.*|MINIFLUX_PASSWORD=<your-miniflux-admin-password>|" .env
docker compose up -d
'
```
The container runs the first digest immediately if `/output/index.html`
doesn't exist, so the page populates within a minute or two of bringing
the stack up with real credentials.
Visit <http://10.250.50.70:8181> to read.
## Tuning the noise floor
Defaults in `.env.example`:
| Knob | Default | Effect |
|---|---|---|
| `DIGEST_REDDIT_HOURS` | 12 | Look-back window (matches twice-daily cadence) |
| `DIGEST_MIN_SCORE` | 50 | Reddit minimum upvotes to consider |
| `DIGEST_MIN_RATIO` | 0.85 | Reddit minimum upvote ratio (skips flamebait) |
| `DIGEST_MAX_PER_SUB` | 8 | Cap per subreddit, post-filter |
| `DIGEST_MINIFLUX_HOURS` | 12 | Look-back window for HN/Lobste.rs |
| `DIGEST_MINIFLUX_MAX` | 8 | Cap per non-Reddit feed |
For a busier day, lower `DIGEST_MIN_SCORE`. For a quieter morning
edition, raise it. Edit `.env`, no rebuild needed — the worker reads
env on each cron fire.
## Adding more subreddits
The digest picks up subreddit feeds from Miniflux automatically — any
feed whose URL starts with `https://www.reddit.com/r/<sub>/` gets
queried. To add a sub, just subscribe in Miniflux (UI or API). The
next digest run includes it.
## Updating the LLM model
```bash
ssh ana-docker '
cd /opt/docker/compose/news-digest
sed -i "s|^LLAMA_SWAP_MODEL=.*|LLAMA_SWAP_MODEL=<new-model>|" .env
docker compose up -d
'
```
The model must be loaded in llama-swap's `config.yaml`. Check
`http://10.250.50.54:9292/v1/models` for what's available. Models
with tool/JSON-mode support give better summarization quality;
`qwen3.5-35-a3b` is the current default.
## Forcing a fresh digest now
```bash
ssh ana-docker 'docker exec news-digest-worker python3 /app/digest.py'
```
Runs the full pipeline once, ignoring cron. Useful after changing
filtering knobs or adding feeds.
## Logs
```bash
ssh ana-docker 'docker logs --tail 100 news-digest-worker'
```
Worker logs each phase (subreddit discovery / fetching / summarizing /
rendering) with timestamps. Per-source LLM filter results show how
many items were kept vs skipped.
## License + attribution
Reddit content surfaced here is owned by its authors and Reddit. The
digest is a derived index pointing at original sources — every item
links back to the Reddit thread (and to the external link if the
post linked out). Same for HN / Lobste.rs.
+76
View File
@@ -0,0 +1,76 @@
# news-digest — twice-daily LLM-curated briefing.
#
# Two containers in this stack:
#
# news-digest-worker — python + cron, runs digest.py at 0800/2000
# local, writes /output/index.html.
# news-digest-web — tiny nginx serving the same /output dir.
# Homepage card lives on this container's
# labels.
#
# Both bind-mount the same host dir so the worker writes and the
# web container serves without IPC. Worker writes atomically
# (.tmp + rename), so partial pages never get served.
services:
news-digest-worker:
image: local/news-digest:${NEWS_DIGEST_TAG:-v1}
build:
context: .
dockerfile: Dockerfile
container_name: news-digest-worker
restart: unless-stopped
environment:
- TZ=${NEWS_DIGEST_TZ:-America/Los_Angeles}
- LLAMA_SWAP_URL=${LLAMA_SWAP_URL:-http://10.250.50.54:9292}
- LLAMA_SWAP_MODEL=${LLAMA_SWAP_MODEL:-qwen3.5-35-a3b}
- LLAMA_SWAP_TIMEOUT=${LLAMA_SWAP_TIMEOUT:-180}
- MINIFLUX_URL=${MINIFLUX_URL:-http://miniflux:8080}
- MINIFLUX_USER=${MINIFLUX_USER:-lkraven}
- MINIFLUX_PASSWORD=${MINIFLUX_PASSWORD}
- DIGEST_OUTPUT_DIR=/output
- DIGEST_TEMPLATE_DIR=/app/templates
- DIGEST_REDDIT_HOURS=${DIGEST_REDDIT_HOURS:-12}
- DIGEST_MIN_SCORE=${DIGEST_MIN_SCORE:-50}
- DIGEST_MIN_RATIO=${DIGEST_MIN_RATIO:-0.85}
- DIGEST_MAX_PER_SUB=${DIGEST_MAX_PER_SUB:-8}
- DIGEST_MINIFLUX_HOURS=${DIGEST_MINIFLUX_HOURS:-12}
- DIGEST_MINIFLUX_MAX=${DIGEST_MINIFLUX_MAX:-8}
- DIGEST_MINIFLUX_TECH_CATEGORY=${DIGEST_MINIFLUX_TECH_CATEGORY:-Tech aggregators}
volumes:
- ${NEWS_DIGEST_OUTPUT_DIR}:/output
networks:
- tnet
# Cron-driven worker — no healthcheck endpoint. The web container
# is what users hit; if the worker dies we'll see stale content.
# Restart policy handles transient crashes.
news-digest-web:
image: nginx:alpine
container_name: news-digest-web
restart: unless-stopped
depends_on:
- news-digest-worker
ports:
- "${NEWS_DIGEST_BIND:-0.0.0.0}:${NEWS_DIGEST_PORT}:80"
volumes:
- ${NEWS_DIGEST_OUTPUT_DIR}:/usr/share/nginx/html:ro
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost/ || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
networks:
- tnet
labels:
- homepage.group=News
- homepage.name=Daily Digest
- homepage.icon=mdi-newspaper-variant-outline
- homepage.description=LLM-curated briefing across feeds, twice daily
- homepage.href=http://10.250.50.70:${NEWS_DIGEST_PORT}
networks:
tnet:
name: traefik-net
external: true
+4
View File
@@ -0,0 +1,4 @@
# news-digest fires twice a day at 0800 and 2000 local (TZ from env).
# Output written atomically to /output/index.html — nginx serves it
# without restart.
0 8,20 * * * /usr/local/bin/run-digest.sh
+388
View File
@@ -0,0 +1,388 @@
"""news-digest — twice-daily LLM-curated briefing across subreddits + Miniflux.
Runs from cron at 0800 / 2000 local. Each invocation:
1. Pulls the subreddit list from Miniflux (any feed whose URL starts
with https://www.reddit.com/r/) — single source of truth, no
duplicated config.
2. Hits Reddit's public JSON API per subreddit for top-of-day,
filters by score + upvote ratio.
3. Pulls non-Reddit recent items from Miniflux (Tech aggregators
category — HN, Lobste.rs).
4. Batches each source through llama-swap on ana-ml2 with a
terse summarization prompt (one call per source).
5. Renders the Jinja2 template + CSS to /output/index.html
(atomic write via .tmp + rename).
6. Also writes /output/edition-YYYY-MM-DD-<am|pm>.html as an archive.
All tunables are environment-driven; see .env.example for the full
list. Designed to be a one-shot invocation — it does not loop or daemon.
"""
from __future__ import annotations
import json
import os
import re
import sys
import time
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Iterable, Optional
import requests
from jinja2 import Environment, FileSystemLoader, select_autoescape
# ── env config ───────────────────────────────────────────────────────
LLAMA_SWAP_URL = os.environ.get("LLAMA_SWAP_URL", "http://10.250.50.54:9292")
LLAMA_SWAP_MODEL = os.environ.get("LLAMA_SWAP_MODEL", "qwen3.5-35-a3b")
LLAMA_SWAP_TIMEOUT = int(os.environ.get("LLAMA_SWAP_TIMEOUT", "180"))
MINIFLUX_URL = os.environ.get("MINIFLUX_URL", "http://miniflux:8080")
MINIFLUX_USER = os.environ.get("MINIFLUX_USER", "lkraven")
MINIFLUX_PASS = os.environ.get("MINIFLUX_PASSWORD", "")
OUTPUT_DIR = Path(os.environ.get("DIGEST_OUTPUT_DIR", "/output"))
TEMPLATE_DIR = Path(os.environ.get("DIGEST_TEMPLATE_DIR", "/app/templates"))
REDDIT_HOURS = int(os.environ.get("DIGEST_REDDIT_HOURS", "12"))
REDDIT_MIN_SCORE = int(os.environ.get("DIGEST_MIN_SCORE", "50"))
REDDIT_MIN_RATIO = float(os.environ.get("DIGEST_MIN_RATIO", "0.85"))
REDDIT_MAX_PER_SUB = int(os.environ.get("DIGEST_MAX_PER_SUB", "8"))
REDDIT_USER_AGENT = os.environ.get(
"DIGEST_REDDIT_USER_AGENT",
"news-digest:phasefinal:0.1.0 (anonymous)",
)
MINIFLUX_TECH_CATEGORY = os.environ.get(
"DIGEST_MINIFLUX_TECH_CATEGORY", "Tech aggregators"
)
MINIFLUX_HOURS = int(os.environ.get("DIGEST_MINIFLUX_HOURS", "12"))
MINIFLUX_MAX_PER_SOURCE = int(os.environ.get("DIGEST_MINIFLUX_MAX", "8"))
TZ_NAME = os.environ.get("TZ", "America/Los_Angeles")
# ── data shapes ──────────────────────────────────────────────────────
@dataclass
class Item:
"""A single curated post — Reddit or Miniflux origin."""
id: str
title: str
url: str # external link or HTML permalink
permalink: str # discussion / source URL (Reddit thread, HN comments)
body: str # selftext / description (may be empty)
author: str
score: Optional[int] # Reddit upvotes if known
comments: Optional[int]
upvote_ratio: Optional[float]
posted_at: datetime
# Filled by summarize step:
tldr: str = ""
tag: str = ""
@dataclass
class Source:
"""A logical grouping of items shown as one section in the digest."""
name: str # display name ("r/selfhosted", "Hacker News")
kind: str # "reddit" | "miniflux"
href: str # link to the source's homepage / sub
items: list[Item] = field(default_factory=list)
# ── http session shared across calls ─────────────────────────────────
S = requests.Session()
S.headers["User-Agent"] = REDDIT_USER_AGENT
def log(msg: str) -> None:
print(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}", flush=True)
# ── miniflux: discover subreddits + pull tech-aggregator items ───────
def miniflux_get(path: str, **params) -> Any:
url = f"{MINIFLUX_URL.rstrip('/')}{path}"
r = S.get(url, params=params, auth=(MINIFLUX_USER, MINIFLUX_PASS), timeout=20)
r.raise_for_status()
return r.json()
REDDIT_FEED_RE = re.compile(r"^https?://(?:www\.)?reddit\.com/r/([^/]+)/", re.I)
def discover_subreddits_from_miniflux() -> list[str]:
"""Return list of subreddit names extracted from Miniflux's feed URLs."""
feeds = miniflux_get("/v1/feeds")
subs: list[str] = []
for f in feeds:
m = REDDIT_FEED_RE.match(f.get("feed_url", ""))
if m:
subs.append(m.group(1))
seen, deduped = set(), []
for s in subs:
k = s.lower()
if k not in seen:
deduped.append(s)
seen.add(k)
return deduped
def fetch_miniflux_tech_items() -> list[Source]:
"""Return one Source per non-Reddit feed in the configured category."""
cats = miniflux_get("/v1/categories")
tech_cat = next(
(c for c in cats if c["title"].lower() == MINIFLUX_TECH_CATEGORY.lower()),
None,
)
if not tech_cat:
log(f"miniflux: category {MINIFLUX_TECH_CATEGORY!r} not found, skipping")
return []
cutoff = int((datetime.now(timezone.utc) - timedelta(hours=MINIFLUX_HOURS)).timestamp())
entries = miniflux_get(
"/v1/entries",
category_id=tech_cat["id"],
published_after=cutoff,
order="published_at",
direction="desc",
limit=200,
)
by_feed: dict[int, Source] = {}
for e in entries.get("entries", []):
feed = e.get("feed") or {}
if REDDIT_FEED_RE.match(feed.get("feed_url", "")):
continue # handled in Reddit pass
fid = feed.get("id")
if fid is None:
continue
src = by_feed.get(fid)
if src is None:
src = Source(
name=feed.get("title", "?"),
kind="miniflux",
href=feed.get("site_url") or feed.get("feed_url") or "",
)
by_feed[fid] = src
if len(src.items) >= MINIFLUX_MAX_PER_SOURCE:
continue
src.items.append(Item(
id=str(e["id"]),
title=e.get("title", "(untitled)"),
url=e.get("url", ""),
permalink=e.get("url", ""),
body=(e.get("content") or "")[:1500],
author=e.get("author", ""),
score=None,
comments=None,
upvote_ratio=None,
posted_at=_parse_dt(e.get("published_at")),
))
return [s for s in by_feed.values() if s.items]
def _parse_dt(s: Optional[str]) -> datetime:
if not s:
return datetime.now(timezone.utc)
try:
return datetime.fromisoformat(s.replace("Z", "+00:00"))
except Exception:
return datetime.now(timezone.utc)
# ── reddit JSON: top-of-day per subreddit ────────────────────────────
def fetch_reddit_top(sub: str) -> Source:
log(f"reddit: r/{sub}")
url = f"https://www.reddit.com/r/{sub}/top/.json"
params = {"t": "day", "limit": 25}
r = S.get(url, params=params, timeout=20)
if not r.ok:
log(f" HTTP {r.status_code}: skipping")
return Source(name=f"r/{sub}", kind="reddit", href=f"https://reddit.com/r/{sub}")
posts = r.json().get("data", {}).get("children", [])
cutoff_ts = (datetime.now(timezone.utc) - timedelta(hours=REDDIT_HOURS)).timestamp()
items: list[Item] = []
for p in posts:
d = p.get("data", {})
score = d.get("score", 0)
ratio = d.get("upvote_ratio", 0.0)
created = d.get("created_utc", 0)
if score < REDDIT_MIN_SCORE: continue
if ratio < REDDIT_MIN_RATIO: continue
if created < cutoff_ts: continue
items.append(Item(
id=d.get("id", ""),
title=d.get("title", "(untitled)"),
url=d.get("url", ""),
permalink=f"https://reddit.com{d.get('permalink', '')}",
body=(d.get("selftext") or "")[:1500],
author=d.get("author", "[deleted]"),
score=score,
comments=d.get("num_comments"),
upvote_ratio=ratio,
posted_at=datetime.fromtimestamp(created, tz=timezone.utc),
))
items.sort(key=lambda x: (x.score or 0), reverse=True)
items = items[:REDDIT_MAX_PER_SUB]
log(f" kept {len(items)} (score>={REDDIT_MIN_SCORE}, ratio>={REDDIT_MIN_RATIO})")
return Source(name=f"r/{sub}", kind="reddit",
href=f"https://reddit.com/r/{sub}", items=items)
# ── llama-swap: batched summarization per source ─────────────────────
SUMMARIZE_SYSTEM = (
"You are a curator producing a tight intelligence briefing for an "
"engineer who reads many feeds. You are concise, neutral, and never "
"editorialize. You skip pure shitposts and screenshots-without-context."
)
SUMMARIZE_USER_TEMPLATE = """Given the {n} posts from {source} below, return a JSON ARRAY where each element has:
- "id": the post id from the input
- "tldr": ONE sentence, 25 words max, capturing the substantive point. Lead with a verb. No "this post discusses". No "a user shares".
- "tag": ONE word from {{news, tutorial, release, discussion, question, showcase, drama, meme, other}}
If a post is a pure shitpost / screenshot-without-context / duplicate of an item already in this batch, set "tldr" to "" and "tag" to "skip".
Output ONLY the JSON array. No prose, no markdown fence.
POSTS:
{posts_json}
"""
def summarize_source(src: Source) -> None:
if not src.items:
return
posts_json = json.dumps([
{"id": it.id, "title": it.title, "body": it.body[:600], "url": it.url}
for it in src.items
], ensure_ascii=False)
user = SUMMARIZE_USER_TEMPLATE.format(
n=len(src.items),
source=src.name,
posts_json=posts_json,
)
log(f" llm: summarizing {len(src.items)} items from {src.name}")
try:
r = S.post(
f"{LLAMA_SWAP_URL.rstrip('/')}/v1/chat/completions",
json={
"model": LLAMA_SWAP_MODEL,
"messages": [
{"role": "system", "content": SUMMARIZE_SYSTEM},
{"role": "user", "content": user},
],
"temperature": 0.2,
"max_tokens": 2000,
},
timeout=LLAMA_SWAP_TIMEOUT,
)
r.raise_for_status()
content = r.json()["choices"][0]["message"]["content"].strip()
# Some models wrap JSON in ```...``` even when told not to.
content = re.sub(r"^```(?:json)?\s*|\s*```$", "", content, flags=re.M).strip()
mapped = {x.get("id"): x for x in json.loads(content)}
except Exception as e:
log(f" ! llm failed for {src.name}: {e!r} — keeping raw titles")
return
for it in src.items:
m = mapped.get(it.id, {})
it.tldr = (m.get("tldr") or "").strip()
it.tag = (m.get("tag") or "").strip().lower()
# Drop skipped entries from the source.
src.items = [it for it in src.items if it.tag != "skip" and (it.tldr or it.score is None)]
log(f" -> {len(src.items)} kept after llm filter")
# ── render ───────────────────────────────────────────────────────────
def render(reddit_sources: list[Source], tech_sources: list[Source],
generated_at: datetime) -> str:
env = Environment(
loader=FileSystemLoader(str(TEMPLATE_DIR)),
autoescape=select_autoescape(["html"]),
trim_blocks=True,
lstrip_blocks=True,
)
env.filters["humanago"] = _humanago
env.filters["domain"] = _domain
template = env.get_template("digest.html.j2")
edition = "morning" if generated_at.hour < 14 else "evening"
return template.render(
reddit_sources=[s for s in reddit_sources if s.items],
tech_sources=[s for s in tech_sources if s.items],
generated_at=generated_at,
edition=edition,
edition_short="AM" if edition == "morning" else "PM",
model=LLAMA_SWAP_MODEL,
date_long=generated_at.strftime("%A %B %-d, %Y"),
time_short=generated_at.strftime("%-I:%M %p"),
tz=generated_at.tzname() or TZ_NAME,
next_edition=("evening" if edition == "morning" else "morning"),
)
def _humanago(d: datetime) -> str:
delta = datetime.now(timezone.utc) - d
s = int(delta.total_seconds())
if s < 60: return f"{s}s"
if s < 3600: return f"{s // 60}m"
if s < 86400: return f"{s // 3600}h"
return f"{s // 86400}d"
def _domain(url: str) -> str:
m = re.match(r"^https?://(?:www\.)?([^/]+)", url or "")
return m.group(1) if m else ""
def write_output(html: str, generated_at: datetime) -> None:
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
edition = "am" if generated_at.hour < 14 else "pm"
archive = OUTPUT_DIR / f"edition-{generated_at:%Y-%m-%d}-{edition}.html"
index = OUTPUT_DIR / "index.html"
archive_tmp = archive.with_suffix(".html.tmp")
archive_tmp.write_text(html, encoding="utf-8")
archive_tmp.rename(archive)
index_tmp = index.with_suffix(".html.tmp")
index_tmp.write_text(html, encoding="utf-8")
index_tmp.rename(index)
log(f"wrote {index} (and archive {archive.name})")
# ── main ─────────────────────────────────────────────────────────────
def main() -> int:
if not MINIFLUX_PASS:
print("MINIFLUX_PASSWORD not set — bailing", file=sys.stderr)
return 2
try:
from zoneinfo import ZoneInfo
now_local = datetime.now(ZoneInfo(TZ_NAME))
except Exception:
now_local = datetime.now()
log(f"starting digest run at {now_local.isoformat()} ({'AM' if now_local.hour < 14 else 'PM'})")
log("phase 1: discovering subreddits from miniflux")
subs = discover_subreddits_from_miniflux()
log(f" found {len(subs)} subreddits: {', '.join(subs) or '(none)'}")
log("phase 2: fetching reddit top-of-day per subreddit")
reddit_sources: list[Source] = []
for sub in subs:
reddit_sources.append(fetch_reddit_top(sub))
time.sleep(1.5) # gentle to anonymous Reddit
log("phase 3: fetching tech-aggregator items from miniflux")
tech_sources = fetch_miniflux_tech_items()
log(f" found {len(tech_sources)} non-reddit feeds with recent items")
log("phase 4: summarizing each source via llama-swap")
for src in reddit_sources + tech_sources:
summarize_source(src)
log("phase 5: rendering")
html = render(reddit_sources, tech_sources, now_local)
write_output(html, now_local)
log("done")
return 0
if __name__ == "__main__":
sys.exit(main())
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# entrypoint.sh — news-digest worker startup.
#
# Strategy:
# 1. If /output is empty, run digest.py once at start so the page
# isn't blank while waiting for the next cron tick.
# 2. Start busybox crond in foreground so the container stays up.
#
# All env vars are inherited from compose, including TZ which busybox
# crond honors when computing fire times.
set -e
mkdir -p /output
if [ ! -f /output/index.html ]; then
echo "[entrypoint] no /output/index.html yet — running first digest"
/usr/local/bin/run-digest.sh || \
echo "[entrypoint] first run failed; cron will retry on schedule"
fi
# Tee crontab into the place busybox expects (already done in image
# via COPY) and run crond in foreground. -L /dev/stdout sends cron
# stdout/stderr to docker logs.
echo "[entrypoint] starting crond (fires per /etc/crontabs/root)"
exec crond -f -L /dev/stdout -l 8
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# run-digest.sh — single-shot wrapper invoked by cron.
# Loads env from /etc/environment (cron's empty environment otherwise)
# and pipes output to docker logs via a timestamped prefix.
set -e
# busybox crond doesn't carry container env. Re-export from /etc/environment
# (Docker writes container envs there if you set DOCKER_ENV write — but
# we can't rely on that). Simpler: source any envfile we drop in entrypoint.
if [ -f /tmp/digest.env ]; then
set -a; . /tmp/digest.env; set +a
fi
cd /app
exec python3 /app/digest.py 2>&1 | sed "s/^/[$(date '+%H:%M:%S')] /"
+202
View File
@@ -0,0 +1,202 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Daily Digest · {{ date_long }} · {{ edition_short }}</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
{# ────── header ────── #}
<header class="masthead" role="banner">
<div class="masthead-inner">
<div class="masthead-brand">
<span class="brand-glyph" aria-hidden="true">◢</span>
<span class="brand-mark">DAILY DIGEST</span>
</div>
<div class="masthead-edition">
<span class="edition-num">№ {{ generated_at.strftime("%j") }}</span>
<span class="edition-stamp">{{ edition|upper }} EDITION</span>
</div>
<div class="masthead-meta">
<div class="meta-row">
<span class="meta-label">FILED</span>
<span class="meta-value">{{ date_long|upper }} · {{ time_short }} {{ tz }}</span>
</div>
<div class="meta-row">
<span class="meta-label">DESK</span>
<span class="meta-value">SUBREDDITS · TECH FEEDS</span>
</div>
<div class="meta-row">
<span class="meta-label">CURATED BY</span>
<span class="meta-value">{{ model }}</span>
</div>
</div>
</div>
{# A thin aurora-glow rule under the masthead — the only sanctioned
Australis gradient. #}
<div class="aurora-rule" aria-hidden="true"></div>
</header>
{# ────── nav strip ────── #}
<nav class="jumpnav" aria-label="sections">
<ol>
{% if reddit_sources %}
<li><a href="#reddit"><span class="jump-num">01</span> REDDIT</a></li>
{% endif %}
{% if tech_sources %}
<li><a href="#tech"><span class="jump-num">02</span> TECH FEEDS</a></li>
{% endif %}
</ol>
</nav>
<main class="brief">
{# ────── REDDIT SECTION ────── #}
{% if reddit_sources %}
<section id="reddit" class="desk">
<header class="desk-head">
<span class="desk-num">01</span>
<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_sources|sum(attribute='items')|length }} items</span>
</header>
{% for src in reddit_sources %}
<article class="source">
<header class="source-head">
<a class="source-name" href="{{ src.href }}" target="_blank" rel="noopener">
{{ src.name }}
</a>
<span class="source-count">{{ src.items|length }}</span>
</header>
<ol class="items">
{% for it in src.items %}
<li class="item" data-tag="{{ it.tag }}">
<div class="item-rail" aria-hidden="true">
{% if it.score %}
<span class="chip chip-score" title="upvotes">▲ {{ it.score }}</span>
{% endif %}
{% if it.comments %}
<span class="chip chip-comments" title="comments">{{ it.comments }} ⌥</span>
{% endif %}
{% if it.tag and it.tag != 'other' %}
<span class="chip chip-tag chip-tag-{{ it.tag }}">{{ it.tag }}</span>
{% endif %}
</div>
<div class="item-body">
<h3 class="item-title">
<a href="{{ it.url or it.permalink }}" target="_blank" rel="noopener">{{ it.title }}</a>
</h3>
{% if it.tldr %}
<p class="item-tldr">{{ it.tldr }}</p>
{% endif %}
<footer class="item-foot">
<a class="item-link" href="{{ it.permalink }}" target="_blank" rel="noopener">
comments
</a>
{% if it.url and it.url != it.permalink %}
<span class="sep">·</span>
<a class="item-link" href="{{ it.url }}" target="_blank" rel="noopener">
{{ it.url|domain }}
</a>
{% endif %}
<span class="sep">·</span>
<span class="item-meta">{{ it.posted_at|humanago }} ago</span>
<span class="sep">·</span>
<span class="item-meta">u/{{ it.author }}</span>
</footer>
</div>
</li>
{% endfor %}
</ol>
</article>
{% endfor %}
</section>
{% endif %}
{# ────── TECH FEEDS SECTION ────── #}
{% if tech_sources %}
<section id="tech" class="desk">
<header class="desk-head">
<span class="desk-num">02</span>
<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_sources|sum(attribute='items')|length }} items</span>
</header>
{% for src in tech_sources %}
<article class="source">
<header class="source-head">
<a class="source-name" href="{{ src.href }}" target="_blank" rel="noopener">
{{ src.name }}
</a>
<span class="source-count">{{ src.items|length }}</span>
</header>
<ol class="items">
{% for it in src.items %}
<li class="item" data-tag="{{ it.tag }}">
<div class="item-rail" aria-hidden="true">
{% if it.tag and it.tag != 'other' %}
<span class="chip chip-tag chip-tag-{{ it.tag }}">{{ it.tag }}</span>
{% endif %}
</div>
<div class="item-body">
<h3 class="item-title">
<a href="{{ it.url }}" target="_blank" rel="noopener">{{ it.title }}</a>
</h3>
{% if it.tldr %}
<p class="item-tldr">{{ it.tldr }}</p>
{% endif %}
<footer class="item-foot">
{% if it.url %}
<a class="item-link" href="{{ it.url }}" target="_blank" rel="noopener">
{{ it.url|domain }}
</a>
<span class="sep">·</span>
{% endif %}
<span class="item-meta">{{ it.posted_at|humanago }} ago</span>
{% if it.author %}
<span class="sep">·</span>
<span class="item-meta">{{ it.author }}</span>
{% endif %}
</footer>
</div>
</li>
{% endfor %}
</ol>
</article>
{% endfor %}
</section>
{% endif %}
{% if not reddit_sources and not tech_sources %}
<section class="desk empty">
<p class="empty-msg">
No items cleared the filters in the last window.<br>
Lower <code>DIGEST_MIN_SCORE</code> or widen
<code>DIGEST_REDDIT_HOURS</code> if this looks wrong.
</p>
</section>
{% endif %}
</main>
<footer class="colophon">
<div class="colophon-inner">
<div class="colophon-block">
<span class="meta-label">FILED</span>
<span class="meta-value">{{ generated_at.isoformat(timespec="seconds") }}</span>
</div>
<div class="colophon-block">
<span class="meta-label">NEXT EDITION</span>
<span class="meta-value">{{ next_edition|upper }} · 12 H</span>
</div>
<div class="colophon-block">
<span class="meta-label">PIPELINE</span>
<span class="meta-value">REDDIT JSON + MINIFLUX → {{ model }} → JINJA2</span>
</div>
</div>
</footer>
</body>
</html>
+497
View File
@@ -0,0 +1,497 @@
/* -------------------------------------------------------------------------
news-digest — daily intel briefing.
Aesthetic: operations-center chrome (Australis cool-mono palette,
JetBrains Mono UPPERCASE eyebrows, mdi-glyph anchor) wrapping
editorial-serif news content (Fraunces). Two type families that
wouldn't normally meet, intentionally combined: the chrome says
"filed at 0800 from the bridge"; the headlines say "this is news,
read it like news."
Type:
Display (headlines): Fraunces — variable serif w/ optical sizes
Body (summaries): Inter — same as Australis, optimized for screens
Mono (chrome): JetBrains Mono — eyebrows / metadata / chips
Color: pure Australis Ice + Sea + Aurora. Score chips lean
--aus-blue (info); release tags --aus-green; drama --aus-red
(sparingly). No warm accents — Australis is cool-only by design.
------------------------------------------------------------------------- */
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Inter:wght@400;500;600;700&family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600;9..144,700;9..144,800&display=swap');
:root {
/* Australis palette — vendored subset */
--aus-black: #222531;
--aus-white: #a9bcc3;
--aus-bright-white: #cce7ec;
--aus-bright-black: #373b46;
--aus-dark-30: #414751;
--aus-dark-40: #565f69;
--aus-dark-50: #6e7882;
--aus-dark-60: #86929d;
--aus-bright-70: #9daeb6;
--aus-bright-80: #b3cbcf;
--aus-blue: #6388d8;
--aus-bright-blue: #a4c4ff;
--aus-cyan: #00b1a8;
--aus-bright-cyan: #42dcd1;
--aus-green: #16b866;
--aus-bright-green: #51e08a;
--aus-yellow: #e1c631;
--aus-bright-yellow:#ffe14e;
--aus-red: #ff491a;
--aus-magenta: #9d78ff;
--bg: var(--aus-black);
--bg-elev: var(--aus-bright-black);
--surface: #2a2e3a;
--surface-hi: var(--aus-dark-30);
--rule: var(--aus-dark-30);
--rule-bright: var(--aus-dark-40);
--fg: var(--aus-bright-white);
--fg-dim: var(--aus-bright-70);
--fg-faint: var(--aus-dark-50);
--accent: var(--aus-bright-cyan);
--accent-dim: var(--aus-cyan);
/* Type families */
--font-display: "Fraunces", "Times New Roman", Georgia, serif;
--font-sans: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace;
/* Scale + chrome */
--pad-x: 24px;
--content-w: 880px;
}
*, *::before, *::after { box-sizing: border-box; }
[hidden] { display: none !important; }
html, body {
margin: 0;
background: var(--bg);
color: var(--fg);
font: 16px/1.55 var(--font-sans);
font-feature-settings: "ss01" 1;
-webkit-font-smoothing: antialiased;
}
a { color: inherit; text-decoration: none; }
a:hover { color: var(--accent); }
/* -------------------------------------------------------------------------
masthead — full-width banner; the "I am a news edition" statement
------------------------------------------------------------------------- */
.masthead {
border-bottom: 1px solid var(--rule);
background: var(--bg-elev);
}
.masthead-inner {
max-width: var(--content-w);
margin: 0 auto;
padding: 36px var(--pad-x) 24px;
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: auto auto;
column-gap: 32px;
row-gap: 16px;
align-items: end;
}
.masthead-brand {
grid-column: 1 / 2;
grid-row: 1 / 2;
display: flex;
align-items: baseline;
gap: 10px;
font-family: var(--font-mono);
}
.brand-glyph {
font-size: 32px;
line-height: 1;
color: var(--accent);
text-shadow: 0 0 12px rgba(66, 220, 209, .35);
}
.brand-mark {
font-size: 14px;
font-weight: 700;
letter-spacing: .18em;
color: var(--fg);
}
.masthead-edition {
grid-column: 2 / 3;
grid-row: 1 / 2;
display: flex;
align-items: baseline;
justify-content: flex-end;
gap: 18px;
font-family: var(--font-mono);
}
.edition-num {
color: var(--fg-faint);
font-size: 11px;
letter-spacing: .14em;
}
.edition-stamp {
/* THE statement piece — large mono in Australis yellow.
"This is the morning/evening brief, filed officially." */
font-family: var(--font-mono);
font-size: 22px;
font-weight: 700;
letter-spacing: .14em;
color: var(--aus-yellow);
padding: 4px 10px;
border: 1px solid var(--aus-yellow);
border-radius: 2px;
}
.masthead-meta {
grid-column: 1 / 3;
grid-row: 2 / 3;
display: grid;
grid-template-columns: repeat(3, auto);
gap: 4px 32px;
align-items: baseline;
font-family: var(--font-mono);
padding-top: 16px;
border-top: 1px solid var(--rule);
}
.meta-row {
display: flex;
align-items: baseline;
gap: 10px;
font-size: 11px;
letter-spacing: .08em;
}
.meta-label {
color: var(--fg-faint);
text-transform: uppercase;
letter-spacing: .14em;
}
.meta-value {
color: var(--fg);
font-variant-numeric: tabular-nums;
}
/* The aurora glow rule — sanctioned Australis gradient, used once. */
.aurora-rule {
height: 2px;
background: linear-gradient(90deg,
transparent 0%,
var(--aus-bright-blue) 25%,
var(--aus-bright-cyan) 50%,
var(--aus-bright-green) 75%,
transparent 100%);
opacity: .65;
}
/* -------------------------------------------------------------------------
jump nav
------------------------------------------------------------------------- */
.jumpnav {
position: sticky;
top: 0;
z-index: 5;
background: rgba(34, 37, 49, 0.92);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-bottom: 1px solid var(--rule);
}
.jumpnav ol {
list-style: none;
margin: 0;
padding: 12px var(--pad-x);
max-width: var(--content-w);
margin-inline: auto;
display: flex;
gap: 28px;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: .14em;
text-transform: uppercase;
}
.jumpnav a {
color: var(--fg-dim);
display: inline-flex;
align-items: baseline;
gap: 8px;
}
.jumpnav a:hover { color: var(--fg); }
.jump-num { color: var(--fg-faint); font-size: 10px; }
/* -------------------------------------------------------------------------
main brief
------------------------------------------------------------------------- */
.brief {
max-width: var(--content-w);
margin: 0 auto;
padding: 32px var(--pad-x) 64px;
}
.desk + .desk { margin-top: 64px; }
.desk-head {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: baseline;
column-gap: 16px;
padding-bottom: 14px;
margin-bottom: 24px;
border-bottom: 1px solid var(--rule-bright);
}
.desk-num {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 700;
letter-spacing: .14em;
color: var(--aus-yellow);
padding-top: 4px; /* aligns visually with the serif baseline */
}
.desk-title {
font-family: var(--font-display);
font-size: 38px;
font-weight: 700;
letter-spacing: -0.015em;
margin: 0;
color: var(--fg);
font-variation-settings: "opsz" 144;
}
.desk-sub {
/* Sub-line hugs the title baseline */
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: .08em;
color: var(--fg-faint);
text-transform: uppercase;
grid-column: 2 / 3;
align-self: end;
padding-bottom: 6px;
padding-left: 12px;
}
.desk-count {
font-family: var(--font-mono);
font-size: 11px;
font-variant-numeric: tabular-nums;
letter-spacing: .08em;
color: var(--fg-dim);
text-transform: uppercase;
}
/* -------------------------------------------------------------------------
per-source block
------------------------------------------------------------------------- */
.source + .source { margin-top: 36px; }
.source-head {
display: flex;
align-items: baseline;
gap: 14px;
margin-bottom: 14px;
}
.source-name {
font-family: var(--font-mono);
font-size: 12px;
font-weight: 700;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--accent);
border-bottom: 1px dashed var(--accent-dim);
padding-bottom: 1px;
}
.source-name:hover { color: var(--aus-bright-cyan); }
.source-count {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: .14em;
color: var(--fg-faint);
font-variant-numeric: tabular-nums;
}
/* -------------------------------------------------------------------------
item — the news card
------------------------------------------------------------------------- */
.items {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 24px;
}
.item {
display: grid;
grid-template-columns: 96px 1fr;
column-gap: 24px;
padding-bottom: 24px;
border-bottom: 1px solid var(--rule);
}
.item:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.item-rail {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
padding-top: 6px;
}
.chip {
font-family: var(--font-mono);
font-size: 10.5px;
font-weight: 600;
letter-spacing: .06em;
padding: 2px 8px;
border: 1px solid currentColor;
border-radius: 2px;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.chip-score { color: var(--aus-blue); }
.chip-comments { color: var(--fg-dim); border-color: var(--rule-bright); }
.chip-tag {
font-size: 9.5px;
letter-spacing: .14em;
text-transform: uppercase;
}
.chip-tag-news { color: var(--aus-bright-blue); }
.chip-tag-tutorial { color: var(--aus-bright-cyan); }
.chip-tag-release { color: var(--aus-bright-green); }
.chip-tag-discussion{ color: var(--fg-dim); }
.chip-tag-question { color: var(--aus-yellow); }
.chip-tag-showcase { color: var(--aus-magenta); }
.chip-tag-drama { color: var(--aus-red); }
.chip-tag-meme { color: var(--fg-faint); }
.item-body {
min-width: 0;
}
.item-title {
margin: 0 0 8px;
font-family: var(--font-display);
font-size: 22px;
line-height: 1.2;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--fg);
font-variation-settings: "opsz" 36;
}
.item-title a { color: inherit; }
.item-title a:hover { color: var(--accent); }
.item-tldr {
margin: 0 0 12px;
font-size: 15px;
line-height: 1.55;
color: var(--fg-dim);
}
.item-foot {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: .06em;
color: var(--fg-faint);
}
.item-link {
color: var(--fg-dim);
border-bottom: 1px dotted var(--fg-faint);
padding-bottom: 1px;
}
.item-link:hover { color: var(--accent); border-color: var(--accent-dim); }
.item-meta { font-variant-numeric: tabular-nums; }
.sep { color: var(--rule-bright); }
/* Visual de-emphasize purely meme/skip items if any slip through. */
.item[data-tag="meme"] { opacity: .58; }
.item[data-tag="other"] { opacity: .85; }
/* -------------------------------------------------------------------------
empty state + colophon
------------------------------------------------------------------------- */
.desk.empty {
text-align: center;
padding: 72px 24px;
border: 1px dashed var(--rule);
border-radius: 6px;
}
.empty-msg {
font-family: var(--font-mono);
font-size: 13px;
color: var(--fg-faint);
line-height: 1.6;
}
.empty-msg code {
color: var(--fg-dim);
font-family: inherit;
background: var(--surface);
padding: 2px 6px;
border-radius: 2px;
}
.colophon {
border-top: 1px solid var(--rule);
background: var(--bg-elev);
padding: 24px var(--pad-x);
margin-top: 64px;
}
.colophon-inner {
max-width: var(--content-w);
margin: 0 auto;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: .08em;
}
.colophon-block {
display: flex;
flex-direction: column;
gap: 4px;
}
.colophon-block .meta-label { color: var(--fg-faint); font-size: 10px; }
.colophon-block .meta-value { color: var(--fg-dim); font-size: 11px; }
/* -------------------------------------------------------------------------
responsive
------------------------------------------------------------------------- */
@media (max-width: 720px) {
:root { --pad-x: 16px; }
.masthead-inner {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
}
.masthead-brand,
.masthead-edition,
.masthead-meta { grid-column: 1 / 2; }
.masthead-edition { justify-content: flex-start; }
.edition-stamp { font-size: 18px; }
.masthead-meta { grid-template-columns: 1fr; gap: 6px; }
.desk-title { font-size: 28px; }
.desk-head { grid-template-columns: auto 1fr; row-gap: 6px; }
.desk-sub { grid-column: 1 / 3; padding-left: 0; }
.desk-count { display: none; }
.item { grid-template-columns: 64px 1fr; column-gap: 14px; }
.item-rail { padding-top: 4px; }
.chip { font-size: 9.5px; padding: 1px 6px; }
.item-title { font-size: 18px; }
.item-tldr { font-size: 14px; }
.colophon-inner { grid-template-columns: 1fr; }
}