From 9312b456e903eb450432534e684d7f7399412ac1 Mon Sep 17 00:00:00 2001 From: NiallJoeMaher Date: Mon, 26 Oct 2026 23:07:55 +0000 Subject: [PATCH] Before going public: iPhone shares are kept when signed out, docs/history goes, the brief is marked as background Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Per4f8yL5A7nL9frZVDJj7 --- CLAUDE.md | 2 +- apps/ios/lib/data.ts | 7 +- docs/handover/README.md | 35 ++----- docs/handover/pluck-product-spec-v2.md | 4 +- docs/handover/pluck-technical-spec-v2.md | 2 + docs/history/CHANGES-since-v1.md | 46 -------- docs/history/CLAUDE.original.md | 32 ------ docs/history/README.md | 8 -- docs/history/SYNC-2026-10-24.md | 21 ---- docs/history/loop/BLOCKED.md | 15 --- docs/history/loop/PROGRESS.md | 128 ----------------------- docs/launch-checklist.md | 14 +-- docs/self-host.md | 2 + docs/store/README.md | 3 + 14 files changed, 33 insertions(+), 286 deletions(-) delete mode 100644 docs/history/CHANGES-since-v1.md delete mode 100644 docs/history/CLAUDE.original.md delete mode 100644 docs/history/README.md delete mode 100644 docs/history/SYNC-2026-10-24.md delete mode 100644 docs/history/loop/BLOCKED.md delete mode 100644 docs/history/loop/PROGRESS.md create mode 100644 docs/store/README.md diff --git a/CLAUDE.md b/CLAUDE.md index b454639..339c3dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md — Pluck -Pluck is a fast, open-source URL saver with an MCP connector. There is no AI inside the app. Read `docs/architecture.md` before any non-trivial change; `docs/handover/pluck-technical-spec-v2.md` has the background. Use the words in `GLOSSARY.md`. `docs/history/` is the record of the first build, not current. +Pluck is a fast, open-source URL saver with an MCP connector. There is no AI inside the app. Read `docs/architecture.md` before any non-trivial change; `docs/handover/pluck-technical-spec-v2.md` has the background. Use the words in `GLOSSARY.md`. ## Non-negotiables diff --git a/apps/ios/lib/data.ts b/apps/ios/lib/data.ts index 39b8293..6b4b323 100644 --- a/apps/ios/lib/data.ts +++ b/apps/ios/lib/data.ts @@ -6,7 +6,7 @@ import { Directory, File, Paths } from "expo-file-system"; import * as Network from "expo-network"; import { useEffect, useState, useSyncExternalStore } from "react"; import { AppState } from "react-native"; -import { createQueue, Unreachable } from "@pluck/client"; +import { createQueue, Refused, Unreachable } from "@pluck/client"; import { KEEP, MAX_BYTES, onWifi, overCap, plan, toKeep, type Kept } from "./keep"; import { kv } from "./kv"; import { api, authHeader, getSession } from "./session"; @@ -184,11 +184,12 @@ async function sync(): Promise { let record: SharedRecord | null = null; try { record = JSON.parse(await f.text()) as SharedRecord; - if (record.body) await api("/api/v1/items", { json: record.body }); + // The file's name is the key: if the upload below fails, sending the body again makes no second Note. + if (record.body) await api("/api/v1/items", { json: { ...record.body, clientKey: f.name } }); if (record.file) await uploadShared(record.file); f.delete(); } catch (e) { - if (e instanceof Unreachable) break; // still offline: try next time + if (record && !(e instanceof Refused)) break; // offline, signed out, busy or down: keep it for next time f.delete(); // refused or unreadable: retrying will not help if (record?.file) removeShared(record.file.path); } diff --git a/docs/handover/README.md b/docs/handover/README.md index 3a8e379..bdc551e 100644 --- a/docs/handover/README.md +++ b/docs/handover/README.md @@ -1,21 +1,16 @@ -# Pluck — handover to Claude Code +# Design boards, brand and the original brief -24 October 2026. Everything needed to build v1 of Pluck (getpluck.io), an open-source URL saver with an MCP connector and no AI inside the app. +How Pluck works now is in [`docs/architecture.md`](../architecture.md). This folder holds what it was designed and built from. -## Read in this order - -1. `pluck-technical-spec-v2.md` — the build spec. Architecture, data model, API, auth, MCP server, extension, iOS, infra, conventions, and a numbered build order at the end. -2. `pluck-product-spec-v2.md` — what it is, the flows, every screen, the voice, the visual rules, the metrics. -3. `CLAUDE.original.md` — the standing rules as handed over. The live, edited version is the repo-root `CLAUDE.md`. -4. `CHANGES-since-v1.md` — if you read the v1 docs before, this is what moved. +- `design/`: the visual source of truth. Copy values from these boards, never round them. +- `brand/`: tokens, icons and the wordmark. +- `pluck-technical-spec-v2.md`, `pluck-product-spec-v2.md`: the brief from 24 October 2026, kept as background. Where they and the code disagree, the code is right; `CLAUDE.md` lists the decisions that changed. ## Design -`design/` holds the canvas as 43 `.dc.html` artboards plus `canvas.json` (the index with each board's title, size and position). Each file is a self-contained HTML page with inline styles, so the exact paddings, sizes, radii and colours are readable in the markup. Treat them as the visual source of truth and copy values from them rather than rounding. - -Two of the boards are shared components the others import: `Logo.dc.html` and `Icon.dc.html`. Port those first into `packages/ui`. +`design/` holds the canvas as 43 `.dc.html` artboards plus `canvas.json` (the index with each board's title, size and position). Each file is a self-contained HTML page with inline styles, so the exact paddings, sizes, radii and colours are readable in the markup. -Board map: +Two of the boards are shared components the others import: `Logo.dc.html` and `Icon.dc.html`, ported to `packages/ui`. | File | What it is | |---|---| @@ -34,19 +29,11 @@ Board map: | `Claude.dc.html` | The connector in use (two example conversations) | | `Email.dc.html` | The Monday digest | | `Landing.dc.html`, `SiteClaude.dc.html`, `SiteOpen.dc.html`, `SiteSupport.dc.html`, `SitePrivacy.dc.html`, `SiteTerms.dc.html` | Marketing site: home, connect your AI, open source, support, privacy, terms | -| `SiteChrome.dc.html`, `SiteIPhone.dc.html` | "Soon." pages while the Chrome Web Store and App Store listings are in review | -| `Cookies.dc.html` | The cookie banner, for the day an analytics cookie arrives (none is needed today) | -| `ConnectLoop.dc.html` | The 10-second "connect your AI" loop: live, with timing, frames, spec and where it goes. The same CSS ships as `apps/web/components/ConnectLoop.tsx`; `motion/connect-loop.html` is the standalone page that `scripts/record-loop.mjs` records | +| `SiteChrome.dc.html`, `SiteIPhone.dc.html` | The "Soon." pages from while the Chrome Web Store and App Store listings were in review | +| `Cookies.dc.html` | The cookie banner | +| `ConnectLoop.dc.html` | The 10-second "connect your AI" loop: live, with timing, frames and spec. The same CSS ships as `apps/web/components/ConnectLoop.tsx`; `motion/connect-loop.html` is the standalone page that `scripts/record-loop.mjs` records | | `AppStore1.dc.html` to `AppStore5.dc.html` | App Store screenshots at 1320 × 2868 (6.9 inch), built from the iOS boards | ## Brand -`brand/tokens.json` is the palette, type, layout and the seven kind glyph paths. `brand/*.svg` are the app icon (regenerated with every other icon size by `node scripts/icons.mjs`, from `packages/ui/src/icon.json`) (forest, mint, dark), favicons at 32 and 16, the bare glyph, and the wordmark. The wordmark SVG uses the live Fredoka font; convert to outlines before shipping it anywhere the font is not loaded. - -## Placeholders to fill before launch - -Legal entity, address, privacy and abuse emails, hosting provider and region, licence, repo URL, takedown response time. They are marked `[LIKE THIS]` in the specs and the site boards. - -## The one rule - -No model calls on the server. Ever. AI features are MCP prompts in `packages/mcp`. A PR that adds one is declined. +`brand/tokens.json` is the palette, type, layout and the seven kind glyph paths. `brand/*.svg` are the app icon (forest, mint, dark; regenerated with every other icon size by `node scripts/icons.mjs`, from `packages/ui/src/icon.json`), favicons at 32 and 16, the bare glyph, and the wordmark. The wordmark SVG uses the live Fredoka font; convert to outlines before using it anywhere the font is not loaded. diff --git a/docs/handover/pluck-product-spec-v2.md b/docs/handover/pluck-product-spec-v2.md index a4b622e..a1e3a1c 100644 --- a/docs/handover/pluck-product-spec-v2.md +++ b/docs/handover/pluck-product-spec-v2.md @@ -1,6 +1,8 @@ # Pluck — Product and Design Spec v2 -24 October 2026. Supersedes v1. The design canvas (33 boards) is the visual source of truth; this doc is the written one. +> **The brief Pluck was built from, kept as background.** Where it and the code disagree, the code is right: see `docs/architecture.md` for how Pluck works now and "Decisions that differ from the spec" in `CLAUDE.md`. + +24 October 2026. Supersedes v1. The design canvas (now 43 boards) is the visual source of truth; this doc is the written one. ## What Pluck is diff --git a/docs/handover/pluck-technical-spec-v2.md b/docs/handover/pluck-technical-spec-v2.md index ed84255..af2e69b 100644 --- a/docs/handover/pluck-technical-spec-v2.md +++ b/docs/handover/pluck-technical-spec-v2.md @@ -1,5 +1,7 @@ # Pluck — Technical Spec v2 +> **The brief Pluck was built from, kept as background.** Where it and the code disagree, the code is right: see `docs/architecture.md` for how Pluck works now and "Decisions that differ from the spec" in `CLAUDE.md`. + 24 October 2026. Supersedes v1. Read with the Product and Design Spec v2 and the design canvas. ## Purpose and scope diff --git a/docs/history/CHANGES-since-v1.md b/docs/history/CHANGES-since-v1.md deleted file mode 100644 index 64d8a5e..0000000 --- a/docs/history/CHANGES-since-v1.md +++ /dev/null @@ -1,46 +0,0 @@ -# Changes since v1 (23 to 24 October 2026) - -## Data model -- Kinds are seven: Read, Watch, PDF, Image, Note, Todo, Thing. Idea and Study are gone. Idea became Note. Study became a Todo with a `study` tag and a due date. -- Notes have an optional title; the first line of the body is the title when none is set. Body is plain text. No highlights on notes. -- Lists replace Collections. Ordered, purpose line, per-item note and tick. An item can be in many lists. -- List visibility is Private or Public. Unlisted is cut. `publicId` is gone. -- Smart lists are cut. `lists.kind` and `lists.query` are gone. -- Items in any list are exempt from expiry. -- New fields: `items.lastTouchedAt`, `items.archivedReason`, `items.titleIsDerived`, `listItems.noteBy`, `lists.lastActivityAt`, `users.settings.expiryDays` and `expiryScope`. New `reports` collection. - -## Expiry and archive -- Expiry length is a user setting: 14, 30, 60, 90 days or Never. Default 30. Scope: Inbox and Later, or Inbox only. -- Expiry moves to Archive. Nothing is deleted. Archive stays searchable, Restore works, and the "expired" filter shows what moved. -- "Archive it all. No, really." in Settings and the digest: `POST /items/archive-stale` with a dry run for the count. - -## Capture -- Multi-link paste: `POST /items` accepts `urls[]` (max 50) or free text with URLs in it. Shared URL extractor in `shared-schemas/urls.ts`, used by web, iOS share, email-in and MCP. -- iOS share sheet saves in place and never opens the app. It gains an optional one-line note field that cancels the auto-close. Chips: two most-used tags plus "Add to ". -- Extension popup: "to list" picker, "pluck all tabs into a dated list", max height 460 px. -- Failed extraction gets "Try the Wayback copy" (client-side link). - -## Lists and sort -- Kind filter row is five chips: All, Links, Notes, Todos, Things. Links expands to the four link kinds. -- Sort toggle on Inbox and Later: Newest or Untouched first (`lastTouchedAt`). -- Shortcut `a` adds to a list. Inside a list: Space ticks, `n` notes, Shift+J and Shift+K reorder. -- Public list page has a Report link that posts to `POST /reports`. - -## AI connector -- Positioning is "Ready for your AI, whichever one you use." Claude is named only alongside ChatGPT, Cursor and Claude Code. No "dumb on purpose" line. -- "by Claude" marks are "by AI". Settings section is "AI tools". -- Twelve tools: `search_items`, `list_items`, `get_item`, `save_item`, `batch_update`, `list_tags`, `create_tag`, `list_lists`, `get_list`, `create_list`, `update_list`, `get_digest`. No `add_to_list` (folded into `update_list`), no `update_item` (folded into `batch_update`), no `search_library` (renamed `search_items`). -- Four prompts: `tidy_inbox`, `build_list` (replaces `group_recent`), `content_ideas`, `weekly_review`. -- MCP is the only public API. No other integrations get built. - -## Marketing site -- Five pages: Home, Connect your AI (MCP install tabs, tools, prompts, limits), Open source, Privacy, Terms. -- Terms cover age 16+, public list rules (no illegal content, CSAM, harassment, doxxing, spam, malware, infringement), takedown, 90 days' notice if the free service ends, Irish law. - -## Brand -- Wordmark: "pluck" in Fredoka 700 with three sparks off the k. Icon: a drawn p with the same sparks, heavier with two sparks under 20 px. No underline, no swoosh. -- Palette: Forest `#1E5C45` (brand), Sea green `#34A07C` (sparks), Mint `#7FD3AE` (ticks and highlights), Clay `#9A5536` (by AI). Cool off-white ground, green-black ink. No cream. -- Logo and Icon are shared components on the canvas; every board uses them. - -## Cut -- Idea and Study kinds, Smart lists, Unlisted, read/unread filters, "Surprise me", any API beyond MCP. diff --git a/docs/history/CLAUDE.original.md b/docs/history/CLAUDE.original.md deleted file mode 100644 index 5d0cf23..0000000 --- a/docs/history/CLAUDE.original.md +++ /dev/null @@ -1,32 +0,0 @@ -# CLAUDE.md — Pluck - -Pluck is a fast, open-source URL saver with an MCP connector. There is no AI inside the app. Read `docs/handover/pluck-technical-spec-v2.md` before any non-trivial change. - -## Non-negotiables - -- **No model calls on the server.** No LLM SDKs, no embeddings, no vector stores, no "smart" anything. AI features are MCP prompts in `packages/mcp` that a connected client runs. A PR that adds a model call is declined. -- **Only `packages/db` touches MongoDB.** Every query is scoped by `userId`. Every new query gets a two-user test. -- **Public reads use one projection.** `packages/db/public.ts` is the only function that serves public list data. It never joins `contents`, `tags`, `ticked` or `status`. Its field list is pinned by a test. -- **Nothing is deleted by expiry or by MCP.** Expiry and "Archive it all" set `status: archived`. MCP has no delete tool outside the `library:delete` scope, which the consent screen does not request. -- **Every type starts as a Zod schema** in `packages/shared-schemas`. OpenAPI is generated from it. -- **MCP is the only public API.** The REST API serves our clients and the MCP layer. Do not add other integrations. - -## Product words - -Use these and no others in UI copy: Pluck (verb), Plucked (confirmation), Item, Kind (Read, Watch, PDF, Image, Note, Todo, Thing), Status (Inbox, Later, Archived, Done), Tag, List. Never "saved", "bookmarked", "collection", "smart", "AI-powered", "magic". Say "your AI" or "AI tools", never one vendor as the only option. Expiry copy always says "to the Archive" and "nothing is deleted". No exclamation marks, no emoji. - -## Stack - -Next.js 16 App Router, TypeScript strict, Node 22, pnpm 10, ESM. MongoDB (Atlas, own `pluck` database). Better Auth. WXT for the extension. Expo plus a Swift share extension for iOS. One Docker image for web and worker. CDK for S3, SES, Route53. - -## Design - -`packages/ui` holds the tokens, the Logo and Icon components and the seven kind glyphs. Copy values from the `.dc.html` boards in `docs/handover/design/`, never round them. Banned: one-sided accent borders, cards in cards, gradients, glows, Inter, pure black, bounce easing, emoji, dashboards, cream grounds. Run `npx impeccable detect` before opening a PR. - -## Working here - -- `main` is deployable. Short branches, squash merges. Open a PR; a human merges. -- `pnpm typecheck`, `pnpm lint`, `pnpm test` must pass. No `any` without a `// why:` comment. -- Tests that matter: `packages/extract` fixtures, `packages/db` scoping and the public projection, `packages/mcp` tool tests, the MCP OAuth end-to-end, Playwright smoke. -- Keep `docs/self-host.md` and `docs/mcp.md` in the same PR as any env var or tool change. -- Follow the build order at the end of the technical spec. Each step ends green and deployable. diff --git a/docs/history/README.md b/docs/history/README.md deleted file mode 100644 index d9dfdab..0000000 --- a/docs/history/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# History - -Notes from building the first version of Pluck in October 2026, kept for the record. None of it is current: for how -Pluck works now, read [`docs/architecture.md`](../architecture.md), [`CONTRIBUTING.md`](../../CONTRIBUTING.md) and -[`CLAUDE.md`](../../CLAUDE.md). - -- `loop/`: the build loop's plan and its blocked steps, slice by slice. -- `CLAUDE.original.md`, `SYNC-2026-10-24.md`, `CHANGES-since-v1.md`: the instructions and notes the build started from. diff --git a/docs/history/SYNC-2026-10-24.md b/docs/history/SYNC-2026-10-24.md deleted file mode 100644 index d5b1f05..0000000 --- a/docs/history/SYNC-2026-10-24.md +++ /dev/null @@ -1,21 +0,0 @@ -# Design sync, 24 October 2026 - -The design canvas was brought in line with the app on `main` (fa939ca). This file lists what moved and what is left for the build loop. - -## Already in this change - -- **Icon centring.** The longer leg pulled the p low and the sparks pulled it right. The glyph now sits at its optical centre (halfway between the p's own centre and the centre of the whole mark). At 16 px and under it stays three sparks, heavier and shorter, so it never reads as two. -- **One source for the icon.** `packages/ui/src/icon.json` holds the geometry; `Icon.tsx` and `scripts/icons.mjs` both read it. `node scripts/icons.mjs` rewrites every size: web (`icon.svg`, favicons, 180, 192, 512, maskable), extension (16, 32, 48, 128 and the hollow 16 and 32), iOS (`assets/icon.png`, `icon-dark.png`, the share extension's set) and `docs/handover/brand`. It replaces `apps/ios/scripts/app-icons.mjs`. -- **Boards match the app.** Home, Connect your AI, Open source, Privacy and Terms carry the live copy. "Made in Dublin." is gone and every footer links to Support. The AI can no longer make a list public on the connector board. The extension, share sheet, iOS first run and iOS Search departures are folded in, so `docs/design-gaps.md` only lists native iOS behaviour now. -- **New boards.** Support; the Chrome and iPhone "Soon." pages; a cookie banner (not needed today, see below); five App Store screenshots at 1320 × 2868; the connect loop. -- **The connect loop**, as code: `apps/web/components/ConnectLoop.tsx`, `ConnectLoop.module.css` and `ConnectLoopFit.tsx`. Pure CSS, a server component. `scripts/record-loop.mjs` records it to MP4 and GIF from `docs/handover/motion/connect-loop.html`. -- **`support.js` fix.** The board stand-in created elements from upper-case tag names, so every `h1`, `p`, `section` and the like rendered as unknown inline elements. Chrome checks against boards before this change compared against a broken render; worth re-running the ones that looked off. - -## For the build loop - -1. **Put the loop in.** In `app/(app)/settings/AiToolsSection.tsx`, replace the drawn chat (`styles.chat` in `aiSide`) with ``. Tick `[CONNECTOR GIF]` off in `docs/launch-checklist.md` and `docs/design-gaps.md`. On `app/(site)/connect/page.tsx`, add it between the hero and "Install. Thirty seconds, honestly." with a 16 px radius. Add both routes to `e2e/design.spec.ts` and run `pnpm design`. -2. **Regenerate icons and check them.** `node scripts/icons.mjs`, then look at the favicon in a tab, the extension toolbar (hollow and filled) and the iOS home screen in the Simulator, light and dark. -3. **"Test it" names one vendor.** Settings, AI tools: the button opens claude.ai only, which breaks "never one vendor as the only option". Make it copy the test prompt ("Copied. Paste it into your AI.") or offer the same tools as the Connect tabs. -4. **Off-palette orange.** The extension's queued badge (`#C27A3A` in `apps/extension/lib/pluck.ts`) and the iOS "2 to sync" pill use an orange that is not in the palette. Use Clay `#9A5536` for "waiting" or the brand green. -5. **Cookies.** Pluck sets two cookies and both are needed, so no banner is required and Privacy already says so. Keep `Cookies.dc.html` for the day an analytics cookie arrives. Until then, optionally add "Cookies: two, both needed, neither tracks you." to the site footer. -6. **App Store.** Export `AppStore1` to `AppStore5` from the canvas at 1320 × 2868 for the 6.9 inch slot. The connect loop can be recorded as an app preview too, but App Store previews must show the app itself, so use it on the site and socials instead. diff --git a/docs/history/loop/BLOCKED.md b/docs/history/loop/BLOCKED.md deleted file mode 100644 index 947cc2c..0000000 --- a/docs/history/loop/BLOCKED.md +++ /dev/null @@ -1,15 +0,0 @@ -# Blocked - -Things the loop needs from a human. Newest first. Remove an entry once it is resolved. - -## Slice 14: load the extension unpacked in Chrome, once - -Everything is built and the Playwright suite drives it in Chromium; what is left is a person using it in a real Chrome. - -1. `pnpm stack`, then run the app on port 3000 (`pnpm dev`, or `pnpm --filter @pluck/web build && pnpm --filter @pluck/web exec next start -p 3000`). -2. `PLUCK_LOCAL=1 pnpm --filter @pluck/extension build` -3. `chrome://extensions` → Developer mode on → Load unpacked → `apps/extension/.output/chrome-mv3`. Pin Pluck to the toolbar. -4. Click Pluck on any page → Sign in. The email link is in Mailpit (http://127.0.0.1:8035). Allow on the consent screen. -5. Try: clicking Pluck on an article (tags, kind, note, To list, Later), Alt+Shift+P, right-click a link, an image and a selection, "Pluck all N tabs" (Chrome asks for permission once), a YouTube video (the transcript lands in the item), and Options (server, key, toggles). Turn Wi-Fi off and pluck to see the queue dot, then back on (it retries within a minute). - -Tell the loop what looked wrong, or say it is fine and slice 14 is marked done. diff --git a/docs/history/loop/PROGRESS.md b/docs/history/loop/PROGRESS.md deleted file mode 100644 index 08a63dd..0000000 --- a/docs/history/loop/PROGRESS.md +++ /dev/null @@ -1,128 +0,0 @@ -# Build loop - -The plan agreed on 2026-10-24. Each run of the loop reads this file, works the **current slice**, and updates this file before it stops. - -## How each iteration works - -1. Read this file and `BLOCKED.md`. Pick the first slice that is not `done` and not `blocked`. Set it as current. -2. Build it as a vertical slice: Zod schema → `packages/db` (with a two-user test) → API route → UI copied from the matching board in `docs/handover/design/` → Playwright e2e. -3. Run the gate (below). Fix what fails. Record the last failing check under the slice. -4. When the gate passes, tick the slice, then `git add -A && git commit` as one squash commit on `main` (`slice N: `). No remote, no push. -5. If a slice needs the human (a credential, a decision, loading the extension in Chrome): write it to `BLOCKED.md`, send a push notification, mark the slice `blocked`, move to the next slice. -6. Three failed attempts at the same check: write it to `BLOCKED.md` with the error, mark `blocked`, move on. -7. Stop the loop when every slice is `done` or the rest are `blocked`. - -## The gate - -All must pass before a slice is committed: - -1. `pnpm typecheck && pnpm lint && pnpm test` -2. `pnpm e2e`: Playwright against `next build && next start` plus `docker-compose.dev.yml`. Every earlier slice's tests stay green. -3. `pnpm design`: the e2e run saves signed-in pages to `test-results/design` (add new routes to `e2e/design.spec.ts`); this serves them and runs the impeccable detector at 1280 and 390 wide. Public pages can also be scanned directly with `pnpm exec impeccable detect http://127.0.0.1:3000/`. -4. Chrome check through Claude in Chrome: run the slice's demo in the user's Chrome, screenshot it next to the matching `.dc.html` board (served over localhost), fix mismatches. -5. If infra changed: `cdk synth` plus template assertions in `infra/cdk` tests. Never deploy. -6. Architecture pass with the `mattpocock-skills:codebase-design` skill (the user asked for `improve-codebase-architecture`, which is not installed; this is the stand-in). Apply what is cheap now, record the rest under "Architecture notes". - -## Local stack - -`docker-compose.dev.yml`, brought up by `pnpm stack` (the e2e web server command also builds the extension with `PLUCK_LOCAL=1`): `mongo:8` single-node replica set (27027), Mailpit (1035 SMTP, 8035 UI and API), MinIO (9010 API, 9011 console, bucket `pluck-content` made by the one-shot `minio-init`), and the fixture server (4180) with static pages plus `/slow`, `/error` and `/flaky?n=`. Next.js runs on the host. Toolchain: Node 22, pnpm 10 via corepack (`packageManager` pinned), Docker running. - -## Slices - -| # | Status | Slice | Chrome demo | -|---|---|---|---| -| 0 | done | Walking skeleton: toolchain (Node 22, pnpm 10), pnpm and Turborepo monorepo, `docker-compose.dev.yml`, `packages/ui` (tokens, Logo, Icon, seven glyphs from `brand/`), `shared-schemas`, `db` skeleton with the ESLint rule, Next app, Vitest and Playwright wired, placeholder-listing test | Branded empty page | -| 1 | done | Magic-link sign-in (Better Auth, Mongo adapter), `sendEmail` seam (SES or SMTP), two-user scoping test harness, Empty inbox board. **CDK `EmailStack` outbound** | Link arrives in Mailpit, click it, land on the empty Inbox | -| 2 | done | Pluck one URL: `POST /items`, inline extraction (Defuddle then Readability, 5 s cap), the 30 fixtures, cover to MinIO, re-pluck, the Mongo job queue and retry worker, `/api/worker/tick`, failed state with Retry, Open original and the Wayback link, `authenticate(request)`, the spec's per-user and per-IP rate limits (Mongo-backed, shared by every route). **CDK `DataStack`** with the app IAM user, granted `ses:SendEmail` on the `EmailStack` identity | Paste a URL, the row lands, the title and cover fill in | -| 3 | done | Multi-link paste (`shared-schemas/urls.ts` and 20 fixtures), notes, todos and things, the note editor, kind chips | Paste a block of links and get N rows, then write a note | -| 4 | done | Triage: Later, Archive, Done, the sort toggle, keyboard triage, bulk select, the delete bin, the reader with highlights, dark mode, PWA shell | Work through the inbox with only the keyboard | -| 5 | done | Tags: CRUD, picker, 422 with the nearest tags, purge-ai | Tag items, then filter by tag | -| 6 | done | Search: `$text`, `searchText` denormalisation, filters, "Ask your AI" empty state, 5k-item budget test | Search finds text in the page body | -| 7 | done | Lists: create, add with `a`, tick, per-list note, reorder (float positions, Shift+J/K, drag), list export | Build an ordered list | -| 8 | done | Public lists: handle, `/@handle/slug`, `public.ts` with its pinned-field test, copy, report, admin kill switches, `noindex` rules | Open the public page in a logged-out tab | -| 9 | done | Expiry and archive: settings, the nightly job batched by timezone, archive-stale with a dry run, the expired filter, restore, "30 days" pill | Run the expiry job and items move to the Archive | -| 10 | done | Monday digest: rule-based, HTML and plain text, signed one-tap links | Digest shows up in Mailpit and its links work | -| 11 | done | Email-in: alias, `.eml` parsing in the worker. **CDK SES receipt rule to S3, record outputs** | Drop an `.eml` in and an item appears | -| 12 | done | Settings: API keys, import (Pocket, Omnivore, Raindrop, HTML), export with a signed link, account delete | Import a Pocket CSV | -| 13 | done | MCP: protected-resource and AS metadata, CIMD and DCR, consent screen, 12 tools, 2 resources, 4 prompts, OAuth e2e with a fake CIMD client, `npx @pluck/mcp` stdio bridge, Settings AI tools | Consent screen, then the MCP Inspector calls each tool | -| 14 | blocked | Browser extension (WXT): popup, capture, tag and list pickers, all-tabs, context menus, badge, options, offline queue. Playwright loads the unpacked build. **Needs the human to load it unpacked in Chrome once** | Popup plucks the current tab | -| 15 | done | Marketing site: home with the live paste box (anonymous item, claimed at sign-up), Connect your AI, Open source, Privacy, Terms | Paste as a visitor, sign up, the item is yours | -| 16 | done | iOS: Expo app (inbox, lists, reader, search, settings, offline queue) and the Swift share extension via a config plugin. Gate: builds for the Simulator, unit tests pass, `simctl` screenshots match the boards, manual checklist written | Simulator screenshots, not Chrome | -| 17 | done | Self-host: the Dockerfile (web and worker), `docker-compose.yml`, `docs/self-host.md`, `docs/mcp.md`, `docs/deploy.md` (Atlas user, `cdk deploy`, `vercel dns add` script, Vercel env and cron, SES out of the sandbox), `docs/launch-checklist.md` | `docker compose up` works from a clean clone | -| 18 | done | Design sync (`docs/handover/SYNC-2026-10-24.md`): ConnectLoop in Settings, AI tools and on Connect your AI; icons regenerated from `packages/ui/src/icon.json` and checked (favicon, extension hollow and filled, iOS light and dark); vendor-neutral Test it (copies the prompt); Clay for the extension's queued badge and the iOS sync pill; `[CONNECTOR GIF]` ticked off; cookie banner arrives with analytics (slice 19); App Store screenshots are a canvas export for the human | Settings and Connect show the loop; Test it copies | -| 19 | done | Analytics and consent: PostHog (EU) through `/ingest`; errors from the first page in memory, usage and the account only after "Pluck it, I'm in" (Cookies board); titles, queries and element text never sent; server errors through `log()`; iOS the same question once plus a Settings switch; Privacy rewritten; `NEXT_PUBLIC_POSTHOG_KEY` in Vercel Production | Banner, answer, toast; nothing sent before a yes | -| 20 | done | TestFlight ready: privacy manifests for the app (`ios.privacyManifests`, with what Pluck collects) and the share extension (App Group defaults, file sizes), `ios.buildNumber`, the upload steps in `docs/ios.md` | Release build for the Simulator; the usage question on first launch | - -## Environment (read every iteration) - -- Node 22 is not first on PATH (Homebrew node@20 is). Prefix every shell command with `export PATH="$HOME/.nvm/versions/node/v22.23.4/bin:$PATH"`. -- The user runs **OrbStack**, not Docker Desktop. Export `DOCKER_CONTEXT=orbstack`. Never start Docker Desktop. -- Ports are offset so other projects keep theirs: Mongo 27027, e2e web 3300, dev or Chrome-check web 3000, design boards 4100. Other projects hold 27017, 1025, 8025, 9000 and 3100. -- Design boards: `python3 -m http.server 4100 --bind 127.0.0.1` from `docs/handover/design`. `support.js` there is our stand-in for the missing canvas runtime. -- `npx impeccable@4.7.0 detect http://127.0.0.1:3000/` scans the running app (the file scan misses contrast rules). -- The user's Chrome is in dark mode, so Chrome checks show dark first. Check light with `document.documentElement.dataset.theme = "light"`. - -## Architecture notes - -Seams agreed so far, in the codebase-design vocabulary: - -- **`packages/db` is the deep module for data.** Interface: userId-scoped functions per aggregate. Mongo is local-substitutable (the real `mongo:8` in Compose), so no repository port and no in-memory fake. Tests hit the real database. -- **One library module behind REST and MCP.** Route handlers and MCP tools both call the same in-process functions (in `apps/web/lib` or a package once it grows). MCP does not call the REST API over HTTP. This is how "MCP is a thin layer over the API" is honoured without a self-HTTP hop. -- **One `authenticate(request) → { userId, scopes }`** used by every REST route. `/mcp` resolves its caller itself (API key, or an OAuth JWT through `requireMcpAuth` plus a live-consent check); slice 14 adds OAuth bearer JWTs to `authenticate()` for the extension and iOS. -- **Themes:** browsers resolve the `light-dark()` tokens at `:root`, so `color-scheme` on an element does not flip them. A page that must look one way whatever the system theme (the marketing site) pins the token values itself (`app/(site)/site.module.css`: `.site` light, `.dark` / `.band` / `.aiCard` / `.terminal` dark). -- **The extension's deep module is `apps/extension/lib/pluck.ts`:** `pluckTab` / `pluckUrl` hide page capture (rendered HTML, selection, captions), the default kind, the screenshot cover, the offline queue and the badge. The popup, the right-click menu and the shortcuts all go through it; `lib/api.ts` owns auth refresh and error mapping (`Unreachable` queues, `Refused` shows, `SignedOut` asks to sign in). -- **The connector is `createPluckServer(library, { scopes })` in `packages/mcp`:** one function behind which sit scope gating, card shaping, the ~8k response cap, text paging, cursors, error mapping and the `update_list` orchestration. It sees the library through `PluckLibrary` (a binding seam forced by dependency direction: the package cannot import the app). `apps/web/lib/connector.ts` is the one adapter, binding the library to a person with every write `by: "mcp"`. Tool tests go through that real adapter against the test db. -- **Email has a real seam:** two adapters (SES, SMTP to Mailpit) behind `sendEmail`. -- **Blobs have no seam:** the S3 SDK against MinIO locally and S3 in prod. One implementation. -- **Extraction:** `extract({ url, html? })` returns a result and does no writes. Tested against the fixture server. -- **Errors:** `lib/errors.ts` `ApiError` is thrown by the library and mapped by each transport (REST wrapper now, MCP later). The library never imports the HTTP layer. -- **`pluckAny` is the one way in** for every pluck shape. MCP `save_item`, email-in and the extension call it rather than branching themselves. -- **Moves go through `lib/moves.ts`** (one item PATCH, many items batch, undo by grouping). Keyboard guards live in `lib/keys.ts`. -- **Actor:** library writes take `{ by: "user" | "mcp" }`; MCP passes `"mcp"` so its tags and list notes carry "by AI". -- **`lib/library/`** is split by aggregate (`items`, `tags`, `lists`, `search`) behind `index.ts`; callers import `@/lib/library`. -- **Async UI state:** anything that updates a picker or dialog after a request uses a functional update keyed on what it was opened for, so a closed or reopened one is left alone. -- **When Search (6) or a List page (7) needs triage on its rows, extract `useTriage` from `ItemList`** instead of copying its move and undo logic. -- **Pages and routes go through `lib/library.ts`,** not `@pluck/db` directly. Only `api/health` and the auth adapter touch db outside the library. - -## Deploy notes (now in docs/deploy.md, which is the source of truth) - -- Vercel env: `MONGODB_URI` (Atlas, `pluck` database, `readWrite` on `pluck` only), `BETTER_AUTH_URL=https://getpluck.io`, `BETTER_AUTH_SECRET` (32+ random bytes), `EMAIL_FROM`, `AWS_REGION=eu-west-1`, the app IAM user's keys. No `SMTP_URL` in prod. -- `cdk deploy PluckEmail PluckData PluckInbound`, then add every `Dns*` output to Vercel DNS (DKIM, MAIL FROM, DMARC, and the `in.` MX). Run the `ActivateRuleSet` output once (CloudFormation cannot activate a receipt rule set). Receiving needs an SES receiving region; eu-west-1 is one. Request SES production access (out of the sandbox) before launch. -- Vercel: `EMAIL_IN_DOMAIN=in.getpluck.io`. -- Copy `pluck/app-user` from Secrets Manager into Vercel as `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`. Set `S3_BUCKET` to the `BucketName` output. No `S3_ENDPOINT` in prod. -- Vercel: `DIGEST_SECRET` (random, optional; falls back to `BETTER_AUTH_SECRET`, but a separate one lets you revoke digest links without signing everyone out). -- Vercel: `ADMIN_EMAILS` (your address), `ABUSE_EMAIL` (where reports are emailed). -- Extension: `pnpm --filter @pluck/extension zip` (and `build:firefox` then `wxt zip -b firefox --mv3`) for the stores. Chrome Web Store and Firefox Add-ons listings need the privacy URL (`/privacy`), a 30-second video and the data-use answers (page content and addresses go to the person's own Pluck server; nothing else). Firefox id `extension@getpluck.io`. -- MCP: `BETTER_AUTH_URL` must be the public origin with no trailing slash; it becomes the token audience (`/mcp`) and the issuer. Keep `/.well-known/*` and `/mcp` free of apex-to-www redirects. The JWKS lives in the `jwks` collection; do not delete it or every connector token dies. -- Publish `packages/mcp-stdio` to npm as `@pluck/mcp` (`pnpm --filter @pluck/mcp build && npm publish --access public` from that folder) before the Settings npx line means anything to self-hosters. -- Vercel: `CRON_SECRET` (random), and `apps/web/vercel.json` pins the function region to `dub1` (next to eu-west-1) and runs `/api/worker/tick` every minute, which needs Vercel Pro. Never set `ALLOW_PRIVATE_FETCH` or `AUTH_RATE_LIMIT` in prod. - -## Design gaps - -`docs/design-gaps.md` lists every screen built without its own board, every departure from a board and every placeholder, for a design pass later (Niall's request, 2026-10-24). **Each slice adds its own rows there before it is committed.** - -## Notes per slice - -_Last failing check, decisions made while building, anything the next iteration needs._ - -- **19:** `lib/analytics.ts` (web) inits PostHog with `persistence: memory` and a `before_send` that drops everything but `$exception` until a yes, then switches to `localStorage+cookie` in place (`set_config`). `advanced_disable_flags` so no flags call carries an id before a yes (it also turns off remote config; `capture_exceptions` is set locally). Headless Chromium is a bot to PostHog, so the e2e build has no key (`playwright.config.ts`) and the banner is checked by hand and by `lib/analytics.test.ts`. `lib/log.ts` sends `error`-level lines to `lib/telemetry.ts` (posthog-node, distinct id `server`, no person), `instrumentation.ts` covers render errors. iOS: `persistence` file (crash reports survive a crash), `before_send` the same rule. The Docker build takes the key as a build arg; `.env*` is now in `.dockerignore`. -- **18:** The branch as handed over passed every gate. `ConnectLoopFit` takes a `radius` (16 on Connect). Settings' `aiSide` widened to 340px so the loop's text stays legible; the drawn chat and its CSS are gone. `node scripts/icons.mjs` rewrote nothing (the branch already ran it). The extension's queued dot and badge and iOS `syncDot` use Clay (`#9A5536`, `#D08A68` dark); tag colours in `packages/db/src/tags.ts` keep their orange, which is a tag colour, not a state. -- **17:** One `Dockerfile` (build stage with placeholder env for `next build`, which imports the server modules; runtime as a non-root user, entrypoint `docker/entrypoint.sh` that generates `BETTER_AUTH_SECRET` into the `/data` volume when unset, shared by web and worker). `docker-compose.yml` (project `pluck`): web, worker (`pnpm --filter @pluck/web worker`), mongo 8.2 replica set, MinIO and `minio-init`, Mailpit (so sign-in works before SMTP is set up); MinIO and Mailpit publish on localhost only. `S3_PUBLIC_ENDPOINT` added to `packages/blob`: signed URLs for browsers and phones use the public address, the server keeps `S3_ENDPOINT`. `/connect` renders per request so a self-hosted copy shows its own connector URL. Verified from a clean export of the tree: build, `up`, health, sign-in through the stack's Mailpit in Chrome, a real pluck fetched and extracted by the container with the kept page in MinIO, the generated secret shared by web and worker and surviving a restart (the session too). `scripts/vercel-dns.mjs` turns the CDK `Dns*` outputs into `vercel dns add` commands (prints by default, `--apply` runs them; tested). Docs: `README.md`, `docs/self-host.md`, `docs/deploy.md` (Atlas, CDK, DNS, SES production access, Vercel env and cron, first-deploy checks), `docs/launch-checklist.md`. -- **16:** `apps/ios` (Expo SDK 58, Expo Router, RN 0.88, new architecture only) and a SwiftUI share extension through `@bacons/apple-targets` (`targets/share`: our own `Info.plist` with the SUBQUERY rule, Safari `Preprocess.js`, `PluckClient.swift`, `SVGPath.swift` draws the token glyphs). See `docs/ios.md` for how it fits and the manual checklist. Server additions: `POST /api/v1/items/files` (Image/PDF item now, signed PUT URL, a `file` job reads the blob once it arrives and retries until then; `createFileItem`, `extractFile`), and `format.ts` moved into `shared-schemas` (web and iOS share the row formatting). OAuth native redirect is `io.getpluck.app:/oauth` (the provider rejects `pluck://oauth`: RFC 8252 wants reverse-domain or host+path). `expo-router` 58 forks React Navigation: tab bars take `BottomTabBarProps` from `expo-router/js-tabs` (`navigateToTab`), and `Link asChild` dropped the child's style, so rows use `Pressable` + `router.push`. Simulator: Release builds (`CODE_SIGN_IDENTITY=- CODE_SIGNING_REQUIRED=NO`), signed in by `EXPO_PUBLIC_DEMO_KEY` from `scripts/demo-seed.mjs`, screens opened with `simctl launch … -demoRoute /path` (deep links raise an "Open in Pluck?" alert that `simctl` cannot dismiss; reboot the simulator if one is left over). Fonts: Geist and Source Serif 4 (`@expo-google-fonts/source-serif-4`) embedded by the `expo-font` plugin. `apps/ios/ios/` is generated (gitignored). -- **15:** Marketing site in the `(site)` route group: `/` (Landing), `/connect` (tabs per AI tool, the connector URL from `BETTER_AUTH_URL`), `/open-source`, `/privacy`, `/terms`, with one header and footer (`SitePage`) and one legal layout (`LegalPage`). Launch links live in `lib/site.ts` as placeholders (`[REPO URL]`, `[ORG]`, `[CHROME STORE URL]`, `[FIREFOX ADDONS URL]`, `[APP STORE URL]`, `[LICENCE]`), plus the legal ones in the pages (`[DATE]`, `[PRIVACY EMAIL]`, `[LEGAL ENTITY]`, `[ADDRESS]`, `[HOSTING PROVIDER]`, `[REGION]`, `[ABUSE EMAIL]`, `[RESPONSE DAYS]`); the placeholder test lists them all. **Paste box:** `POST /api/v1/try` (public, 10 a minute per IP). Signed in, it plucks for real (text without links becomes a Note, like the app's paste box). A visitor's links (up to 5 a paste, 20 held) go into `tries` (`packages/db/src/tries.ts`, TTL index, 24 h) against an httpOnly `pluck_try` cookie, and are **never fetched while anonymous**; `/signin` sends the email link to `/try/claim` when that cookie exists, which plucks them into the new account and clears the cookie (redirects use `BETTER_AUTH_URL`, where the session cookie lives). Copy changed from the boards to follow the product words: "Save anything" → "Pluck anything", "saves" → "plucks", "kind=idea" → `kind=note` (there is no idea kind), "marked as saved by AI" → "plucked by AI", "and no delete tool" → a delete tool only if granted separately; Privacy drops the "we count sign-ups and plucks" line (we count nothing), mentions the paste-box cookie, and says delete is immediate (it is). The Connect headline is smaller than the board (the detector flags a 41-character h1 at 132 px); the "Plucked." stamp sits higher so it does not cover the shortcut keys; AI chat bubbles lost their border (nested cards); legal text is 60ch wide. `/` is no longer the placeholder; OAuth errors already go to `/oauth/error`. -- **14:** `apps/extension` (WXT 0.21, React, MV3; Firefox builds MV3 too). **Sign-in runs in an ordinary tab, not `launchWebAuthFlow`:** the magic link opens in its own tab, which a web-auth window cannot see, so the redirect is `/extension/connected` and the background reads `?code` from that tab (`tabs.onUpdated`) and swaps it with the PKCE verifier from `storage.session`. Each server gets one DCR registration (`clientIds` in storage), requesting the new **API resource** `/api/v1` (`resources` + `clientRegistrationAllowedResources` in `lib/auth.ts`); `authenticate()` now accepts JWTs for that audience and checks the consent is still there, so Settings can disconnect the extension (it is listed there as "Pluck for your browser"). The consent screen words an app (resource `/api/v1`) differently from an AI tool. Options also take a `pk_live_` key (self-host). New API: `GET /items/lookup?url=` (badge tick, "already in your library"), `PUT /items/:id/cover` (JPEG/PNG/WebP ≤ 2 MB, only when the item has no cover: the tab screenshot), and `CreateItemBody.selection` (becomes a highlight anchored by its quote) and `.transcript` (appended to the kept text under "## Transcript"). Captions come from `ytInitialPlayerResponse` in the page's main world (skipped when it belongs to another video after in-page navigation). Decisions against the board: "Pluck again as new" is **"Pluck it again"** (re-pluck never duplicates); "To list" defaults to None with the last used list first, marked "(last used)", because preselecting it would file every pluck; Note, Todo and Thing are shown but disabled in the kind row (a link cannot become one); the options copy says "your AI", not one vendor. Bookmark import stays on the web (Settings, Import takes the browser's exported HTML), so no `bookmarks` permission. Fonts are bundled (`geist`), no font host calls. Permissions: `activeTab contextMenus storage scripting alarms`, the server's origin, `` optional (asked for by "Pluck all tabs", the badge on every page, and a custom server). e2e (`e2e/extension.spec.ts`) loads the unpacked `PLUCK_LOCAL=1` build in Chromium: popup pluck with rendered content, tags, kind, note, list, Later, the already-plucked view, menus (via a `pluckTest` hook on the service worker), selection highlight, captions, the offline queue and badge dot, OAuth sign-in through consent and sign-out. Not automated: "Pluck all tabs" (the permission prompt), the screenshot cover, Alt+Shift+P. Blocked only on the one manual load (see BLOCKED.md). -- **13:** Better Auth `mcp()` (wraps `oauthProvider`) + `jwt()` + `cimd()`: discovery at `/.well-known/oauth-protected-resource[/mcp]` and `/.well-known/oauth-authorization-server[/api/auth]`, DCR at `/api/auth/oauth2/register` (unauthenticated, default scopes `offline_access library:read library:write`), CIMD for HTTPS client ids (dev and tests fetch with plain `fetch` under `ALLOW_PRIVATE_FETCH`; prod uses the guarded Node transport). Access tokens: JWT, 1 h, audience `/mcp`; refresh 90 days. `/oauth/consent` verifies the signed query server-side, names the client, lists scopes in plain words, POSTs `{ accept, oauth_query }` to `/api/auth/oauth2/consent`. Sign-in resumes OAuth: `/signin` with `sig` + `client_id` sets the magic-link callback to `/api/auth/oauth2/authorize?`. OAuth errors land on `/oauth/error` (`onAPIError.errorURL`). `/mcp` is stateless `createMcpHandler` (default `legacy: "stateless"`, so 2025-era clients work too; `"reject"` turned real clients away), per-IP 300/min, per-person 600/min, `users.lastMcpCallAt` at most once a minute. **Disconnect** (Settings, `POST /api/v1/ai-tools/disconnect`) deletes the consent and refresh tokens, and `/mcp` checks the consent on every OAuth call, so it bites at once. Account delete also removes `oauthConsent` and `oauthRefreshToken`. Tools are offered by scope (`delete_item` only with `library:delete`); descriptions are one sentence and vendor-neutral (tested). `save_item` tags and lists after `pluckAny`; `update_list` refuses `purpose` (strict schema) and `visibility: public` without `personAskedForPublic: true`. `get_digest` takes `limit` only (the spec's `since` has no meaning for a rule snapshot). Stdio bridge `packages/mcp-stdio` (`@pluck/mcp`, MIT, bin `pluck-mcp`): an SDK client to `/mcp` behind a low-level `Server` on `serveStdio`, so both sides negotiate their own protocol era; e2e runs `node packages/mcp-stdio/src/cli.ts` (Node 22 strips types). Settings "AI tools": connector URL, Claude Code line, npx line with the newest key's prefix, Test it (opens claude.ai with the test question, as on the board), `[CONNECTOR GIF]` placeholder, Connected · last call, connected tools with Disconnect. Docs: `docs/mcp.md`. An e2e failure seen once here (`toContainText`) was found in slice 17: `lists.spec.ts` opened Lists while the last add-to-list request was still in flight, so the server-rendered count was one short. The test now waits for each add. - -- **12:** API keys are ours, not the Better Auth plugin (`packages/db/src/api-keys.ts`: `pk_live_` + 32 hex, SHA-256 hash, 6-char prefix, scopes default `library:read library:write`, `lastUsedAt` written at most once a minute). `authenticate()` takes `Bearer pk_live_…` or the session; `route({ scope })` enforces `library:read` / `library:write` by default, `library:delete` on item delete, and `"full"` (browser session only) on keys, export and account delete. Import: `POST /imports { source, size }` returns a signed PUT URL (Vercel bodies cap at 4.5 MB), then `POST /imports/:id/start`; the `import` job parses (`parseImport` in `packages/extract`: Pocket CSV, Raindrop CSV, Netscape HTML, Omnivore zip with kept pages) and works in batches of 200, re-queueing itself. **Imported items land in the Archive with `archivedReason: import`** (Archive filter Imported), existing items are never touched, tags are made by name, Omnivore pages are read offline and the rest are fetched slowly (staggered extract jobs). Export: `POST /export` (a POST, not the spec's GET: it starts work) runs a job that zips `pluck.json` plus Markdown per list and per item to `u//exports/`, and emails a 7-day signed link; `GET /export` shows the latest. Import start and export run the queue with `after()` so they finish in seconds without waiting for the cron. Account delete: `DELETE /me { confirm: email }` removes every collection's rows, Better Auth sessions and accounts, and `u//` blobs at once. Sign-out (Settings header) and delete clear the service worker caches and `pluck:queue`. CDK: bucket CORS allows PUT from `appOrigin` (cdk.json context). Fixed a real drag-and-drop race on the list page (the dragged id now lives in a ref). -- **11:** Email-in: one active alias per person (`emailAliases`, `pluck+<12 hex>@EMAIL_IN_DOMAIN`), regenerate retires the old one (`GET /email-in`, `POST /email-in/regenerate`, Settings section). SES writes raw mail to the bucket under `inbound/`; `runScheduled` lists that prefix each tick, claims each key once (`claimOnce`) and queues an `email-in` job; `ingestInbound` parses with `parseEmail` (mailparser, HTML through the same Defuddle extraction as pages), drops spam/virus verdicts, unknown or retired aliases, messages over 10 MB and anything past 200 a day per person, then creates a Read item with **no URL** (`source: email`, sender as site, deduped on Message-ID via `urlHash`) and moves the raw message to `u//items//file.eml`. "Text item" checks across the code now use `isTextKind(kind)`, not `url === null`, so link kinds can exist without a URL; highlights work on them. CDK: a third stack, `InboundStack` (receipt rule set `pluck-inbound`, rule for `in.` with scanning and an S3 action to `inbound/`), separate so DataStack and EmailStack do not depend on each other; DataStack expires `inbound/` after 7 days. Fixtures in `packages/extract/fixtures/email/*.eml.tmpl` (TOKEN is replaced in tests). e2e drops mail with `putObject` from `e2e/db.ts`. -- **10:** Digest: `packages/db/src/digest.ts` `digestFor` (expiring within 7 days and not in a list, due this week and not done, five oldest in the Inbox, lists idle 30 days) and `claimDueDigests` (the person's `digestDay`, at or after 06:00 local, once, claimed with `users.digestSentOn`; `digestDay: null` is off). `GET /digest` returns the same data (MCP `get_digest` will call `digest()` in the library). `lib/digest-email.ts` is a pure builder: plain text first, HTML from the Email board, subject "N things you plucked and forgot.", `List-Unsubscribe` and one-click headers (SES gets them through `Content.Simple.Headers`). Signed links (`lib/signed.ts`, HMAC with `DIGEST_SECRET` or `BETTER_AUTH_SECRET`, 7 days): `/d/[token]` shows the action and submits a POST with script so mail scanners that only fetch never act; Archive it all shows its count and waits for a yes; `/d/done` confirms with Undo for Let it go. `POST /digest/send` and Settings "Send me one now"; Settings "Weekly email" day or Off. `runScheduled` now sends due digests after expiry. -- **9:** Expiry: `users.settings` (`expiryDays` 14/30/60/90/null, `expiryScope`, `digestDay`, `timezone`; defaults in `DEFAULT_SETTINGS`) via `PATCH /me { settings }`; the browser's timezone is synced by `TimezoneSync` in the app shell. Items carry `inList` (kept by `syncListNames`), which both the job and the pill use for the list exemption. `packages/db/src/expiry.ts`: `expireItems` (reason `expired`), `archiveStale` (reason `bulk`, with a dry run), `countExpiringSoon`, `runDueExpiry` (once per local day at or after 02:00, claimed atomically with `users.expiryRanOn`), `claimOnce(key)` on a `meta` collection with a 7-day TTL. `lib/schedule.ts` `runScheduled()` is the one entry for time-based work: bin purge once a UTC day, then due expiry; it runs from `/api/worker/tick` and every 60 s in `worker.ts`. **Slice 10 adds the Monday digest here, claimed with `users.digestSentOn`.** `POST /items/archive-stale { olderThanDays?, dryRun }`. UI: "30 days" pill (click keeps it, which is a touch through `/open`), Later's "N expire this week" subtitle, Settings "Expiry and archive" with "Archive it all. No, really." (dry-run count, then confirm), Archive's "expired after N days, kept here". e2e ages items through `e2e/db.ts` (`ageItemsOf` in `@pluck/db/testing`). -- **8:** Public lists: `packages/db/src/public.ts` is the only public read (`publicList`, `publicListById`, `publicCoverKey`, `recordView`, `createReport`); `PUBLIC_ITEM_FIELDS` and the output keys are pinned by `public.test.ts`, which also plants page text, tag names, a note and a tick and checks none of them leak. The page is `/p/[handle]/[slug]` behind a rewrite from `/@handle/slug` (a folder cannot start with "@"); it renders dynamically so Private is a 404 at once, counts views on the server except for the owner, sets `noindex` for lists or accounts under 7 days old, and has an OG image (`opengraph-image.tsx`; plain font, Fredoka would need the font file). `GET /api/v1/p/:handle/:slug` is the same data, cached 60 s, not counted. Handles: 3 to 24, reserved words refused, unique, changeable once a month (`PATCH /me`). Visibility is `PATCH /lists/:id { visibility }`, 409 `handle_required` asks for a handle inline; the slug freezes at first publish (`publishedAt`). "Pluck this list" goes to `/copy/:listId` (sign in first via `/signin?next=`, then one POST, 20 a day); copied links are re-plucks with `source: copied`, note items come over as notes with title and excerpt. Reports: `POST /reports` (5 a minute per IP, IP stored as a salted hash), emailed to `ABUSE_EMAIL`. `/admin` (404 unless the email is in `ADMIN_EMAILS`) lists open reports with Take down list (sets `killed`, private) and Close their public pages (`publicKilled`). `publicRoute()` in `lib/api.ts` is the wrapper for unauthenticated routes. Root `metadataBase` comes from `BETTER_AUTH_URL`. -- **7:** Lists: `lists` and `listItems` (accessor `listRows()` in db; `listItems()` is the Inbox query). Routes: `GET|POST /lists`, `GET|PATCH|DELETE /lists/:id`, `POST /lists/:id/items { itemId | itemIds, position?, note? }`, `PATCH|DELETE /lists/:id/items/:itemId`, `POST /lists/:id/reorder { itemIds }` (must be the whole list), `GET /lists/:id/export` (JSON with `markdown`; `?format=md` is a file), `GET /items/:id?include=lists`, `GET /counts` (the rail). Positions are floats 1024 apart, a move is one write at the midpoint, the list renumbers when neighbours get within 1e-6. Counts cover live items only (bin excluded) and recount on bin, restore and purge; purge also removes list rows. `listNames` sync for search; search has `listId` and a List chip. Slugs follow the name for now (**slice 8: freeze the slug once a list has been public**). `inAnyList(userId)` in `packages/db/src/lists.ts` is the expiry exemption filter for **slice 9** (export it there). UI: generic `Picker` behind `TagPicker` and `ListPicker`; `a` in Inbox and Later (row, selection, item page); Lists index with inline New list; list page with rename, purpose, Copy for your AI, export, ticks (Space), notes (n), Shift+J/K and drag, Take out with undo, Delete with confirm. Rail shows Inbox, Later and Lists counts, refreshed on navigation and on a `pluck:counts` window event. Two real bugs fixed: a picker reopened by a late request (Esc then type sent keys into it), and a late toggle marking a list "on" in the next item's picker; rows now take the cursor on mouse move, not enter. -- **6:** Search: one `$text` index on `items` named `search` (title 10, note 5, tagNames 5, listNames 3, searchText 2, searchBody 1, english stemming, `language_override: searchLanguage` so `lang` never switches it). **Deviation from the spec:** the kept text is copied onto the item as `searchBody` (capped at 32 KB) instead of a second `$text` index on `contents`, because two collections cannot be ranked in one query. List and item reads use the `LEAN` projection so those copies never travel. `searchText` is excerpt, site, host and author. Tag names sync inside `packages/db` on every tag write. `GET /search` takes q, kinds, tagIds, status (omitted means everything but the bin), since, until, limit and an opaque cursor (an offset inside, since text ranking cannot use keysets); it returns `{ results: [{ item, snippet }], total, nextCursor }`, pins an exact URL first, and prefers a snippet from the note, then the kept text, then the excerpt. Budget test: 5,000 items, median under 300 ms (`packages/db/src/search.test.ts`). **Slice 7 must keep `listNames` in step and add the List filter chip and `listId` to search.** Items plucked before this slice have no `searchBody`; there is no backfill because nothing real exists yet. -- **5:** Tags: `GET|POST /tags` (POST is idempotent on the slug), `PATCH /tags/:id` (rename; a clash is 409 "merge instead"; renaming makes it the person's), `DELETE /tags/:id?reassignTo=` (merge) or `?removeFromItems=true`, `POST /tags/purge-ai { days }`. Item tags: `PATCH /items/:id { tagIds }` (ids or existing names) and batch `addTagIds` / `removeTagIds`; unknown refs are 422 `unknown_tags` with `{ unknown, nearest }`, never created. Who tagged: `items.aiTags [{ tagId, at }]` (API `aiTagIds`), set when the library is called with `{ by: "mcp" }`, cleared when the person sets the item's tags. Counts are recomputed per touched tag. `GET /items?tagId=` filters. UI: `t` picker (rows, selection, item page), chips on rows (click filters), Settings page with the Tags table. **Slice 6 (search) must update `searchText` on tag rename and delete.** -- **4:** Status moves: `PATCH /items/:id { status }` and `POST /items/batch { itemIds, patch }` (Done only lands on todos and things; kind changes only where they fit). Items gained `statusChangedAt` (Archive sort and "when") and `deletedAt` in the API. Views on `GET /items?status=`: a status, `archive` (archived and done, filters all/you/expired/import) or `bin`. Delete is soft (`DELETE /items/:id`, `POST /items/:id/restore`); `purgeBinNow()` in the library hard-deletes after 30 days with blobs, **wire it into the nightly job in slice 9**, and remove list rows there once lists exist (slice 7). Opening is a touch via `POST /items/:id/open` from the reader (not on render, so prefetch does not touch); reading progress is saved but is not a touch. Highlights: `POST /highlights`, `PATCH|DELETE /highlights/:id`, anchored by text offsets with the quote as fallback (`lib/highlight-dom.ts`). Reader markdown is markdown-it with raw HTML off. `ItemList` (Inbox and Later) holds rows, keyboard (j k o l e d x c / ? Esc), bulk bar, undo toast; `ItemBar` is the item page bar (Esc, l, e, d). The cover shows in the reader only when the kept text has no image. PWA: `app/manifest.ts`, `public/sw.js` (static cache-first, app pages network-first, never API), `/offline`, offline bar, and the paste box queues plucks in localStorage while offline. **Sign-out (slice 12) must clear the service worker caches and `pluck:queue`.** Icons are PNGs rendered from the brand SVG, full-bleed on forest. Global `text-wrap: balance` on headings and `pretty` on paragraphs. -- **3:** `POST /items` now always answers `{ results: [{ item, created }], rejected: [{ input, message }] }` (400 when nothing was plucked). One link reads inline; many links land at once and are read after the response, six at a time, through the same `extractAttempt`. Text with no link is a Note; `kind` note/todo/thing keeps it that kind even with links in it. `PATCH /items/:id` takes `title`, `text`, `kind`, `dueAt` for now (status, tags and the rest join in slices 4 and 5); link items take only link kinds and no text. `extractUrls` splits on `,http` but keeps Wayback's `/https://`; 21 paste fixtures in `packages/shared-schemas/fixtures/paste.json`. Title and excerpt rules for text items are pure functions in `shared-schemas/notes.ts` (iOS can reuse them). The editor autosaves with a 600 ms debounce and a keepalive PATCH on leave; Esc flushes and goes to the Inbox. The board shim does not render `` or `