diff --git a/README.md b/README.md
index 151af9e..fac1ef9 100644
--- a/README.md
+++ b/README.md
@@ -178,11 +178,21 @@ run missed while the machine was off is caught up on the next boot.
## Next Steps
-1. Run the poller for a few days and inspect which sources produce useful candidates.
-2. Add source-level quality notes and deactivate noisy feeds.
-3. Replace or supplement `heuristic-v0` with a local model classifier.
-4. Add a daily brief builder that selects 5 items using scores and source diversity.
-5. Add a small web/API layer once the ingest data looks trustworthy.
+Done so far: RSS/Atom ingestion with exact + semantic dedup, heuristic + local-LLM
+classification with topic/flavor tagging, the daily brief, the FastAPI web/API layer
+and site, scheduled `cycle` via systemd, a pytest suite, and device-local Calm Filters.
+
+Still ahead:
+
+1. **Supervised source pipeline** — paste a feed URL, preview a scored sample
+ (freshness, acceptance rate, topic/flavor mix, cortisol/ragebait/PR averages,
+ example items), then add to quarantine before it can reach the main feed.
+2. **Learned "Less like this" weighting** — replace the interim flavor-pause with
+ real preference down-ranking.
+3. **Corpus rebalancing** — add calm/feelgood sources (currently science-heavy).
+4. **Retention/pruning** — soft-delete + time-window indexes as the corpus grows
+ toward ~10k articles (don't rush; not yet needed).
+5. **Go-public hardening** — TLS via a reverse proxy, then a domain.
## Local Model Configuration
diff --git a/goodnews/static/index.html b/goodnews/static/index.html
index 96a3ec6..e3f0a08 100644
--- a/goodnews/static/index.html
+++ b/goodnews/static/index.html
@@ -192,28 +192,55 @@
return r.json();
}
+ // Build cards with the DOM API (textContent) rather than HTML strings, so
+ // feed-supplied text can never inject markup even if upstream cleaning misses.
+ const node = (tag, cls, text) => {
+ const e = document.createElement(tag);
+ if (cls) e.className = cls;
+ if (text != null) e.textContent = text;
+ return e;
+ };
+
function articleCard(a, showRank) {
- const rank = showRank && a.rank ? `${a.rank}` : "";
- const tags = [a.topic, a.flavor].filter(Boolean).map(t => `${t}`).join(" ");
- const desc = a.description ? `
${a.description}
` : "";
- const why = a.reason_text ? `
${a.reason_text}
` : "";
- const acts = [];
- if (a.topic) acts.push(``);
- if (a.flavor) acts.push(``);
- if (a.topic) acts.push(``);
- return `
-