= {
+ rss: "RSS",
+ atom: "Atom",
+ json: "JSON",
+};
+
+const NativeFeedChip = ({ feed }: { feed: NativeFeed }) => {
+ const label = NATIVE_LABELS[feed.type];
+ return (
+
+ );
+};
+
+export const NativeFeeds = ({ feeds }: { feeds: NativeFeed[] }) => {
+ if (feeds.length === 0) return null;
+ return (
+
+ );
+};
+```
+
+- [ ] **Step 4: Add `NativeFeedPill` to `src/routes/admin.tsx`**
+
+After `ConfirmationPill` (after its closing `);`, ~line 230) add:
+
+```tsx
+const NativeFeedPill = ({ feedId }: { feedId: string }) => (
+
+ Native feed available
+
+);
+```
+
+Render it next to the confirmation pill (~line 639-641), so the block reads:
+
+```tsx
+{
+ feed.pendingConfirmation && ;
+}
+{
+ feed.hasNativeFeed && ;
+}
+```
+
+- [ ] **Step 5: Run the test to verify it passes**
+
+Run: `npx vitest run src/routes/admin.test.ts -t "pill-native"`
+Expected: PASS. (The `NativeFeeds` component is now exported and used by Task 8; nothing here renders it yet, which is fine — this task ends green.)
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/routes/admin/ui.tsx src/routes/admin.tsx src/routes/admin.test.ts
+git commit -m "feat(admin): native feed chips + dashboard pill"
+```
+
+---
+
+### Task 8: Feed detail page — render group + dismissable banner + route + client
+
+**Files:**
+
+- Modify: `src/routes/admin/emails.tsx` (detail handler ~line 137; render after `FeedFormats` ~line 166; banner after confirmation banner ~line 186; dismiss route after line 733; import `NativeFeeds` + `unionNativeFeeds`)
+- Modify: `src/scripts/client/emails-page.ts` (append a dismiss handler after line 636)
+- Test: `src/routes/admin.test.ts` (the detail test from Task 7 + a dismiss-route test)
+
+- [ ] **Step 1: Add the detail-group test and the dismiss-route test**
+
+Append both to `src/routes/admin.test.ts` (mirror the confirmation detail/dismiss tests ~line 1137 / ~line 1234):
+
+```ts
+it("feed detail shows a native-feeds group when a native feed was detected", async () => {
+ const env = createMockEnv() as unknown as Env;
+ const feedId = FeedId.generate();
+ const mailboxId = MailboxId.unchecked("native.detail.07");
+ const repo = FeedRepository.from(env);
+ const feed = Feed.create(
+ feedId,
+ { title: "N", language: "en", allowedSenders: [], blockedSenders: [] },
+ { mailboxId },
+ );
+ feed.ingest(
+ { key: "k1", subject: "s", receivedAt: 1, size: 10 },
+ {
+ maxBytes: 1e9,
+ nativeFeeds: {
+ senderKey: "a@x.com",
+ feeds: [{ url: "https://blog.example.com/feed.xml", type: "rss" }],
+ },
+ },
+ );
+ await repo.save(feed);
+
+ const res = await request(`/admin/feeds/${feedId.value}/emails`, {}, env);
+ const body = await res.text();
+ expect(body).toContain("native-feeds");
+ expect(body).toContain("https://blog.example.com/feed.xml");
+});
+
+it("native-feed dismiss route clears the flag", async () => {
+ const env = createMockEnv() as unknown as Env;
+ const feedId = FeedId.generate();
+ const mailboxId = MailboxId.unchecked("native.dismiss.09");
+ const repo = FeedRepository.from(env);
+ const feed = Feed.create(
+ feedId,
+ { title: "N", language: "en", allowedSenders: [], blockedSenders: [] },
+ { mailboxId },
+ );
+ feed.ingest(
+ { key: "k1", subject: "s", receivedAt: 1, size: 10 },
+ {
+ maxBytes: 1e9,
+ nativeFeeds: {
+ senderKey: "a@x.com",
+ feeds: [{ url: "https://x.com/rss", type: "rss" }],
+ },
+ },
+ );
+ await repo.save(feed);
+
+ const res = await request(
+ `/admin/feeds/${feedId.value}/native-feed/dismiss`,
+ { method: "POST", headers: { "Content-Type": "application/json" } },
+ env,
+ );
+ expect(res.status).toBe(200);
+ const reloaded = await repo.load(feedId);
+ expect(reloaded!.hasNativeFeed()).toBe(false);
+ expect(reloaded!.nativeFeeds()).toHaveLength(1); // URLs preserved
+});
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `npx vitest run src/routes/admin.test.ts -t "native"`
+Expected: FAIL — the detail `native-feeds` group is absent and the dismiss route 404s.
+
+- [ ] **Step 3: Render the group + banner in `src/routes/admin/emails.tsx`**
+
+(a) Extend the import from `./ui` (line ~9) to include `NativeFeeds`, and add the domain import:
+
+```ts
+import { unionNativeFeeds } from "../../domain/native-feed";
+```
+
+(b) In the detail handler, after `const feedMetadata = await repo.getMetadata(id);` (line 137) and the null guard (line 139), compute:
+
+```ts
+const nativeFeeds = unionNativeFeeds(feedMetadata.nativeFeeds);
+```
+
+(c) Render the group right after `` (line 166):
+
+```tsx
+
+
+```
+
+(d) Add the dismissable banner right after the confirmation-banner block (after line 186):
+
+```tsx
+{
+ nativeFeeds.length > 0 && !feedMetadata.nativeFeedDismissed && (
+
+
+ This newsletter publishes its own feed — subscribe to it directly from
+ "Native feeds" above.
+
+
+
+
+
+ );
+}
+```
+
+- [ ] **Step 4: Add the dismiss route in `src/routes/admin/emails.tsx`**
+
+After the confirmation dismiss route (after its closing `});`, ~line 733) add:
+
+```ts
+// ── Dismiss native-feed notice ───────────────────────────────────────────────
+
+emailsRouter.post("/feeds/:feedId/native-feed/dismiss", async (c) => {
+ const env = c.env;
+ const repo = FeedRepository.from(env);
+ const feedId = c.req.param("feedId");
+ const wantsJson = (
+ c.req.header("Accept") ||
+ c.req.header("Content-Type") ||
+ ""
+ ).includes("application/json");
+
+ const feed = await repo.load(FeedId.unchecked(feedId));
+ if (!feed) {
+ return wantsJson
+ ? c.json({ ok: false, error: "Feed not found" }, 404)
+ : c.text("Feed not found", 404);
+ }
+ feed.dismissNativeFeed();
+ await repo.saveMetadata(feed);
+
+ return wantsJson
+ ? c.json({ ok: true })
+ : c.redirect(`/admin/feeds/${feedId}/emails`);
+});
+```
+
+- [ ] **Step 5: Add the client dismiss handler**
+
+Append to `src/scripts/client/emails-page.ts` (after line 636):
+
+```ts
+// ── Native-feed banner dismiss ────────────────────────────────────────────────
+
+const nativeDismissBtn = document.getElementById("native-feed-dismiss");
+const nativeBanner = document.getElementById("native-feed-banner");
+if (nativeDismissBtn && nativeBanner) {
+ nativeDismissBtn.addEventListener("click", () => {
+ const feedId = nativeBanner.getAttribute("data-feed-id") ?? "";
+ fetch(`/admin/feeds/${feedId}/native-feed/dismiss`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ })
+ .then((r) => r.json())
+ .then((d) => {
+ if ((d as { ok?: boolean }).ok) nativeBanner.remove();
+ })
+ .catch(() => {});
+ });
+}
+```
+
+- [ ] **Step 6: Rebuild client scripts**
+
+Run: `npm run build:client`
+Expected: rebuilds `src/scripts/generated/` with no errors.
+
+- [ ] **Step 7: Run the tests to verify they pass**
+
+Run: `npx vitest run src/routes/admin.test.ts -t "native"`
+Expected: PASS (detail group, dashboard pill, dismiss route).
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add src/routes/admin/emails.tsx src/scripts/client/emails-page.ts src/routes/admin.test.ts
+git commit -m "feat(admin): native-feed detail group + dismissable notice"
+```
+
+---
+
+### Task 9: Styles
+
+**Files:**
+
+- Modify: `src/styles/components.css` (add `.pill-native` after `.pill-confirmation:hover` ~line 1392; add `.native-feeds` spacing)
+
+No unit test (CSS) — verified via the build + a manual dev-server check at the end.
+
+- [ ] **Step 1: Add `.pill-native` and `.native-feeds` rules**
+
+After the `.pill-confirmation:hover { … }` block (~line 1392) in `src/styles/components.css`, add:
+
+```css
+/* Dashboard pill — */
+.pill-native {
+ background: var(--color-surface);
+ color: var(--color-primary);
+ border-color: var(--color-primary);
+ text-decoration: none;
+ transition:
+ opacity var(--transition-fast),
+ transform var(--transition-fast);
+}
+
+.pill-native:hover {
+ opacity: 0.88;
+ transform: translateY(-1px);
+}
+
+/* Native-feeds group sits below the KTN "Subscribe" chips */
+.native-feeds {
+ margin-top: var(--spacing-sm);
+}
+```
+
+- [ ] **Step 2: Verify the build**
+
+Run: `npm run build`
+Expected: dry-run deploy bundle succeeds.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/styles/components.css
+git commit -m "style(admin): pill-native + native-feeds group spacing"
+```
+
+---
+
+### Task 10: Documentation
+
+**Files:**
+
+- Modify: `README.md`, `INSTALL.md`, `docs/index.html`, `CLAUDE.md`, `TODO.md`
+
+- [ ] **Step 1: README.md** — under the features/feed section, add a bullet:
+
+```md
+- **Native feed detection** — when a newsletter advertises its own RSS/Atom/JSON feed, KTN surfaces it in the admin (and the REST API) so you can subscribe to the source directly.
+```
+
+- [ ] **Step 2: INSTALL.md** — add a short note where other automatic ingestion behaviors (confirmation detection, favicons) are documented, explaining that native feeds are detected from `` and shown per feed; no configuration required.
+
+- [ ] **Step 3: docs/index.html (marketing landing)** — add a feature card matching the existing card markup/section style, headline e.g. "Find the source feed", body: "If a newsletter already publishes RSS/Atom/JSON, KTN spots it and points you to the original — subscribe at the source when you prefer." (It's a differentiator we ship before upstream.)
+
+- [ ] **Step 4: CLAUDE.md** — in the `src/domain/` source-layout list, add:
+
+```md
+ native-feed.ts # Detect a newsletter's self-advertised Atom/RSS/JSON feed (pure)
+```
+
+and in the KV-schema `feed::metadata` row, extend the value shape note to mention `nativeFeeds` (per-sender `Record`) and `nativeFeedDismissed`.
+
+- [ ] **Step 5: TODO.md** — mark the item done. Change the line (currently ~line 67) from `- [ ] `P2·S` **Detect a newsletter's native Atom/RSS feed**` to `- [x]` and append a `— **Shipped:**` note summarizing: per-sender detection of `` (Atom/RSS/JSON), admin detail group + dashboard pill + dismiss, read-only REST `FeedSchema.nativeFeeds`.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add README.md INSTALL.md docs/index.html CLAUDE.md TODO.md
+git commit -m "docs: document native feed detection; mark TODO item shipped"
+```
+
+---
+
+### Task 11: Full verification gate
+
+- [ ] **Step 1: Type-check** — `npx tsc --noEmit` → no errors.
+- [ ] **Step 2: Tests** — `npm test` → all green.
+- [ ] **Step 3: Build** — `npm run build` → dry-run deploy succeeds.
+- [ ] **Step 4: Manual UI check** — `npm run dev`, create a feed, POST an email containing `` to its inbound webhook, then:
+ - the feed's emails page shows a "Native feeds" group with a copyable RSS chip;
+ - the dashboard shows the `pill-native` pill;
+ - clicking "Dismiss" removes the banner and the pill disappears on reload, but the chip stays;
+ - `GET /api/v1/feeds/` (Bearer admin password) returns `nativeFeeds: [{ url, type: "rss" }]`.
+- [ ] **Step 5:** If any check fails, fix and re-run the gate before declaring done.
+
+---
+
+## Notes for the implementer
+
+- **DRY senderKey:** Task 5 deliberately computes `senderKey` once and shares it between `unsub` and `nativeFeeds` — do not duplicate the `input.senders[0] || iconDomain || input.from` expression.
+- **Additive persistence:** `nativeFeeds`/`nativeFeedDismissed` live on `FeedMetadata`, which is stored directly in KV (no mapper translation for the metadata blob). Pre-feature feeds simply have them `undefined` → `hasNativeFeed()` is `false`. No migration.
+- **No public XML change:** native feeds are intentionally NOT emitted into the rendered RSS/Atom/JSON output — admin + REST only.
+- **Test harness fidelity:** Tasks 5–8 reference helpers (`seedFeed`, `buildInput`, `request`, etc.) by intent. Always match the actual helpers/imports already used in the target test file rather than introducing new ones.
+- **One admin surface, not two:** the spec mentioned a "list badge" _and_ a "dashboard pill". Because a native feed is a feed-level fact (not per-email), these collapse to a single surface — the `pill-native` on the dashboard feed table — plus the copyable group on the feed detail page. There is no separate per-email badge (unlike confirmation, which is per-email).