diff --git a/playbooks/deploy-news-digest.yaml b/playbooks/deploy-news-digest.yaml new file mode 100644 index 0000000..6a8a2bb --- /dev/null +++ b/playbooks/deploy-news-digest.yaml @@ -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" diff --git a/stacks/news-digest/.env.example b/stacks/news-digest/.env.example new file mode 100644 index 0000000..e486955 --- /dev/null +++ b/stacks/news-digest/.env.example @@ -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// to keep generated content +# distinct from config. Owned by container UID; writes are atomic. +NEWS_DIGEST_OUTPUT_DIR=/opt/docker/data/news-digest diff --git a/stacks/news-digest/Dockerfile b/stacks/news-digest/Dockerfile new file mode 100644 index 0000000..d03f981 --- /dev/null +++ b/stacks/news-digest/Dockerfile @@ -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"] diff --git a/stacks/news-digest/README.md b/stacks/news-digest/README.md new file mode 100644 index 0000000..b52aeb9 --- /dev/null +++ b/stacks/news-digest/README.md @@ -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=|" .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 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//` 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=|" .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. diff --git a/stacks/news-digest/compose.yaml b/stacks/news-digest/compose.yaml new file mode 100644 index 0000000..a8d4ed3 --- /dev/null +++ b/stacks/news-digest/compose.yaml @@ -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 diff --git a/stacks/news-digest/crontab b/stacks/news-digest/crontab new file mode 100644 index 0000000..a30dce8 --- /dev/null +++ b/stacks/news-digest/crontab @@ -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 diff --git a/stacks/news-digest/digest.py b/stacks/news-digest/digest.py new file mode 100644 index 0000000..0526049 --- /dev/null +++ b/stacks/news-digest/digest.py @@ -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-.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()) diff --git a/stacks/news-digest/entrypoint.sh b/stacks/news-digest/entrypoint.sh new file mode 100644 index 0000000..40d4159 --- /dev/null +++ b/stacks/news-digest/entrypoint.sh @@ -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 diff --git a/stacks/news-digest/run-digest.sh b/stacks/news-digest/run-digest.sh new file mode 100644 index 0000000..b2b8589 --- /dev/null +++ b/stacks/news-digest/run-digest.sh @@ -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')] /" diff --git a/stacks/news-digest/templates/digest.html.j2 b/stacks/news-digest/templates/digest.html.j2 new file mode 100644 index 0000000..1eed4e9 --- /dev/null +++ b/stacks/news-digest/templates/digest.html.j2 @@ -0,0 +1,202 @@ + + + + + + Daily Digest · {{ date_long }} · {{ edition_short }} + + + + {# ────── header ────── #} + + + {# ────── nav strip ────── #} + + +
+ + {# ────── REDDIT SECTION ────── #} + {% if reddit_sources %} +
+
+ 01 +

Reddit

+ top of last {{ generated_at.hour < 14 and "12" or "12" }} hours · score-filtered + {{ reddit_sources|sum(attribute='items')|length }} items +
+ + {% for src in reddit_sources %} +
+
+ + {{ src.name }} + + {{ src.items|length }} +
+
    + {% for it in src.items %} +
  1. + +
    +

    + {{ it.title }} +

    + {% if it.tldr %} +

    {{ it.tldr }}

    + {% endif %} + +
    +
  2. + {% endfor %} +
+
+ {% endfor %} +
+ {% endif %} + + {# ────── TECH FEEDS SECTION ────── #} + {% if tech_sources %} +
+
+ 02 +

Tech Feeds

+ non-reddit · last {{ generated_at.hour < 14 and "12" or "12" }} hours + {{ tech_sources|sum(attribute='items')|length }} items +
+ + {% for src in tech_sources %} +
+
+ + {{ src.name }} + + {{ src.items|length }} +
+
    + {% for it in src.items %} +
  1. + +
    +

    + {{ it.title }} +

    + {% if it.tldr %} +

    {{ it.tldr }}

    + {% endif %} +
    + {% if it.url %} + + {{ it.url|domain }} + + · + {% endif %} + {{ it.posted_at|humanago }} ago + {% if it.author %} + · + {{ it.author }} + {% endif %} +
    +
    +
  2. + {% endfor %} +
+
+ {% endfor %} +
+ {% endif %} + + {% if not reddit_sources and not tech_sources %} +
+

+ No items cleared the filters in the last window.
+ Lower DIGEST_MIN_SCORE or widen + DIGEST_REDDIT_HOURS if this looks wrong. +

+
+ {% endif %} + +
+ + + + diff --git a/stacks/news-digest/templates/style.css b/stacks/news-digest/templates/style.css new file mode 100644 index 0000000..3857c3a --- /dev/null +++ b/stacks/news-digest/templates/style.css @@ -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; } +}