From c3cd453eeb602783d6ceb452094311e6c39f5302 Mon Sep 17 00:00:00 2001 From: Andrew Simachev Date: Sat, 24 Oct 2026 14:58:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(news):=20M14.11=20the=20news=20as=20a=20pa?= =?UTF-8?q?per=20=E2=80=94=20datelines,=20tiers,=20pictures=20from=20the?= =?UTF-8?q?=20article=20page,=20a=20calendar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The News view sets its items as a paper by default: a dateline per day stuck to the top of the pane, the day's articles in hero / feature / brief tiers (pictured ones fill the big slots), each card the link to the article and a click marking it read; Paper / Table in the heading; a month calendar beside the items that narrows both views to a day; a newspaper icon in the nav. A feed that gives no picture (13 of the 27 built-in sources) would leave a hero blank, so the daemon reads the article's own page once for its Open Graph picture (core pageImage: og:image, then the Twitter card, never an SVG; the head of the page only, six at a time, same user agent) and remembers in news_items.page_at that the page was asked, picture or not. [security] pictures = false keeps the daemon to the feeds; the privacy guide and config reference say so. Also: the M14.1 stack test ran the refresh route with the default config, so it read the real built-in sources; it now sets news = false. Verified: typecheck · lint · test (1086 pass) · smoke · build:web + check:classes · docs:check · site:build; headless Chrome against a scratch daemon on this repository (265 of 360 items pictured, the sticky dateline, the day filter in both views, the calendar after pinning display: table). Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 12 + docs/06-roadmap.md | 1 + docs/08-interface.md | 2 +- docs/13-config.md | 2 + docs/16-security.md | 4 +- docs/guide/10-privacy-and-faq.md | 2 +- docs/guide/12-observatory.md | 4 +- docs/guide/13-security.md | 3 + packages/core/src/config.test.ts | 1 + packages/core/src/config.ts | 6 + packages/core/src/newsfeed.test.ts | 16 ++ packages/core/src/newsfeed.ts | 24 ++ packages/daemon/src/app.test.ts | 64 ++++- packages/daemon/src/security.ts | 57 +++- packages/daemon/src/store.ts | 25 +- packages/web/src/app/views.ts | 2 +- packages/web/src/lib/calendar.ts | 84 ++++++ packages/web/src/state/ui.ts | 6 + packages/web/src/styles/dashboard.css | 334 +++++++++++++++++++++++ packages/web/src/views/News.tsx | 125 ++++++--- packages/web/src/views/news/Calendar.tsx | 143 ++++++++++ packages/web/src/views/news/Paper.tsx | 96 +++++++ packages/web/src/views/news/tiers.ts | 106 +++++++ packages/web/tools/build.ts | 2 + 24 files changed, 1080 insertions(+), 41 deletions(-) create mode 100644 packages/web/src/lib/calendar.ts create mode 100644 packages/web/src/views/news/Calendar.tsx create mode 100644 packages/web/src/views/news/Paper.tsx create mode 100644 packages/web/src/views/news/tiers.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 162f58aa..2e50a989 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Every Swarm release, newest first. Each one starts with a short summary, then th | Version | Date | Summary | | --- | --- | --- | +| [0.18.1](#0181--2026-10-24) | Oct 24 | The news as a paper: datelines, tiers, pictures, a calendar | | [0.18.0](#0180--2026-10-24) | Oct 24 | What each project runs, what of it is known-bad, what is happening around it · two new rules · `swarm security` | | [0.17.0](#0170--2026-10-16) | Oct 16 | Codex gets the whole hook pipeline · cost by MCP, skill and subagent · CI resumes the run · signals | | [0.16.0](#0160--2026-10-01) | Oct 1 | The guards ask by default · sandboxed runs · a reviewer on prompts · scheduled workflows | @@ -31,6 +32,17 @@ Every Swarm release, newest first. Each one starts with a short summary, then th | [0.2.2](#022--2026-08-21) | Aug 21 | The npm package | | [0.0.6](#006--2026-08-21) | Aug 21 | The first signed macOS build | +## [0.18.1] — 2026-10-24 + +The News view reads like a paper now. The table it shipped with a few hours ago was a list of headlines; nobody reads news as a list. + +### Changes + +- **News as a paper.** A dateline for each day, stuck to the top of the pane while the day scrolls, and under it the day's articles set in tiers as a front page is: the first two as heroes across the width (one alone sets with its picture beside the text), the next three as features in a row, the rest as briefs in as many columns as fit. Each card is the link to the article and a click marks it read. **Paper / Table** in the heading switches back to the table. +- **Pictures.** An article's picture comes from its feed, or — since 13 of the 27 built-in sources give a picture to no article at all — from the article's own page: the daemon reads the head of the page once for its Open Graph picture, with the same user agent as a feed read, and never asks that page again. `[security] pictures = false` keeps the daemon to the feeds; the privacy guide says what the default fetches. +- **A calendar.** A month calendar beside the items shades the days by how many articles they hold; a day narrows the paper and the table to itself, and the chip over them lets it go. +- **A newspaper icon** for News in the Guard menu, which shared the RSS glyph with Team. + ## [0.18.0] — 2026-10-24 The security release. There are a lot of security incidents in the package ecosystems right now, and Swarm could watch every agent on the machine without knowing what any project actually ran. Now it reads the lockfiles, can ask OSV about the exact versions (off until you turn it on — the query is your dependency list), follows the security news that concerns that stack, tells the agent, and can dispatch the upgrade. Nothing new leaves the machine by default except the public feed reads, and `SWARM_OFFLINE=1` silences all of it. diff --git a/docs/06-roadmap.md b/docs/06-roadmap.md index c8382739..f5447039 100644 --- a/docs/06-roadmap.md +++ b/docs/06-roadmap.md @@ -285,6 +285,7 @@ Goal: Security stops being only a log of what agents reached for. Swarm reads ea | M14.8 | **Advisories in the agent's context**: the `SessionStart` context (M1.3) and `swarm_context` list the project's open advisories with their fixed versions, so an agent avoids a vulnerable version before a rule has to ask, and can upgrade one unprompted | M14.4, M1.3 | ✅ 2026-10-24 `store.advisoryContextLine`: the project's open hits, worst first, one per package and version, at most six, each with its fixed version and advisory id, as one `[swarm] known advisories …` line in `sessionContext` — which both the `SessionStart` hook and `swarm_context` (`/v1/context`) render; null while advisories are off or the project is clean. `vulnerable_install` joins the rule modes the context names. Daemon test: the line appears after the fake OSV hit and is absent while the switch is off. The test also found that `/v1/context` was registered twice and Hono answered from the first (the M9.6 context-waste report), so `swarm_context` had always said *nothing to report*; one handler now serves both by the presence of `cwd` | | M14.9 | **Dispatch a fix**: an **Upgrade** action on an advisory row creates a task (*bump X to ≥ fixed, run the gates*) and dispatches it through M7 into its own worktree, gated and PR'd — Dependabot behaviour, local, on any forge, through code that exists | M14.4, M7.5, M7.3 | ✅ 2026-10-24 `core/src/advisories.ts` `fixTaskId` (`sec/-`) + `fixPrompt` (what, why with the OSV link, which manifest, the package manager the lockfile belongs to, gates, a commit naming the advisory, a PR; nothing unrelated, no other vulnerable install); `POST /v1/advisories/fix` finds the open hit and starts the M3.1 runner on that task — which claims the worktree fail-closed and honours `[dispatch]` (profile, sandbox, `require_pr`) — or answers the task and prompt with `dry`; the report marks a hit `fixing` while a claim holds its task; **Upgrade** on the Advisories row (disabled while fixing, the failure shown beside the grid). Daemon test: dry answers the task and the prompt; missing fields 400, an unknown hit 404 | | M14.10 | **`secret_commit` rule family**: `git add` / `git commit` naming a path on the secrets list, and a `git commit` whose staged diff (read with `git diff --cached` at `PreToolUse`) carries a known token prefix — `ask` by default, counted on Security like the other families | M12.5 | ✅ 2026-10-24 `guards.ts` `secretCommit` (a `git add` / `stage` / `commit` / `rm --cached` segment naming a file on the secrets list, quoted strings dropped so a commit message is not a leak) + `secretTokensIn` (added lines of a unified diff against ten vendor-prefixed shapes: private key blocks, AWS, GitHub, Anthropic, OpenAI, Slack, GitLab, Google, Stripe, npm — the shape is named, never the value); the diff comes lazily from `git diff --cached -U0` (`daemon/src/git.ts stagedDiff`) through `guardBash`'s `ctx.stagedDiff`, read only when the command commits and the family is on; family `secret_commit` (`ask`), counted on Activity from the text, named in the session context; without a reader (the offline shim, a non-repo) it fails open. Daemon test on a real temporary repository: `git add .env` asks, a commit whose staged file adds an AWS key asks naming the shape, a clean commit is allowed | +| M14.11 | **News as a paper**: a dateline per day stuck to the top, hero / feature / brief tiers, pictures from the feed or the article's own page (read once, head only; `[security] pictures`), a Paper / Table switch, a month calendar that narrows to a day, a newspaper icon in the nav | M14.2 | ✅ 2026-10-24 (v0.18.1) | ## Later (not scheduled) Hosted (SaaS) team daemon wrapping the M8.3 binary · adapters for other agent CLIs · M14.8 GitHub Dependabot / code-scanning / secret-scanning alerts through `gh` (OQ-34) · Rust, Go and Ruby lockfiles for the stack. *(plan-gate-check — ✅ unreachable without a passing gate — is now M13.1.)* diff --git a/docs/08-interface.md b/docs/08-interface.md index b7bd25c2..bf775cea 100644 --- a/docs/08-interface.md +++ b/docs/08-interface.md @@ -10,7 +10,7 @@ The first three are surfaces you *go to*. The fourth comes to you: it is the onl - **Nav**: grouped sidebar nav from the view registry (M9.1) — Observe: Fleet / Timeline / Graphs · Work: Board / PRs / Trials / Hygiene · Insight: Outcomes / Gates / MCP / Context / Files / Spend / Stats / Search · Guard: Security / News / Provenance / Incidents / Signals / Rules / Team — plus a Session detail reached from any session row, and a ⌘K palette. Every list is a data-grid (sortable, resizable, filterable columns, layouts persisted per table). - **Board** = a KPI strip (live / held / worktrees / ready or projects / incidents) + Tasks as a kanban (Ready · In progress · Blocked · Done) + Dispatch + Gates + Processes + Resources + Claims + a Worktree map (one tile per worktree, grouped by project, colored by live / dirty / unpushed / merged) + Incidents. Tasks and Worktrees carry a Cards/Table toggle (persisted). Every row, card and tile has one menu — the hover kebab, right-click, or Enter when focused — instead of inline action links; the menu carries the row's actions (open, diff, PR, run, claim, gates, release, stop, ack, codify, merge, copy …) with destructive ones last and confirmed. "View 4 — Incidents" ships as its own **Incidents** view (feed, Open/All, ack, ack-all) with a short open-only section on the Board. -- **Security** (M14, [16-security](16-security.md)) has chip tabs: **Activity** (the M9.9 scan) and **Stack** (M14.1, what each project runs from its lockfiles). **News** (M14.2) is its own Guard view: the security news feed, each item matched against every project's stack and narrowed to the selected project's keywords; kind / Unread / All items chips, Mark all read. Its **Sources** chip (M14.3) opens every source with its state and a per-URL format check, and shows the `[[security.feeds]]` block to paste for one that parses. The **Advisories** tab (M14.4) shows known advisories on the exact versions each project runs once `[security] advisories = true`, and the Enable card with the TOML to paste while it is off. +- **Security** (M14, [16-security](16-security.md)) has chip tabs: **Activity** (the M9.9 scan) and **Stack** (M14.1, what each project runs from its lockfiles). **News** (M14.2) is its own Guard view: the security news feed, each item matched against every project's stack and narrowed to the selected project's keywords; kind / Unread / All items chips, Mark all read. Its items set as a paper — a dateline per day, the articles in hero / feature / brief tiers with their pictures — or as a table, with a month calendar that narrows both to a day (M14.11). Its **Sources** chip (M14.3) opens every source with its state and a per-URL format check, and shows the `[[security.feeds]]` block to paste for one that parses. The **Advisories** tab (M14.4) shows known advisories on the exact versions each project runs once `[security] advisories = true`, and the Enable card with the TOML to paste while it is off. - **Steering** (M13): Fleet rows carry the permission-broker Allow/Deny card for *interactive* sessions, not just spawned runs, and Incidents carry Codify → Apply. The Session input box exists for spawned runs (M3.3). - **Not yet**: the full keyboard map (`g f`, `j/k`, `?`) and the Settings page — project settings live in the sidebar menu, hook/rule status in `swarm doctor`. The ⌘K palette shipped with M9.1. - **CLI** and **MCP** sections below are split into *Today* / *Planned*; section D lists the HTTP routes the daemon actually serves and section E the in-session surface. diff --git a/docs/13-config.md b/docs/13-config.md index 31e6e12d..0a3ab622 100644 --- a/docs/13-config.md +++ b/docs/13-config.md @@ -327,6 +327,8 @@ stack = true # SWARM_OFFLINE=1 also stops it. news = true news_days = 30 # keep items this long (1–365) +pictures = true # M14.11: fetch the page of an article whose feed gave no picture, once, for its + # Open Graph picture — the head of the page only. false: one fetch per feed. refresh_minutes = 30 # how often the sources are re-read (10–1440); conditional requests kinds = ["vendor", "authority", "press"] # built-in kinds to read; add "ai" for the AI press. # "vendor" / "ai" items are shown whole; "authority" / "press" only diff --git a/docs/16-security.md b/docs/16-security.md index 1c236365..7f9bb8ea 100644 --- a/docs/16-security.md +++ b/docs/16-security.md @@ -47,7 +47,7 @@ This doc records where the code is today and what changes, so M14 lands as seven - **Keywords come from the stack.** The earlier feed kept a hand-written list of vendors, products and words. Swarm derives the set per project from the detected packages (plus `[security] keywords`), so relevance follows what the project actually runs and needs no upkeep. The heuristics are OQ-35. - **SQLite, not a JSON file in the temp dir.** Items, per-source state and the advisory cache are tables in `~/.swarm/swarm.db`, pruned on the daily schedule, so a project added later is matched against items already fetched and a restart loses nothing. - **Conditional requests.** `ETag` / `Last-Modified` per source, and a per-source backoff, so thirty feeds every half hour cost little on a metered connection. -- **No page scraping for images.** The earlier feed fetched each article page for an Open Graph image. Swarm's News is a list, not a paper; one fetch per feed, none per article. +- **Pictures from the article page, once.** A feed that gives no picture would leave a hero blank, so the daemon asks the article's own page for its Open Graph picture — the head of the page only, once in the item's life, `[security] pictures = false` keeps it to one fetch per feed. (v0.18.0 shipped the News as a table without this; M14.11 made it a paper.) - **Malicious-package advisories rank `critical`.** OSV `MAL-*` entries carry no CVSS; a malicious package is the worst case, not an unknown. ## M14.1 — Stack detection (`core`, local, always on) @@ -404,3 +404,5 @@ Everything above landed the same day as ten PRs, in the cut order, each useful a - **The format check is scriptable.** `swarm security feeds check` without a URL checks every configured source and exits 1 when one parses as none; the Sources panel does the same for one URL. - **No schema version bump** was needed: every table is `CREATE TABLE IF NOT EXISTS` and nothing back-fills. - Measured on this repository against the real services: 159 packages from 4 manifests; all 27 default sources parsed, 360 items in 30 days, 98 relevant; 19 open advisories (1 critical, 4 high, 14 medium) with fixed versions. + +**2026-10-24, v0.18.1 — News as a paper (M14.11).** The table was not how anyone reads news. The News view now sets its items as a paper by default — a dateline per day, stuck to the top of the pane while the day scrolls, and under it the day's articles in tiers as a front page sets them: the first two as heroes across the width (one alone with its picture beside the text), the next three as features in a row, the rest as briefs; the pictured articles fill the big slots first. Each card is the link to the article and a click marks it read. A **Paper / Table** switch sits in the heading, and a month calendar beside the items shades the days by how many articles they hold; a day narrows both views to itself. The pictures come from the feed, or from the article's own page (`pageImage`: `og:image`, then the Twitter card; never an SVG), fetched by the daemon once per item with the same user agent as a feed read, six at a time, the first 300 KB only; `news_items.page_at` remembers that the page was asked, picture or not. `[security] pictures = false` keeps the daemon to the feeds. The nav's News entry carries a newspaper icon. Measured on this repository: 265 of 360 items pictured, 13 of 27 sources giving a picture to no item of their own. diff --git a/docs/guide/10-privacy-and-faq.md b/docs/guide/10-privacy-and-faq.md index 3d18fe65..e1d6c311 100644 --- a/docs/guide/10-privacy-and-faq.md +++ b/docs/guide/10-privacy-and-faq.md @@ -41,7 +41,7 @@ Eight things, each optional and under your control: 4. **Team forwarding** — only if you set `[team] url` to your own self-hosted [team daemon](11-teams.md). What is sent is the `forward` list: audit events (claims, gates, incidents, with actors) and daily spend rollups by default; transcript titles/text only if you opt in, and always after your `[privacy] redact` rules. Off entirely by default. 5. **Incident webhook** — only if you set `[notify] webhook`; each incident is POSTed to that URL as `{text}` JSON. 6. **OTLP export** — only if you set `[otel] endpoint` to a collector of your choosing. Session spans (tool names, timings, success) and token / cost metrics go there; commands, file paths and prompt text do not unless you also set `include_content = true`. -7. **Security news sources** (M14.2) — on by default: every `[security] refresh_minutes` the daemon reads the built-in list of public RSS / Atom feeds (the projects' own security channels, CISA and CERT-Bund, the security press; the [Observatory guide](12-observatory.md) names them) plus any `[[security.feeds]]` you add. The request carries a `swarm/` user agent and nothing about you or your projects; what your projects run is matched *locally* against what came back. `[security] news = false` or `SWARM_OFFLINE=1` stops it. +7. **Security news sources** (M14.2) — on by default: every `[security] refresh_minutes` the daemon reads the built-in list of public RSS / Atom feeds (the projects' own security channels, CISA and CERT-Bund, the security press; the [Observatory guide](12-observatory.md) names them) plus any `[[security.feeds]]` you add. The request carries a `swarm/` user agent and nothing about you or your projects; what your projects run is matched *locally* against what came back. With `pictures = true` (the default) the daemon also fetches the page of an article whose feed gave no picture, once, for its Open Graph picture — the same user agent, the head of the page only — and the dashboard loads the pictures from the publishers' hosts without a referrer; `pictures = false` keeps it to the feeds. `[security] news = false` or `SWARM_OFFLINE=1` stops it. 8. **OSV advisories** (M14.4) — **off until you set `[security] advisories = true`**. Then, every six hours and after a lockfile changes, the daemon asks `api.osv.dev` about the exact versions your projects run. The query carries each dependency's ecosystem, name and version — the project's dependency list — and nothing else: no project name, no path, no machine identifier beyond a `swarm/` user agent. `SWARM_OFFLINE=1` wins over the switch. With none of those configured, no session content, command, transcript, token count or cost ever leaves your machine. diff --git a/docs/guide/12-observatory.md b/docs/guide/12-observatory.md index 85c1a188..a2b29646 100644 --- a/docs/guide/12-observatory.md +++ b/docs/guide/12-observatory.md @@ -94,7 +94,9 @@ The view has tabs. **Activity** is the scan above. **Stack** is what each projec A separate view in the Guard group. The daemon reads a built-in list of public RSS / Atom sources every half hour — the projects' own security channels (Node.js, Python, PyPI, Django, PostgreSQL, GitHub, AWS, …), two authorities (CISA, CERT-Bund) and the security press — and matches every item against the packages your projects run. **The sidebar's project filter is the relevance filter**: a vendor's own channel is shown whole, the authorities and the press only when an item names something the selected project runs (a product like PostgreSQL or Next.js, an ecosystem like npm, a package by name). The words that matched are shown on the row; *All items* shows what was fetched regardless. -Mark an item read with the row's action, or all of them from the heading. Sources are configured in `[security]` ([config](03-rules-and-config.md)): `kinds` picks which built-in kinds are read (the AI press is off by default), `feeds_off` skips a source by name, `keywords` adds words of your own, and `[[security.feeds]]` adds your own sources. Before you add one, open **Sources** on the News view and check the URL: the daemon fetches it once with the same reader it runs every half hour and says whether it parsed as RSS or Atom and how many items it holds — or, when it did not, the status and content type it got back, so a 403 is told from a page that is not a feed. A source that passes comes with the TOML block to paste. The same panel lists every source with its state and the last error. The request carries a `swarm/` user agent and nothing about you; `news = false` or `SWARM_OFFLINE=1` stops it entirely. +The items set as a **paper** by default — a dateline per day, stuck to the top while the day scrolls, and the day's articles under it in tiers as a front page sets them, each card the link to the article — or as a **table**; the switch is in the heading. A month calendar beside them shades the days by how many articles they hold, and a day narrows both views to itself. A picture comes from the feed, or from the article's own page, which the daemon reads once (`[security] pictures = false` turns that off). + +Clicking a card marks it read; in the table, the row's action does, and *Mark all read* in the heading does for everything shown. Sources are configured in `[security]` ([config](03-rules-and-config.md)): `kinds` picks which built-in kinds are read (the AI press is off by default), `feeds_off` skips a source by name, `keywords` adds words of your own, and `[[security.feeds]]` adds your own sources. Before you add one, open **Sources** on the News view and check the URL: the daemon fetches it once with the same reader it runs every half hour and says whether it parsed as RSS or Atom and how many items it holds — or, when it did not, the status and content type it got back, so a 403 is told from a page that is not a feed. A source that passes comes with the TOML block to paste. The same panel lists every source with its state and the last error. The request carries a `swarm/` user agent and nothing about you; `news = false` or `SWARM_OFFLINE=1` stops it entirely. ## Rules — is a rule teaching anyone anything? diff --git a/docs/guide/13-security.md b/docs/guide/13-security.md index 71d02414..4f0552fb 100644 --- a/docs/guide/13-security.md +++ b/docs/guide/13-security.md @@ -34,6 +34,8 @@ Three things happen once an advisory applies: **Guard → News.** The daemon reads a built-in list of public RSS / Atom sources every half hour — the projects' own security channels (Node.js, Python, PyPI, Django, PostgreSQL, GitHub, AWS, HashiCorp, Next.js), two authorities (CISA, CERT-Bund) and the security press — and matches every item against the packages your projects run. **The sidebar's project filter is the relevance filter**: a vendor's own channel is shown whole, the authorities and the press only when an item names something the selected project runs — a product (PostgreSQL, Next.js), an ecosystem (npm, PyPI), or a package by name. The words that matched sit on the row; *All items* shows what was fetched regardless. +**A paper, or a table.** By default the items set as a paper: a dateline per day, and under it the day's articles in tiers as a front page sets them — two heroes across the width, three features in a row, the rest as briefs — each card the link to the article, and a click marks it read. The heading switches to a table. A month calendar beside the items shades the days by how many articles they hold; a day narrows both views to itself. A picture comes from the feed, or from the article's own page, which the daemon reads once, head only; `[security] pictures = false` keeps it to the feeds. + The keywords come from the lockfiles, not from a list you maintain: products behind the packages, the ecosystems, and the declared runtime package names (transitive helpers are left out — a package called `which` would match every headline). `[security] keywords` adds your own words. **Sources.** The **Sources** chip lists every source with its state and the last error. To add one, paste its URL there and press **Check**: the daemon fetches it once with the same reader it runs every round and says whether it parsed as RSS or Atom and how many items it holds — or the status and content type it got back, so a 403 is told from a page that is not a feed. A source that passes comes with the `[[security.feeds]]` block to paste. `kinds` picks which built-in kinds are read (the AI press is off by default), `feeds_off` skips a source by name. The request carries a `swarm/` user agent and nothing about you; `news = false` or `SWARM_OFFLINE=1` stops it. @@ -67,6 +69,7 @@ advisories = false # off until you set it; the query carries the dep advisory_min_severity = "high" # the incident threshold news = true news_days = 30 +pictures = true # the page of an article without a picture is read once for its Open Graph picture refresh_minutes = 30 kinds = ["vendor", "authority", "press"] # add "ai" keywords = [] diff --git a/packages/core/src/config.test.ts b/packages/core/src/config.test.ts index 86875485..e62ace53 100644 --- a/packages/core/src/config.test.ts +++ b/packages/core/src/config.test.ts @@ -162,6 +162,7 @@ describe("[security] (M14)", () => { advisory_min_severity: "high", news: true, news_days: 30, + pictures: true, refresh_minutes: 30, kinds: ["vendor", "authority", "press"], keywords: [], diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 792079a4..bcf56d23 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -263,6 +263,10 @@ export interface SecurityConfig { news: boolean; /** Days items are kept (1–365). */ news_days: number; + /** M14.11: fetch the page of an article whose feed gave no picture, once, for its Open Graph + * picture — the paper layout wants one per hero. The request carries the same user agent and + * nothing else; false keeps the daemon to one fetch per feed. */ + pictures: boolean; /** Minutes between feed reads (10–1440). */ refresh_minutes: number; /** Built-in feed kinds to read; `vendor` / `ai` items show whole, `authority` / `press` need a keyword. */ @@ -281,6 +285,7 @@ export const DEFAULT_SECURITY: SecurityConfig = { advisory_min_severity: "high", news: true, news_days: 30, + pictures: true, refresh_minutes: 30, kinds: ["vendor", "authority", "press"], keywords: [], @@ -340,6 +345,7 @@ function validateSecurity(raw: unknown): SecurityConfig { : "high", news: s.news !== false, news_days: int(s.news_days, 1, 365, 30), + pictures: s.pictures !== false, refresh_minutes: int(s.refresh_minutes, 10, 1440, 30), kinds: Array.isArray(s.kinds) ? FEED_KINDS.filter((k) => (s.kinds as unknown[]).includes(k)) diff --git a/packages/core/src/newsfeed.test.ts b/packages/core/src/newsfeed.test.ts index b255550a..ba8d9e4d 100644 --- a/packages/core/src/newsfeed.test.ts +++ b/packages/core/src/newsfeed.test.ts @@ -3,6 +3,7 @@ import { isRelevant, keywordMatcher, normalizeUrl, + pageImage, parseFeed, plainText, stackKeywords, @@ -82,6 +83,21 @@ describe("parseFeed", () => { expect(parseFeed("").items).toEqual([]); }); + test("pageImage takes the Open Graph picture, then the Twitter card, never an SVG or http", () => { + expect( + pageImage( + ``, + ), + ).toBe("https://x.example/og.jpg"); + expect( + pageImage( + ``, + ), + ).toBe("https://x.example/card.png"); + expect(pageImage(``)).toBe(null); + expect(pageImage("")).toBe(null); + }); + test("plainText cuts at a word and decodes twice", () => { expect(plainText("&lt;b&gt;x&lt;/b&gt; y")).toBe("x y"); expect(plainText("one two three four", 9)).toBe("one two…"); diff --git a/packages/core/src/newsfeed.ts b/packages/core/src/newsfeed.ts index c1daae73..7c61eb8e 100644 --- a/packages/core/src/newsfeed.ts +++ b/packages/core/src/newsfeed.ts @@ -159,6 +159,30 @@ export function imageOf(block: string): string | null { return candidates.find((u) => IMAGE_URL.test(u)) ?? null; } +const META = /]*>/gi; +const PICTURE_META = ["og:image:secure_url", "og:image", "twitter:image", "twitter:image:src"]; + +/** + * The publisher's own picture for an article whose feed gave none: the Open Graph or Twitter card + * picture the page's head names, in that order of preference. Only an https address counts, and + * a logo drawn as SVG is not a picture of the article. The caller hands in the head of the page + * (the first few hundred KB); a long article need not be downloaded. + */ +export function pageImage(html: string): string | null { + const found: Record = {}; + for (const m of String(html ?? "").matchAll(META)) { + const t = m[0]; + const name = (attr(t, "property") || attr(t, "name")).toLowerCase(); + if (PICTURE_META.includes(name) && !found[name]) found[name] = attr(t, "content"); + } + return ( + PICTURE_META.map((k) => found[k]).find( + (u): u is string => + Boolean(u) && IMAGE_URL.test(u as string) && !/\.svg(?:[?#]|$)/i.test(u as string), + ) ?? null + ); +} + /** * The items of an RSS or Atom feed, as the feed orders them, and which branch read them. A * malformed feed answers what can be read of it; a page that is not a feed answers `none`. diff --git a/packages/daemon/src/app.test.ts b/packages/daemon/src/app.test.ts index 29e0b9db..12ad0a3b 100644 --- a/packages/daemon/src/app.test.ts +++ b/packages/daemon/src/app.test.ts @@ -3550,7 +3550,10 @@ describe("M14.1 stack", () => { }); it("an empty folder is an empty stack, and the refresh route answers status", async () => { - const { app, store, security } = createApp(new Store(tmpHome())); + // news off: the refresh route would otherwise read the real built-in sources + const home = tmpHome(); + require("node:fs").writeFileSync(join(home, "config.toml"), "[security]\nnews = false\n"); + const { app, store, security } = createApp(new Store(home)); const root = mkdtempSync(join(tmpdir(), "swarm-stack-empty-")); const p = store.resolveProject(root, true, "empty"); security.scanStacks(); @@ -3705,6 +3708,65 @@ describe("M14.2 news", () => { server.stop(true); } }, 20_000); + it("fills the pictures the feeds left empty from the article pages, once each, unless pictures = false (M14.11)", async () => { + let pages = 0; + const server = Bun.serve({ + port: 0, + fetch(req) { + const u = new URL(req.url); + const base = `http://127.0.0.1:${u.port}`; + if (u.pathname === "/rss") + return new Response( + `V + Page picture${base}/article${recent(1).toUTCString()} + Feed picture${base}/pictured${recent(2).toUTCString()} + Bare${base}/bare${recent(3).toUTCString()} + `, + { headers: { "content-type": "application/rss+xml" } }, + ); + pages++; + if (u.pathname === "/article") + return new Response( + `${"x".repeat(400_000)}`, + { headers: { "content-type": "text/html" } }, + ); + return new Response("bare", { + headers: { "content-type": "text/html" }, + }); + }, + }); + try { + const base = `http://127.0.0.1:${server.port}`; + const feed = (extra: string) => + `[security]\nkinds = []\n${extra}[[security.feeds]]\nname = "V"\nurl = "${base}/rss"\nkind = "vendor"\n`; + const home = tmpHome(); + writeF(join(home, "config.toml"), feed("")); + const { app, security } = createApp(new Store(home)); + await security.readNews(Date.now(), true); + const pics = async () => { + const r = (await (await app.request("/v1/news?all=1")).json()) as { + items: Array<{ title: string; image: string | null }>; + }; + return Object.fromEntries(r.items.map((i) => [i.title, i.image])); + }; + expect(await pics()).toEqual({ + "Page picture": "https://cdn.example/og.jpg", + "Feed picture": "https://cdn.example/feed.jpg", + Bare: null, + }); + expect(pages).toBe(2); // the pictured item's page is never asked + await security.readNews(Date.now(), true); + expect(pages).toBe(2); // and a page is asked once in its life, picture or not + + const quiet = tmpHome(); + writeF(join(quiet, "config.toml"), feed("pictures = false\n")); + const off = createApp(new Store(quiet)); + await off.security.readNews(Date.now(), true); + expect(pages).toBe(2); + } finally { + server.stop(true); + } + }); }); describe("M14.3 sources", () => { diff --git a/packages/daemon/src/security.ts b/packages/daemon/src/security.ts index a15a948b..f0cfa5f7 100644 --- a/packages/daemon/src/security.ts +++ b/packages/daemon/src/security.ts @@ -23,7 +23,13 @@ import { parseOsvBatch, parseOsvVuln, } from "@swarm/core/advisories"; -import { type FeedCheck, type FeedItem, keywordMatcher, parseFeed } from "@swarm/core/newsfeed"; +import { + type FeedCheck, + type FeedItem, + keywordMatcher, + pageImage, + parseFeed, +} from "@swarm/core/newsfeed"; import { resolveSources } from "@swarm/core/newsfeeds"; import { type ManifestKind, manifestKind, parseManifest } from "@swarm/core/stack"; import type { NewsFeedRow, Store } from "./store"; @@ -48,6 +54,9 @@ const MAX_BYTES = 20 * 1024 * 1024; const STACK_EVERY_MS = 60_000; const FEED_TIMEOUT_MS = 20_000; const FEED_RETRY_MS = 1_000; +/** An article page is asked for its picture with a shorter patience than a feed, and only its head is read. */ +const PAGE_TIMEOUT_MS = 10_000; +const PAGE_MAX_BYTES = 300_000; const FEED_CONCURRENCY = 6; /** A whole-run failure (no network) backs off to at most this. */ const NEWS_BACKOFF_MAX_MS = 60 * 60_000; @@ -109,6 +118,21 @@ function fetchReason(e: unknown): string { const pause = (ms: number) => new Promise((r) => setTimeout(r, ms)); +/** At most the first `max` characters of a response: the head is at the top, and the rest is not downloaded. */ +async function readHead(res: Response, max: number): Promise { + const reader = res.body?.getReader(); + if (!reader) return (await res.text()).slice(0, max); + const decoder = new TextDecoder(); + let text = ""; + while (text.length < max) { + const { done, value } = await reader.read(); + if (done) break; + text += decoder.decode(value, { stream: true }); + } + reader.cancel().catch(() => {}); + return text; +} + /** Run at most `width` of the jobs at once. */ async function pooled(jobs: Array<() => Promise>, width: number): Promise { let next = 0; @@ -326,6 +350,7 @@ export class SecurityService { ), FEED_CONCURRENCY, ); + if (cfg.pictures) await this.readPictures(cfg.news_days, nowIso); this.store.setMetaValue("security_news_at", nowIso); this.store.setMetaValue("security_news_error", ""); this.newsBackoffMs = 0; @@ -337,6 +362,36 @@ export class SecurityService { } } + /** + * The pictures the feeds left empty, from the articles' own pages (M14.11): six at a time, the + * head of the page only, each page asked once in its life — a page without a picture, or one + * that cannot be read, is remembered as asked so no round asks it again. The request carries + * the same user agent as a feed read and nothing else. + */ + private async readPictures(newsDays: number, nowIso: string): Promise { + const since = new Date(Date.parse(nowIso) - newsDays * 86_400_000).toISOString(); + const pending = this.store.newsWithoutPicture(since); + await pooled( + pending.map((i) => async () => { + let image: string | null = null; + try { + const res = await this.fetchFn(i.url, { + headers: { accept: "text/html", "user-agent": `swarm/${this.version}` }, + signal: AbortSignal.timeout(PAGE_TIMEOUT_MS), + redirect: "follow", + }); + if (res.ok && /html/i.test(res.headers.get("content-type") ?? "text/html")) + image = pageImage(await readHead(res, PAGE_MAX_BYTES)); + else await res.body?.cancel().catch(() => {}); + } catch { + image = null; + } + this.store.setNewsPicture(i.key, image, nowIso); + }), + FEED_CONCURRENCY, + ); + } + private async fetchFeed(f: NewsFeedRow): Promise { const headers: Record = { accept: diff --git a/packages/daemon/src/store.ts b/packages/daemon/src/store.ts index 94e6c1b7..d8e36214 100644 --- a/packages/daemon/src/store.ts +++ b/packages/daemon/src/store.ts @@ -420,7 +420,8 @@ CREATE TABLE IF NOT EXISTS news_feeds ( CREATE TABLE IF NOT EXISTS news_items ( key TEXT PRIMARY KEY, feed_url TEXT NOT NULL, title TEXT NOT NULL, url TEXT NOT NULL, published TEXT NOT NULL, summary TEXT NOT NULL DEFAULT '', image TEXT, - matched TEXT NOT NULL DEFAULT '[]', fetched_at TEXT NOT NULL, read_at TEXT + matched TEXT NOT NULL DEFAULT '[]', fetched_at TEXT NOT NULL, read_at TEXT, + page_at TEXT ); CREATE INDEX IF NOT EXISTS news_items_published ON news_items(published); CREATE INDEX IF NOT EXISTS news_items_feed ON news_items(feed_url, published); @@ -509,6 +510,7 @@ export class Store { // M13.7: null = Swarm made the worktree; "claude-code" = adopted — recorded, never removed this.ensureColumn("claims", "origin", "TEXT"); this.ensureColumn("turns", "cost_fixed", "INTEGER DEFAULT 0"); + this.ensureColumn("news_items", "page_at", "TEXT"); // M14.11: the article page asked once for a picture // M12.9: the skill a turn ran under; the tail carries the active one across chunks, and a // subagent's tail its type (from the subagent's .meta.json) this.ensureColumn("turns", "skill", "TEXT"); @@ -7121,6 +7123,27 @@ export class Store { return n; } + /** + * Items in the window whose feed gave no picture and whose page has not been asked yet — each + * page is asked once in its life, whatever it answers. + */ + newsWithoutPicture(since: string, limit = 400): Array<{ key: string; url: string }> { + return this.db + .query( + `SELECT key, url FROM news_items + WHERE image IS NULL AND page_at IS NULL AND published >= ? AND url LIKE 'http%' + ORDER BY published DESC LIMIT ?`, + ) + .all(since, limit) as Array<{ key: string; url: string }>; + } + + /** What the article's own page gave for a picture (null when it had none); the page is never asked again. */ + setNewsPicture(key: string, image: string | null, at: string): void { + this.db + .query("UPDATE news_items SET image = COALESCE(?, image), page_at = ? WHERE key = ?") + .run(image, at, key); + } + /** The keyword set of one project, or the union over every live project, plus `[security] keywords`. */ newsKeywords(projectId?: string): string[] { const cfg = this.policyFor(null).config.security; diff --git a/packages/web/src/app/views.ts b/packages/web/src/app/views.ts index 7db33abe..a21dd279 100644 --- a/packages/web/src/app/views.ts +++ b/packages/web/src/app/views.ts @@ -42,7 +42,7 @@ export const VIEW_DEFS = [ { id: "stats", label: "Stats", icon: "chart-bar", group: "Insight" }, { id: "search", label: "Search", icon: "magnifying-glass", group: "Insight" }, { id: "security", label: "Security", icon: "shield", group: "Guard" }, - { id: "news", label: "News", icon: "broadcast", group: "Guard" }, + { id: "news", label: "News", icon: "newspaper", group: "Guard" }, { id: "provenance", label: "Provenance", icon: "git-commit", group: "Guard" }, { id: "incidents", label: "Incidents", icon: "warning", group: "Guard" }, { id: "signals", label: "Signals", icon: "bell", group: "Guard" }, diff --git a/packages/web/src/lib/calendar.ts b/packages/web/src/lib/calendar.ts new file mode 100644 index 00000000..807ae136 --- /dev/null +++ b/packages/web/src/lib/calendar.ts @@ -0,0 +1,84 @@ +/** + * The month grid the news is browsed by (M14.11). Everything here is UTC, like the stamps the + * daemon stores: an article published at 23:30 belongs to the day it names, wherever the reader is. + */ + +const MONTHS = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; + +/** Monday first. */ +export const WEEKDAYS = ["M", "T", "W", "T", "F", "S", "S"]; + +const two = (n: number) => String(n).padStart(2, "0"); + +/** A month, as the only two numbers that identify one. */ +export interface Month { + year: number; + /** Zero-based, as `Date` counts them. */ + month: number; +} + +/** The UTC day a moment falls on, as `2026-09-16`. */ +export const dayKey = (date: Date): string => + `${date.getUTCFullYear()}-${two(date.getUTCMonth() + 1)}-${two(date.getUTCDate())}`; + +/** The month a day key falls in. */ +export const monthOf = (key: string): Month => ({ + year: Number(key.slice(0, 4)), + month: Number(key.slice(5, 7)) - 1, +}); + +/** `September 2026`. */ +export const monthLabel = ({ year, month }: Month): string => `${MONTHS[month]} ${year}`; + +/** The month `n` months later (or earlier, for a negative `n`), rolling over the year as needed. */ +export const shiftMonth = ({ year, month }: Month, n: number): Month => { + const date = new Date(Date.UTC(year, month + n, 1)); + return { year: date.getUTCFullYear(), month: date.getUTCMonth() }; +}; + +/** How a month sorts against another: negative when `a` is earlier. */ +export const compareMonths = (a: Month, b: Month) => a.year - b.year || a.month - b.month; + +/** + * A month as the weeks a calendar draws: whole weeks from Monday, with the days outside the month + * left empty. Showing the neighbouring months' days would invite clicks this month cannot answer. + */ +export const monthGrid = ({ year, month }: Month): Array> => { + const first = new Date(Date.UTC(year, month, 1)); + const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate(); + // getUTCDay counts from Sunday; shifting by six makes Monday 0 and Sunday 6. + const leadingBlanks = (first.getUTCDay() + 6) % 7; + const cells: Array = [ + ...Array(leadingBlanks).fill(null), + ...Array.from({ length: daysInMonth }, (_, i) => `${year}-${two(month + 1)}-${two(i + 1)}`), + ]; + while (cells.length % 7) cells.push(null); + const weeks = cells.length / 7; + return Array.from({ length: weeks }, (_, week) => cells.slice(week * 7, week * 7 + 7)); +}; + +/** + * How dark a day reads against the busiest day in view. Four steps only, so the reader never has + * to measure a shade. Step 0 is a day with nothing on it. + */ +export const heat = (count: number, busiest: number): 0 | 1 | 2 | 3 => { + if (count <= 0) return 0; + if (busiest <= 1) return 3; + const share = count / busiest; + if (share > 0.66) return 3; + if (share > 0.33) return 2; + return 1; +}; diff --git a/packages/web/src/state/ui.ts b/packages/web/src/state/ui.ts index 83032c98..aa985957 100644 --- a/packages/web/src/state/ui.ts +++ b/packages/web/src/state/ui.ts @@ -26,6 +26,8 @@ interface UiState { graphTab: string; /** Sub-tab within the Security view (M14): activity · stack · advisories. */ securityTab: string; + /** M14.11: the news as a paper or a table. */ + newsView: "paper" | "table"; /** * The Search view's query. It lives here rather than in the view because the ⌘K palette seeds it * — "search Swarm for X" has to survive the navigation that shows the results. @@ -48,6 +50,7 @@ interface UiState { setSidebarWidth: (px: number) => void; setGraphTab: (tab: string) => void; setSecurityTab: (tab: string) => void; + setNewsView: (view: "paper" | "table") => void; /** Set the query and go to Search. */ setSearch: (query: string) => void; setBoardWorktrees: (mode: "map" | "table") => void; @@ -77,6 +80,7 @@ export const useUiStore = create()( sidebarWidth: SIDEBAR_WIDTH.default, graphTab: "collisions", securityTab: "activity", + newsView: "paper", search: "", boardWorktrees: "map", boardTasks: "table", @@ -93,6 +97,7 @@ export const useUiStore = create()( setSidebarWidth: (px) => set({ sidebarWidth: clampSidebarWidth(px) }), setGraphTab: (graphTab) => set({ graphTab }), setSecurityTab: (securityTab) => set({ securityTab }), + setNewsView: (newsView) => set({ newsView }), setSearch: (search) => set({ search, view: "search", session: null }), setBoardWorktrees: (boardWorktrees) => set({ boardWorktrees }), setBoardTasks: (boardTasks) => set({ boardTasks }), @@ -108,6 +113,7 @@ export const useUiStore = create()( sidebarWidth: s.sidebarWidth, graphTab: s.graphTab, securityTab: s.securityTab, + newsView: s.newsView, boardWorktrees: s.boardWorktrees, boardTasks: s.boardTasks, }), diff --git a/packages/web/src/styles/dashboard.css b/packages/web/src/styles/dashboard.css index 8957fe58..10828da9 100644 --- a/packages/web/src/styles/dashboard.css +++ b/packages/web/src/styles/dashboard.css @@ -4062,3 +4062,337 @@ tr.clickable { .wt-group { min-width: 0; } + +/* ---- News as a paper (M14.11) ---- + A dateline per day over a rule, stuck to the top of the pane while the day scrolls, and the + day's articles under it in tiers as a front page sets them. The pull-out is one value, read by + the cards and the dateline, so a hovered card's ground and the dateline's never drift apart. + `main` pads 16px at the top; the dateline reaches up by that much so it sticks at the edge. */ +.news-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 232px; + gap: 24px; + align-items: start; +} +.news-main { + min-width: 0; +} +.news-aside { + position: sticky; + top: 0; +} +@media (max-width: 960px) { + .news-layout { + grid-template-columns: minmax(0, 1fr); + } + .news-aside { + position: static; + } +} +.paper { + --paper-pull: 10px; + display: flex; + flex-direction: column; + gap: 24px; + padding-top: 16px; +} +.paper-day { + display: flex; + flex-direction: column; + gap: 12px; +} +.paper-day > h3 { + position: sticky; + top: -16px; + z-index: 2; + display: flex; + align-items: baseline; + gap: 10px; + margin: -16px 0 4px; + padding: 22px 0 6px; + font-size: var(--fs-xs); + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--fg); +} +.paper-day > h3::before { + content: ""; + position: absolute; + inset: 0 calc(-1 * var(--paper-pull)) -3px; + z-index: -1; + background: var(--bg); +} +.paper-day > h3::after { + content: ""; + position: absolute; + inset: auto 0 -2px; + height: 2px; + background: var(--fg); +} +.paper-ago { + font-weight: 500; + letter-spacing: 0.04em; + text-transform: none; + color: var(--faint); +} +.paper-row { + display: grid; + gap: 8px 16px; +} +.paper-row.hero { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr)); +} +.paper-row.feature { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr)); +} +.paper-row.brief { + grid-template-columns: repeat(auto-fill, minmax(min(100%, 200px), 1fr)); +} +.paper-card { + display: block; + min-width: 0; + margin: 0 calc(-1 * var(--paper-pull)); + padding: var(--paper-pull); + border-radius: var(--r); + color: inherit; + text-decoration: none; + transition: background var(--t-fast); +} +.paper-card:hover { + background: var(--panel); + text-decoration: none; +} +.paper-picture { + display: block; + aspect-ratio: 16 / 9; + margin: 0 0 10px; + border-radius: var(--r); + overflow: hidden; + background: var(--panel-2); +} +.paper-picture img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} +.paper-card.hero > .paper-picture { + max-height: 320px; +} +.paper-card.feature > .paper-picture { + max-height: 260px; +} +.paper-card.brief > .paper-picture { + max-height: 150px; +} +/* One hero alone in its row sets with the picture beside the text, as a lead story does. */ +.paper-row.hero > .paper-card.with-picture:only-child { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); + grid-template-rows: auto auto 1fr auto; + column-gap: 24px; +} +.paper-row.hero > .paper-card.with-picture:only-child > :not(.paper-picture) { + grid-column: 1; +} +.paper-row.hero > .paper-card.with-picture:only-child > .paper-picture { + grid-column: 2; + grid-row: 1 / -1; + aspect-ratio: 16 / 10; + max-height: none; + margin: 0; +} +@media (max-width: 760px) { + .paper-row.hero > .paper-card.with-picture:only-child { + display: block; + } + .paper-row.hero > .paper-card.with-picture:only-child > .paper-picture { + aspect-ratio: 16 / 9; + margin-bottom: 10px; + } +} +.paper-kicker { + display: flex; + flex-wrap: wrap; + align-items: center; + column-gap: 10px; + margin: 0 0 6px; + font-size: var(--fs-2xs); + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--acc); +} +.paper-card.unread .paper-kicker::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--acc); +} +.paper-kind { + color: var(--faint); +} +.paper-headline { + margin: 0 0 6px; + font-size: var(--fs-lg); + line-height: 1.3; + font-weight: 700; + letter-spacing: -0.01em; + color: var(--fg); + text-wrap: balance; +} +.paper-card.feature .paper-headline { + font-size: var(--fs-xl); +} +.paper-card.hero .paper-headline { + max-width: 60ch; + font-size: var(--fs-2xl); + line-height: 1.2; + letter-spacing: -0.02em; +} +.paper-summary { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + overflow: hidden; + margin: 0; + font-size: var(--fs-sm); + line-height: 1.5; + color: var(--fg-2); + text-wrap: pretty; +} +.paper-card.feature .paper-summary { + font-size: var(--fs-md); + -webkit-line-clamp: 4; +} +.paper-card.hero .paper-summary { + max-width: 60ch; + font-size: var(--fs-lg); +} +.paper-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin: 8px 0 0; +} + +/* ---- The month calendar beside the news (M14.11) ---- */ +.cal { + padding: 12px 14px 10px; + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--r); + box-shadow: var(--shadow); +} +.cal-h { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; + font-size: var(--fs-md); + color: var(--fg); +} +.cal-nav { + appearance: none; + width: 24px; + height: 24px; + padding: 0; + border: 1px solid var(--line); + border-radius: var(--r-sm); + background: var(--panel); + color: var(--dim); + font-size: var(--fs-lg); + line-height: 1; + cursor: pointer; + transition: + border-color var(--t-fast), + color var(--t-fast); +} +.cal-nav:hover:not(:disabled) { + border-color: var(--acc); + color: var(--fg); +} +.cal-nav:disabled { + opacity: 0.35; + cursor: default; +} +/* `display: table` said outright: a stylesheet rule further up lays every table in a sidebar out as + a grid, which would set the weekday row as seven shrink-wrapped cells. */ +.cal-grid { + display: table; + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} +.cal-grid thead { + display: table-header-group; +} +.cal-grid tbody { + display: table-row-group; +} +.cal-grid .wd { + padding: 0 0 4px; + font-size: var(--fs-2xs); + font-weight: 600; + color: var(--faint); + text-align: center; + background: none; + border: 0; +} +.cal-grid .cell { + padding: 1px; + text-align: center; + border: 0; +} +.cal-day { + display: block; + width: 100%; + padding: 4px 0; + border: 1px solid transparent; + border-radius: var(--r-xs); + background: none; + font: var(--fs-sm) var(--mono); + font-variant-numeric: tabular-nums; + color: var(--faint); +} +.cal-day.empty { + opacity: 0.55; +} +/* Four steps, so the shade is read rather than measured: nothing, a little, a working day, the busiest. */ +button.cal-day { + appearance: none; + cursor: pointer; + color: var(--acc); + transition: + background var(--t-fast), + border-color var(--t-fast); +} +button.cal-day.h1 { + background: color-mix(in srgb, var(--acc) 8%, transparent); +} +button.cal-day.h2 { + background: color-mix(in srgb, var(--acc) 14%, transparent); + font-weight: 600; +} +button.cal-day.h3 { + background: color-mix(in srgb, var(--acc) 22%, transparent); + font-weight: 600; +} +button.cal-day:hover { + border-color: var(--acc); +} +button.cal-day.on { + background: var(--acc); + border-color: var(--acc); + color: #ffffff; + font-weight: 600; +} +.cal-foot { + margin: 10px 0 0; + color: var(--faint); + font-size: var(--fs-2xs); + line-height: 1.5; +} diff --git a/packages/web/src/views/News.tsx b/packages/web/src/views/News.tsx index 4be0e966..2dac6f85 100644 --- a/packages/web/src/views/News.tsx +++ b/packages/web/src/views/News.tsx @@ -1,10 +1,12 @@ /** - * News (M14.2): the security news that concerns what your projects run. + * News (M14.2, as a paper since M14.11): the security news that concerns what your projects run. * * The daemon reads public RSS / Atom sources on a schedule and matches every item against the * stack of every project. This view narrows to the selected project's keywords — a vendor's own * channel is shown whole, the authorities and the press only when they name something the project - * runs — so the sidebar's project filter is the relevance filter. + * runs — so the sidebar's project filter is the relevance filter. The items set as a paper by + * default (a dateline per day, the day's articles in tiers) or as a table; a month calendar beside + * them narrows both to one day. */ import { NEWS_KINDS, type NewsKind, type NewsReport } from "@swarm/core/newsfeed"; import { type ReactNode, useState } from "react"; @@ -15,15 +17,23 @@ import { DataGrid } from "../components/DataGrid"; import { Empty, Failed, Loading, Section } from "../components/ui"; import { ago } from "../lib/format"; import { useUiStore } from "../state/ui"; +import { NewsCalendar } from "./news/Calendar"; import { KIND_LABEL, NEWS_COLUMNS } from "./news/columns"; +import { NewsPaper } from "./news/Paper"; import { Sources } from "./news/Sources"; +import { dayLabel, dayOf } from "./news/tiers"; + +const VIEWS = ["paper", "table"] as const; export function News() { const project = useUiStore((s) => s.project); + const view = useUiStore((s) => s.newsView); + const setView = useUiStore((s) => s.setNewsView); const [off, setOff] = useState>(new Set()); const [unread, setUnread] = useState(false); const [all, setAll] = useState(false); const [sources, setSources] = useState(false); + const [day, setDay] = useState(""); const kinds = NEWS_KINDS.filter((k) => !off.has(k)); // the feeds move every refresh_minutes; a 30 s poll is plenty const { data, error, reload } = useResource( @@ -56,11 +66,25 @@ export function News() { title="News" hint={hint} actions={ - data.counts.unread > 0 ? ( - - ) : undefined + <> + {data.counts.unread > 0 && ( + + )} + + {VIEWS.map((v) => ( + + ))} + + } />
@@ -95,9 +119,22 @@ export function News() { > Sources + {day && ( + + )}
{sources && } - + ); } @@ -137,40 +174,62 @@ function NewsBody({ data, scoped, all, + view, + day, + onDay, onRead, }: { data: NewsReport; scoped: boolean; all: boolean; + view: (typeof VIEWS)[number]; + day: string; + onDay: (day: string) => void; onRead: (keys: readonly string[]) => void; }) { const empty = emptyReason(data, scoped, all); if (empty) return empty; + const items = day ? data.items.filter((i) => dayOf(i.published) === day) : data.items; return ( - <> - i.key} - leading={{ width: 24, cell: (i) => }} - trailing={{ - width: 70, - cell: (i) => - i.read ? null : ( - - ), - }} - /> -

- Narrowed to {data.keywords.length} keywords derived from{" "} - {scoped ? "this project's" : "every project's"} lockfiles and{" "} - [security] keywords. Vendor channels and the AI press are shown whole; - authorities and the press only when they name one of them. Sources are public RSS / Atom - feeds; the request carries nothing about you. -

- +
+
+ {items.length === 0 ? ( + Nothing on {dayLabel(day)} among the items shown. + ) : view === "table" ? ( + i.key} + leading={{ + width: 24, + cell: (i) => , + }} + trailing={{ + width: 70, + cell: (i) => + i.read ? null : ( + + ), + }} + /> + ) : ( + onRead([key])} /> + )} +

+ Narrowed to {data.keywords.length} keywords derived from{" "} + {scoped ? "this project's" : "every project's"} lockfiles and{" "} + [security] keywords. Vendor channels and the AI press are shown whole; + authorities and the press only when they name one of them. Sources are public RSS / Atom + feeds; the request carries nothing about you. A picture comes from the feed, or from the + article's own page, read once. +

+
+ +
); } diff --git a/packages/web/src/views/news/Calendar.tsx b/packages/web/src/views/news/Calendar.tsx new file mode 100644 index 00000000..0464fab8 --- /dev/null +++ b/packages/web/src/views/news/Calendar.tsx @@ -0,0 +1,143 @@ +/** + * The month calendar beside the news (M14.11): the days with articles, shaded by how many. A day + * narrows the paper and the table to itself; the same day again lets it go. The arrows stop where + * the window does. + */ +import type { NewsItemView } from "@swarm/core/newsfeed"; +import { useState } from "react"; +import { + compareMonths, + dayKey, + heat, + type Month, + monthGrid, + monthLabel, + monthOf, + shiftMonth, + WEEKDAYS, +} from "../../lib/calendar"; +import { countByDay, dayLabel } from "./tiers"; + +/** `3 articles over 2 days this month`, or `3 articles this month` when every day holds one. */ +function footOf(total: number, days: number): string { + const what = `${total} article${total === 1 ? "" : "s"}`; + if (total === days) return `${what} this month`; + return `${what} over ${days} day${days === 1 ? "" : "s"} this month`; +} + +export function NewsCalendar({ + items, + day, + onDay, +}: { + items: readonly NewsItemView[]; + /** The selected day, or "" for every day. */ + day: string; + onDay: (day: string) => void; +}) { + const [turned, setTurned] = useState(null); + const counts = countByDay(items); + const days = [...counts.keys()].sort(); + const today = dayKey(new Date()); + const first = days[0] ?? today; + const last = days[days.length - 1] ?? today; + const month = turned ?? monthOf(day || last); + const span: [Month, Month] = [monthOf(first), monthOf(last)]; + + const weeks = monthGrid(month); + const countOf = (key: string) => counts.get(key) ?? 0; + const inView = weeks.flat().filter((key): key is string => Boolean(key)); + const busiest = Math.max(0, ...inView.map(countOf)); + const written = inView.filter((key) => countOf(key) > 0); + const total = written.reduce((sum, key) => sum + countOf(key), 0); + const previous = shiftMonth(month, -1); + const next = shiftMonth(month, 1); + + return ( +
+
+ + {monthLabel(month)} + +
+ + + + {WEEKDAYS.map((initial, i) => ( + // The initials repeat (two Ts, two Ss), so the column's position is the key. + // biome-ignore lint/suspicious/noArrayIndexKey: a weekday is its position in the week + + ))} + + + + {weeks.map((week) => ( + + {week.map((key, i) => ( + + ))} + + ))} + +
+ {initial} +
+ {key && ( + + )} +
+

{footOf(total, written.length)}

+
+ ); +} + +/** One day of the grid: a plain number on a day with nothing, else a button shaded by its count. */ +function CalendarDay({ + dayKey: key, + count, + busiest, + selected, + onDay, +}: { + dayKey: string; + count: number; + busiest: number; + selected: boolean; + onDay: (day: string) => void; +}) { + const dayOfMonth = Number(key.slice(8)); + if (!count) return {dayOfMonth}; + return ( + + ); +} diff --git a/packages/web/src/views/news/Paper.tsx b/packages/web/src/views/news/Paper.tsx new file mode 100644 index 00000000..93054476 --- /dev/null +++ b/packages/web/src/views/news/Paper.tsx @@ -0,0 +1,96 @@ +/** + * The news as a paper (M14.11): a dateline for each day, newest first, and under it the day's + * articles set in tiers as a front page is — the first two as heroes across the width, the next + * three as features in a row, the rest as briefs in as many columns as fit. + * + * Each card is the link to the article, whole, and lights under the pointer; the app's delegated + * click handler hands the URL to the real browser, and the click also marks the item read. The + * picture is the one the feed gave, or the article page's own Open Graph picture the daemon read + * once; it comes from the publisher's host, lazily and without a referrer, and one that fails to + * load leaves no hole. + */ +import type { NewsItemView } from "@swarm/core/newsfeed"; +import { Badge } from "../../components/ui"; +import { KIND_LABEL } from "./columns"; +import { byDay, dayLabel, inTiers, relativeDay, type Tier } from "./tiers"; + +export function NewsPaper({ + items, + onRead, +}: { + items: readonly NewsItemView[]; + onRead: (key: string) => void; +}) { + const days = byDay(items); + return ( +
+ {days.map(({ day, items: articles }) => ( +
+

+ {day ? dayLabel(day) : "Undated"} + {day && {relativeDay(day)}} +

+ {inTiers(articles).map((row) => ( +
+ {row.items.map((item) => ( + + ))} +
+ ))} +
+ ))} +
+ ); +} + +function NewsCard({ + item, + tier, + onRead, +}: { + item: NewsItemView; + tier: Tier; + onRead: (key: string) => void; +}) { + const cls = ["paper-card", tier, item.image && "with-picture", !item.read && "unread"] + .filter(Boolean) + .join(" "); + return ( + { + if (!item.read) onRead(item.key); + }} + > + {item.image && ( + + e.currentTarget.parentElement?.remove()} + /> + + )} +

+ {item.feed} + {KIND_LABEL[item.kind]} +

+

{item.title}

+ {item.summary &&

{item.summary}

} + {item.matched.length > 0 && ( +

+ {item.matched.map((k) => ( + + {k} + + ))} +

+ )} +
+ ); +} diff --git a/packages/web/src/views/news/tiers.ts b/packages/web/src/views/news/tiers.ts new file mode 100644 index 00000000..3dbadc9e --- /dev/null +++ b/packages/web/src/views/news/tiers.ts @@ -0,0 +1,106 @@ +/** + * The news as a paper (M14.11): the pure helpers that set a day's articles in tiers. + * + * A front page runs its first stories across the width, the next few in a row, and the rest as + * briefs. The same here, per day, newest day first. The pictured articles move up to fill the big + * slots, since a hero without a picture would be a headline over a hole. + */ +import type { NewsItemView } from "@swarm/core/newsfeed"; + +/** The tiers a day's articles set in. */ +export type Tier = "hero" | "feature" | "brief"; + +/** How many articles the big tiers hold: two heroes, then three features; the rest are briefs. */ +export const TIER_SLOTS = { hero: 2, feature: 3 } as const; + +/** The UTC day an item was published on, as `2026-10-23`: what the paper and the calendar group by. */ +export const dayOf = (iso: string): string => iso.slice(0, 10); + +/** The articles grouped by the day they were published, newest day first, the feed's order kept. */ +export function byDay( + items: readonly NewsItemView[], +): Array<{ day: string; items: NewsItemView[] }> { + const groups = new Map(); + for (const item of items) { + const day = dayOf(item.published); + const list = groups.get(day) ?? []; + list.push(item); + groups.set(day, list); + } + return [...groups] + .sort(([a], [b]) => b.localeCompare(a)) + .map(([day, list]) => ({ day, items: list })); +} + +/** A dateline as a paper prints it: `Thursday, 23 October`. */ +export function dayLabel(day: string): string { + const date = new Date(`${day}T00:00:00Z`); + if (Number.isNaN(date.getTime())) return day; + const weekday = date.toLocaleDateString("en-GB", { weekday: "long", timeZone: "UTC" }); + const rest = date.toLocaleDateString("en-GB", { day: "numeric", month: "long", timeZone: "UTC" }); + return `${weekday}, ${rest}`; +} + +const DAY_MS = 86_400_000; +const startOfDay = (d: Date) => Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); + +/** `today`, `yesterday`, `3 days ago`, `2 weeks ago` — the dateline's grey companion. */ +export function relativeDay(day: string, now = new Date()): string { + const date = new Date(`${day}T00:00:00Z`); + if (Number.isNaN(date.getTime())) return ""; + const days = Math.round((startOfDay(now) - startOfDay(date)) / DAY_MS); + if (days < 0) return "ahead of today"; + if (days === 0) return "today"; + if (days === 1) return "yesterday"; + if (days < 7) return `${days} days ago`; + if (days < 30) { + const w = Math.floor(days / 7); + return `${w} week${w === 1 ? "" : "s"} ago`; + } + const m = Math.floor(days / 30); + return `${m} month${m === 1 ? "" : "s"} ago`; +} + +/** + * A day's articles with the pictured ones moved up to fill the big slots: the first `slots` + * places go to the articles with a picture, in their order, and the rest follow in theirs. + */ +export function picturedFirst(items: readonly NewsItemView[], slots: number): NewsItemView[] { + const up: NewsItemView[] = []; + const rest: NewsItemView[] = []; + for (const item of items) { + if (item.image && up.length < slots) up.push(item); + else rest.push(item); + } + return [...up, ...rest]; +} + +/** + * A day's articles in their tiers: the first two as heroes across the width, the next three as + * features in a row, the rest as briefs. A day of one or two articles is all heroes. A feature + * left alone in its row sets as a hero too, since one feature across the width would be a tall + * picture over a line of text. + */ +export function inTiers( + items: readonly NewsItemView[], +): Array<{ tier: Tier; items: NewsItemView[] }> { + const ordered = picturedFirst(items, TIER_SLOTS.hero + TIER_SLOTS.feature); + const features = ordered.slice(TIER_SLOTS.hero, TIER_SLOTS.hero + TIER_SLOTS.feature); + const rows: Array<{ tier: Tier; items: NewsItemView[] }> = [ + { tier: "hero", items: ordered.slice(0, TIER_SLOTS.hero) }, + { tier: features.length === 1 ? "hero" : "feature", items: features }, + { tier: "brief", items: ordered.slice(TIER_SLOTS.hero + TIER_SLOTS.feature) }, + ]; + return rows.filter((row) => row.items.length); +} + +/** How many articles each day holds, for the calendar. */ +export function countByDay(items: readonly NewsItemView[]): Map { + const counts = new Map(); + for (const item of items) { + const day = dayOf(item.published); + if (!day) continue; + counts.set(day, (counts.get(day) ?? 0) + 1); + } + return counts; +} diff --git a/packages/web/tools/build.ts b/packages/web/tools/build.ts index 944c391f..407b3785 100644 --- a/packages/web/tools/build.ts +++ b/packages/web/tools/build.ts @@ -63,6 +63,7 @@ export const ICONS = [ "pause", "stop", "broadcast", + "newspaper", "plugs-connected", "plugs", "currency-dollar", @@ -133,6 +134,7 @@ const NAME_MAP: Record = { pause: "pause", stop: "stop", broadcast: "rss-square", + newspaper: "article", "plugs-connected": "plug", plugs: "plug", "currency-dollar": "dollar",