Back to shop

Changelog

LLM-optimised

Written for Claude sessions, not humans — dense paths, schema changes and gotchas. The readable one is at /changelog.

# MightBuy changelog — LLM-optimised

Read newest entry first at session start. Each entry ends with a scope marker.

## 0.0.32
- **BRAND CASING: "Mightbuy" → "MightBuy"** (Chris's call — matches the
  Supabase sender name set in 0.0.31). Case-aware sweep over user-facing
  strings only. CHANGED: page/tab titles, legal-page prose (terms/privacy/
  how-we-make-money), toast messages, extension popup UI, meta descriptions,
  the lowercase logo wordmark spans (sidebar/share-header/extension/footer —
  Chris chose "MightBuy everywhere", not a lowercase-logo look), both
  changelog `#` headings
- **DELIBERATELY UNCHANGED (do NOT rename these — lowercase `mightbuy` is
  load-bearing):** `useMightbuyStore`/`openMightbuy` (identifiers),
  `@mightbuy/*` (package names), storage keys (`mightbuy:v2`, `mightbuy:token`,
  `mightbuy:lists`, `mightbuy:prefs:v1`, etc.), URLs (`mightbuy.xyz`,
  `mightbuy.vercel.app`), `MightbuyBot/0.1` User-Agent (external services may
  key off the exact string), internal code comments (left as-is to keep the
  diff to user-facing text). Sweep protected these tokens explicitly
- verify: pnpm typecheck 3/3 + web lint + extension build clean; live-checked
  footer ("MightBuy v0.0.32") + /terms prose + /changelog (200) render the new
  casing; identifiers/keys/UA all confirmed still lowercase
- no spec/behaviour change — cosmetic only. Docs (CLAUDE.md, concept.md) left
  on "Mightbuy" as dev-facing internal text (low value to churn; not shown to
  users). Historical changelog ENTRIES left verbatim (record, like maybuy)
- pending: flip Supabase Site URL to mightbuy.xyz; Chris: §3 Google, legal
  reds, capture sweep, icon PNGs, rotate Resend key
scope: tree f2bba146eb83a1ee662d411cf96e94c511a00088 (from `git add -A && git write-tree`; last commit 2b82827)

## 0.0.31
- **SMTP LIVE**: Resend wired into Supabase custom SMTP (host smtp.resend.com,
  465, user `resend`, From `noreply@send.mightbuy.xyz`, sender name
  "MightBuy"). Real magic link **delivered** to chris@ — **landed in SPAM**
  (expected: fresh .xyz; "not spam" trains it). DMARC still p=none → tighten
  to quarantine after ~2wk. Escape hatch if it bites: Postmark $15/mo. Resend
  API key = sending-access, scoped to send.mightbuy.xyz (stored in Supabase +
  Chris's password mgr; NOT in repo). NOTE Chris's key was briefly on-screen
  in a screenshot — low risk (send-only, domain-scoped) but rotate when tidy
- **URL REPOINT vercel.app → mightbuy.xyz** (kept vercel.app as fallback):
  extension config.ts WEB_APP_URL default; wxt.config host_permissions (+xyz,
  +www implicit via /*); lib/cors.ts isOwnWebOrigin (+mightbuy.xyz +www);
  Supabase auth uri_allow_list (+https://mightbuy.xyz/** +www, via Mgmt API).
  Supabase **Site URL still mightbuy.vercel.app** — flip to xyz once confirmed
  serving live for Chris (deferred, low risk — app answers on both)
- feat: /login sent-state gained a "check spam / mark not spam" hint
  (login/page.tsx) — the .xyz deliverability precaution
- **EXTENSION CLICK-TEST PASSED (Chris, real Chrome → prod)**: reloaded
  unpacked build, pasted personal token, saved Argos De'Longhi kettle →
  landed in shop, DB-confirmed (£69.00 GBP, Argos, source json-ld). Silent
  add works on mightbuy.xyz. Launch item 1.3 DONE. Also confirms the
  token-gate UX (website session ≠ extension session — separate contexts, by
  design; token bridges them)
- launch.md: 1.1 SMTP + 1.3 extension test ticked DONE; critical path now =
  legal reds (top blocker) + capture sweep; session-wrap SKILL Step 4¾ used
- **PENDING codeside**: MightBuy capitalisation sweep — Supabase sender name
  is "MightBuy" but codebase is uniformly "Mightbuy"; Chris wants MightBuy →
  needs a rename sweep (like maybuy→mightbuy was). Flip Supabase Site URL to
  xyz once serving. Chris: §3 Google, legal reds, capture sweep, icon PNGs,
  2nd-email stranger test, rotate Resend key
- verify: pnpm typecheck (3/3) + web lint + extension build clean; built
  bundle confirmed targeting mightbuy.xyz; spec stamps moved on popup.md,
  api-items.md, login.md (all 0.0.31)
scope: tree 1669ac472fdeb751706fbdae9d867aee40842076 (from `git add -A && git write-tree`; last commit e971591)

## 0.0.30
- **INFRA ONLY — no app code changed** (4 files: infra.md, launch.md,
  session-wrap SKILL, this). No spec audit (no surface touched). Domain +
  email plumbing, all on Vercel DNS via CLI.
- **domain `mightbuy.xyz` bought via Vercel** (registrar, Hobby team, $1.99
  yr1/$13 renewal). Apex attached to `mightbuy` project + apex A `64.29.17.1`
  + ALIAS. **`www.mightbuy.xyz` → apex 308 redirect** (set via Vercel API
  PATCH `redirect`/`redirectStatusCode`); canonical = naked apex.
  mightbuy.vercel.app still serves. ICANN registrant-verify may be pending
  (Chris). Account-structure decision: all stays personal, transferable
  later (infra.md)
- **RECEIVING email = ImprovMX free**: catch-all `*@mightbuy.xyz` →
  chris@chrisdykes.co.uk, **Active**. DNS in Vercel: MX mx1/mx2.improvmx.com
  (10/20) + SPF TXT `v=spf1 include:spf.improvmx.com ~all`. Free = receive
  only; reply-as hello@ is paid (deferred → Forward Email $3/mo or
  Purelymail $10/yr at public launch)
- **SENDING email = Resend, IN PROGRESS**: sending subdomain
  `send.mightbuy.xyz` (region eu-west-1 Ireland) — chosen over apex to
  isolate app-mail reputation. DNS in Vercel (names expanded to full
  subdomain since Vercel owns the whole zone): DKIM TXT
  `resend._domainkey.send` (p=MIG…SrsZfLwIDAQAB), SPF MX `send.send` →
  feedback-smtp.eu-west-1.amazonses.com (10), SPF TXT `send.send`
  `v=spf1 include:amazonses.com ~all`, DMARC TXT `_dmarc.send`
  `v=DMARC1; p=none;`. All verified live at ns1.vercel-dns.com + public
  8.8.8.8. Resend domain = **Pending/Checking DNS** (will verify)
- **REMAINING to finish SMTP (next session)**: Resend verifies → grab its
  SMTP creds → Supabase Auth → SMTP settings (custom SMTP on, From
  `noreply@send.mightbuy.xyz`) → send real magic link → confirm inbox not
  spam. THEN code (CC): repoint extension WEB_APP_URL + app URLs
  vercel.app→mightbuy.xyz; add `https://mightbuy.xyz/**` to Supabase redirect
  allowlist + switch Site URL once domain serves; "check spam" hint on /login
  (.xyz deliverability). Escape hatch if magic links spam-fold: Postmark $15/mo
- process: session-wrap SKILL gained **Step 4¾ — update launch.md** (tick
  off completed launch items every wrap). launch.md updated: domain (1.5) +
  receiving email DONE; SMTP (1.1) marked in-progress
- **.xyz caveat live**: fresh + .xyz = mediocre deliverability; magic links
  are the forgiving case + full SPF/DKIM/DMARC offsets; tell beta users to
  check spam. Research: agents this session, sources in-thread
- verify: no typecheck needed (no code); all DNS records dig-confirmed at
  source + public resolver
- pending: SMTP finish (above); Chris: §3 Google, legal reds, capture sweep,
  extension click-test, icon PNGs
scope: tree 3e6bf828f94aaa1b0675562325baca9d26313cbf (from `git add -A && git write-tree`; last commit 1a3bcfd)

## 0.0.29
- **feat: owner admin dashboard /admin** (src/app/admin/). Server component,
  force-dynamic + robots:noindex. GATE: lib/admin.ts isAdmin() → notFound()
  for non-admins (signed-out OR signed-in-non-admin both get plain 404 — route
  doesn't leak existence). Allowlist = ADMIN_USER_IDS env (server-only,
  comma-sep; Chris's id in all 3 Vercel envs + .env.local). Empty = FAIL
  CLOSED (nobody admin)
- data: lib/admin-data.ts (service client, `import "server-only"`) —
  getStats (users=profiles count, items±active, lists±shared, shares,
  tokens), getCaptureHealth (items by source → adapter hint),
  getActivity(30d, saved_at + auth.users.created_at day-bucketed),
  getTopStores (site_name tally), getRecentUsers (auth.admin.listUsers +
  item counts). AGGREGATE-ONLY by design — no shop contents shown (privacy)
- actions: src/app/admin/actions.ts Server Actions, each RE-CHECKS isAdmin()
  (never trusts caller): revokeUserTokens (delete api_tokens for user),
  deleteUser (auth.admin.deleteUser → FK cascade wipes everything = also the
  GDPR delete-my-account lever). revalidatePath("/admin"). Confirm dialog in
  user-row.tsx (client)
- UI: page.tsx (stat grid, 14-day bars, capture-health bars, top-store
  chips, signups table), user-row.tsx (client — dropdown + AlertDialog +
  useTransition + sonner toast)
- E2E VERIFIED: signed-out /admin → 404 (gate); signed-in-as-Chris → renders,
  stats matched direct SQL (1 user/10 items/7 active/2 lists/0 tokens/9
  json-ld+1 dom); revoke-tokens clicked through real Server Action → "Revoked
  1 token" + DB confirmed 0. deleteUser wired+gated, NOT run on sole live acct
- docs: NEW specs/web/admin.md; 00-overview + infra (ADMIN_USER_IDS row) +
  launch.md (GDPR lever now built) updated
- verify: pnpm typecheck + lint + build clean (/admin = ƒ dynamic)
- pending unchanged: SMTP (beta gate), ext click-test, capture sweep, legal
  reds, domain; admin per-user-shop view deferred
scope: tree d267048ab6d30bcbb638d4f375f112a5bbcc7145 (from `git add -A && git write-tree`; last commit 39ce61e)

## 0.0.28
- **feat: extension SILENT ADD** — popup (apps/extension/entrypoints/popup/
  App.tsx, full rewrite) saves DIRECT via POST /api/items with a personal
  token; no tab. States: loading → (no token → paste-a-token sign-in) →
  ready → saving → saved ("✓ Saved / Undo / Save another / Open my shop") /
  error. Undo = DELETE /api/items/:id. 401 → token cleared → sign-in.
  NEW config.ts: WEB_APP_URL = import.meta.env.MIGHTBUY_WEB_URL ?? prod
  (mightbuy.vercel.app) — **defaults to PROD**; keys mightbuy:token +
  mightbuy:lists
- **token flow = paste-a-token** (Chris's choice): create on /account API
  card → paste into popup once → browser.storage.local. No OAuth in the
  extension
- **DELETED scaffolding** (App.tsx): openMightbuy focus-or-create, AfterSave
  stay/open pref + toggle + mightbuy:prefs key, ?add= URL-param builder.
  KEPT (graduated): My Mightbuy link (now tabs.create), list picker +
  mightbuy:lists. Web app's ?add= consumer UNTOUCHED (mobile/paste-URL still
  use it)
- **CORS** (NEW lib/cors.ts): /api/items + /api/items/:id handle OPTIONS
  (preflight) + reflect Access-Control-Allow-Origin for chrome-extension://
  / moz-extension:// / our-own-web-origins (prod + dev-preview host +
  http://localhost). Handlers wrapped by withCors
- **CSRF guard** (isTrustedMutationOrigin, added after audit flagged it):
  these routes ALSO accept a cookie session (api-auth.ts), so a cross-site
  cookie-borne POST/DELETE could write regardless of CORS. Each mutation now
  403s unless caller is bearer-token (no ambient cred → any origin ok) OR
  cookie-with-own-origin/no-origin. Tightened allowlist off blanket
  *.vercel.app (was: any Vercel deploy) → explicit hosts. Verified: bearer
  X-origin 201; cookie-style hostile + evil.vercel.app → 403; same-origin →
  auth. undoSave now clears token on 401 too (was swallowed)
- manifest (wxt.config.ts): + host_permissions [mightbuy.vercel.app/*,
  localhost:3000/*] for the cross-origin fetch; permissions unchanged
  (tabs, storage). content.ts / extraction UNCHANGED
- E2E: full API lifecycle curl-verified against dev server simulating the
  extension (Origin: chrome-extension://…): preflight 204+headers, POST 201
  cross-origin (+ auto-created list, + duplicateOf path), DELETE 204 (undo,
  item gone + list cascade), bad token 401, evil.example.com → NO cors. Test
  token + list cleaned off Chris's account. Popup bundle mounts+renders (no
  console errors); popup-in-real-Chrome→prod is Chris's manual test
- specs: popup.md rewritten (silent add), silent-add.md marked BUILT,
  api-items.md CORS section + stamp, data-model.md + mightbuy:token row,
  NOW.md (blockers resolved: keep-browsing, WEB_APP_URL-localhost)
- verify: pnpm -r typecheck (3/3) + web lint + extension build all clean
- pending: extension real list-sync (still local names); popup-in-Chrome
  manual test owed by Chris; NOTE CORS reaches PROD only after this ships to
  main (dev has it now); Chris: §3 Google, §4 SMTP, icon PNGs, CWS runbook
scope: tree bb04a86e1ade4c0c63089a532c259c4cd261abc9 (from `git add -A && git write-tree`; last commit 38665f8)

## 0.0.27
- **feat: real cross-person sharing** — /s/[slug] is now a SERVER component
  (force-dynamic), resolving slugs against Supabase for ANY visitor (was
  localStorage → owner's browser only). NEW `resolveSharedListDb(client,
  slug)` in lib/shop-db.ts, called with the **service client** (public
  visitors have no session → RLS bypassed; the QUERY is the security guard).
  page.tsx wraps it in try/catch → null → NotAvailable (no stack-trace leak);
  generateMetadata resolves too (tab title/description)
- **security boundary** (the whole point): reads ONLY owner-opted-in rows
  (lists.shared=true / profiles.my_shop_shared=true) + status='active' only
  (bought/archived never surface). Named list → active item_lists members;
  My shop → owner's active items with NO membership. Item query also pins
  user_id to the list owner (defence in depth); slug trimmed. NEVER widen
  these filters. Un-share nulls the slug → old link dies
- **hardening from adversarial audit (1 subagent, PASS on all leak vectors)**:
  (a) My-shop "no membership" now fetches active items THEN memberships in
  id-chunks of those items — was fetching ALL owner memberships and diffing,
  which PostgREST's 1000-row default cap could truncate → a listed item
  leaking into My shop at scale; (b) slug .trim() before lookup
- refactor: PublicItemCard + NotAvailable extracted page.tsx →
  src/components/public-list.tsx (pure, server-rendered). Old localStorage
  resolveSharedList + SharedListView REMOVED from store.ts (dead after swap);
  shareStateFor stays (Share dialog reads it from the snapshot)
- E2E VERIFIED (real UI + cookieless curl): share toggle mints slug →
  cookieless GET renders list (6 items) → toggle off → same link →
  not-available. Boundary tests: unshared list w/ known slug → not-available;
  bought/archived filtered; named-list + My-shop paths both correct. All test
  share state cleaned off Chris's real account after
- specs: share.md rewritten (server-rendered, security boundary, verified
  stamp 0.0.27); data-model.md resolver note updated
- verify: pnpm -r typecheck + web lint + web build clean (/s/[slug] = ƒ dynamic)
- pending: sharing caching (force-dynamic re-queries every hit — deferred);
  owner attribution heading (no display-name column yet); extension silent
  add (#3 now top build); Chris: §3 Google, §4 SMTP, icon PNGs
scope: tree b1623ff244fd3e2fc487f98e78d8295eb71bbdf6 (from `git add -A && git write-tree`; last commit 21c2f60)

## 0.0.26
- docs-only wrap (dashboard work session; no code). 0.0.25 was SHIPPED to
  prod earlier today (main ff 73f7b3a→d1fd5a1; verified: footer v0.0.25,
  unauth/SSRF extract probes → 401)
- **auth-setup §1 DONE (API-verified)**: gotcha — Chris's  "§1
  done" was HALF-saved: allowlist yes, Site URL still localhost (unsaved
  form). Fixed + verified via Management API GET /v1/projects/{ref}/config/
  auth (site_url now https://mightbuy.vercel.app). Verify recipe: CLI sbp_
  token in macOS keychain "Supabase CLI" (go-keyring-base64)
- **auth-setup §2 DONE**: Magic Link + Confirm signup templates → token_hash
  style (`{{ .RedirectTo }}?token_hash={{ .TokenHash }}&type=email`) —
  scanner-proof + cross-device; /auth/confirm already handled both shapes
- **LIVE MAGIC-LINK TEST PASSED (Chris, on mightbuy.vercel.app)** + the
  0.0.25 one-shot migration fired on his real account: prod DB now 1 user
  (chris@chrisdykes.co.uk), 10 items, 2 lists, 1 membership, 4 collections,
  1 profile — SQL-verified
- docs: auth-setup.md §1/§2 marked done; specs/web/auth.md allowlist line
  updated (stamp not moved — code untouched); NOW.md refreshed (prod =
  backend era live; auth next-up now just §3 Google + §4 SMTP + icon PNGs)
- verify: docs-only — no subagent audit (nothing code-facing changed);
  pnpm -r typecheck clean after version bumps
- pending unchanged: sharing server route (#2), extension silent add (#3),
  capture sweep, mobile rungs, legal reds; Chris: §3 Google, §4 SMTP, icons
scope: tree 57a91a26a0be52d743b75b1eda858bfb10d1f8ad (from `git add -A && git write-tree`; last commit d1fd5a1)

## 0.0.25
- **BACKEND ERA: shop data now in Supabase Postgres** (was localStorage).
  Schema (migrations core_shop_schema, api_tokens_and_rate_limits,
  lock_down_trigger_function): profiles (my_shop share state; auto-row
  trigger on auth.users), items (SavedItem snake_case, client-uuid PK),
  lists (id+name unique per user, shared/slug), **item_lists join**
  (single-list is app-enforced replace-on-change), collections registry,
  api_tokens (sha256 hash only), rate_limits + check_rate_limit(key,max,
  window_seconds) SECURITY DEFINER fn (service_role-only execute). All RLS
  `(select auth.uid())`. Full map: specs/data-model.md
- **store.ts swapped**: same interface + NEW `ready` flag (gate empty
  states on it — shop.tsx grid + account export/import do). Hydrates per
  sign-in via lib/shop-db.ts fetchShopState; optimistic mutations persist
  through a serial queue (order guaranteed, toast on failure, no rollback).
  GOTCHA: fresh-minted Supabase tokens can bounce "JWT issued at future"
  (sub-second iat rounding) → hydrate retries 1s/2.5s before erroring
- **one-shot migration**: cloud shop empty + mightbuy:v2/maybuy:v2 has data
  → replaceAllRows import (share slugs preserved), raw JSON stashed at
  mightbuy:v2:pre-cloud-backup, old keys removed. Cloud data wins, no merge.
  E2E-tested in-browser (admin generate_link token_hash → /auth/confirm)
- **NEW POST /api/items + DELETE /api/items/:id** (specs/web/api-items.md):
  cookie session OR personal token via lib/api-auth.ts authenticate();
  60/min rate limit; field allowlist + caps; id = idempotency key (upsert =
  replace); duplicateOf on same-url active item (warn-not-block); writes via
  shop-db saveItemRow on the service client — SAME code path as web store
- **NEW personal tokens** (specs/web/api-tokens.md): mb_+24B base64url,
  sha256-hex stored, prefix shown; /api/tokens GET/POST/DELETE cookie-ONLY
  (token can't mint token — 401 tested); account page "API access" card
  (create/copy-once/list/delete); delete-account revokes tokens first
- **api/extract hardened** (specs/web/api-extract.md): auth required
  (session or token), 20/min rate limit (Postgres fixed-window, fails open),
  SSRF guard lib/net-guard.ts (http(s)+80/443 only, host denylist, DNS
  all-address BlockList check re-applied per redirect hop, max 5, 8s shared
  budget, streamed 2MB cap, extractFromHtml gets post-redirect finalUrl).
  GOTCHA (found in E2E): never put ::ffff:0:0/96 in a net.BlockList — it
  matches EVERY plain IPv4 check; v4-mapped literals handled via embeddedV4
  extraction. Residual: DNS rebinding TOCTOU (accepted, documented).
  ⚠️ PROD STILL RUNS UNGUARDED 0.0.23 EXTRACT until next ship-to-main
- infra: SUPABASE_SECRET_KEY wired (.env.local + all 3 Vercel envs) — see
  infra.md for the reveal-via-Management-API recipe; lib/supabase/service.ts
  service client (per-request, bypasses RLS)
- specs: NEW api-items.md, api-tokens.md; api-extract.md + data-model.md
  rewritten; account/shop/00-overview/silent-add/infra updated. Independent
  audit (1 subagent) caught 5 drifts, all fixed — incl. import drops
  exported myShop share state (documented asymmetry, account.md)
- verify: pnpm -r typecheck + web lint + web build clean; curl battery
  (401/400 SSRF cases/429+Retry-After/200+redirects, items 201/duplicateOf/
  idempotent/204/404); browser E2E (migration, UI add → DB row, tokens
  card). Test user + rows deleted after. NOTE: agent-browser default 720px
  viewport puts the add-dialog save button off-screen — clicks land on the
  backdrop and close the dialog (Base UI outside-press); set viewport ≥900
- pending: sharing /s/[slug] still viewer-local (needs server route — now
  unblocked); extension silent add popup work (backend deps DONE); prefs
  still localStorage; no realtime/cross-tab sync (API saves appear on
  reload); Chris: live magic-link test, auth §2-4, icon PNGs, sweep
scope: tree 18bae60f2c0dacbd2bc7bb633b01f19cbd8eb899 (from `git add -A && git write-tree`; last commit 9f03ce5)
- **SHIPPED TO PROD** (first ship-to-main run, between wraps): main
  fast-forwarded 175f50b→73f7b3a (0.0.19→0.0.23 public: real auth, rename,
  logo, sidebar). Verified: mightbuy.vercel.app 200, footer v0.0.23. Chris
  did auth-setup §1 (URL allowlist) beforehand — claimed done, not
  API-verifiable; live magic-link test still owed by Chris. Shipped-past
  (accepted): /api/extract unguarded, legal red markers public
- docs: NEW **documents/mobile-capture.md** — researched build plan for
  phone capture (3 subagents, sources verified 2026-07). Truth table:
  Android installed-PWA share_target ✅ (URL arrives in `text` not `url` —
  regex it out); iOS PWA share sheet ❌ (WebKit neutral since 2019) → iOS
  Shortcut CAN do authenticated POST + native notification without app
  switch (= silent-add UX, no App Store); Capacitor wrapper deferred (Swift
  share-extension glue + App Store 4.2 thin-wrapper risk); Safari extension
  = viable port, Safari-only capture. Competitors: share sheet is the
  universal pattern (Pinterest/Moonsift/Locker); Karma's in-app browser =
  cashback-driven, not our model; nobody ships clipboard-watch
- **decisions (Chris , recorded in mobile-capture.md):** phased
  confirm-first (share → /share pre-filled dialog; silent save only when
  POST /api/items + personal tokens exist — 0.0.14 lesson: silent requires
  REAL save); exactly ONE setting "After saving: keep browsing (default) |
  open my Mightbuy" — server-side pref honoured by extension + Android
  share + Shortcut; extension's local afterSave migrates into it; no other
  dials. Key unlock for everything: items API + tokens (rides Supabase
  build, shared with silent-add)
- docs: architecture.md ladder → points at mobile-capture.md; NOW.md parked
  mobile row updated
- verify: docs-only wrap (no code since 0.0.23) — subagent audit skipped
  for that reason; pnpm -r typecheck clean
- pending unchanged: /api/extract hardening (now live-public, still #0);
  Supabase schema build (#2, now also gates mobile rungs 2-3); sweep rows;
  Chris: live magic-link test, auth §3 Google, Supabase display-name rename,
  icon PNG exports (extension toolbar + future PWA — one export session)
scope: tree c02962899bd85d08064aedd0fd0f9adb5c8238fa (from `git add -A && git write-tree`; last commit 73f7b3a)

## 0.0.23
- ux: **sidebar de-iconed** (apps/web/src/components/app-sidebar.tsx) —
  removed repeated-per-row lucide icons: FacetGroup `icon` prop deleted
  (Categories/Brands/Stores rows text-only), top nav rows lost
  Layers/User/LayoutGrid/BadgeCheck/Archive/CircleDashed, ListRow lost Gift.
  KEPT (meaningful/action): SharedDot (Globe) shared marker, ⋯ menu
  (MoreHorizontal) + Pencil/Trash2, Plus on New list. 10 now-unused imports
  removed. No behaviour change (filters/scopes/counts identical)
- fix: **changelog localhost links** — CHANGELOG.md had 17 hardcoded
  http://localhost:3000 links (clickable-in-dev artifacts) that rendered
  live on /changelog pointing at localhost. (a) rewrote all to relative
  paths in CHANGELOG.md; (b) NEW `stripDevOrigin(href)` in lib/changelog.ts
  (exported) applied in changelog/page.tsx markdown `a` component — rewrites
  any localhost/127.0.0.1 URL (any port) to its path at render; real https/
  mailto/anchors/relative pass through. Regression-proof. LLM page renders
  in <pre> (plain text), needs no guard. Footer was already relative Links —
  never the bug
- process: **branch/deploy model formalised** — session-wrap now pushes
  `dev` (→ login-protected preview mightbuy-git-dev-…vercel.app); NEW
  `.claude/skills/ship-to-main/SKILL.md` = the gated go-live (ff-merge
  dev→main, verifies auth §1 URL-allowlist gate first, confirms diff, checks
  live). CLAUDE.md process rules + structure updated; session-wrap SKILL
  Step 5 renamed "Commit, push to dev, and report"
- verify: pnpm -r typecheck + web lint clean; rendered /changelog has 0
  localhost links (curl-checked). Independent subagent audit of shop.md
  (sidebar) + changelog.md specs
- pending unchanged: /api/extract hardening; extension WEB_APP_URL localhost
  (build-time config todo); Supabase schema; auth §1 before prod login;
  toolbar-icon PNGs
scope: tree e49385188397ecc034f99ee9881f9dc0910f938e (from `git add -A && git write-tree`; last commit 1eef9dc)

## 0.0.22
- feat: **brand mark** — new M-monogram SVG. Source of truth
  apps/web/src/components/logo.tsx `<Logo>` (currentColor tile +
  var(--logo-fg,#fff) mark; role/title only when `title` prop passed, else
  aria-hidden). Mirrored: apps/web/src/app/icon.svg (Next 16 app-dir favicon,
  concrete #111/#fff) + apps/extension BrandLogo (inline, identical path).
  Replaced ShoppingBag brand usage in app-sidebar/login/s-[slug] header +
  🛍 emoji in extension popup. ShoppingBag KEPT where decorative (shop empty
  state, share not-available state, edit-sheet buy button). Old favicon.ico
  DELETED (it was served ahead of icon.svg); dead public/wxt.svg removed.
  .brand-mark CSS added (extension)
- fix: **extension popup crash** — `Cannot read properties of undefined
  (reading 'source')` (App.tsx). Root cause: popup sends `mightbuy:extract`;
  a tab with a pre-rename content script (listening for `maybuy:extract`) or
  any unreachable page returns undefined → `state.draft.source` threw. Guard:
  `if (!draft || !draft.source)` → friendly "Can't read this page" error.
  Covers rename transition + general robustness. Extension rebuilt
- raster gap (CANNOT auto-fix — needs manual PNG export of the monogram):
  apps/extension/public/icon/{16,32,48,48,96,128}.png still scaffold art →
  before CWS submission (chrome-web-store.md updated). Web favicon now SVG-only
- audit: **5 parallel subagents** (Chris explicitly requested — cost note
  waived this once): (1) leftover-text = ZERO misses, only intentional
  migration fallbacks; (2) logo/asset = all code PASS, 2 raster gaps
  (expected); (3) storage-migration = correct, no data-loss/loop in any
  scenario; (4) infra = complete + verified live (gh/vercel/domains);
  (5) build/runtime = 7/7 PASS. All findings actioned (favicon delete,
  wxt.svg delete, docs)
- verify: pnpm -r typecheck + web lint + web build (icon.svg route ✓) +
  extension build all clean; /login serves the SVG; /icon.svg 200
- pending unchanged: /api/extract hardening; extension WEB_APP_URL localhost;
  Supabase schema; toolbar-icon PNGs; Chris auth §1
scope: tree b3cc0e195e6d690987366e43220dae8b0a10f845 (from `git add -A && git write-tree`; last commit efd35e7)

## 0.0.21
- **RENAME: maybuy → mightbuy, everywhere** (Chris's decision — maybuy was a
  placeholder; mightbuy wins the say-it-aloud/radio test, matches "it's a
  might-buy"). Case-aware sweep over 65 files incl. BOTH changelogs'
  history (old entries now read "Mightbuy" — do not treat pre-0.0.21 entries
  as evidence the old name was "mightbuy"). Packages now @mightbuy/*
  (lockfile refreshed)
- storage migrations (data preserved): web `read()` falls back
  maybuy:v2→mightbuy:v2 and maybuy:prefs:v1→mightbuy:prefs:v1 (rewrite new
  key, remove old — store.ts/prefs.ts); extension reads legacy
  maybuy:lists/maybuy:prefs as fallback (no rewrite). These three files hold
  the ONLY remaining "maybuy" literals in the repo — intentional
- infra renamed: GitHub repo → iamchrisdykes/mightbuy (old URL redirects;
  local remote updated); Vercel project → mightbuy. GOTCHA: project rename
  does NOT move the default domain — had to POST the new
  mightbuy.vercel.app domain + DELETE maybuy.vercel.app + redeploy main via
  API (deployment aliases are stamped at deploy time). Verified:
  mightbuy.vercel.app 200, maybuy.vercel.app 404. Dev previews now
  mightbuy-git-dev-chris-5013s-projects.vercel.app
- Chris chores: Supabase project display name still "maybuy" (Settings →
  General — cosmetic; ref mrmtzvquydcvqkxevhxl unchanged, keys/env
  untouched); reload the unpacked extension (name/build changed); buy
  domains (mightbuy.xyz $1.99 + .shop/.co.uk available; both .coms taken —
  checked )
- verify: pnpm install + -r typecheck + web lint + extension build clean;
  grep sweep = zero unexpected leftovers; live domains checked. Specs were
  renamed in-place by the sweep; no behaviour change beyond storage keys
  (data-model.md rows updated with migration notes). Subagent audit skipped:
  mechanical rename, verification = grep + builds + live checks
- prod note: main redeployed (rename-era code NOT merged — prod still runs
  0.0.19 code but now at mightbuy.vercel.app); dev holds 0.0.20 auth +
  0.0.21 rename. Merge dev→main when Chris wants auth live (gate:
  auth-setup.md §1)
scope: tree 083c5880540b8b768beb05a01c651b76a63d83ca (from `git add -A && git write-tree`; last commit e680c9a)

## 0.0.20
- feat: **real Supabase Auth** (magic link + Google) — fake localStorage
  session DELETED. New: lib/supabase/{client,server,proxy}.ts (+ src/proxy.ts
  — Next 16 renamed middleware→proxy; getClaims() refresh every non-asset
  request), app/auth/confirm/route.ts (accepts token_hash+type AND ?code=
  fallback; `next` param validated `/…` not `//…`), app/auth/callback/route.ts
  (OAuth exchangeCodeForSession, x-forwarded-host in prod). lib/session.ts
  rewritten: same useSession contract (undefined=unknown/null=signed-out,
  server snapshot undefined), Supabase-backed via onAuthStateChange;
  signOut/updateProfile now ASYNC (callers await); provider "email"→
  "magic-link" display mapping, missing→"magic-link"; name fallback chain
  ends at "there". signIn export GONE. mightbuy:session:v1 dead (never read;
  audit-grepped). Deps: @supabase/supabase-js + @supabase/ssr (web only)
- login page: real signInWithOtp (emailRedirectTo origin+/auth/confirm;
  sign-up = same flow, shouldCreateUser default), 60s resend cooldown, 429
  friendly copy, ?error=confirm|oauth alerts, Suspense for useSearchParams.
  **Google button renders ONLY when GET /auth/v1/settings reports
  external.google:true** (probe w/ publishable key) — signInWithOAuth
  navigates before the server can reject, so a dead button = raw JSON wall
  (Chris hit exactly this)
- gotcha: OTP links requested OUTSIDE the app (plain supabase-js script) use
  the implicit flow → tokens in URL fragment → server route can't see them →
  error=confirm. Magic links MUST be requested through the app's browser
  client (PKCE). Cost one confused test
- gotcha: Supabase built-in email ≈ a few sends/hour (dev-grade). Real SMTP
  before launch — auth-setup.md §4
- audit (ONE subagent): 5 specs vs code — found the `//` redirect-hygiene gap
  (fixed in both auth routes) + session.ts fallback omissions (spec'd).
  Stamps → 0.0.20 on auth.md (NEW spec), login.md, account.md,
  data-model.md, 00-overview.md
- CHRIS DASHBOARD STEPS (documents/auth-setup.md): §1 URL allowlist —
  **REQUIRED before prod login works; gate on merging dev→main**; §2 email
  templates → token_hash (scanner-proof); §3 Google GCP creds; §4 SMTP.
  Local dev works with zero config (Site URL default = localhost:3000)
- verify: pnpm -r typecheck + web lint + `next build` clean; login/confirm
  routes exercised on dev server; magic link E2E clicked by Chris on
  localhost — signed in OK. account Delete caveat: does NOT delete the
  Supabase user (needs server admin call — backend era)
- pending: /api/extract hardening (next-up #0); sweep rows; Supabase schema
  build (items/lists/item_lists) now unblocked by real auth
scope: tree 3d57d8f78cb1b8eda05d2d6d910b3587658d3871 (from `git add -A && git write-tree`; last commit on dev = infra docs commit)

## 0.0.19
- **LIVE & PUBLIC: https://mightbuy.vercel.app** — production branch = `main`,
  auto-deploy on every push (decision: Chris, "make it public", accepting
  unguarded /api/extract + draft legal pages on a public URL). The 0.0.18
  protected-preview model (production=`release`, main→login-only previews)
  was DROPPED same day: git pushes to main deployed as production 3× despite
  link.productionBranch=release (all deleted at the time), then CLI added 2
  more (CLI 56 defaults `vercel deploy` to PRODUCTION — always pass
  `--target=preview` for previews). GitHub default branch flipped
  release→main again; `release` branch still exists, inert. vercel.json
  deploymentEnabled guard added (d7a5860) then removed (be7dff1) — gone from
  tree. infra.md rewritten to match; CLAUDE.md services line updated
- resolved-blocker: /changelog + /changelog/llm WORK on Vercel — the
  `cwd/../..` fs reads trigger Next whole-project file tracing (build warning
  "Encountered unexpected file in NFT list"), which bundles the repo files
  into the function. Verified live (newest entry renders). Cost: bundle size;
  revisit only if builds slow. NOW.md blocker struck through
- priority: **/api/extract hardening is now next-up #0** (SSRF guard: block
  private/loopback ranges + http(s)-only, rate limit, response-size cap) —
  it's on a public URL
- dashboard note (Chris asked 3×): Vercel "Active Branches" lists ONLY
  non-production branches with preview deploys; the production branch shows
  in the top "Production Deployment" panel instead. One branch (main) =
  production ⇒ list legitimately empty
- verify: production smoke test — all 7 public routes 200; /changelog
  renders newest entry; /terms shows Decision markers. Docs-only wrap,
  subagent audit skipped (no product code changed since 0.0.17; the
  vercel.json guard was added+removed within the window)
- pending: sweep rows 1,3,5–20; extension reload for 0.0.17 build if not
  done; extension WEB_APP_URL still localhost (captures hit dev server, not
  prod — fine for wireframe); Supabase schema build; red legal Decisions
  (now publicly visible)
scope: tree d5f38ac2064d0a38ebe3d5c3d207398cc0afae07 (from `git add -A && git write-tree`; last commit be7dff1)

## 0.0.18
- infra: **GitHub + Vercel + Supabase provisioned** — all facts/IDs/decisions
  in NEW documents/infra.md (read it before touching deploys/env/db).
  Highlights: repo iamchrisdykes/mightbuy (private, `main`+`release`); Vercel
  prj_ksRJ0E3nlJcAdmPr6eDAXxzmCTmo rootDirectory=apps/web, **production
  branch = `release`** (Hobby can't protect prod, so main→protected previews
  only; launch = merge to release); Supabase ref mrmtzvquydcvqkxevhxl
  eu-west-2 London $10/mo, NO schema yet (first migration must follow
  data-model.md incl. item_lists join table). Env:
  NEXT_PUBLIC_SUPABASE_URL + NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY in Vercel
  (all 3 envs) + apps/web/.env.local (gitignored). CLAUDE.md services line
  updated; NOW.md unparked the hookup, Supabase build = next-up #2
- history: the footer "Remove all" button (briefly v0.0.18) was reverted and
  its changelog entry deleted at Chris's request; the old v0.0.18 commit was
  `git reset --soft`'d away BEFORE first push, so published history goes
  0.0.17 → this entry (which reuses the 0.0.18 number). Shop ⋯ → Clear all
  remains the single wipe entry point
- decision (Chris, asked explicitly): Supabase $10/mo accepted; region
  eu-west-2 London (resolves privacy-page region Decision — wording still
  owed in /privacy); repo private; protected-preview deploy model
- gotcha: `vercel git connect` needs .git in cwd (doesn't walk up from
  apps/web link dir — copy .vercel to root temporarily); `vercel env add`
  = one environment per call; Vercel Auth on ALL deployments rejected on
  Hobby (invalid_sso_protection) — preview-only is the max
- warning: /changelog + /changelog/llm read repo files via cwd/../.. →
  expected to 500 on Vercel (runtime cwd ≠ repo); bundle-vs-drop decision now
  urgent. /api/extract still unguarded (SSRF/auth/rate-limit) — gate on
  `release`, previews are login-protected
- verify: pnpm -r typecheck clean; working tree vs 0.0.17 scope tree =
  changelog + 00-overview stamp note only (all reverts confirmed exact).
  Docs-only wrap — subagent audit skipped (no code changes since 0.0.17)
- pending: first Vercel build untested (this wrap's push is the test); sweep
  rows 1,3,5–20; extension reload for 0.0.17 build if not yet done
scope: tree a1d0592d489c0d746b616cfdb213c861b17df400 (from `git add -A && git write-tree`; last commit 0f26c32)

## 0.0.17
- fix: `openMightbuy` (apps/extension/entrypoints/popup/App.tsx:34-46) —
  `tabs.query` now passes **`currentWindow: true`**: reuse is scoped to the
  window the user saves from, else `tabs.create` there. All `browser.windows`
  usage DELETED (`windows.update({focused:true})` doesn't restore a minimised
  window on macOS — saves navigated an invisible tab and read as silent
  no-ops). Found in sweep: dev-server log showed 3× iPhone `?add=` handoffs
  landing with no visible tab. Extension rebuilt (`wxt build`) — **unpacked
  extension reload owed for 0.0.17**
- sweep (documents/capture-sweep.md): argos.co.uk 🟢 (full JSON-LD incl.
  brand/category/sku — server path 403'd , extension path fine =
  access-strategy thesis confirmed); apple.com/uk 🟢 (sku + breadcrumb
  category; price arrives as "1099" no decimals — web-side £ formatting not
  yet eyeballed). With currys (0.0.16): 3 tested, all 🟢, zero adapters yet
- ux-trap (logged in NOW.md blockers; fix DECLINED for now, Chris unticked
  locally): keep-browsing/"stay" mode only pre-fills the bg tab's dialog (no
  save) and the next handoff overwrites it → silent loss of queued saves;
  popup copy "Saves to Mightbuy in the background" is false. Default still
  "stay" (fresh installs hit it). Interim option discussed: client-side
  auto-save + Undo toast + handoff nonce; real fix = silent add (Supabase)
- chore: wxt.config.ts permissions comment += after-save pref (audit note)
- verify: pnpm -r typecheck + web lint clean. popup.md audited by ONE
  independent subagent — zero drift (2nd consecutive clean audit; two
  cosmetic code-comment notes only). Stamp popup.md → 0.0.17;
  package.json + footer 0.0.17
- pending: sweep rows 1,3,5–20 unfilled; extension reload for 0.0.17;
  unchanged (/api/extract no SSRF/auth/rate-limit, WEB_APP_URL localhost,
  sharing browser-local, silent add rides Supabase, red legal Decisions)
scope: tree 80f2c9ed09cbc875c24e2e45c390c350e8aa022c (from `git add -A && git write-tree`; last commit f8d8ec4)

## 0.0.16
- decision: **lists = single-list UX, multi-capable schema** (open-questions.md
  resolved ). Keep `SavedItem.list` (single string) + move-not-copy
  UX; the Supabase schema MUST model membership as an **`item_lists` join
  table** (item_id, list_id, unique pair) — NOT a list column on items —
  single membership enforced as an application rule (replace-on-add).
  Migration mapping: `list` name → `lists` row FK; undefined → no join row
  (implicit My shop). Encoded in specs/data-model.md "List membership" section
  + warning comment on `list?` in packages/shared/src/types.ts. Don't build
  anything that treats one-list-per-item as a data guarantee
- feat: `CATEGORY_KEYWORDS.Kitchen` += white goods (categorise.ts:66-68):
  cooker, hob, fridge, freezer, dishwasher, washing machine, tumble dryer,
  washer dryer, **airfryer** (one-word spelling — "air fryer" can't word-boundary
  match "AirFry"/"Airfryer" titles). Driver: Currys BEKO induction cooker
  suggested Uncategorised. Verified via tsx run: BEKO title → "Kitchen"
  (user-collection override), running-shoe title still → Footwear. NO new
  canonical category (still 18 — appliances deliberately live in Kitchen,
  decided over a separate "Appliances"); /categories page renders the change
  live, no page edit needed
- docs: NEW **documents/capture-sweep.md** — pre-launch extension capture
  test: ~20 UK retailer table (verdicts 🟢🟡🔴; wrong price outranks missing;
  "via page scan"+correct = 🟢 but watch-list). Notes the 
  /api/extract results (argos 403, uniqlo timeout) are server-path only, NOT
  predictive of the extension path. Row 4 currys.co.uk = 🟢 (JSON-LD, full
  capture, ) → no adapter. Process: 🔴/🟡 rows → one ADAPTERS entry
  each in packages/shared/src/hosts.ts, Notes URL = the test case
- env: extension reloaded in Chrome  — 0.0.9–0.0.14 finally picked
  up; 0.0.14 manual flow checks (tab reuse, bg/fg toggle, My Mightbuy) piggyback
  on the sweep. NOW.md reload warning cleared
- verify: pnpm -r typecheck clean; web lint clean. Specs data-model.md +
  web/categories.md audited by ONE independent subagent (cost rule honoured):
  **zero drift found** (first fully clean audit); auto-categorisation.md also
  confirmed accurate (weights 3/2/1, 18 categories, "kit"≠"kitchen" whole-word
  override). Both stamps moved to 0.0.16. package.json + footer 0.0.16
- pending (unchanged): /api/extract no SSRF/auth/rate-limit; extension
  WEB_APP_URL localhost; sharing browser-local; extension silent add rides
  Supabase; red legal Decisions before deploy. NEW pending: sweep rows 1–3,
  5–20 unfilled (Chris drives; hand rows to Claude for adapters)
scope: tree 0cebcb48639b416a1edb312f7cf804e8f851205d (from `git add -A && git write-tree`; last commit 2598708)

## 0.0.15
- feat: **legal pages** — 3 new public routes (no auth gate): /terms, /privacy,
  /how-we-make-money (apps/web/src/app/<route>/page.tsx). Static server
  components, shared shell in src/components/legal.tsx (LegalShell/
  LegalSection/P/Ul/Decision). `Decision` = unresolved content decision,
  renders `[Decision needed: …]` in font-bold text-red-600 dark:text-red-400 —
  Chris resolves these before launch (entity, emails+domain, analytics y/n,
  Supabase region, AI provider, min age, liability cap, E&W law, ICO reg,
  Amazon wording, /s/ inline disclosure). Draft banner on each page
- **affiliate design rule (binding, from audit):** links become affiliate
  links ONLY on user click in the WEB APP (server-side wrap, e.g. future
  /go/:itemId); the EXTENSION never adds/modifies affiliate links, codes, or
  cookies. Driver: CWS Affiliate Ads policy (enforced : disclosure
  pre-install+in-UI, user action per insertion, user benefit) + Honey class
  action. Encoded in /how-we-make-money content + specs/web/legal.md; affects
  future silent-add + sharing builds
- footer (site-footer.tsx): +3 links (How we make money, Privacy, Terms), now
  6; flex-wrap added; version text → v0.0.15
- docs: NEW **documents/competitive-audit.md** — verified findings (CWS policy
  3-req, Honey suit 5:24-cv-09470 active, Karma ships whole automation
  roadmap, Locker=wantlocker.com near-total overlap, Carted AU$13M shutdown
  2025-10, Skimlinks alive/Taboola) + **feature matrix** (12 rivals — from a
  LIGHT unverified pass, "?" cells unconfirmed; verified-vs-light split
  labelled in-doc). Strategic conclusion: differentiate on positioning
  (personal "might-buy" shop + transparent affiliate), NOT features (alerts
  are commodity)
- docs: architecture.md += **mobile ladder** (decided ): paste-URL →
  iOS Shortcut → Android PWA share-target → Capacitor wrapper → native; every
  rung = thin client on the same save API; prereq Supabase; native "likely
  eventually" (phones=capture moments, push=retention, rivals all have apps)
  but only after web+extension retention proven
- specs: NEW web/legal.md (one spec covers the 3 sibling pages);
  changelog.md footer row + 00-overview.md site map/index updated. Stamps
  moved on legal.md/changelog.md/00-overview.md after claim-by-claim
  **self-check only** (subagent audit skipped — Chris's cost instruction this
  session). One drift found+fixed: spec claimed all 3 pages open with "The
  short version"; terms opens with "What Mightbuy is"
- verified: pnpm -r typecheck clean; web lint clean; all 4 routes 200 on dev
  server; Decision markers render (grep HTML). package.json 0.0.15
- process: Chris is cost-sensitive on agents — no multi-agent workflows
  without asking (memory saved); deep-research x2 this session was too heavy,
  single light agent did the matrix fine
- pending (unchanged): /api/extract no SSRF/auth/rate-limit; extension
  WEB_APP_URL localhost; lists one-per-item; sharing browser-local; extension
  silent add. NEW pending: resolve red legal Decisions + solicitor review
  before deploy; extension reload in Chrome STILL owed (0.0.9–0.0.14 unpicked)
scope: tree e0c2b9a43e584fb53617c664661f52256e9894f6 (from `git add -A && git write-tree`; last commit 6833f6b)

## 0.0.14
- feat: **extension add-flow polish** (apps/extension/entrypoints/popup/App.tsx).
  First extension code change since 0.0.9. **All of it is SCAFFOLDING** —
  labelled in-code with `SCAFFOLDING` comments pointing at
  documents/specs/extension/silent-add.md; deleted when the backend save API
  lands
- **focus-or-create** (`openMightbuy(url, focus)`, App.tsx:34): reuse the one open
  Mightbuy tab instead of `tabs.create` per save (killed the tab pile-up).
  `tabs.query({url:`${WEB_APP_URL}/*`})` → newest by lastAccessed → if found
  `tabs.update(id,{url,active:focus})` (+ `windows.update(windowId,{focused})`
  when focus) else `tabs.create({url,active:focus})`. **No `windows` permission
  needed** — `windows.update` works without one; manifest still `["tabs",
  "storage"]` (wxt.config.ts unchanged)
- **after-save pref** (`AfterSave = "stay" | "open"`, default **"stay"**):
  sticky in `browser.storage.local` key **`mightbuy:prefs`** → `{ afterSave }`.
  Read defensively on mount alongside `mightbuy:lists` (single `.get([...])`),
  persisted via `updateAfterSave` (best-effort try/catch). "stay" → tab opens/
  refreshes in **background** (`active:false`), "open" → **foreground**.
  Checkbox `checked={afterSave==="stay"}` ("Keep browsing after saving"), shown
  in both product + save-anyway states; save-button hint copy is choice-aware
- **"My Mightbuy →"** footer link (`openMyAccount`): focus-or-create to
  `${WEB_APP_URL}/`, **always foreground**, independent of the pref. Rendered
  OUTSIDE the `state.status==="ready"` block → shows in loading/error/ready (all
  states). App.css += `.aftersave` / `.popup-footer` / `.account-link`
- **idempotency-safe** to reuse/refresh the tab: the web handoff only *pre-fills
  the add dialog* (shop.tsx:173-201, no auto-save), so re-firing `?add=…` just
  re-opens the dialog with the new product — no double-save. Web side unchanged
- docs: NEW **documents/specs/extension/silent-add.md** (PLANNED, design-only) —
  the real silent add (`POST /api/items` @ apps/web/src/app/api/items/route.ts,
  Supabase, extension auth token in browser.storage.local, in-popup "Saved ✓ /
  Undo"), its deps, and the explicit scaffolding-removal list (openMightbuy,
  AfterSave pref+toggle, `?add=` builder go; My-Mightbuy link + list picker stay).
  Rides the Supabase milestone (same as real sharing)
- specs: extension/popup.md updated (4b after-save toggle, step 5 focus-or-
  create, step 6 My-Mightbuy link, manifest note, scaffolding-removal section,
  Data table) + stamp → 0.0.14. **Independently audited** (adversarial subagent,
  spec+code fresh): **zero drift** in both popup.md and silent-add.md; confirmed
  DEFAULT_AFTER_SAVE="stay", `mightbuy:prefs`/`mightbuy:lists` keys, no windows perm,
  footer outside ready block
- verified: `pnpm -r typecheck` clean (3 pkgs); web lint clean; extension build
  clean (valid MV3, 533B manifest, no new permission). **NOT** verified live:
  browser tab behaviour (focus-or-create, bg/fg, My-Mightbuy) — needs manual
  unpacked-extension reload in Chrome; can't drive `browser.tabs`/`windows`
  headlessly
- pending (unchanged): /api/extract no SSRF/auth/rate-limit; extension
  WEB_APP_URL localhost; lists one-per-item (multi-membership open); sharing
  browser-local until the backend. NEW pending: extension silent add (backend)
scope: tree b2926076b751cecefedbc1a86e5a6ed5cf2f25e1 (from `git add -A && git write-tree`; last commit 442a894)

## 0.0.13
- feat: **multi-select + bulk actions**. item-card.tsx: whole card clickable via
  a stretched overlay `<button>` (`absolute inset-0 z-10`, `aria-label="Open …"`,
  `onClick=onEdit` always); checkbox (top-left) + ⋯ raised to **z-20** so they
  stay clickable (no nested interactives). **Gmail model** — checkbox is the
  dedicated select control, card click always *opens*; NO hidden "select mode".
  Card props += `selected` (→ `ring-2 ring-primary`) / `selectionActive` /
  `onToggleSelect`. Checkbox visible on `group-hover`, always once
  selected/selectionActive
- NEW `ui/checkbox.tsx` (base-ui `@base-ui/react/checkbox`). **GOTCHA**: base-ui
  `Checkbox.Root` defaults `display:inline`, which **ignores `width`/`height`**,
  so `size-4` was dropped → the box collapsed to ~2px×20px (a visible sliver).
  Fix: add `inline-flex items-center justify-center`. Runtime CSS bug — invisible
  to typecheck/lint AND to a "does it click?" test; only a rendered-pixel check
  caught it (measured `getBoundingClientRect`)
- selection-bar.tsx (NEW): shown when ≥1 selected. "N selected" (`role=status
  aria-live`) + **Select all** (visible) + **Add to list** (My shop / each list /
  **New list…**→dialog) + **Remove** (confirm) + **Clear** (×). **Status-aware
  lifecycle** via `context` prop: shop's `selectionContext` = derived from the
  *selected items'* statuses (homogeneous archived→"archived"; homogeneous
  bought→"bought"; else "active"). active/mixed → Mark bought + Archive;
  archived → **Unarchive**; bought → Restore + Archive. Restore/Unarchive both =
  `updateMany {status:'active', boughtAt:undefined}`
- store.ts: **bulk methods** (each ONE write + notify): `updateMany(ids, patch)`
  (registers list/collection names via withName/withListName), `removeMany(ids)`,
  `markBoughtMany(ids, onBought)`. All on the hook return
- shop.tsx: `selected: Set<string>`; toggleSelect/clearSelection/selectAllVisible;
  lifted per-item `markItemBought`/`restoreItem` (shared by card + sheet);
  bulkAddToList/MarkBought/Archive/Restore/Remove/CreateListAndAdd; bulk-remove
  confirm AlertDialog + new-list Dialog. `changeListScope` also clears selection
- feat: **edit-sheet parity** (edit-item-sheet.tsx): Buy → real `<a>`
  (`nativeButton={false}`, was window.open); + Mark bought/Restore (close-after)
  + bottom destructive **Remove from shop** (`onRequestDelete` → shop closes
  sheet + opens shared delete confirm). Wired via onMarkBought/onRestore/
  onRequestDelete
- feat: **Clear all confirms** (shop.tsx) — AlertDialog quoting total count
- a11y/UX pass (ran vercel-labs **`web-design-guidelines`** skill; installed to
  `.agents/`, gitignored): aria-labels on rename / new-list / NameSelect-create /
  search inputs; results count `role=status aria-live`; Open-site → real `<a>`;
  login email `autoComplete=email`+`inputMode`+`spellCheck=false`; search
  `type=search` (+ `[&::-webkit-search-cancel-button]:appearance-none`); image +
  product-URL `type=url`; `motion-reduce:` on card hover-scale (item-card +
  public card); `width`/`height` on card/sheet/public images
- fix: **cursor-pointer** on interactive controls (Tailwind v4 dropped the
  default; the Web Interface Guidelines ruleset has **no cursor rule** so the
  skill couldn't flag it). Added to buttonVariants, sidebar menu-button +
  SidebarMenuAction, Switch, Checkbox, dropdown-menu items, SelectItem, the card
  overlay + search-clear buttons
- feat: **changelog dates hidden in browser** (lib/changelog.ts
  `stripChangelogDates`) — /changelog + /changelog/llm strip heading dates
  (`— YYYY-MM-DD`), scope `@ …Z`, and bare ISO dates at render. Dates **retained
  in the source .md files** (read server-side, never served raw) = the tracked
  record. Verified 0 dates in served HTML on both pages
- chore: `.gitignore` += `.agents/` / `skills-lock.json` /
  `.claude/skills/web-design-guidelines` (installed-skill artifacts; reproducible
  via `npx skills add`)
- E2E (agent-browser): card→sheet; ⋯ works above overlay; checkbox→bar; Select
  all (20); New list & add (moved 20); bulk mark bought / remove (confirm);
  archive → Archived view → **Unarchive** → restored; checkbox renders 16px +
  cursor pointer; both changelog pages 0 dates. typecheck + web lint clean; 0
  console errors
- specs: shop.md (clickable card + status-aware multi-select + sheet parity +
  Clear-all confirm), data-model.md (bulk methods), changelog.md (date strip),
  login.md + share.md re-verified — all independently audited
- pending (unchanged): /api/extract no SSRF/auth/rate-limit; extension
  WEB_APP_URL localhost; lists one-per-item (multi-membership open); sharing
  browser-local until the backend
scope: tree 97ba85559e14448eb66776963c33664f0be55325 (from `git add -A && git write-tree`; last commit 23c2d97)

## 0.0.12
- feat: **shop search + sort** toolbar (apps/web/src/components/shop.tsx, above
  the grid, rendered only when `items.length > 0`). **Search** = case-insensitive
  substring over title / brand / siteName / collection / notes (`matchesQuery`);
  clear (×) button + "N results" count, both gated on a non-empty `query`.
  **Sort** = base-ui Select (`items={SORT_LABELS}`): recent (default) / oldest /
  price-asc / price-desc / name → `SORTERS` map. `savedAt` string-compared (ISO,
  chronological). `comparePrice(a,b,dir)` returns ±1 for an unpriced item
  **without** ×dir, so unpriced items sort **last in both** price directions
- `visible` useMemo now chains `inListScope && matchesFilter && matchesQuery`
  then `.sort(SORTERS[sort])` — `.filter` returns a fresh array so the store's
  `items` is never sorted in place; deps += `[query, sort]`. `changeListScope`
  also `setQuery("")` (a query from another list would otherwise empty the view —
  mirrors the existing facet-filter reset). `query`/`sort` are ephemeral
  `useState`, **not** persisted to prefs/localStorage
- empty-view copy: `query ? "No items match “…”." : "Nothing here yet."`. New
  imports in shop.tsx: `Input`, Select bits, `ArrowUpDown`/`Search`/`X` icons
- E2E (agent-browser, built app): search "sonos" → 1 result (store match);
  no-match → "No items match"; clear × → all 20 back; Price high→low descending
  (£1,395 / £630 / £450); Name A–Z alphabetical (8BitDo, Aeron, Anker, BEKANT…);
  switching list scope clears the search (confirmed after a hard reload — HMR
  staleness had masked it mid-session, code was correct). 0 console errors;
  typecheck + web lint clean
- spec: shop.md — Search & sort behaviour paragraph, toolbar in UI-anatomy,
  search-aware States line, "sort/search not built yet" open-question removed;
  **independently audited, 0 discrepancies** (all 8 claims + no-mutation check)
- pending (unchanged): /api/extract no SSRF/auth/rate-limit; extension
  WEB_APP_URL hardcoded localhost:3000; lists one-per-item (multi-membership
  open); sharing browser-local until the backend
scope: tree 4d2de75f0e70c7b9ec81a153dbada8b4088a7ae6 (from `git add -A && git write-tree`; last commit c41f3d0)

## 0.0.11
- feat: **list rename + delete** (apps/web/src/components/app-sidebar.tsx
  `ListRow`). Each named list row (My shop / All lists excluded): hover ⋯ menu
  → **Rename** = inline input (pre-filled + text-selected; Enter/blur commit,
  Escape cancel) → store `renameList`; **Delete** = AlertDialog with adaptive
  copy (empty / "Its N items will move to My shop — nothing is deleted" / +
  "Its public share link will stop working" when shared) → store `deleteList`.
  Count badge hides on hover so it doesn't collide with the ⋯ trigger
- store (store.ts): `renameList(old, new): boolean` — updates the ListMeta name
  + **re-points every item** `list===old`→new, **keeps shared/slug** (a public
  link survives a rename), re-sorts; returns false on blank/unchanged/collision.
  Collision is checked against the **union of meta names ∪ item-referenced
  names** (not just `lists`), so an imported shop can't silently merge two
  lists. `deleteList(name)` — **non-destructive**: removes the ListMeta and sets
  `list: undefined` on its items (→ My shop); the share state dies with the meta
  row (its slug stops resolving in `resolveSharedList`). Both added to the hook
  return
- reconciliation (shop.tsx `handleRenameList`/`handleDeleteList`): the store
  touches only items+lists; the component syncs the other name-keyed state —
  `prefs.defaultList` (rename→new name, delete→"") and the view `listScope`
  (rename→follows to new name, delete→back to All lists). Success toasts
  "Renamed to …" / "Deleted …"
- gotcha (real bug, caught in browser verify — never shipped): a
  `DropdownMenuTrigger render={<SidebarMenuAction/>}` **silently breaks** the
  menu-item→AlertDialog flow — the menu won't close and the dialog won't open.
  `SidebarMenuAction` is a base-ui `useRender` component; composing it as the
  trigger double-wraps and swallows the item selection. item-card uses a plain
  `<Button>` trigger (works), so ListRow now does too. Rename *appeared* to work
  only because it swaps the row for an input (unmounts the menu); Delete (parent
  state, menu stays mounted) exposed it. NOT caught by typecheck/lint — only by
  actually clicking / the Next.js dev overlay
- E2E (agent-browser, built app): rename Mum→Mummy (its 2 items followed, active
  scope followed, sidebar relabelled); delete Dave (dialog "Its 2 items will
  move to My shop", My shop count 16→18 as items moved in, Dave row gone, scope
  reset to All lists); rename-collision Mummy→"Wedding" rejected (no merge, both
  lists intact). 0 console errors; typecheck + web lint clean
- specs: shop.md (ListRow per-list actions paragraph incl. toasts + the
  Button-not-SidebarMenuAction NB), data-model.md (`renameList`/`deleteList` API,
  the name-referenced/caller-reconciles invariant, collision scope),
  open-questions.md "List rename / delete" **resolved**
- pending (unchanged): /api/extract no SSRF/auth/rate-limit; extension
  WEB_APP_URL hardcoded localhost:3000; lists one-per-item (multi-membership
  still open); delete has no "delete the items too" option; sharing browser-local
  until the backend
scope: tree a372194d8828fe81c0cd172ddf71d0ac49d7a60a (from `git add -A && git write-tree`; last commit 1c5b5a3)

## 0.0.10
- feat: **per-list public sharing** + public read-only page. Any list (a named
  list or the implicit "My shop") can be toggled shared → non-guessable slug →
  `/s/[slug]` page. Decision (with Chris): a simple per-list `shared` toggle, NOT
  a mine-vs-gift list *type* (safe because off by default)
- schema/store (apps/web/src/lib/store.ts): `lists: string[]` → **`ListMeta[]`**
  (`{name, shared, slug?}`); new optional `StoreState.myShop: ListShare`
  (`{shared, slug?}`) holds the implicit primary list's share state (My shop has
  no ListMeta row). `PRIMARY_LIST="__my_shop__"` = reserved share-target key,
  never stored on items. Migration: `normalize()`/`normalizeLists()` (exported)
  coerce legacy `string[]`→`ListMeta[]` on read AND in `replaceAll` (typed via
  new `StoreInput` whose `lists` is `unknown`). Same `mightbuy:v2` key — `lists`
  reshaped + `myShop` added without a version bump (read merges onto EMPTY)
- store API: new `setListShared(target, shared)` — target = list name |
  PRIMARY_LIST; enable mints a FRESH slug (`newSlug` = `slugify(name)` + 8 hex
  from `crypto.randomUUID`), disable DROPS the slug (revoke; re-share = new
  link). `createList`/`addItem`/`updateItem` register lists via `withListName`
  (ListMeta-aware, sorted by name). Pure resolvers (no hook, for the public
  page): `resolveSharedList(state, slug)` → `{title, items}|null` (My shop
  first, then named by shared slug; **ACTIVE items only**; null for
  unknown/revoked); `shareStateFor(state, target)` → ListShare. All exported
- UI: `share-list-dialog.tsx` — Switch "Share this list" (base-ui
  `checked`/`onCheckedChange`) → `setListShared`; when shared shows
  `{origin}/s/{slug}` in a readonly Input with Copy (navigator.clipboard) +
  Open. shop.tsx: **Share** button in header, rendered only when
  `listScope.kind` is `primary`|`named` (derives `shareTarget`; hidden on "All
  lists"); threads `myShopShared={!!myShop?.shared}` to sidebar. app-sidebar.tsx:
  `lists` prop is now `ListMeta[]`; `SharedDot` (🌐 Globe, aria "Shared
  publicly") after shared list names + My shop. Name-only consumers use
  `lists.map(l=>l.name)` (add-item-dialog, edit-item-sheet ×2, account ×3)
- new route: `apps/web/src/app/s/[slug]/page.tsx` — `'use client'`, `useParams`,
  **public (NO auth gate; separate from the Shop auth flow)**, own header chrome
  (not the app sidebar). Hydration gate =
  `useSyncExternalStore(()=>()=>{}, ()=>true, ()=>false)` → false on
  server/first-paint, true after hydration (store snapshot is empty pre-hydration
  → would falsely resolve "not available"). Chosen over a mounted-flag effect to
  satisfy the `react-hooks/set-state-in-effect` lint rule. Read-only
  `PublicItemCard`; empty→"Nothing in this list yet"; null→"This list isn't
  available" (same page for wrong-slug and revoked)
- gotcha: Base UI `Button` defaults `nativeButton=true`; rendering it as an
  anchor (`render={<a/>}`) throws a **console error** ("expected a native
  <button>…"). Must set **`nativeButton={false}`**. NOT caught by typecheck/lint
  — only surfaced in the Next.js dev overlay during browser verify. (Reinforces
  the 0.0.9 lesson: verify UI in the real browser, watch the dev-tools issue
  badge)
- wireframe limit (by design): `/s/[slug]` resolves against the **current
  browser's** localStorage only — genuine cross-person/cross-device sharing
  needs the backend (a server route reading the shared list by slug). Renders
  correctly for the owner; the flow is fully demonstrable. Consistent with the
  parked Supabase hookup
- E2E (agent-browser, built app): Google fake-login → load demo → scope My shop
  → Share → toggle on → slug `my-shop-…` → `/s/…` renders 16 items read-only;
  named "Mum" → 2 scoped items; toggle off → old link = "This list isn't
  available"; sidebar 🌐 persists across reload; **0 console errors** after the
  nativeButton fix. typecheck + web lint clean
- specs: data-model.md (ListMeta entity + store API + migration), shop.md
  (Sharing section, Share button, SharedDot, data rows), NEW share.md (public
  page) — all updated + **independently audited** (2 adversarial subagents);
  account.md re-verified (change was mechanical `l`→`l.name` only, no behaviour
  drift). All 4 stamped 0.0.10
- pending: /api/extract still no SSRF guard/auth/rate-limit; extension
  WEB_APP_URL hardcoded localhost:3000; lists still one-per-item, no
  rename/delete; sharing is browser-local until the backend
scope: tree 572be9354a687b27d91a4f2673e27b5a67889a60 (from `git add -A && git write-tree`; last commit 985306e)

## 0.0.9
- feat: **host-adapter capture layer** — packages/shared/src/hosts.ts. Per-
  retailer extraction for sites with no structured data. `HostAdapter`
  {matches, fromUrl (pure), dom (DomSpec)}; `ADAPTERS` registry (Amazon only
  for now); `getHostAdapter(url)`. Adding a retailer = one entry, no caller
  changes
- Amazon adapter: hostname `/(^|\.)amazon\.[a-z][a-z.]+$/`; `fromUrl` → ASIN
  from `/dp|/gp/product|/gp/aw/d|/product` (10-char) → `{ sku }` (also flips
  looksLikeProduct true on its own); `dom` selectors: `#productTitle`; ordered
  buybox price (`.a-offscreen` under `#corePriceDisplay_desktop_feature_div`/
  `#corePrice_feature_div`/`#apex_desktop`, then `#price_inside_buybox`/
  `#priceblock_ourprice`/`#priceblock_dealprice`/`#sns-base-price .a-offscreen`);
  `#bylineInfo` brand (cleaned "Visit the X Store"/"Brand: X"→X); image
  `#landingImage`→`#imgTagWrapperId img` (src|data-old-hires)
- shared helpers (hosts.ts): `parsePriceText` ("£21.90"→{price,currency};
  £/$/€→GBP/USD/EUR; thousands/decimal + euro "1.234,56"); `fieldsFromDom(DomLike,
  spec)`; `fillGaps` (gap-only, never overwrites structured data);
  `applyHostUrlFields`; `DomLike`/`ElementLike` interfaces (Document satisfies
  structurally). Exported from index.ts
- wiring: content.ts runs adapter AFTER JSON-LD/OG merge, BEFORE generic
  h1/document.title fallback; fillGaps(domFields) then fillGaps(fromUrl);
  source="dom" when title/price contributed. html.ts (server /api/extract)
  applies `applyHostUrlFields` — URL fields only (DOM price needs the live page;
  server-fetched Amazon has no price in its HTML)
- fix (real bug, shipped in 0.0.8): **popup blank/crash**. App.tsx used
  `browser.storage.local` but wxt.config.ts manifest lacked the `storage`
  permission → `browser.storage` undefined → TypeError "reading 'local'" on
  mount → blank popup on EVERY page. Fix: `permissions: ['tabs','storage']` +
  defensive optional-chaining/try-catch around storage get (useEffect) + set
  (addToMightbuy). Remembered-lists is best-effort, must never blank capture
- gotcha: **verify EXTENSION changes by opening the real popup**
  (`chrome-extension://<id>/popup.html` against a live tab via
  `agent-browser tab new`), not just web-handoff URL params or the
  content-script test bridge — that verification gap let the storage crash ship
  in 0.0.8. Extension ID (unpacked, path-derived) = mhhpajbmdibphbdcklaehhedbikikabl
- E2E: real built extension on amazon.co.uk/dp/B08T1HR5CS → draft
  {title, price:"21.90", currency:"GBP", sku:"B08T1HR5CS", imageUrl,
  source:"dom"}; looksLikeProduct=true; popup renders product preview +
  "Add to my shop". 17 pure-logic assertions (node ran hosts.ts TS directly)
  pass. typecheck + web lint clean. specs audited (content-script/api-extract/
  popup): api-extract ACCURATE; other two had minor selector/wording drift,
  fixed this wrap
- limits (not bugs): brand via #bylineInfo missed on the test page (best-effort);
  server /api/extract can't get Amazon price (not in HTML meta) — extension is
  the Amazon path
- action required: **reload the unpacked extension in Chrome** to pick up the
  new manifest permission
- pending: /api/extract still no SSRF guard/auth/rate-limit; WEB_APP_URL
  hardcoded localhost:3000
scope: tree 7ce79531254f2ae2612db23888cfcf13500ebb3d (from `git add -A && git write-tree`; last commit 101ebff)

## 0.0.8
- feat: **Lists** — a second grouping axis ("who it's for"), orthogonal to
  collections ("what kind"). schema: SavedItem += `list?` (packages/shared/
  src/types.ts); undefined = primary "My shop" list. One list per item (multi-
  membership parked → documents/open-questions.md)
- store (lib/store.ts): StoreState += `lists: string[]` (persists empty lists);
  EMPTY += lists:[]; read() now merges parsed onto EMPTY so old mightbuy:v2 shops
  get lists:[] (no key bump); addItem/updateItem register item.list via shared
  `withName` (renamed from withCollection); new `createList(name)` adds an empty
  list. mightbuy:v2 = { items, collections, lists }
- prefs (lib/prefs.ts): += `defaultList: ""` ("" = My shop) — where new saves land
- sidebar (components/app-sidebar.tsx): new `ListScope` type (all|primary|named)
  + Lists group (All lists / My shop / named / inline "+ New list" via NewListRow);
  exports `inListScope(item, scope)`. Facets + status groups now computed from
  items scoped to the active list. `matchesFilter` unchanged (facet only) —
  shop composes inListScope && matchesFilter
- shop (components/shop.tsx): `listScope` state (default {kind:"all"}); switching
  scope resets facet filter (changeListScope); header shows "{list} /" prefix for
  primary/named; handoff parses `list` param → handoffList → AddItemDialog
  initialList; clearAll resets scope + lists:[]; ItemCard showList = kind!=="named"
- item-fields.tsx: extracted shared `NameSelect` (used by both Collection + List);
  ItemFormValue += `list`; `showList?` prop (edit sheet passes false)
- add-item-dialog.tsx: EMPTY_FORM += list; seeds list = initialList ?? prefs.defaultList
  across all 4 paths (handoff/fetch-ok/fetch-err/manual); save writes list; new
  `initialList?` prop
- edit-item-sheet.tsx: quick move-to-list Select up front → updateItem immediately
  + toast (no Save); ItemFields showList={false}; form seeds/saves list
- item-card.tsx: 🎁 list badge bottom-right when showList && item.list
- account/page.tsx: Lists stat (5 tiles, sm:grid-cols-5; count = distinct named
  lists + "My shop" if any listless item); Default list select in Saving; import
  carries lists ?? []; delete/replaceAll include lists:[]
- extension (entrypoints/popup/App.tsx + App.css): "Add to list" input (plain
  element, not nested component — avoids focus loss) with <datalist> of names
  remembered in browser.storage.local `mightbuy:lists`; handoff URL += list param
  (+ gtin/mpn/sku/availability already present); remembers typed name on save
- demo-items.ts: mk(1)+mk(11) list:"Mum", mk(9)+mk(17) list:"Dave" (2+2)
- docs: new documents/open-questions.md (multi-membership, list rename/delete,
  extension↔web list sync — all parked). Specs trued-up + audited (data-model,
  web/shop, web/account [was stale at 0.0.1], extension/popup) — all ACCURATE
- verify: E2E in browser — handoff ?list=Dave saved with list:"Dave"; list
  scoping filters + counts correct; move/settings render. typecheck + web lint clean
- gotcha: `agent-browser click` doesn't fire the shadcn Dialog save button's React
  onClick (portal/synthetic-event quirk); a native el.click() works (20→21). NOT a
  product bug — use eval el.click() to drive dialog buttons in browser tests
- pending: /api/extract still no SSRF guard/auth/rate-limit; extension WEB_APP_URL
  hardcoded localhost:3000; handoff loses params if signed out
scope: tree ac29a2bdb4b73c06acc63786bb21c708231d998a (from `git add -A && git write-tree`; last commit 4c73d17)

## 0.0.7
- fix (real bug): add-item save() rebuilt SavedItem from form fields only,
  **never wrote brand** → no dialog/handoff-saved item ever had a brand (only
  hardcoded demo items did). Now brand persisted on all paths
- feat: brand + store are distinct editable fields — ItemFormValue += brand,
  store; ItemFields renders Brand|Store row after title; add-item-dialog
  seeds (handoff/fetch/error/manual) + saves brand→brand, store→siteName
  (form.store || hostname); edit-item-sheet seeds/saves both
- fix: add save() also dropped gtin/mpn/sku/availability/description (find-
  other-sellers match keys). Now carried via draftExtras state (set in
  handoff+fetch seeds, spread into SavedItem). Extension handoff += gtin/mpn/
  sku/availability params (popup App.tsx builds, shop.tsx parses)
- feat: edit sheet redesigned buy-first — title heading, brand·store, large
  price+date, primary "Buy at {store} →" (window.open item.url; "View
  product" if no store) + "Find other sellers" (onFindSellers → shop closes
  edit, opens sellers sheet w/ item), editing in collapsible <details> "Edit
  details" (full ItemFields + Save). onFindSellers prop on EditItemSheet
- audit: both docs accurate; no in-scope bugs. gtin/sku carry-through was the
  auditor's flagged pre-existing gap, fixed same wrap
- E2E: Nike handoff → brand "Nike"+store "nike.com" prefilled → saved → Nike
  in Brands sidebar; gtin/sku/availability round-trip verified
scope: tree fb6a1bd53641690b0f34dee622096e1c416c947b

## 0.0.6
- feat: category keywords expanded — CATEGORY_KEYWORDS now 18 categories /
  ~832 keywords (was 13/~130). New: Pets, Office & Stationery, Automotive,
  Food & Drink, Music. Singular forms (s? matches plural); multi-word anchors
  for collisions ("dog collar","car seat","midi keyboard","highlighter pen",
  "track pump"). Verified: 0 within-list dupes, 0 cross-category shared
  keywords, 20/20 real-product classification test passed
- feat: changelog page files-touched disclosure — lib/changelog.ts
  filesTouchedByVersion() shells to git (execFileSync, cwd=REPO_ROOT):
  diffs each `v<semver>:` commit vs previous (first vs EMPTY_TREE
  4b825dc…), excludes pnpm-lock; page.tsx splits md by /^## /m into
  per-version <section>s, native <details> per version (files &&
  files.length guard). Human page only, dev-only (git). 0.0.2 has no
  disclosure (folded into combined v0.0.1+v0.0.2 commit)
- skill: session-wrap scope marker simplified to tree-hash only (commit hash
  inside its own commit is circular) — committed 3f9aa9b, folded here
- audit: docs-clean (changelog.md spec updated for files feature;
  auto-categorisation.md 18-cat count); code-review found no bugs (note:
  s?-only plural misses irregular -es/-ies on multi-word keywords, negligible)
scope: tree 9caed46973e88d667b14bbf8f0ef336060954430

## 0.0.5
- feat: /categories page (apps/web/src/app/categories/page.tsx) renders
  CATEGORY_KEYWORDS live from @mightbuy/shared (one Card/category) — cannot
  drift; footer link "How categories work" (site-footer.tsx)
- feat: Uncategorised — ShopFilter union += {type:"uncategorised"}, matchesFilter
  returns !item.collection (active-only); app-sidebar FacetGroup gains
  `trailing` prop, Categories group ends with Uncategorised row when
  uncategorised.length>0 (null when 0 → guard hides cleanly); item-fields
  NO_COLLECTION label "None"→"Uncategorised"; shop heading/FILTER_TITLE
- feat: looksLikeProduct(draft) in shared (extract.ts) — true if
  source==="json-ld" || price|gtin|sku|mpn. Popup shows "not a product" state
  + "Save it anyway →" ghost fallback when false (bbc.co.uk). INVARIANT:
  trusts source==="json-ld" ⇒ Product node found (productFromJsonLd null for
  other @types + mergeExtractions only stamps source on non-empty layers) —
  comment in extract.ts; add hasProductNode flag if that ever changes
- known: looksLikeProduct false-negative on OG-only-no-price real products
  (mainstream/client-rendered retailers) — deliberate, Save-anyway covers it
- audit: first fully-clean wrap (4 specs accurate, 2 non-bug code notes
  hardened: invariant comment + OG-only known-behaviour)
scope: tree 001eb1b10f4582e09ef49af0c2b9a54f8e82c505 (commit: this wrap = v0.0.5)

## 0.0.4
- feat: auto-categorisation stage 1+keyword mapping — shared/src/categorise.ts
  (CATEGORY_KEYWORDS 13 canonical cats; suggestCategory(draft, collections):
  weights category×3/title×2/brand+site×1, \b keyword s?\b matching, ties →
  first-listed; user-collection override on shared whole words ≥2 chars)
- schema: ProductDraft += category (raw signal: JSON-LD Product.category
  string|Thing|array via categoryString, else BreadcrumbList joined " > "
  Home-dropped via breadcrumbFromJsonLd — backfills ONLY when a product node
  exists). Persisted on SavedItem via add-dialog rawCategory state
- feat: dialog pre-selects suggestion (only when prefs.defaultCollection
  empty) + ✨ Suggested badge (shows while form.collection === suggested);
  ItemFields collectionHint prop + `options` list surfaces suggested-but-new
  names in the Select; extension handoff += category param (popup App.tsx,
  shop.tsx parser)
- fix (audit): plural keywords removed (s? suffix double-counted "shoes" et
  al); greedy substring override → whole-word match; suggested-badge
  mislabelling on HTTP-error/manual fallbacks (setSuggested(null));
  suggestedCollection setState-in-helper refactored to pure
  computeSuggestion + explicit setSuggested at call sites
- E2E: adidas SL 72 → category "Shoes" → suggestion "Footwear" verified via
  extension handoff AND direct /?add=…&category=Shoes URL post-fix
- pending: LLM classification at backend era (auto-categorisation.md TODOs:
  prompt, per-(title,collections) caching, correction feedback)
scope: tree 1c0685c4b54d76969fdf241469e80d0e09c789a4 = commit ce59881

## 0.0.3
- feat: extension MVP — content script (apps/extension/entrypoints/content.ts)
  extracts from live DOM via @mightbuy/shared (JSON-LD → meta → h1/title),
  message {type:"mightbuy:extract"}; test bridge: html[data-mightbuy-ready],
  event "mightbuy-extract-request" → html[data-mightbuy-result]. Popup
  (entrypoints/popup/App.tsx, plain CSS) previews + hands off to
  localhost:3000/?add=1&url&title&price&currency&image&site&brand&source;
  target tab = active http(s) else most-recent (lastAccessed). manifest:
  permissions ["tabs"], MV3 via WXT
- feat: web handoff — shop.tsx consumes ?add params once (handoffConsumed
  flag, router.replace("/")), clears draft on dialog close; add-item-dialog
  initialDraft prop seeds details stage (render-time reset keyed on draft
  url); page.tsx wraps Shop in Suspense (useSearchParams)
- fix: extractor handles schema.org **ProductGroup** (Adidas/Nike pattern):
  first variant WITH offers (else first, else group) merged over group,
  group name/description preserved — extract.ts findProductNode. Server
  route benefits too
- fix: decodeEntities += &apos; (named entity); exported from shared
  (html.ts) and applied to title/brand in content script (sites HTML-encode
  inside JSON-LD — Rapha)
- fix: handoff draft never cleared → stale pre-fill on later Add clicks
  (audit catch); shop.tsx onOpenChange now nulls handoffDraft
- fix: add-item HTTP-error fallback seeds prefs (audit catch, 0.0.2 era)
- skill: session-start created (.claude/skills/session-start) reads
  CHANGELOG-LLM newest + documents/NOW.md + unwrapped-work check; NOW.md
  created (state of play; wrap step 4½ updates it); wrap: commit is part of
  wrap (standing instruction), tree-hash scoping, audit-by-default
- E2E proven (agent-browser --extension): ooni.com full JSON-LD; rapha.cc
  client-rendered → JSON-LD+sku from live DOM (server gets generic OG only);
  adidas.co.uk ProductGroup £54/£100 GBP incl. sale price
- pending: WEB_APP_URL hardcoded localhost:3000 in popup; handoff loses
  params when signed out; category/LLM-classification discussed, awaiting
  go-ahead
scope: tree e7091f51ed4c1ec97bd86696f9cb2de65194a049 = commit db70a81

## 0.0.2
- feat: changelog pages /changelog (react-markdown) + /changelog/llm (pre);
  global footer site-footer.tsx in root layout ("mightbuy v<ver> — wireframe");
  fs read via lib/changelog.ts (cwd/../.. — dev-only, bundle-or-drop pre-deploy)
- docs: 9 specs created in documents/specs/ (web/{shop,login,account,
  api-extract,changelog}, data-model, 00-overview, extension/{popup,
  content-script}); all audited by 2 independent subagents vs code; drift
  fixed in 5 specs; all stamped `_Verified against code: 0.0.1 (...)_`
- fix: add-item HTTP-error fallback now seeds prefs.currency/defaultCollection
  (was EMPTY_FORM GBP/"") — add-item-dialog.tsx
- skill: session-wrap hardened — verification stamps (move only after
  claim-by-claim compare), proof-of-work table (1 concrete code fact per
  spec), per-file accounting, independent audit DEFAULT (skip only trivial
  ≤2-file wraps, must state), typecheck+lint sanity before changelogs,
  version bump per wrap (patch; minor on milestones; sync package.json +
  footer), UK sentence-case headings, scope via `git add -A && git write-tree`
  tree hash in scope: line — enables multiple wraps/session
- convention: every wrap = new changelog entry, incl. docs/skill-only wraps
- versioning: 0.0.2 current; apps/web package.json + footer synced
scope: tree 7495ebb140b41c3f29f24e9cb1844658e5743159 = commit f6edb54 (0.0.1+0.0.2 combined catch-up commit)

## 0.0.1
- stack: pnpm monorepo; apps/web = Next.js 16.2.10 App Router + Tailwind 4 +
  shadcn v4 style "base-nova" on **Base UI** (NOT Radix — `render` prop, not
  `asChild`); apps/extension = WXT 0.20 starter (UNBUILT); packages/shared =
  TS source, `transpilePackages` in next.config
- feat: shop `/` — sidebar facets (categories/brands/stores, active items
  only) + status views Bought/Archived; components: shop.tsx, app-sidebar.tsx
  (ShopFilter union + matchesFilter), item-card.tsx
- feat: add-item dialog — POST /api/extract → preview/edit → save; duplicate
  warning (trailing-slash-insensitive, prefs.duplicateWarning); manual
  fallback keeps URL; defaults from prefs.defaultCollection/currency
- feat: fake auth — lib/session.ts (`mightbuy:session:v1`); /login (Google
  instant + magic-link demo flow); gates on / and /account
- feat: account page — profile edit, stats (active items), settings via
  lib/prefs.ts (`mightbuy:prefs:v1`, DEFAULT_PREFS merge), export/import JSON,
  delete account
- feat: item lifecycle — SavedItem.status active|bought|archived (missing ⇒
  active), boughtAt; store.markBought(id, onBought), autoArchive(days) runs
  on shop mount per prefs.autoArchiveDays; restore clears status+boughtAt
- feat: other-sellers sheet — deterministic mocks (lib/mock-sellers.ts, hash
  of item.id — Date.now/Math.random unavailable-by-design in demo data path);
  match tiers exact(GTIN)/possible; DEMO banner
- schema: ProductDraft += gtin/mpn/sku; extractor reads gtin13|12|14|gtin,
  mpn, sku from JSON-LD product+offer (packages/shared/src/extract.ts)
- storage: `mightbuy:v2` {items, collections} (v1 discarded — picsum/example
  demo data), `mightbuy:session:v1`, `mightbuy:prefs:v1`
- fix: hydration auth-bounce — useSession server snapshot `undefined`
  (unknown) vs `null` (signed out); gates redirect ONLY on `=== null`
- fix: crash on non-URL + manual entry (unguarded `new URL` in render)
- gotcha: Base UI DropdownMenuLabel throws outside DropdownMenuGroup
- gotcha: eslint react-hooks/set-state-in-effect — use render-time state
  reset keyed by id (see edit-item-sheet.tsx, account page name seeding)
- gotcha: shadcn init writes circular `--font-sans: var(--font-sans)` in
  globals.css @theme inline — use literal "Geist" names
- extraction reality (tested): Shopify=full JSON-LD; JL/Amazon=OG; Rapha=
  client-rendered (generic OG); Argos=403 Akamai; Uniqlo=timeout → extension
  is the answer for those (documents/access-strategy.md)
- pending: extension unbuilt; /api/extract NO SSRF guard/auth/rate-limit
  (must fix pre-deploy); no search/sort; no cross-tab sync; demo prefs
  (nudges, purchaseDetection, priceAlerts, digest) saved but unconsumed
- process: specs in documents/specs/ (one per surface; keep in sync — CLAUDE
  .md rule); /session-wrap skill writes this file; commit only when asked
scope: features through  (last commit 51225fd)