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.52
- feat: **landing-page visual overhaul** — indigo brand accent, MARKETING-ONLY.
  New tokens in globals.css: `--brand`/`--brand-foreground`/`--brand-muted` in
  BOTH `:root` (light `oklch(0.53 0.22 264)`) and `.dark` (brighter
  `oklch(0.62 0.2 264)`), registered in `@theme inline` as `--color-brand*` →
  `bg-brand`/`text-brand`. App's `--primary` UNTOUCHED (stays neutral). Page
  is theme-aware (next-themes system default) — verified light + dark + mobile.
- feat: **new "One shop, two ways to fill it" section** (landing.tsx) — two
  equal cards, web app (Globe) + browser extension (Puzzle), right after hero.
  Deliberate: stops visitors/affiliate-networks reading MightBuy as "just an
  extension".
- change: **hero** — eyebrow tagline "Might buy it, may buy it" → "For
  everything you haven't decided to buy yet"; bigger h1 "Save what you might
  buy into your own shop" (indigo highlight); indigo primary buttons + brand
  glow. **step 2** rebalanced: "Save it in one click" → "Save it, two ways",
  paste-a-link and extension now EQUAL (both bolded, paste leads), icon
  Puzzle→Plus.
- change: **shop screenshot reshot** — /shop-preview.png → /might-buy-app.png
  (1600×977, no "fgsdf" test list); old file removed. extension2.png unchanged.
- change: **em-dash sweep** (0.0.51 work folded in here) — all user-facing
  prose across landing/privacy/terms/categories/login/how-we-make-money now
  em-dash-free (rephrased naturally, not just deleted). Page-title separators
  `—`→`|`/`:`. Code comments left with em dashes (invisible).
- fix: **marketing page pinned LIGHT** — was following the app's
  `system`/`next-themes` theme, so dark-mode visitors got a dark, off-brand
  landing. New `.force-light` class (globals.css) redeclares light tokens +
  light-mode indigo, applied to landing.tsx `<main>` → renders light
  regardless of `<html>.dark`. App keeps its light/dark/system toggle;
  marketing is a controlled single look. Verified: renders light with browser
  forced dark. (Bug surfaced because a stale/zombie dev server on :3000 was
  ALSO masking the earlier eyebrow edit — killed, one clean dev server now.)
- style: hero eyebrow pill+sparkle icon → plain `text-brand` line (the
  badge+sparkle look reads AI-generated); removed unused `Sparkles` import.
- copy: "No creepy tracking" Promise card → title "No tracking" ("creepy"
  dropped — Chris's call, also read as an implicit swipe), body trimmed to
  analytics-only (removed the stray "extension only reads on save" line, which
  is already covered by the two-box section + /privacy). landing.tsx.
- change: **SKIMLINKS REMOVED from public content** (post-rejection )
  — how-we-make-money networks line → generic "established affiliate networks
  and retailer programmes" (no named partners, can't go stale). sellers/
  types.ts comment "Awin/Skimlinks"→"affiliate-network". Privacy/terms never
  named it. robots/sitemap comments keep "Skimlinks-readiness audit" (accurate
  history). Internal strategy docs keep the history incl. the rejection.
- context: **SKIMLINKS APPLICATION REJECTED ** — they don't work
  with browser extensions or scrapers (their site-type policy); appeal sent
  re: platform-not-extension framing. Aggregators (Skimlinks/Sovrn) are a
  structural mismatch for the extension+capture model. Per-merchant networks
  (Awin/CJ/Amazon) are the pivot — they judge per-brand, not site-type, AND
  give product feeds that fix the paste-a-URL capture blocking. Big-retailer
  paste-a-URL capture is STILL broken server-side (bot-blocked); extension
  works. See [[skimlinks-rejected-pivot-per-merchant]].
- infra.md: email-auth SPF/DKIM/DMARC table + `send.send` explanation + DMARC
  rua reporting (added ) + Google Workspace migration plan + Ltd
  ownership/billing model (all from the  email session).
- specs: landing.md rewritten (design system + narrative), legal.md
  (Skimlinks-removed + dashes), login.md (copy re-sync), categories.md (dash
  note) all stamped 0.0.52. Independent subagent audit: all pairs MATCH,
  Skimlinks-gone confirmed.
- pending: reshoot done ✅; Starling UTR; eBay keys; og:image; Google
  Workspace email setup (Chris, in progress); paste-a-URL capture fix
  (headless/proxy, no affiliate dep) is the real product unblock
scope: tree ee3fda37a6a5f3d699c0f84ccf3989de4b496794 (from `git add -A && git write-tree`)

## 0.0.51
- change: **ICO reference published on /privacy.** privacy/page.tsx "Who we
  are" section: the "registration reference will be shown here once the ICO
  issues it" placeholder → "registered with the ICO as a data protection fee
  payer, registration reference C1986846". Closes the last legal-page pending
  item; verified rendering on the dev privacy page. Context: Skimlinks doesn't
  require an ICO number — this is just keeping the page's own promise (Chris
  asked while prepping the Skimlinks application).
- specs: legal.md ICO line trued + stamp 0.0.51. Trivial wrap, inline verify.
- pending unchanged: SKIMLINKS APPLICATION (Chris applying now); Starling UTR;
  eBay keys; og:image; reshoot shop-preview without "fgsdf" (Chris deferred)
scope: tree 5462efb0a4c9194e1d52eb86b033aa4550acff39 (from `git add -A && git write-tree`)

## 0.0.50
- change: **"How categories work" footer link → dev-only.** site-footer.tsx:
  the `/categories` `<Link>` now wrapped in `isDev && (…)` where
  `isDev = process.env.NODE_ENV === "development"` (server component, read at
  render). Shows on localhost, HIDDEN on dev-preview + prod (both
  NODE_ENV=production). Public footer set drops from five → four (hello@,
  how-we-make-money, privacy, terms). The `/categories` PAGE stays a public
  route — only the link is gated (same pattern as the changelog pages).
- specs: 00-overview.md footer arrows corrected (categories link dev-only;
  changelog/todo links owner/dev-gated); changelog.md footer row "public
  five"→"public four" + dev-only note + stamp 0.0.50; categories.md
  access/footer line + partial re-stamp (footer line re-checked 0.0.50, body
  still 0.0.16). Trivial wrap — inline verify, no subagent.
- pending unchanged: SKIMLINKS APPLICATION; Starling UTR; eBay keys; ICO ref
  → /privacy; og:image; reshoot shop-preview without "fgsdf"
scope: tree 82457f0566cc31a1002c9e0ef118cc8a6965e66d (from `git add -A && git write-tree`)

## 0.0.49
- feat: **landing.tsx marketing content pass** — full rewrite, narrative
  order hero → how-it-works → visual proof → why-different → use-cases →
  sharing → FAQ → CTA. New local helpers: `Shot` (framed+captioned plain
  `<img>`, replaced old `ScreenshotSlot` placeholder), `StepNumber`,
  `Promise` (4 trust cards), `Faq` (4 items). How-it-works = 3 NUMBERED
  steps (was feature cards); step 2 names the browser extension explicitly.
  Four trust promises PROMOTED from grey footer bullets → bordered cards
  (data-never-sold / no-tracking / free-stays-free / earn-only-on-purchase).
  Hero "Free — and it stays free" promoted from text-xs footnote.
  Values-positive framing, NO competitor naming (Chris's call).
- feat: **two real screenshots** — apps/web/public/shop-preview.png
  (1600×998, shop grid) + extension2.png (1600×1198, extension popup
  mid-save on live Argos page). Both resized from ~2850px originals with
  `sips --resampleWidth 1600` (3.1M→1.2M, ~1M→580K). ⚠️ shop-preview.png is
  from Chris's REAL account seeded with demo items — faint "fgsdf" test list
  + real Morphy Richards/De'Longhi items visible in sidebar (accepted; can
  reshoot). Extension popup can ONLY be captured by Chris manually
  (agent-browser can't drive it — see CC memory extension-screenshots-manual)
- feat: **demoItems() now has stock photos** — lib/demo-items.ts: `img(id)`
  helper → `https://images.unsplash.com/photo-${id}?w=600&h=600&fit=crop&q=80`,
  all 20 items given a verified-200 Unsplash id. Was "no imageUrl on purpose"
  (black placeholders). Permanent upgrade: every "Load demo items" user now
  gets a visual shop. (source.unsplash.com is DEAD/503 — use direct
  images.unsplash.com/photo-<id> form.)
- fix: **owner pages 404'd on localhost** — /todo + /admin gated on
  isAdmin() which fails closed on ADMIN_USER_IDS mismatch (Chris's screenshot
  session was a non-owner account). Added `canViewOwnerPages()` (lib/admin.ts)
  = `isAdmin() || NODE_ENV==="development"`; todo/page.tsx:87 + admin/page.tsx:30
  swapped to it. ⚠️ DELIBERATELY pages-only: admin/actions.ts deleteUser +
  revokeUserTokens STILL gate on strict isAdmin() (localhost → PROD Supabase;
  delete-user is a real GDPR cascade). Verified live: /todo renders logged-out
  in dev. Independent subagent audit confirmed the actions gate is untouched.
- seo: metadata descriptions aligned (free/data-never-sold/earn-when-you-buy
  hook) across app/page.tsx + root app/layout.tsx.
- money page: how-we-make-money/page.tsx "Our rules" lead-in softened
  "criticised — and sued" → values-positive; four `<li>` rules unchanged.
- specs: landing.md REWRITTEN (was stale — described old 3-section page +
  "no screenshots yet"); legal.md money note + shop.md demo-items line +
  todo.md/admin.md gate + all 5 stamped 0.0.49. Independent subagent audit:
  all 5 spec↔code pairs MATCH, security check (#3 actions gate) passed.
- pending unchanged: SKIMLINKS APPLICATION; Starling UTR; eBay keys; ICO ref
  → /privacy; og:image still missing; reshoot shop-preview without "fgsdf"
scope: tree df1c22de4a686c8bfe3a12aa10322c304116a3aa (from `git add -A && git write-tree`)

## 0.0.48
- feat: **Vercel Web Analytics** — @vercel/analytics dep, `<Analytics />`
  from "@vercel/analytics/next" in app/layout.tsx body (docs-verified
  ; sends nothing in dev). ⚠️ RECORDS ONLY once Chris clicks
  Enable on the Vercel project's Analytics tab — was pending at wrap time;
  harmless if deploy lands first (script tag present, no data until
  enabled)
- privacy page (cookie audit findings): Cookies para now discloses the
  shadcn `sidebar_state` preference cookie (set client-side at
  ui/sidebar.tsx:86, signed-in only); analytics bullet fulfils the
  "we'll name the tool" promise — Vercel Web Analytics named, cookieless/
  no-device-storage claims per vendor privacy docs. VERIFIED LIVE EARLIER:
  anonymous visitors get ZERO Set-Cookie on / /login /terms → no consent
  banner required (PECR untriggered), posture unchanged by analytics
  (cookieless)
- specs: legal.md cookie/analytics claims + stamp 0.0.48; tech-stack +=
  @vercel/analytics row
- pending: Chris's Enable click (if not already); SKIMLINKS APPLICATION;
  ICO ref → /privacy; Starling UTR; eBay keys
scope: tree fa8af4e0720f6af8ebf1d7b6f01a8ae8bc29559d (from `git add -A && git write-tree`)

## 0.0.47
- footer gating v2 (Chris's spec): components/footer-links.tsx NEW
  (`SignedInFooterLinks`) — To-do + Changelog + Extension changelog +
  Changelog (LLM) render iff `session?.email === "chris@chrisdykes.co.uk"`
  (hardcoded OWNER_EMAIL; local Session type has flat `.email`, NOT
  `.user.email` — tsc caught that) **OR NODE_ENV=development** (localhost
  always shows them, signed-out included). todo-link.tsx DELETED.
  site-footer.tsx rewritten: public five = hello@mightbuy.xyz (address as
  visible label, was "Contact"), categories, money, privacy, terms
- gating is cosmetic (owner email ships in bundle — accepted); real guard
  stays /todo's server isAdmin() 404; changelog PAGES remain public routes,
  just unlinked. Beta users: public five only, no To-do 404 trap
- verify gap noted honestly: owner-email path not provable from dev (dev
  override always shows) — prod check = signed-out curl has no /todo link;
  Chris's signed-in browser is the owner-path proof
- specs: changelog.md footer row + todo.md arrived-at-from rewritten (first
  todo.md edit was a silent python no-op caught at wrap scope check —
  string mismatched across a line break; fixed with Edit tool), both
  stamped 0.0.47 inline (small wrap, no subagent)
- pending unchanged: SKIMLINKS APPLICATION; ICO ref → /privacy; Starling
  UTR; eBay keys
scope: tree 2ec061ca0fc9c13034208208b1a8aab21dbeeeeb (from `git add -A && git write-tree`)

## 0.0.46
- login/page.tsx: logo+wordmark wrapped in Link → `/` (centred via
  justify-center + justify-self-center in the CardHeader), new
  "← Back to website" Link under the Card (main → flex-col gap-4).
  next/link import added. login.md spec updated + stamped (trivial wrap,
  inline verification, no subagent)
- side facts from the session tail: Starling sole-trader account = legally
  Christopher Dykes, trading name MightBuy recorded (CoP matches both);
  signup still parked at the UTR step
- pending unchanged from 0.0.45: SKIMLINKS APPLICATION IS THE ACTION;
  ICO reference → /privacy on arrival; Starling UTR; eBay keys
scope: tree 6395d7f5d501502185059392b61b9eac51ab09ea (from `git add -A && git write-tree`)

## 0.0.45
- **🚀 WENT LIVE.** COMING_SOON removed from Vercel production env (from
  apps/web/, per the two-links gotcha) + this release's main deploy = gate
  off. mightbuy.xyz public. Gate code (proxy.ts + /coming-soon) remains,
  dormant — re-darken = re-add COMING_SOON=1 + deploy from main
- **business day (all Chris, guided in-session):** virtual office bought +
  AML-verified (Virtually There, £12.50/mo monthly trial, Swan Buildings
  M4 5JW — NOT the Registered Office addon, sole trader); **ICO registered
  + paid** (application C1986846, Tier 1 £52 by card — fees rose 2025, DD
  £47 missed; "Software Development" category; no DPO; reference number
  pending by email ≤1 working day → drop into /privacy when it arrives);
  **Starling sole-trader account** signup reached the UTR step (his
  personal SA UTR — on old returns/HMRC app). Email conventions settled:
  hello@ for all business accounts EXCEPT anything controlling the domain
  (circular-lockout rule); ImprovMX SMTP send-as available for replies
- **legal pages LAUNCH-CLEANED (Chris chose "clean everything" at
  go-live = his sign-off):** ALL Update spans → plain text; last 3
  Decision markers OFF-page (tracked in NOW.md: Amazon wording at
  enrolment, AI provider at categorisation-ship, ICO ref on arrival);
  networks line plain "such as Skimlinks and Awin…"; LegalShell banner →
  "Last updated: 18 July 2026" only. Decision/Update components remain
  defined in legal.tsx, unused. Pages: terms/privacy/money all rewritten
  via full Write — zero markers, zero marker imports
- specs: legal.md — audit caught its own top half still describing the
  marker era (intro, anatomy rows, banner line) → trued + stamped 0.0.45.
  00-overview coming-soon row → DORMANT. launch.md: 1.6 ✅ complete,
  gate history += removed-for-good 0.0.45, last-compiled line → LIVE
- NOW.md: TL;DR #1 = APPLY TO SKIMLINKS (nothing blocks it); Starling
  UTR finish; "SITE IS LIVE" focus bullet replaces the dark-state one
- context for next session: Skimlinks application is the single
  highest-leverage action; then Awin; eBay keys anytime; capture sweep +
  LLM categorisation remain the build queue. Solicitor pass = pre-promoted
  gate (banner no longer advertises draft status — Chris's launch call)
scope: tree 9832eead1b07178fcd8add8fa1f264f229ff8261 (from `git add -A && git write-tree`)

## 0.0.44
- **Skimlinks-readiness audit — verdict: REJECTED today, sole hard blocker =
  dark site.** Fixed this wrap: app/robots.ts (allow /, disallow /api/
  /admin /todo, sitemap pointer) + app/sitemap.ts (7 public URLs, /s/…
  deliberately absent) — BOTH WERE 404s; footer "wireframe"→"beta"; To-do
  link session-gated (new components/todo-link.tsx — useSession, renders
  null when server-snapshot-undefined OR signed-out, so no flash);
  Contact mailto:hello@ added. Path to apply (order): Chris reviews legal
  blues → address+ICO fill reds → soften "early draft" banner (Chris's
  call) → un-dark → apply same day. og:image still missing (not a blocker)
- **incident + process decision: NO LOCAL `next build`, EVER.** The 0.0.43
  wrap's local build wiped .next under the running dev server → Chris's
  /todo broke (mismatched chunks; the two share one dir, `next build` wipes
  it first). Dev server restarted detached by CC (logs in session
  scratchpad). A phase-keyed distDir guard (.next-build) was built, PROVEN,
  and then REVERTED at Chris's call — he wants boring vanilla config +
  correct process, not defensive guards. Wrap gates = typecheck + lint;
  Vercel builds on push (build errors surface on the dev preview BY
  DESIGN). Ship flow now: wrap → push dev → confirm preview build green →
  ship-to-main. Skill + CC memory updated; gotcha: Next auto-appends
  distDir type paths to tsconfig.json — that churn was reverted too
- virtual-office research (Chris asked): pick = Manchester Virtual Offices
  £10/mo Swan Buildings M4 5JW (explicit trading-address use); alt SUAZ
  £99.99/yr; Salford = Regus-only £60–80/mo, skip. Recorded in NOW.md
  signups block. Sole trader naming settled in 0.0.43 stands: personal name
  must appear (CA 2006 Pt 41); ICO = self-assessment first, slim exemption
  chance
- specs: landing.md += SEO plumbing note (robots/sitemap/og:image-gap);
  changelog.md footer row (beta, To-do gating, Contact); todo.md
  arrived-at-from rewritten + footer-visibility open question resolved. All
  3 stamped 0.0.44 after independent subagent audit — zero discrepancies,
  config reverts verified clean
- NOT touched: /todo page itself, sellers engine, extension, legal pages
  (0.0.43 state stands)
scope: tree fcb295a0c51ec4d4b1b791dc1e40e5f179b8bae7 (from `git add -A && git write-tree`)

## 0.0.43
- feat: **/todo owner dashboard** (app/todo/page.tsx) — isAdmin() gate (404
  like /admin), renders documents/NOW.md + launch.md via readChangelog
  (union widened to 5; same repo-root reader the changelog pages use in
  prod — ⚠️ documents/ is one level deeper, CONFIRM both docs render on the
  next preview/prod deploy; graceful per-doc fallback if not). remark-gfm
  ADDED (dep) for launch.md tables. Repo-relative md links render as plain
  spans (would 404 on-site); http(s) links keep stripDevOrigin. Footer:
  unconditional "To-do" link first (non-admins → 404; revisit visibility at
  public relaunch — spec'd in todo.md)
- legal: **all 12 decisions resolved in a Q&A session with Chris** → pages
  now 6 reds, ALL action-gated: virtual address ×2 (Chris signing up —
  renter, moves often, NEVER publish home address; street address not PO
  Box), ICO reference (Chris registers pre-relaunch; self-assessment first
  — slim exemption chance), AI provider (gated on categorisation feature),
  network names (fill at signup), Amazon wording (at enrolment). Blue fills:
  sole trader "Chris Dykes, a sole trader trading as MightBuy" (CA 2006
  Pt 41 requires the personal name; hello@ terms / privacy@ privacy), age
  18 (terms + privacy children), £100 liability cap (+ mandatory
  fraud/death/PI carve-out), E&W law/courts, privacy-friendly cookieless
  analytics commitment (none running), shared-list commission note promise
  (money page — now has Update import + 18 July date). All three pages
  dated 18 July 2026
- NOW.md: **new top section "TL;DR — who's holding what"** (on-Chris vs
  still-to-code) — session-wrap skill amended to require refreshing it
  every wrap; renders first on /todo. Signups block += virtual address +
  ICO items
- specs: todo.md NEW; legal.md trued (audit fixed its stale "money page
  unchanged" line); changelog.md footer row trued (Extension-changelog link
  had been missing from the spec since 0.0.35). All stamped 0.0.43 after
  independent subagent audit — legal marker counts/topics verified per page
- launch.md 1.6 → 🟡 decisions-done (remaining: Chris reviews blues, then
  action-gated reds); tech-stack += remark-gfm
- pending: Chris's blue-text read-through → unwrap Updates to plain;
  virtual address → fill both red placeholders; ICO ref → privacy; /todo
  render check on deployed preview
scope: tree 7599285863c9838fa8b25d0a405b9825595d5bad (from `git add -A && git write-tree`)

## 0.0.42
- feat: **"find other sellers" real backend.** New surface `POST /api/sellers`
  (route.ts: auth, 20/min `sellers:<uid>`, maxDuration 15, 400 unless
  gtin|brand+mpn|title) + provider layer apps/web/src/lib/sellers/{types,
  ebay-provider,mock-provider,search}.ts. `bestLookup()` ladder: gtin ≥8
  digits→exact / brand+mpn (lowercased key)→strong / title→possible; the
  lookup key doubles as the cache key
- schema: **`seller_offers` table** (Supabase migration `seller_offers_cache`,
  applied via MCP — project ref mrmtzvquydcvqkxevhxl, dashboard shows
  "maybuy"): lookup_key/source/seller/title/price/currency/url/image_url/
  condition/availability/match_grade CHECK/fetched_at, index (lookup_key,
  fetched_at desc). **RLS on, ZERO policies** — global product cache, service
  role only, never user data, mock never written
- search.ts: cache (12h TTL) → Promise.allSettled fan-out over enabled real
  providers → delete-then-insert write-through; `degraded:true` when all real
  providers fail; mock only when NO real provider enabled (dev or
  SELLERS_MOCK=1). Offers sorted cheapest-first server-side
- ebay-provider: client-credentials token (module cache, 60s early refresh),
  GET /buy/browse/v1/item_summary/search, `gtin=` XOR `q=` (docs forbid
  combining), X-EBAY-C-MARKETPLACE-ID default EBAY_GB, limit 10, 6s timeouts.
  Enabled iff EBAY_CLIENT_ID+EBAY_CLIENT_SECRET. ⚠️ eBay doc pages disagree
  on response field names (price vs currentPrice, itemWebUrl vs itemHref) —
  parsing is defensive; **first call with real keys = true contract test**
- UI: other-sellers-sheet.tsx rewired to the API — GRADE_HINT tooltips
  (exact✓/strong?/possible?), seller's listing title line, condition-when-not-
  New, per-offer currency, savings only when currencies match, real Visit
  links (Base UI `render={<a/>}` — NOT Radix asChild, that was a tsc error),
  demo badge iff sources includes "mock". lib/mock-sellers.ts DELETED (logic
  moved to mock-provider). Gotcha: eBay provider sets no `availability` →
  "Low stock" label is currently mock-only
- specs: api-sellers.md NEW (+ wrap-skill mapping row + 00-overview rows);
  audit (independent subagent) caught: data-model seller_offers row missing 5
  columns code depends on (fixed), /api/sellers absent from overview site-map
  API grouping (fixed), 3 unspecced details folded into api-sellers.md
- verified: unauth 401 curl; typecheck/lint/build 3/3; full authed path needs
  Chris's dev click (mock flow) — not yet human-verified
- pending: **Chris signups** (NOW.md ☐ block): eBay dev keyset (anytime,
  free) → 2 env vars → live; Skimlinks/Sovrn + Awin at relaunch; PA-API
  parked. Feed importer deliberately NOT pre-built (no real feed file to
  build against). 0.0.41 pendings (Shortcut rebuild+test, legal reds) stand
scope: tree c2ae6f8758777327b2898b1e5dd64bed627cbf0c (from `git add -A && git write-tree`)

## 0.0.41
- feat: **graceful degrade for blocked-store saves.** New
  `titleFromUrl(url)` in packages/shared/src/title-from-url.ts (exported via
  index.ts): best-effort title from the URL path slug — word tokens need a
  letter; pure-digit END tokens ≥4 chars dropped (catalogue ids); needs ≥2
  words + ≥6 alpha chars (kills "en-gb", "products"); later segment wins
  ties; Title Case; 120 cap. Smoke-tested 11 shapes: Currys/ASOS/Etsy/
  IKEA/JL → good guesses; Argos `/product/9539106`, Amazon `/dp/ASIN` →
  undefined (id-only, correct)
- add dialog (add-item-dialog.tsx): 502 from /api/extract → NO toast, drops
  to details with url + slug-guessed title + hostname store + prefs defaults,
  new `unreachable` state renders CloudOff Alert ("Couldn't fetch details
  from this store" + guessed-vs-blank copy variants + extension tip).
  **Non-502 (400/401/429) now STAYS on the URL step** (was: dump to empty
  manual form) — fixable errors get fixed in place. Network throw unchanged
  (toast, stays). "Enter manually" also prefills the slug guess
- /api/items route.ts:128: title chain now `str(body.title) ?? pick("title")
  ?? titleFromUrl(url)` — thin-client (Shortcut/Android) bare saves to
  blocked stores get a readable title when the slug has words; source stays
  "manual" (guess is presentation, not extraction). Enrichment gating +
  best-effort swallow unchanged
- specs: shop.md add-flow failure paths rewritten + api-items.md title chain;
  both stamped 0.0.41 after independent subagent audit (1 omission found+
  fixed: prefs defaults on degrade paths; everything else confirmed, incl.
  titleFromUrl algorithm trace)
- config: **root `.vercel/` DELETED** (was linked to dead `_ecomm` project —
  the 0.0.40 gotcha). Untracked/gitignored, local-only. `vercel` from root
  now says "not linked" instead of the misleading access error;
  apps/web/.vercel → `mightbuy` is the only link left. NOW.md gotcha line
  resolved
- strategy (discussed, not built): **affiliate feeds confirmed as the
  paste-a-URL fix for blocked retailers** — Awin datafeeds (Argos/Currys/
  etc.) = product catalogue lookup keyed by URL-parsed product id; pasted
  links stay plain, monetised wrapping stays outbound-click-only. Skimlinks/
  Sovrn day-one at relaunch, Awin per-merchant behind it, PA-API parked
  (needs 3 sales). Chris's iOS Shortcut being rebuilt by hand as ON-DEVICE
  capture: Receive Safari web pages → Get Type branch → Run JavaScript on
  Web Page (JS extractor snippet provided in-session, mirrors shared
  extraction: JSON-LD → OG → title) → POST JSON.stringify'd payload as File
  body to /api/items; needs Settings→Shortcuts→Advanced→Allow Running
  Scripts. Chrome-iOS/Android shares stay URL-relay (fall to Otherwise
  branch). UNTESTED experiment: Shortcuts "Get Contents of URL" on-device
  fetch (no CORS, residential IP) might beat Akamai — 2-min Quick Look test
  on Chris's phone would tell
- pending: Chris finishing + testing the rebuilt Shortcut on Argos-in-Safari;
  iCloud link → IOS_SHORTCUT_URL still outstanding; legal reds (Next up #0)
  now also on the affiliate-approval critical path — networks review live
  legal pages
scope: tree 1aae7452e52c16dd1be65434ee7aafe98d4100af (from `git add -A && git write-tree`)

## 0.0.40
- **RE-DARKENED PRODUCTION** (Chris's call, ~5h after 0.0.39 removed the gate).
  `vercel env add COMING_SOON production` = `1`, then ship-to-main → the
  deploy from `main` is what makes it bite (env change alone never redeploys —
  same lesson as 0.0.39's empty commit 41fcd96, in reverse)
- **ZERO CODE CHANGED.** Config-only wrap: `git diff --name-only <0.0.39 tree>`
  = CHANGELOG-LLM.md only. The gate was always intact and needed no rebuild —
  proxy.ts:10 `COMING_SOON === "1"` + app/coming-soon/page.tsx both untouched
  since they were written. Specs unaffected (no surface changed) → no stamps
  moved, no audit subagents (skill's trivial-wrap exemption, honestly earned:
  there was nothing to audit). typecheck clean, 3/3 packages
- **🔴 GOTCHA — TWO `.vercel` LINKS IN THIS REPO.** Root `.vercel/project.json`
  → `_ecomm` (prj_GNbLWJM5c52El4EOj7C5t9CrXLQZ) — a project that NO LONGER
  EXISTS; `apps/web/.vercel/project.json` → `mightbuy`
  (prj_ksRJ0E3nlJcAdmPr6eDAXxzmCTmo) — the real one. **ALWAYS run `vercel`
  from `apps/web/`.** From the root the CLI returns "Your Project was either
  deleted, transferred to a new Team, or you don't have access to it anymore"
  — a catch-all that reads like an auth/ownership failure but here just means
  "this dead project id". I mis-read it as the project being gone and told
  Chris so; he corrected me with a dashboard screenshot. Auth was fine
  throughout (`vercel whoami` → chris-5013). Stale root link is still there —
  Chris's call whether to delete it
- **Gate semantics (unchanged, worth restating):** proxy.ts rewrites all pages
  → /coming-soon + `X-Robots-Tag: noindex`, but `/api/*`, /robots.txt,
  /sitemap.xml fall through. So **the extension keeps saving while dark, and
  the API stays publicly reachable** — dark ≠ closed. If the API surface ever
  needs closing too, that's a separate change
- prod env now: COMING_SOON, ADMIN_USER_IDS, SUPABASE_SECRET_KEY,
  NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY, NEXT_PUBLIC_SUPABASE_URL
- pending (unchanged from 0.0.39): iOS Shortcut token + iCloud link (Chris);
  entity decision; affiliate APIs as the real capture fix (server fetch is
  blocked by ~5/9 big retailers); full capture sweep. **Legal reds are private
  again** — no longer the live-site blocker, back to a pre-relaunch blocker
scope: tree ac9f28a26e4c7967e2b2da7ce90615e54edfdef0 (from `git add -A && git write-tree`; config-only — no code delta vs 0.0.39's tree 64f60f71)

## 0.0.39
- **PRODUCTION IS LIVE AGAIN — GATE REMOVED** (Chris's call: needed the real
  site to test capture). `vercel env rm COMING_SOON production` + redeploy
  (empty commit 41fcd96 — the env change alone doesn't redeploy). Verified:
  `/` = landing page, /login 200, no X-Robots-Tag, /terms shows its red
  markers **publicly**. The gate CODE remains (proxy.ts + /coming-soon) —
  re-dark = re-add the env var + redeploy. **Legal reds are now public and
  the site is indexable** — accepted trade, still the top beta blocker
- **SAVE API: SERVER-SIDE ENRICHMENT.** Root-caused from a real failure:
  Chris's iOS Shortcut saved BLANK items. `POST /api/items` only ever stored
  what it was sent (the extension extracts on-page → full draft; a Shortcut
  can only send `{url}` → blank). Now: **when body has no `title`**, the
  route calls `extractProduct(url)` and fills ONLY omitted fields (`??` per
  field → client ALWAYS wins); `source` becomes the extractor's real verdict
  (`enriched?.source ?? "manual"`). **Best-effort**: try/catch swallows all
  failures → bare item still saved 201, never lost. `maxDuration = 15`
- **NEW apps/web/src/lib/extract-product.ts** — `extractProduct()` = the
  guardedFetchHtml + extractFromHtml pair factored OUT of
  /api/extract/route.ts so /api/items reuses it directly (NO HTTP self-call,
  one implementation). extract route refactored to call it; behaviour
  unchanged (still maps BlockedUrlError→400, UpstreamError→502)
- verified empirically: bare `{url:"https://example.com"}` → title/siteName/
  source populated (was blank); client-sent title survives untouched; full
  drafts ~0.45s vs bare ~1.06s (proves no needless fetch); dead domain → 201
- **🔴 MAJOR FINDING — SERVER FETCH IS BLOCKED BY BIG RETAILERS.** Probe of
  prod /api/extract (homepages, to avoid bad-URL false negatives): 🟢 John
  Lewis, IKEA (full product extract, BILLY £55), eBay · 🔴 Amazon 503, Argos
  403 (confirms  access-strategy.md), Currys 403, Next 403, Etsy
  403, ASOS timeout. **~4/9 reachable.** So enrichment CANNOT rescue those —
  no HTML to parse. **This is NOT an iOS problem, it's a SERVER-FETCH
  problem**: paste-a-URL, the iOS Shortcut AND the Android share→add-dialog
  all read via our server and so ALL fail on blocked sites; only the
  EXTENSION works (reads DOM in the user's browser/IP — Chris's Argos kettle
  + Currys TV saves are proof). Host adapters are USELESS against a 403 (the
  Amazon adapter's `fromUrl`/ASIN half already runs on paste-a-URL via
  html.ts:49 `applyHostUrlFields`; its `dom` half needs HTML we can't get).
  Answers, in leverage order: (1) affiliate APIs — fixes every path incl.
  paste-a-URL, is the revenue route anyway, Amazon PA-API keyed by the ASIN
  we already parse; (2) on-device capture (app / on-device-fetch Shortcut —
  how Locker & Pinterest do it); (3) adapters only for reachable-but-messy
  sites. Written up in capture-sweep.md
- **NEW documents/monetisation.md** (Chris's, 376 lines, researched
  ): affiliate plan — networks, order, blockers, tax. Must never
  contradict /how-we-make-money
- specs: api-items.md (+enrichment step 1½), api-extract.md (step 4 →
  extractProduct), mobile-capture.md (bare-{url} now enriched)
- **PROCESS SLIP (mine):** shipped the enrichment fix + relaunch to main
  BEFORE wrapping (rhythm is build→wrap→push dev→ship). Docs/code were out
  of sync until this wrap. Don't repeat: wrap first, ship after
- pending: iOS Shortcut token + iCloud link (Chris); legal reds NOW PUBLIC;
  entity decision; affiliate/Skimlinks as the real capture fix; full capture
  sweep
scope: tree 64f60f71536ae5c1d4e4b632cc2df956158fcbbe (from `git add -A && git write-tree`; enrichment+relaunch already on main as fb66b46/41fcd96 — this wrap is the docs catching up)

## 0.0.38
- **MOBILE SHARE CAPTURE** (rungs 2+3 of mobile-capture.md, Phase 1
  confirm-first). Spec: specs/web/mobile-capture.md (NB named to avoid the
  existing share.md = `/s/[slug]` LISTS collision)
- **NEW app/manifest.ts** — PWA manifest; installable + `share_target`
  `{action:"/share", method:"GET", params:{title,text,url}}`. share_target
  isn't in Next's MetadataRoute.Manifest type → typed spread. icons
  192/512 (added to extension's gen-icons.mjs sizes, copied to
  apps/web/public/icon/ — one monogram source)
- **NEW app/share/route.ts** — GET. Reads `url ?? text ?? title`, regexes
  first `https?://…` out (Android buries URL in `text`; never trust field
  names) → builds `/?add=1&source=manual&url=…` handoff (+title if not a
  URL). Signed-in → redirect handoff; signed-out → `/login?next=<full
  path+query>`. No URL → still opens add dialog for manual paste
- **AUTH `next`-with-query plumbing** (needed so the share survives login):
  login/page.tsx derives `nextParam` from same-origin `?next=` (`/…` not
  `//…`), forwards into `emailRedirectTo` + Google `redirectTo`.
  **confirm/route.ts CHANGED**: was `redirectTo.search=""` (stripped query);
  now parses next via `new URL(rawNext,origin)` and keeps
  `redirectTo.search=nextUrl.search` → `/?add=1&url=…` round-trips.
  callback/route.ts already preserved query via `${origin}${next}` concat
- **NEW save-from-phone.tsx** (account card, before API-access): iOS/Android
  tabs (default from UA `guessPlatform`, lazy useState init — not setState-
  in-effect), per-platform walkthrough, Android `beforeinstallprompt`→live
  Install button, iOS button gated on `IOS_SHORTCUT_URL` (=`""` →disabled
  "Shortcut coming soon"). Wired into account/page.tsx
- **iOS SHORTCUT: NOT DONE** — Chris tried building it on-device, couldn't
  finish. KEY FINDING: `.shortcut` files are Apple-signed binary, CANNOT be
  server-generated (kills the "bake token in" auto-gen idea). No App Store /
  approval / fee — distributed via iCloud link only; Apple's "untrusted
  shortcut" prompt is the one speed-bump. Best achievable flow (designed,
  UNBUILT): Shortcut uses an Import-Question token prompt + account-page
  "mint token + copy to clipboard + open Shortcut" button. Recipe for the
  master Shortcut: Receive URLs from Share Sheet → Get Contents of URL (POST
  https://mightbuy.xyz/api/items, headers Authorization: Bearer <token> +
  Content-Type: application/json, JSON body {url}) → Show Notification. NO
  Origin header needed (api-items.md: bearer path skips CSRF/origin check)
- gate note: `/share` + manifest are currently rewritten by the coming-soon
  gate (correct — nobody should install/capture while prod is dark); they go
  live at relaunch. api/* stays open so a token POST works today
- specs updated: account.md (+card), auth.md (next-query preservation +
  flows), 00-overview.md (site-map /share line + index row); mobile-capture.md
  NEW. Independent subagent audit: all code facts confirmed; found the 3 doc
  gaps above + confirmed the share.md naming-collision risk (avoided)
- verify: pnpm -r typecheck 3/3 + web lint clean; manifest serves
  share_target; /share regexes URL from Android text-blob + routes
  signed-out via /login?next=… (all curl-verified on dev)
- pending: iOS Shortcut authoring + token-baked flow (Chris); relaunch to
  expose /share + PWA; Chris legal reds + blue-text review; entity decision
scope: tree ba2a4d6a58f145fdaef8c3fbb8792186052b6e94 (from `git add -A && git write-tree`; last pre-wrap commit 942f207)

## 0.0.37
- **MARKETING LANDING PAGE at `/`** (Next.js, not Vite — confirmed this
  session; whole web app is Next 16 App Router). NEW
  apps/web/src/components/landing.tsx (server component, static, no
  "use client", no state/data). `/` now FORKS SERVER-SIDE in app/page.tsx:
  `const {data}=await supabase.auth.getClaims(); if(!data?.claims) return
  <Landing/>` else `<Suspense><Shop/></Suspense>`. Signed-out visitors get
  the pitch instead of the old bounce-to-/login; signed-in unchanged. No
  redirect flash. page.tsx gained SEO metadata (title/description)
- landing.tsx sections: hero (logo+wordmark, tagline, h1, Create-your-shop +
  Sign-in CTAs BOTH → /login [magic link signs up first-timers], free-to-use
  line → /how-we-make-money); how-it-works 3-col; trust (no-ads/data-never-
  for-sale/never-touch-links/price-unchanged → /how-we-make-money). Base UI
  Button `render={<Link/>}`, lucide icons. NO screenshots yet (nothing
  staged); extension CTA soft-pedalled until CWS-live
- Shop keeps its CLIENT gate as backup (shop.tsx:368-370, `session===null`
  →replace("/login")) for mid-visit expiry; stale ":367 fake session in
  localStorage" comment CORRECTED (real Supabase session now)
- specs: NEW web/landing.md; shop.md Route&access rewritten (server fork +
  client backup); 00-overview.md site-map diagram + fork bullet + index row.
  Independent subagent audit: landing.md & shop.md accurate first pass;
  found the overview site-map diagram stale (still showed `/` login-gated
  for all) → fixed
- gotcha (unchanged, reconfirmed): only ONE `next dev` per project; the dev
  server on :3000 stays up across wraps — don't launch a 2nd
- verify: typecheck + lint clean; signed-out GET / renders landing
  (Create-your-shop / tagline / trust section present), /login still 200
- pending: hero screenshots once shop UI showable; extension store-badge CTA
  once CWS-live; Chris reviews legal blue text; entity decision; legal reds
scope: tree 6087178d3d1500e78e66dd5ce3d3e460fd94a382 (from `git add -A && git write-tree`; last pre-wrap commit e836782)

## 0.0.36
- **PRODUCTION IS DARK (Chris's call — legal not sorted).** COMING-SOON GATE:
  src/proxy.ts reads `COMING_SOON==="1"` at module load → rewrites EVERY
  route to /coming-soon (new page, static, robots noindex + X-Robots-Tag
  header) EXCEPT `/api/*` (extension keeps saving; routes carry own auth),
  /robots.txt, /sitemap.xml, /coming-soon itself. Gated requests SKIP
  updateSession (no cookie refresh while dark). Flag lives in Vercel
  **production env only** → previews/localhost unaffected. GOING LIVE AGAIN =
  `vercel env rm COMING_SOON production` + redeploy; NO code change
- **SHIPPED TO MAIN mid-session** (Chris asked, pre-wrap): main=dev @001adcc;
  verified live: mightbuy.xyz + .vercel.app all pages → coming-soon,
  unauth POST /api/items → 401 (not HTML), noindex header present. This
  wrap's commit ships after too — **main stays dark** (env var untouched)
- **LEGAL AUDIT** (terms/privacy; money page unchanged): NEW blue `Update`
  marker (legal.tsx — filled/corrected text pending Chris's sign-off; unwrap
  to plain text once approved; red `Decision` = still open). FILLED (infra
  resolved): privacy@/takedown@mightbuy.xyz (ImprovMX catch-all), Supabase
  London eu-west-2 residency. CORRECTED stale wireframe-era claims:
  localStorage-only→server-side accounts, simulated-auth→magic-link (email
  stored), "extension sends nothing to servers"→posts to /api/items,
  "no cookies"→essential sign-in cookies, rights→self-serve export/delete
  via /account. NEW "Your data is not for sale" section (Chris's ask).
  Dates→16 July (terms+privacy). REMAINING RED: entity+address ×2, min age
  ×2, liability cap, governing law, analytics y/n, AI provider, ICO
  (sharpened: now DUE — accounts live), affiliate networks, Amazon wording,
  /s/ inline disclosure
- entity direction (discussed, undecided): fresh "MightBuy Ltd" + registered
  office service (~£100 yr-one; plain PO boxes banned since 2024) + director's
  service address; sole-trader wording acceptable for private beta
- specs: legal.md updated (Update row, backend-era story, resolved-list);
  coming-soon.md NEW; auth.md + 00-overview.md gate-related updates
- gotchas: Next 16 = ONE dev server per project (2nd `next dev` refuses;
  verify via `next build` + `next start -p 3001`); vercel CLI link lives in
  **apps/web/.vercel** not repo root (env cmds must run from apps/web)
- verify: pnpm typecheck 3/3 + web lint clean; gate curl-verified local prod
  build AND live prod (pages rewritten, API 401, noindex, ungated dev normal)
- pending: Chris reviews blue text (then unwrap); entity decision; ICO
  registration; legal reds; listing copy/screenshots; rotate Resend key
scope: tree e89671c78787ecc33aa5d8bf29e763b31c94b815 (from `git add -A && git write-tree`; last pre-wrap commit 001adcc)

## 0.0.35
- **EXTENSION STORE-READY WORK** (all in apps/extension/):
  - **tabs→activeTab** (wxt.config permissions): removed the broad `tabs`,
    now `['activeTab','storage']`. Dropped the all-tabs `findTargetTab`
    fallback (App.tsx) — it needed `tabs` + only existed for the old test
    harness. activeTab reads/messages ONLY the invoked tab; tabs.create
    (open shop) needs no permission. Verified in built manifest + bundle
  - **test bridge → DEV-ONLY** (content.ts): wrapped in
    `if(import.meta.env.DEV)` → tree-shaken from `wxt build` (0 occurrences
    in shipped content.js, 1 in `wxt dev` build — both verified empirically)
  - **FIXED latent bug**: config.ts read `import.meta.env.MIGHTBUY_WEB_URL`
    which was ALWAYS undefined (WXT/Vite only expose VITE_/WXT_-prefixed env
    → the dev-URL override never worked). Now `WXT_WEB_URL`; verified default
    →mightbuy.xyz, override→localhost:3000
  - **version 0.0.0 → 0.0.10** (package.json; clears CWS version blocker)
- **EXTENSION CHANGELOG SYSTEM** (Chris's design): extension now has its OWN
  version line + changelog, independent of web app (ships to CWS on its own
  cadence). NEW **CHANGELOG-EXTENSION.md** — renumbered from 0.0.1, one entry
  per real extension milestone (MVP→…→activeTab = 0.0.1→0.0.10). NEW page
  **/changelog/extension** (app/changelog/extension/page.tsx — mirrors the
  main changelog, no files-touched details, cross-links to /changelog).
  changelog.ts readChangelog() union widened to accept the 3rd file. Footer
  link added (site-footer.tsx)
- **session-wrap SKILL updated** (the root-cause fix): the extension version
  sat at 0.0.0 for 30+ web wraps because the ritual only knew the web app.
  Skill now: web version bumps every wrap (CHANGELOG.md/LLM +
  apps/web/package.json + footer); **CHANGELOG-EXTENSION.md + apps/extension/
  package.json bump ONLY when the wrap touched apps/extension/**. All
  "two changelog" framing (description/intro/Step4/report) updated to include
  the conditional 3rd
- CWS blocker status: icons✅ name✅ WEB_APP_URL✅ version✅ test-bridge✅
  activeTab✅. Remaining: legal reds + listing copy/screenshots (Chris)
- verify: pnpm typecheck 3/3 + web lint + extension build clean; ext manifest
  = activeTab+storage, v0.0.10, bridge-free; /changelog/extension renders 10
  entries, dates stripped, footer link live; screenshot eyeballed
- pending: Chris reload+click-test the activeTab build (final live confirm);
  legal reds, listing, §3 Google, rotate Resend key
scope: tree d9c7bd9e33fbc1a3aa9056fdc572a9787528d5f2 (from `git add -A && git write-tree`; last commit 6d20eea)

## 0.0.34
- **extension ICONS = real M-monogram** (was scaffold art). NEW
  `apps/extension/icon-source.svg` (concrete #111 tile/#fff mark — raster
  can't inherit currentColor) + `scripts/gen-icons.mjs` (sharp, dev dep) →
  `public/icon/{16,32,48,96,128}.png`. Re-run `node scripts/gen-icons.mjs`
  on brand change. **Clears CWS icon blocker + launch item 2.4.** Renders
  crisp incl. 16px
- **BRAND-CASING MISSES fixed** (the 0.0.32 sweep missed these):
  (1) **wxt.config manifest `name` "Mightbuy — save…" → "MightBuy…"** — the
  STORE-LISTING NAME, most user-visible of all; also `default_title`
  "Save to MightBuy" + a comment + extension package.json description;
  (2) **login/page.tsx wordmark span** `mightbuy`→`MightBuy`;
  (3) **s/[slug]/page.tsx prose** "shared on mightbuy"→"MightBuy".
  Root cause of the misses: 0.0.32 used a hand-list of 4 wordmark spots +
  a single-line `>mightbuy<` grep that couldn't see multi-line JSX text or
  config files. This time: word-boundary + whitespace-tolerant + classify-
  every-occurrence sweep. NOW CLEAN — verified every remaining lowercase
  `mightbuy` is a key/url/identifier/message/**export-filename slug**
  (`mightbuy-export-*.json` deliberately kept lowercase — filenames follow
  filename convention, nothing matches the prefix)
- tech-stack.md += sharp (extension devDep, icon gen). chrome-web-store.md
  icon blocker marked DONE. launch.md 2.4 done. popup.md manifest+icon note
  + stamp 0.0.34
- verify: pnpm typecheck 3/3 + extension build (icons bundle 16/32/48/96/128)
  + rendered /login shows MightBuy ×5, 0 lowercase; built manifest name
  = "MightBuy — save things you might buy"
- pending CWS blockers still open: version 0.0.0 (bump before submit);
  strip E2E test bridge from prod build; investigate tabs→activeTab
  downgrade; legal reds; listing copy/screenshots. Chris: rotate Resend key,
  §3 Google, Supabase project-label rename
scope: tree 67a7d69430631685391bdfea1faa5af3e551b1f5 (from `git add -A && git write-tree`; last commit 7d0cb54)

## 0.0.33
- **DOCS/PROCESS ONLY — no app code changed.** No spec audit (branding sweep,
  not behaviour).
- **NEW documents/tech-stack.md** — languages/frameworks/libraries + all
  third-party services (Vercel, Supabase, Resend [sending], ImprovMX
  [receiving], GitHub); "planned" + "deliberately NOT using" (Cloudflare, ORM,
  state lib, mailbox host) sections; points at infra.md for account/wiring.
  Versions pulled from actual package.json (Next 16.2, React 19.2, WXT 0.20,
  supabase-js 2.110, Tailwind 4, Node 24, pnpm 9.15.9). Linked from CLAUDE.md
- **session-wrap SKILL gained Step 4⅞** — keep tech-stack.md current (only
  when a dep/service actually changes; skip on normal feature builds)
- **config: Supabase Site URL → https://mightbuy.xyz** (was vercel.app; domain
  now serves 200, www→apex 308 confirmed). allowlist already had xyz.
  Applied via Mgmt API PATCH /config/auth
- **docs branding sweep Mightbuy→MightBuy** across 16 dev-facing files (docs,
  specs, CLAUDE.md, all 3 skill SKILL.md + their frontmatter). SKIPPED the
  changelog files (history verbatim, like maybuy). Protected keep-tokens
  (useMightbuyStore/openMightbuy/@mightbuy/MightbuyBot) + all lowercase
  mightbuy:* keys + mightbuy.xyz/.vercel.app URLs — verified none broke
- verify: no typecheck (no code); grep-confirmed no MightBuy:v2 / MightBuy.xyz
  / @MightBuy / useMightBuy anywhere; lowercase keys intact in skill examples
- pending: rotate Resend key (Chris, dashboard); rename Supabase project label
  maybuy→MightBuy (Chris, cosmetic); Chris: §3 Google, legal reds, capture
  sweep, icon PNGs
scope: tree 76d5f4b92f07f709f9301de6bdabc1e61efa2aac (from `git add -A && git write-tree`; last commit a52ebc7)

## 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)