Written for Claude sessions, not humans — dense paths, schema changes and gotchas. The readable one is at /changelog.
# MightBuy changelog — LLM-optimised
Read newest entry first at session start. Each entry ends with a scope marker.
## 0.0.32
- **BRAND CASING: "Mightbuy" → "MightBuy"** (Chris's call — matches the
Supabase sender name set in 0.0.31). Case-aware sweep over user-facing
strings only. CHANGED: page/tab titles, legal-page prose (terms/privacy/
how-we-make-money), toast messages, extension popup UI, meta descriptions,
the lowercase logo wordmark spans (sidebar/share-header/extension/footer —
Chris chose "MightBuy everywhere", not a lowercase-logo look), both
changelog `#` headings
- **DELIBERATELY UNCHANGED (do NOT rename these — lowercase `mightbuy` is
load-bearing):** `useMightbuyStore`/`openMightbuy` (identifiers),
`@mightbuy/*` (package names), storage keys (`mightbuy:v2`, `mightbuy:token`,
`mightbuy:lists`, `mightbuy:prefs:v1`, etc.), URLs (`mightbuy.xyz`,
`mightbuy.vercel.app`), `MightbuyBot/0.1` User-Agent (external services may
key off the exact string), internal code comments (left as-is to keep the
diff to user-facing text). Sweep protected these tokens explicitly
- verify: pnpm typecheck 3/3 + web lint + extension build clean; live-checked
footer ("MightBuy v0.0.32") + /terms prose + /changelog (200) render the new
casing; identifiers/keys/UA all confirmed still lowercase
- no spec/behaviour change — cosmetic only. Docs (CLAUDE.md, concept.md) left
on "Mightbuy" as dev-facing internal text (low value to churn; not shown to
users). Historical changelog ENTRIES left verbatim (record, like maybuy)
- pending: flip Supabase Site URL to mightbuy.xyz; Chris: §3 Google, legal
reds, capture sweep, icon PNGs, rotate Resend key
scope: tree f2bba146eb83a1ee662d411cf96e94c511a00088 (from `git add -A && git write-tree`; last commit 2b82827)
## 0.0.31
- **SMTP LIVE**: Resend wired into Supabase custom SMTP (host smtp.resend.com,
465, user `resend`, From `noreply@send.mightbuy.xyz`, sender name
"MightBuy"). Real magic link **delivered** to chris@ — **landed in SPAM**
(expected: fresh .xyz; "not spam" trains it). DMARC still p=none → tighten
to quarantine after ~2wk. Escape hatch if it bites: Postmark $15/mo. Resend
API key = sending-access, scoped to send.mightbuy.xyz (stored in Supabase +
Chris's password mgr; NOT in repo). NOTE Chris's key was briefly on-screen
in a screenshot — low risk (send-only, domain-scoped) but rotate when tidy
- **URL REPOINT vercel.app → mightbuy.xyz** (kept vercel.app as fallback):
extension config.ts WEB_APP_URL default; wxt.config host_permissions (+xyz,
+www implicit via /*); lib/cors.ts isOwnWebOrigin (+mightbuy.xyz +www);
Supabase auth uri_allow_list (+https://mightbuy.xyz/** +www, via Mgmt API).
Supabase **Site URL still mightbuy.vercel.app** — flip to xyz once confirmed
serving live for Chris (deferred, low risk — app answers on both)
- feat: /login sent-state gained a "check spam / mark not spam" hint
(login/page.tsx) — the .xyz deliverability precaution
- **EXTENSION CLICK-TEST PASSED (Chris, real Chrome → prod)**: reloaded
unpacked build, pasted personal token, saved Argos De'Longhi kettle →
landed in shop, DB-confirmed (£69.00 GBP, Argos, source json-ld). Silent
add works on mightbuy.xyz. Launch item 1.3 DONE. Also confirms the
token-gate UX (website session ≠ extension session — separate contexts, by
design; token bridges them)
- launch.md: 1.1 SMTP + 1.3 extension test ticked DONE; critical path now =
legal reds (top blocker) + capture sweep; session-wrap SKILL Step 4¾ used
- **PENDING codeside**: MightBuy capitalisation sweep — Supabase sender name
is "MightBuy" but codebase is uniformly "Mightbuy"; Chris wants MightBuy →
needs a rename sweep (like maybuy→mightbuy was). Flip Supabase Site URL to
xyz once serving. Chris: §3 Google, legal reds, capture sweep, icon PNGs,
2nd-email stranger test, rotate Resend key
- verify: pnpm typecheck (3/3) + web lint + extension build clean; built
bundle confirmed targeting mightbuy.xyz; spec stamps moved on popup.md,
api-items.md, login.md (all 0.0.31)
scope: tree 1669ac472fdeb751706fbdae9d867aee40842076 (from `git add -A && git write-tree`; last commit e971591)
## 0.0.30
- **INFRA ONLY — no app code changed** (4 files: infra.md, launch.md,
session-wrap SKILL, this). No spec audit (no surface touched). Domain +
email plumbing, all on Vercel DNS via CLI.
- **domain `mightbuy.xyz` bought via Vercel** (registrar, Hobby team, $1.99
yr1/$13 renewal). Apex attached to `mightbuy` project + apex A `64.29.17.1`
+ ALIAS. **`www.mightbuy.xyz` → apex 308 redirect** (set via Vercel API
PATCH `redirect`/`redirectStatusCode`); canonical = naked apex.
mightbuy.vercel.app still serves. ICANN registrant-verify may be pending
(Chris). Account-structure decision: all stays personal, transferable
later (infra.md)
- **RECEIVING email = ImprovMX free**: catch-all `*@mightbuy.xyz` →
chris@chrisdykes.co.uk, **Active**. DNS in Vercel: MX mx1/mx2.improvmx.com
(10/20) + SPF TXT `v=spf1 include:spf.improvmx.com ~all`. Free = receive
only; reply-as hello@ is paid (deferred → Forward Email $3/mo or
Purelymail $10/yr at public launch)
- **SENDING email = Resend, IN PROGRESS**: sending subdomain
`send.mightbuy.xyz` (region eu-west-1 Ireland) — chosen over apex to
isolate app-mail reputation. DNS in Vercel (names expanded to full
subdomain since Vercel owns the whole zone): DKIM TXT
`resend._domainkey.send` (p=MIG…SrsZfLwIDAQAB), SPF MX `send.send` →
feedback-smtp.eu-west-1.amazonses.com (10), SPF TXT `send.send`
`v=spf1 include:amazonses.com ~all`, DMARC TXT `_dmarc.send`
`v=DMARC1; p=none;`. All verified live at ns1.vercel-dns.com + public
8.8.8.8. Resend domain = **Pending/Checking DNS** (will verify)
- **REMAINING to finish SMTP (next session)**: Resend verifies → grab its
SMTP creds → Supabase Auth → SMTP settings (custom SMTP on, From
`noreply@send.mightbuy.xyz`) → send real magic link → confirm inbox not
spam. THEN code (CC): repoint extension WEB_APP_URL + app URLs
vercel.app→mightbuy.xyz; add `https://mightbuy.xyz/**` to Supabase redirect
allowlist + switch Site URL once domain serves; "check spam" hint on /login
(.xyz deliverability). Escape hatch if magic links spam-fold: Postmark $15/mo
- process: session-wrap SKILL gained **Step 4¾ — update launch.md** (tick
off completed launch items every wrap). launch.md updated: domain (1.5) +
receiving email DONE; SMTP (1.1) marked in-progress
- **.xyz caveat live**: fresh + .xyz = mediocre deliverability; magic links
are the forgiving case + full SPF/DKIM/DMARC offsets; tell beta users to
check spam. Research: agents this session, sources in-thread
- verify: no typecheck needed (no code); all DNS records dig-confirmed at
source + public resolver
- pending: SMTP finish (above); Chris: §3 Google, legal reds, capture sweep,
extension click-test, icon PNGs
scope: tree 3e6bf828f94aaa1b0675562325baca9d26313cbf (from `git add -A && git write-tree`; last commit 1a3bcfd)
## 0.0.29
- **feat: owner admin dashboard /admin** (src/app/admin/). Server component,
force-dynamic + robots:noindex. GATE: lib/admin.ts isAdmin() → notFound()
for non-admins (signed-out OR signed-in-non-admin both get plain 404 — route
doesn't leak existence). Allowlist = ADMIN_USER_IDS env (server-only,
comma-sep; Chris's id in all 3 Vercel envs + .env.local). Empty = FAIL
CLOSED (nobody admin)
- data: lib/admin-data.ts (service client, `import "server-only"`) —
getStats (users=profiles count, items±active, lists±shared, shares,
tokens), getCaptureHealth (items by source → adapter hint),
getActivity(30d, saved_at + auth.users.created_at day-bucketed),
getTopStores (site_name tally), getRecentUsers (auth.admin.listUsers +
item counts). AGGREGATE-ONLY by design — no shop contents shown (privacy)
- actions: src/app/admin/actions.ts Server Actions, each RE-CHECKS isAdmin()
(never trusts caller): revokeUserTokens (delete api_tokens for user),
deleteUser (auth.admin.deleteUser → FK cascade wipes everything = also the
GDPR delete-my-account lever). revalidatePath("/admin"). Confirm dialog in
user-row.tsx (client)
- UI: page.tsx (stat grid, 14-day bars, capture-health bars, top-store
chips, signups table), user-row.tsx (client — dropdown + AlertDialog +
useTransition + sonner toast)
- E2E VERIFIED: signed-out /admin → 404 (gate); signed-in-as-Chris → renders,
stats matched direct SQL (1 user/10 items/7 active/2 lists/0 tokens/9
json-ld+1 dom); revoke-tokens clicked through real Server Action → "Revoked
1 token" + DB confirmed 0. deleteUser wired+gated, NOT run on sole live acct
- docs: NEW specs/web/admin.md; 00-overview + infra (ADMIN_USER_IDS row) +
launch.md (GDPR lever now built) updated
- verify: pnpm typecheck + lint + build clean (/admin = ƒ dynamic)
- pending unchanged: SMTP (beta gate), ext click-test, capture sweep, legal
reds, domain; admin per-user-shop view deferred
scope: tree d267048ab6d30bcbb638d4f375f112a5bbcc7145 (from `git add -A && git write-tree`; last commit 39ce61e)
## 0.0.28
- **feat: extension SILENT ADD** — popup (apps/extension/entrypoints/popup/
App.tsx, full rewrite) saves DIRECT via POST /api/items with a personal
token; no tab. States: loading → (no token → paste-a-token sign-in) →
ready → saving → saved ("✓ Saved / Undo / Save another / Open my shop") /
error. Undo = DELETE /api/items/:id. 401 → token cleared → sign-in.
NEW config.ts: WEB_APP_URL = import.meta.env.MIGHTBUY_WEB_URL ?? prod
(mightbuy.vercel.app) — **defaults to PROD**; keys mightbuy:token +
mightbuy:lists
- **token flow = paste-a-token** (Chris's choice): create on /account API
card → paste into popup once → browser.storage.local. No OAuth in the
extension
- **DELETED scaffolding** (App.tsx): openMightbuy focus-or-create, AfterSave
stay/open pref + toggle + mightbuy:prefs key, ?add= URL-param builder.
KEPT (graduated): My Mightbuy link (now tabs.create), list picker +
mightbuy:lists. Web app's ?add= consumer UNTOUCHED (mobile/paste-URL still
use it)
- **CORS** (NEW lib/cors.ts): /api/items + /api/items/:id handle OPTIONS
(preflight) + reflect Access-Control-Allow-Origin for chrome-extension://
/ moz-extension:// / our-own-web-origins (prod + dev-preview host +
http://localhost). Handlers wrapped by withCors
- **CSRF guard** (isTrustedMutationOrigin, added after audit flagged it):
these routes ALSO accept a cookie session (api-auth.ts), so a cross-site
cookie-borne POST/DELETE could write regardless of CORS. Each mutation now
403s unless caller is bearer-token (no ambient cred → any origin ok) OR
cookie-with-own-origin/no-origin. Tightened allowlist off blanket
*.vercel.app (was: any Vercel deploy) → explicit hosts. Verified: bearer
X-origin 201; cookie-style hostile + evil.vercel.app → 403; same-origin →
auth. undoSave now clears token on 401 too (was swallowed)
- manifest (wxt.config.ts): + host_permissions [mightbuy.vercel.app/*,
localhost:3000/*] for the cross-origin fetch; permissions unchanged
(tabs, storage). content.ts / extraction UNCHANGED
- E2E: full API lifecycle curl-verified against dev server simulating the
extension (Origin: chrome-extension://…): preflight 204+headers, POST 201
cross-origin (+ auto-created list, + duplicateOf path), DELETE 204 (undo,
item gone + list cascade), bad token 401, evil.example.com → NO cors. Test
token + list cleaned off Chris's account. Popup bundle mounts+renders (no
console errors); popup-in-real-Chrome→prod is Chris's manual test
- specs: popup.md rewritten (silent add), silent-add.md marked BUILT,
api-items.md CORS section + stamp, data-model.md + mightbuy:token row,
NOW.md (blockers resolved: keep-browsing, WEB_APP_URL-localhost)
- verify: pnpm -r typecheck (3/3) + web lint + extension build all clean
- pending: extension real list-sync (still local names); popup-in-Chrome
manual test owed by Chris; NOTE CORS reaches PROD only after this ships to
main (dev has it now); Chris: §3 Google, §4 SMTP, icon PNGs, CWS runbook
scope: tree bb04a86e1ade4c0c63089a532c259c4cd261abc9 (from `git add -A && git write-tree`; last commit 38665f8)
## 0.0.27
- **feat: real cross-person sharing** — /s/[slug] is now a SERVER component
(force-dynamic), resolving slugs against Supabase for ANY visitor (was
localStorage → owner's browser only). NEW `resolveSharedListDb(client,
slug)` in lib/shop-db.ts, called with the **service client** (public
visitors have no session → RLS bypassed; the QUERY is the security guard).
page.tsx wraps it in try/catch → null → NotAvailable (no stack-trace leak);
generateMetadata resolves too (tab title/description)
- **security boundary** (the whole point): reads ONLY owner-opted-in rows
(lists.shared=true / profiles.my_shop_shared=true) + status='active' only
(bought/archived never surface). Named list → active item_lists members;
My shop → owner's active items with NO membership. Item query also pins
user_id to the list owner (defence in depth); slug trimmed. NEVER widen
these filters. Un-share nulls the slug → old link dies
- **hardening from adversarial audit (1 subagent, PASS on all leak vectors)**:
(a) My-shop "no membership" now fetches active items THEN memberships in
id-chunks of those items — was fetching ALL owner memberships and diffing,
which PostgREST's 1000-row default cap could truncate → a listed item
leaking into My shop at scale; (b) slug .trim() before lookup
- refactor: PublicItemCard + NotAvailable extracted page.tsx →
src/components/public-list.tsx (pure, server-rendered). Old localStorage
resolveSharedList + SharedListView REMOVED from store.ts (dead after swap);
shareStateFor stays (Share dialog reads it from the snapshot)
- E2E VERIFIED (real UI + cookieless curl): share toggle mints slug →
cookieless GET renders list (6 items) → toggle off → same link →
not-available. Boundary tests: unshared list w/ known slug → not-available;
bought/archived filtered; named-list + My-shop paths both correct. All test
share state cleaned off Chris's real account after
- specs: share.md rewritten (server-rendered, security boundary, verified
stamp 0.0.27); data-model.md resolver note updated
- verify: pnpm -r typecheck + web lint + web build clean (/s/[slug] = ƒ dynamic)
- pending: sharing caching (force-dynamic re-queries every hit — deferred);
owner attribution heading (no display-name column yet); extension silent
add (#3 now top build); Chris: §3 Google, §4 SMTP, icon PNGs
scope: tree b1623ff244fd3e2fc487f98e78d8295eb71bbdf6 (from `git add -A && git write-tree`; last commit 21c2f60)
## 0.0.26
- docs-only wrap (dashboard work session; no code). 0.0.25 was SHIPPED to
prod earlier today (main ff 73f7b3a→d1fd5a1; verified: footer v0.0.25,
unauth/SSRF extract probes → 401)
- **auth-setup §1 DONE (API-verified)**: gotcha — Chris's "§1
done" was HALF-saved: allowlist yes, Site URL still localhost (unsaved
form). Fixed + verified via Management API GET /v1/projects/{ref}/config/
auth (site_url now https://mightbuy.vercel.app). Verify recipe: CLI sbp_
token in macOS keychain "Supabase CLI" (go-keyring-base64)
- **auth-setup §2 DONE**: Magic Link + Confirm signup templates → token_hash
style (`{{ .RedirectTo }}?token_hash={{ .TokenHash }}&type=email`) —
scanner-proof + cross-device; /auth/confirm already handled both shapes
- **LIVE MAGIC-LINK TEST PASSED (Chris, on mightbuy.vercel.app)** + the
0.0.25 one-shot migration fired on his real account: prod DB now 1 user
(chris@chrisdykes.co.uk), 10 items, 2 lists, 1 membership, 4 collections,
1 profile — SQL-verified
- docs: auth-setup.md §1/§2 marked done; specs/web/auth.md allowlist line
updated (stamp not moved — code untouched); NOW.md refreshed (prod =
backend era live; auth next-up now just §3 Google + §4 SMTP + icon PNGs)
- verify: docs-only — no subagent audit (nothing code-facing changed);
pnpm -r typecheck clean after version bumps
- pending unchanged: sharing server route (#2), extension silent add (#3),
capture sweep, mobile rungs, legal reds; Chris: §3 Google, §4 SMTP, icons
scope: tree 57a91a26a0be52d743b75b1eda858bfb10d1f8ad (from `git add -A && git write-tree`; last commit d1fd5a1)
## 0.0.25
- **BACKEND ERA: shop data now in Supabase Postgres** (was localStorage).
Schema (migrations core_shop_schema, api_tokens_and_rate_limits,
lock_down_trigger_function): profiles (my_shop share state; auto-row
trigger on auth.users), items (SavedItem snake_case, client-uuid PK),
lists (id+name unique per user, shared/slug), **item_lists join**
(single-list is app-enforced replace-on-change), collections registry,
api_tokens (sha256 hash only), rate_limits + check_rate_limit(key,max,
window_seconds) SECURITY DEFINER fn (service_role-only execute). All RLS
`(select auth.uid())`. Full map: specs/data-model.md
- **store.ts swapped**: same interface + NEW `ready` flag (gate empty
states on it — shop.tsx grid + account export/import do). Hydrates per
sign-in via lib/shop-db.ts fetchShopState; optimistic mutations persist
through a serial queue (order guaranteed, toast on failure, no rollback).
GOTCHA: fresh-minted Supabase tokens can bounce "JWT issued at future"
(sub-second iat rounding) → hydrate retries 1s/2.5s before erroring
- **one-shot migration**: cloud shop empty + mightbuy:v2/maybuy:v2 has data
→ replaceAllRows import (share slugs preserved), raw JSON stashed at
mightbuy:v2:pre-cloud-backup, old keys removed. Cloud data wins, no merge.
E2E-tested in-browser (admin generate_link token_hash → /auth/confirm)
- **NEW POST /api/items + DELETE /api/items/:id** (specs/web/api-items.md):
cookie session OR personal token via lib/api-auth.ts authenticate();
60/min rate limit; field allowlist + caps; id = idempotency key (upsert =
replace); duplicateOf on same-url active item (warn-not-block); writes via
shop-db saveItemRow on the service client — SAME code path as web store
- **NEW personal tokens** (specs/web/api-tokens.md): mb_+24B base64url,
sha256-hex stored, prefix shown; /api/tokens GET/POST/DELETE cookie-ONLY
(token can't mint token — 401 tested); account page "API access" card
(create/copy-once/list/delete); delete-account revokes tokens first
- **api/extract hardened** (specs/web/api-extract.md): auth required
(session or token), 20/min rate limit (Postgres fixed-window, fails open),
SSRF guard lib/net-guard.ts (http(s)+80/443 only, host denylist, DNS
all-address BlockList check re-applied per redirect hop, max 5, 8s shared
budget, streamed 2MB cap, extractFromHtml gets post-redirect finalUrl).
GOTCHA (found in E2E): never put ::ffff:0:0/96 in a net.BlockList — it
matches EVERY plain IPv4 check; v4-mapped literals handled via embeddedV4
extraction. Residual: DNS rebinding TOCTOU (accepted, documented).
⚠️ PROD STILL RUNS UNGUARDED 0.0.23 EXTRACT until next ship-to-main
- infra: SUPABASE_SECRET_KEY wired (.env.local + all 3 Vercel envs) — see
infra.md for the reveal-via-Management-API recipe; lib/supabase/service.ts
service client (per-request, bypasses RLS)
- specs: NEW api-items.md, api-tokens.md; api-extract.md + data-model.md
rewritten; account/shop/00-overview/silent-add/infra updated. Independent
audit (1 subagent) caught 5 drifts, all fixed — incl. import drops
exported myShop share state (documented asymmetry, account.md)
- verify: pnpm -r typecheck + web lint + web build clean; curl battery
(401/400 SSRF cases/429+Retry-After/200+redirects, items 201/duplicateOf/
idempotent/204/404); browser E2E (migration, UI add → DB row, tokens
card). Test user + rows deleted after. NOTE: agent-browser default 720px
viewport puts the add-dialog save button off-screen — clicks land on the
backdrop and close the dialog (Base UI outside-press); set viewport ≥900
- pending: sharing /s/[slug] still viewer-local (needs server route — now
unblocked); extension silent add popup work (backend deps DONE); prefs
still localStorage; no realtime/cross-tab sync (API saves appear on
reload); Chris: live magic-link test, auth §2-4, icon PNGs, sweep
scope: tree 18bae60f2c0dacbd2bc7bb633b01f19cbd8eb899 (from `git add -A && git write-tree`; last commit 9f03ce5)
- **SHIPPED TO PROD** (first ship-to-main run, between wraps): main
fast-forwarded 175f50b→73f7b3a (0.0.19→0.0.23 public: real auth, rename,
logo, sidebar). Verified: mightbuy.vercel.app 200, footer v0.0.23. Chris
did auth-setup §1 (URL allowlist) beforehand — claimed done, not
API-verifiable; live magic-link test still owed by Chris. Shipped-past
(accepted): /api/extract unguarded, legal red markers public
- docs: NEW **documents/mobile-capture.md** — researched build plan for
phone capture (3 subagents, sources verified 2026-07). Truth table:
Android installed-PWA share_target ✅ (URL arrives in `text` not `url` —
regex it out); iOS PWA share sheet ❌ (WebKit neutral since 2019) → iOS
Shortcut CAN do authenticated POST + native notification without app
switch (= silent-add UX, no App Store); Capacitor wrapper deferred (Swift
share-extension glue + App Store 4.2 thin-wrapper risk); Safari extension
= viable port, Safari-only capture. Competitors: share sheet is the
universal pattern (Pinterest/Moonsift/Locker); Karma's in-app browser =
cashback-driven, not our model; nobody ships clipboard-watch
- **decisions (Chris , recorded in mobile-capture.md):** phased
confirm-first (share → /share pre-filled dialog; silent save only when
POST /api/items + personal tokens exist — 0.0.14 lesson: silent requires
REAL save); exactly ONE setting "After saving: keep browsing (default) |
open my Mightbuy" — server-side pref honoured by extension + Android
share + Shortcut; extension's local afterSave migrates into it; no other
dials. Key unlock for everything: items API + tokens (rides Supabase
build, shared with silent-add)
- docs: architecture.md ladder → points at mobile-capture.md; NOW.md parked
mobile row updated
- verify: docs-only wrap (no code since 0.0.23) — subagent audit skipped
for that reason; pnpm -r typecheck clean
- pending unchanged: /api/extract hardening (now live-public, still #0);
Supabase schema build (#2, now also gates mobile rungs 2-3); sweep rows;
Chris: live magic-link test, auth §3 Google, Supabase display-name rename,
icon PNG exports (extension toolbar + future PWA — one export session)
scope: tree c02962899bd85d08064aedd0fd0f9adb5c8238fa (from `git add -A && git write-tree`; last commit 73f7b3a)
## 0.0.23
- ux: **sidebar de-iconed** (apps/web/src/components/app-sidebar.tsx) —
removed repeated-per-row lucide icons: FacetGroup `icon` prop deleted
(Categories/Brands/Stores rows text-only), top nav rows lost
Layers/User/LayoutGrid/BadgeCheck/Archive/CircleDashed, ListRow lost Gift.
KEPT (meaningful/action): SharedDot (Globe) shared marker, ⋯ menu
(MoreHorizontal) + Pencil/Trash2, Plus on New list. 10 now-unused imports
removed. No behaviour change (filters/scopes/counts identical)
- fix: **changelog localhost links** — CHANGELOG.md had 17 hardcoded
http://localhost:3000 links (clickable-in-dev artifacts) that rendered
live on /changelog pointing at localhost. (a) rewrote all to relative
paths in CHANGELOG.md; (b) NEW `stripDevOrigin(href)` in lib/changelog.ts
(exported) applied in changelog/page.tsx markdown `a` component — rewrites
any localhost/127.0.0.1 URL (any port) to its path at render; real https/
mailto/anchors/relative pass through. Regression-proof. LLM page renders
in <pre> (plain text), needs no guard. Footer was already relative Links —
never the bug
- process: **branch/deploy model formalised** — session-wrap now pushes
`dev` (→ login-protected preview mightbuy-git-dev-…vercel.app); NEW
`.claude/skills/ship-to-main/SKILL.md` = the gated go-live (ff-merge
dev→main, verifies auth §1 URL-allowlist gate first, confirms diff, checks
live). CLAUDE.md process rules + structure updated; session-wrap SKILL
Step 5 renamed "Commit, push to dev, and report"
- verify: pnpm -r typecheck + web lint clean; rendered /changelog has 0
localhost links (curl-checked). Independent subagent audit of shop.md
(sidebar) + changelog.md specs
- pending unchanged: /api/extract hardening; extension WEB_APP_URL localhost
(build-time config todo); Supabase schema; auth §1 before prod login;
toolbar-icon PNGs
scope: tree e49385188397ecc034f99ee9881f9dc0910f938e (from `git add -A && git write-tree`; last commit 1eef9dc)
## 0.0.22
- feat: **brand mark** — new M-monogram SVG. Source of truth
apps/web/src/components/logo.tsx `<Logo>` (currentColor tile +
var(--logo-fg,#fff) mark; role/title only when `title` prop passed, else
aria-hidden). Mirrored: apps/web/src/app/icon.svg (Next 16 app-dir favicon,
concrete #111/#fff) + apps/extension BrandLogo (inline, identical path).
Replaced ShoppingBag brand usage in app-sidebar/login/s-[slug] header +
🛍 emoji in extension popup. ShoppingBag KEPT where decorative (shop empty
state, share not-available state, edit-sheet buy button). Old favicon.ico
DELETED (it was served ahead of icon.svg); dead public/wxt.svg removed.
.brand-mark CSS added (extension)
- fix: **extension popup crash** — `Cannot read properties of undefined
(reading 'source')` (App.tsx). Root cause: popup sends `mightbuy:extract`;
a tab with a pre-rename content script (listening for `maybuy:extract`) or
any unreachable page returns undefined → `state.draft.source` threw. Guard:
`if (!draft || !draft.source)` → friendly "Can't read this page" error.
Covers rename transition + general robustness. Extension rebuilt
- raster gap (CANNOT auto-fix — needs manual PNG export of the monogram):
apps/extension/public/icon/{16,32,48,48,96,128}.png still scaffold art →
before CWS submission (chrome-web-store.md updated). Web favicon now SVG-only
- audit: **5 parallel subagents** (Chris explicitly requested — cost note
waived this once): (1) leftover-text = ZERO misses, only intentional
migration fallbacks; (2) logo/asset = all code PASS, 2 raster gaps
(expected); (3) storage-migration = correct, no data-loss/loop in any
scenario; (4) infra = complete + verified live (gh/vercel/domains);
(5) build/runtime = 7/7 PASS. All findings actioned (favicon delete,
wxt.svg delete, docs)
- verify: pnpm -r typecheck + web lint + web build (icon.svg route ✓) +
extension build all clean; /login serves the SVG; /icon.svg 200
- pending unchanged: /api/extract hardening; extension WEB_APP_URL localhost;
Supabase schema; toolbar-icon PNGs; Chris auth §1
scope: tree b3cc0e195e6d690987366e43220dae8b0a10f845 (from `git add -A && git write-tree`; last commit efd35e7)
## 0.0.21
- **RENAME: maybuy → mightbuy, everywhere** (Chris's decision — maybuy was a
placeholder; mightbuy wins the say-it-aloud/radio test, matches "it's a
might-buy"). Case-aware sweep over 65 files incl. BOTH changelogs'
history (old entries now read "Mightbuy" — do not treat pre-0.0.21 entries
as evidence the old name was "mightbuy"). Packages now @mightbuy/*
(lockfile refreshed)
- storage migrations (data preserved): web `read()` falls back
maybuy:v2→mightbuy:v2 and maybuy:prefs:v1→mightbuy:prefs:v1 (rewrite new
key, remove old — store.ts/prefs.ts); extension reads legacy
maybuy:lists/maybuy:prefs as fallback (no rewrite). These three files hold
the ONLY remaining "maybuy" literals in the repo — intentional
- infra renamed: GitHub repo → iamchrisdykes/mightbuy (old URL redirects;
local remote updated); Vercel project → mightbuy. GOTCHA: project rename
does NOT move the default domain — had to POST the new
mightbuy.vercel.app domain + DELETE maybuy.vercel.app + redeploy main via
API (deployment aliases are stamped at deploy time). Verified:
mightbuy.vercel.app 200, maybuy.vercel.app 404. Dev previews now
mightbuy-git-dev-chris-5013s-projects.vercel.app
- Chris chores: Supabase project display name still "maybuy" (Settings →
General — cosmetic; ref mrmtzvquydcvqkxevhxl unchanged, keys/env
untouched); reload the unpacked extension (name/build changed); buy
domains (mightbuy.xyz $1.99 + .shop/.co.uk available; both .coms taken —
checked )
- verify: pnpm install + -r typecheck + web lint + extension build clean;
grep sweep = zero unexpected leftovers; live domains checked. Specs were
renamed in-place by the sweep; no behaviour change beyond storage keys
(data-model.md rows updated with migration notes). Subagent audit skipped:
mechanical rename, verification = grep + builds + live checks
- prod note: main redeployed (rename-era code NOT merged — prod still runs
0.0.19 code but now at mightbuy.vercel.app); dev holds 0.0.20 auth +
0.0.21 rename. Merge dev→main when Chris wants auth live (gate:
auth-setup.md §1)
scope: tree 083c5880540b8b768beb05a01c651b76a63d83ca (from `git add -A && git write-tree`; last commit e680c9a)
## 0.0.20
- feat: **real Supabase Auth** (magic link + Google) — fake localStorage
session DELETED. New: lib/supabase/{client,server,proxy}.ts (+ src/proxy.ts
— Next 16 renamed middleware→proxy; getClaims() refresh every non-asset
request), app/auth/confirm/route.ts (accepts token_hash+type AND ?code=
fallback; `next` param validated `/…` not `//…`), app/auth/callback/route.ts
(OAuth exchangeCodeForSession, x-forwarded-host in prod). lib/session.ts
rewritten: same useSession contract (undefined=unknown/null=signed-out,
server snapshot undefined), Supabase-backed via onAuthStateChange;
signOut/updateProfile now ASYNC (callers await); provider "email"→
"magic-link" display mapping, missing→"magic-link"; name fallback chain
ends at "there". signIn export GONE. mightbuy:session:v1 dead (never read;
audit-grepped). Deps: @supabase/supabase-js + @supabase/ssr (web only)
- login page: real signInWithOtp (emailRedirectTo origin+/auth/confirm;
sign-up = same flow, shouldCreateUser default), 60s resend cooldown, 429
friendly copy, ?error=confirm|oauth alerts, Suspense for useSearchParams.
**Google button renders ONLY when GET /auth/v1/settings reports
external.google:true** (probe w/ publishable key) — signInWithOAuth
navigates before the server can reject, so a dead button = raw JSON wall
(Chris hit exactly this)
- gotcha: OTP links requested OUTSIDE the app (plain supabase-js script) use
the implicit flow → tokens in URL fragment → server route can't see them →
error=confirm. Magic links MUST be requested through the app's browser
client (PKCE). Cost one confused test
- gotcha: Supabase built-in email ≈ a few sends/hour (dev-grade). Real SMTP
before launch — auth-setup.md §4
- audit (ONE subagent): 5 specs vs code — found the `//` redirect-hygiene gap
(fixed in both auth routes) + session.ts fallback omissions (spec'd).
Stamps → 0.0.20 on auth.md (NEW spec), login.md, account.md,
data-model.md, 00-overview.md
- CHRIS DASHBOARD STEPS (documents/auth-setup.md): §1 URL allowlist —
**REQUIRED before prod login works; gate on merging dev→main**; §2 email
templates → token_hash (scanner-proof); §3 Google GCP creds; §4 SMTP.
Local dev works with zero config (Site URL default = localhost:3000)
- verify: pnpm -r typecheck + web lint + `next build` clean; login/confirm
routes exercised on dev server; magic link E2E clicked by Chris on
localhost — signed in OK. account Delete caveat: does NOT delete the
Supabase user (needs server admin call — backend era)
- pending: /api/extract hardening (next-up #0); sweep rows; Supabase schema
build (items/lists/item_lists) now unblocked by real auth
scope: tree 3d57d8f78cb1b8eda05d2d6d910b3587658d3871 (from `git add -A && git write-tree`; last commit on dev = infra docs commit)
## 0.0.19
- **LIVE & PUBLIC: https://mightbuy.vercel.app** — production branch = `main`,
auto-deploy on every push (decision: Chris, "make it public", accepting
unguarded /api/extract + draft legal pages on a public URL). The 0.0.18
protected-preview model (production=`release`, main→login-only previews)
was DROPPED same day: git pushes to main deployed as production 3× despite
link.productionBranch=release (all deleted at the time), then CLI added 2
more (CLI 56 defaults `vercel deploy` to PRODUCTION — always pass
`--target=preview` for previews). GitHub default branch flipped
release→main again; `release` branch still exists, inert. vercel.json
deploymentEnabled guard added (d7a5860) then removed (be7dff1) — gone from
tree. infra.md rewritten to match; CLAUDE.md services line updated
- resolved-blocker: /changelog + /changelog/llm WORK on Vercel — the
`cwd/../..` fs reads trigger Next whole-project file tracing (build warning
"Encountered unexpected file in NFT list"), which bundles the repo files
into the function. Verified live (newest entry renders). Cost: bundle size;
revisit only if builds slow. NOW.md blocker struck through
- priority: **/api/extract hardening is now next-up #0** (SSRF guard: block
private/loopback ranges + http(s)-only, rate limit, response-size cap) —
it's on a public URL
- dashboard note (Chris asked 3×): Vercel "Active Branches" lists ONLY
non-production branches with preview deploys; the production branch shows
in the top "Production Deployment" panel instead. One branch (main) =
production ⇒ list legitimately empty
- verify: production smoke test — all 7 public routes 200; /changelog
renders newest entry; /terms shows Decision markers. Docs-only wrap,
subagent audit skipped (no product code changed since 0.0.17; the
vercel.json guard was added+removed within the window)
- pending: sweep rows 1,3,5–20; extension reload for 0.0.17 build if not
done; extension WEB_APP_URL still localhost (captures hit dev server, not
prod — fine for wireframe); Supabase schema build; red legal Decisions
(now publicly visible)
scope: tree d5f38ac2064d0a38ebe3d5c3d207398cc0afae07 (from `git add -A && git write-tree`; last commit be7dff1)
## 0.0.18
- infra: **GitHub + Vercel + Supabase provisioned** — all facts/IDs/decisions
in NEW documents/infra.md (read it before touching deploys/env/db).
Highlights: repo iamchrisdykes/mightbuy (private, `main`+`release`); Vercel
prj_ksRJ0E3nlJcAdmPr6eDAXxzmCTmo rootDirectory=apps/web, **production
branch = `release`** (Hobby can't protect prod, so main→protected previews
only; launch = merge to release); Supabase ref mrmtzvquydcvqkxevhxl
eu-west-2 London $10/mo, NO schema yet (first migration must follow
data-model.md incl. item_lists join table). Env:
NEXT_PUBLIC_SUPABASE_URL + NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY in Vercel
(all 3 envs) + apps/web/.env.local (gitignored). CLAUDE.md services line
updated; NOW.md unparked the hookup, Supabase build = next-up #2
- history: the footer "Remove all" button (briefly v0.0.18) was reverted and
its changelog entry deleted at Chris's request; the old v0.0.18 commit was
`git reset --soft`'d away BEFORE first push, so published history goes
0.0.17 → this entry (which reuses the 0.0.18 number). Shop ⋯ → Clear all
remains the single wipe entry point
- decision (Chris, asked explicitly): Supabase $10/mo accepted; region
eu-west-2 London (resolves privacy-page region Decision — wording still
owed in /privacy); repo private; protected-preview deploy model
- gotcha: `vercel git connect` needs .git in cwd (doesn't walk up from
apps/web link dir — copy .vercel to root temporarily); `vercel env add`
= one environment per call; Vercel Auth on ALL deployments rejected on
Hobby (invalid_sso_protection) — preview-only is the max
- warning: /changelog + /changelog/llm read repo files via cwd/../.. →
expected to 500 on Vercel (runtime cwd ≠ repo); bundle-vs-drop decision now
urgent. /api/extract still unguarded (SSRF/auth/rate-limit) — gate on
`release`, previews are login-protected
- verify: pnpm -r typecheck clean; working tree vs 0.0.17 scope tree =
changelog + 00-overview stamp note only (all reverts confirmed exact).
Docs-only wrap — subagent audit skipped (no code changes since 0.0.17)
- pending: first Vercel build untested (this wrap's push is the test); sweep
rows 1,3,5–20; extension reload for 0.0.17 build if not yet done
scope: tree a1d0592d489c0d746b616cfdb213c861b17df400 (from `git add -A && git write-tree`; last commit 0f26c32)
## 0.0.17
- fix: `openMightbuy` (apps/extension/entrypoints/popup/App.tsx:34-46) —
`tabs.query` now passes **`currentWindow: true`**: reuse is scoped to the
window the user saves from, else `tabs.create` there. All `browser.windows`
usage DELETED (`windows.update({focused:true})` doesn't restore a minimised
window on macOS — saves navigated an invisible tab and read as silent
no-ops). Found in sweep: dev-server log showed 3× iPhone `?add=` handoffs
landing with no visible tab. Extension rebuilt (`wxt build`) — **unpacked
extension reload owed for 0.0.17**
- sweep (documents/capture-sweep.md): argos.co.uk 🟢 (full JSON-LD incl.
brand/category/sku — server path 403'd , extension path fine =
access-strategy thesis confirmed); apple.com/uk 🟢 (sku + breadcrumb
category; price arrives as "1099" no decimals — web-side £ formatting not
yet eyeballed). With currys (0.0.16): 3 tested, all 🟢, zero adapters yet
- ux-trap (logged in NOW.md blockers; fix DECLINED for now, Chris unticked
locally): keep-browsing/"stay" mode only pre-fills the bg tab's dialog (no
save) and the next handoff overwrites it → silent loss of queued saves;
popup copy "Saves to Mightbuy in the background" is false. Default still
"stay" (fresh installs hit it). Interim option discussed: client-side
auto-save + Undo toast + handoff nonce; real fix = silent add (Supabase)
- chore: wxt.config.ts permissions comment += after-save pref (audit note)
- verify: pnpm -r typecheck + web lint clean. popup.md audited by ONE
independent subagent — zero drift (2nd consecutive clean audit; two
cosmetic code-comment notes only). Stamp popup.md → 0.0.17;
package.json + footer 0.0.17
- pending: sweep rows 1,3,5–20 unfilled; extension reload for 0.0.17;
unchanged (/api/extract no SSRF/auth/rate-limit, WEB_APP_URL localhost,
sharing browser-local, silent add rides Supabase, red legal Decisions)
scope: tree 80f2c9ed09cbc875c24e2e45c390c350e8aa022c (from `git add -A && git write-tree`; last commit f8d8ec4)
## 0.0.16
- decision: **lists = single-list UX, multi-capable schema** (open-questions.md
resolved ). Keep `SavedItem.list` (single string) + move-not-copy
UX; the Supabase schema MUST model membership as an **`item_lists` join
table** (item_id, list_id, unique pair) — NOT a list column on items —
single membership enforced as an application rule (replace-on-add).
Migration mapping: `list` name → `lists` row FK; undefined → no join row
(implicit My shop). Encoded in specs/data-model.md "List membership" section
+ warning comment on `list?` in packages/shared/src/types.ts. Don't build
anything that treats one-list-per-item as a data guarantee
- feat: `CATEGORY_KEYWORDS.Kitchen` += white goods (categorise.ts:66-68):
cooker, hob, fridge, freezer, dishwasher, washing machine, tumble dryer,
washer dryer, **airfryer** (one-word spelling — "air fryer" can't word-boundary
match "AirFry"/"Airfryer" titles). Driver: Currys BEKO induction cooker
suggested Uncategorised. Verified via tsx run: BEKO title → "Kitchen"
(user-collection override), running-shoe title still → Footwear. NO new
canonical category (still 18 — appliances deliberately live in Kitchen,
decided over a separate "Appliances"); /categories page renders the change
live, no page edit needed
- docs: NEW **documents/capture-sweep.md** — pre-launch extension capture
test: ~20 UK retailer table (verdicts 🟢🟡🔴; wrong price outranks missing;
"via page scan"+correct = 🟢 but watch-list). Notes the
/api/extract results (argos 403, uniqlo timeout) are server-path only, NOT
predictive of the extension path. Row 4 currys.co.uk = 🟢 (JSON-LD, full
capture, ) → no adapter. Process: 🔴/🟡 rows → one ADAPTERS entry
each in packages/shared/src/hosts.ts, Notes URL = the test case
- env: extension reloaded in Chrome — 0.0.9–0.0.14 finally picked
up; 0.0.14 manual flow checks (tab reuse, bg/fg toggle, My Mightbuy) piggyback
on the sweep. NOW.md reload warning cleared
- verify: pnpm -r typecheck clean; web lint clean. Specs data-model.md +
web/categories.md audited by ONE independent subagent (cost rule honoured):
**zero drift found** (first fully clean audit); auto-categorisation.md also
confirmed accurate (weights 3/2/1, 18 categories, "kit"≠"kitchen" whole-word
override). Both stamps moved to 0.0.16. package.json + footer 0.0.16
- pending (unchanged): /api/extract no SSRF/auth/rate-limit; extension
WEB_APP_URL localhost; sharing browser-local; extension silent add rides
Supabase; red legal Decisions before deploy. NEW pending: sweep rows 1–3,
5–20 unfilled (Chris drives; hand rows to Claude for adapters)
scope: tree 0cebcb48639b416a1edb312f7cf804e8f851205d (from `git add -A && git write-tree`; last commit 2598708)
## 0.0.15
- feat: **legal pages** — 3 new public routes (no auth gate): /terms, /privacy,
/how-we-make-money (apps/web/src/app/<route>/page.tsx). Static server
components, shared shell in src/components/legal.tsx (LegalShell/
LegalSection/P/Ul/Decision). `Decision` = unresolved content decision,
renders `[Decision needed: …]` in font-bold text-red-600 dark:text-red-400 —
Chris resolves these before launch (entity, emails+domain, analytics y/n,
Supabase region, AI provider, min age, liability cap, E&W law, ICO reg,
Amazon wording, /s/ inline disclosure). Draft banner on each page
- **affiliate design rule (binding, from audit):** links become affiliate
links ONLY on user click in the WEB APP (server-side wrap, e.g. future
/go/:itemId); the EXTENSION never adds/modifies affiliate links, codes, or
cookies. Driver: CWS Affiliate Ads policy (enforced : disclosure
pre-install+in-UI, user action per insertion, user benefit) + Honey class
action. Encoded in /how-we-make-money content + specs/web/legal.md; affects
future silent-add + sharing builds
- footer (site-footer.tsx): +3 links (How we make money, Privacy, Terms), now
6; flex-wrap added; version text → v0.0.15
- docs: NEW **documents/competitive-audit.md** — verified findings (CWS policy
3-req, Honey suit 5:24-cv-09470 active, Karma ships whole automation
roadmap, Locker=wantlocker.com near-total overlap, Carted AU$13M shutdown
2025-10, Skimlinks alive/Taboola) + **feature matrix** (12 rivals — from a
LIGHT unverified pass, "?" cells unconfirmed; verified-vs-light split
labelled in-doc). Strategic conclusion: differentiate on positioning
(personal "might-buy" shop + transparent affiliate), NOT features (alerts
are commodity)
- docs: architecture.md += **mobile ladder** (decided ): paste-URL →
iOS Shortcut → Android PWA share-target → Capacitor wrapper → native; every
rung = thin client on the same save API; prereq Supabase; native "likely
eventually" (phones=capture moments, push=retention, rivals all have apps)
but only after web+extension retention proven
- specs: NEW web/legal.md (one spec covers the 3 sibling pages);
changelog.md footer row + 00-overview.md site map/index updated. Stamps
moved on legal.md/changelog.md/00-overview.md after claim-by-claim
**self-check only** (subagent audit skipped — Chris's cost instruction this
session). One drift found+fixed: spec claimed all 3 pages open with "The
short version"; terms opens with "What Mightbuy is"
- verified: pnpm -r typecheck clean; web lint clean; all 4 routes 200 on dev
server; Decision markers render (grep HTML). package.json 0.0.15
- process: Chris is cost-sensitive on agents — no multi-agent workflows
without asking (memory saved); deep-research x2 this session was too heavy,
single light agent did the matrix fine
- pending (unchanged): /api/extract no SSRF/auth/rate-limit; extension
WEB_APP_URL localhost; lists one-per-item; sharing browser-local; extension
silent add. NEW pending: resolve red legal Decisions + solicitor review
before deploy; extension reload in Chrome STILL owed (0.0.9–0.0.14 unpicked)
scope: tree e0c2b9a43e584fb53617c664661f52256e9894f6 (from `git add -A && git write-tree`; last commit 6833f6b)
## 0.0.14
- feat: **extension add-flow polish** (apps/extension/entrypoints/popup/App.tsx).
First extension code change since 0.0.9. **All of it is SCAFFOLDING** —
labelled in-code with `SCAFFOLDING` comments pointing at
documents/specs/extension/silent-add.md; deleted when the backend save API
lands
- **focus-or-create** (`openMightbuy(url, focus)`, App.tsx:34): reuse the one open
Mightbuy tab instead of `tabs.create` per save (killed the tab pile-up).
`tabs.query({url:`${WEB_APP_URL}/*`})` → newest by lastAccessed → if found
`tabs.update(id,{url,active:focus})` (+ `windows.update(windowId,{focused})`
when focus) else `tabs.create({url,active:focus})`. **No `windows` permission
needed** — `windows.update` works without one; manifest still `["tabs",
"storage"]` (wxt.config.ts unchanged)
- **after-save pref** (`AfterSave = "stay" | "open"`, default **"stay"**):
sticky in `browser.storage.local` key **`mightbuy:prefs`** → `{ afterSave }`.
Read defensively on mount alongside `mightbuy:lists` (single `.get([...])`),
persisted via `updateAfterSave` (best-effort try/catch). "stay" → tab opens/
refreshes in **background** (`active:false`), "open" → **foreground**.
Checkbox `checked={afterSave==="stay"}` ("Keep browsing after saving"), shown
in both product + save-anyway states; save-button hint copy is choice-aware
- **"My Mightbuy →"** footer link (`openMyAccount`): focus-or-create to
`${WEB_APP_URL}/`, **always foreground**, independent of the pref. Rendered
OUTSIDE the `state.status==="ready"` block → shows in loading/error/ready (all
states). App.css += `.aftersave` / `.popup-footer` / `.account-link`
- **idempotency-safe** to reuse/refresh the tab: the web handoff only *pre-fills
the add dialog* (shop.tsx:173-201, no auto-save), so re-firing `?add=…` just
re-opens the dialog with the new product — no double-save. Web side unchanged
- docs: NEW **documents/specs/extension/silent-add.md** (PLANNED, design-only) —
the real silent add (`POST /api/items` @ apps/web/src/app/api/items/route.ts,
Supabase, extension auth token in browser.storage.local, in-popup "Saved ✓ /
Undo"), its deps, and the explicit scaffolding-removal list (openMightbuy,
AfterSave pref+toggle, `?add=` builder go; My-Mightbuy link + list picker stay).
Rides the Supabase milestone (same as real sharing)
- specs: extension/popup.md updated (4b after-save toggle, step 5 focus-or-
create, step 6 My-Mightbuy link, manifest note, scaffolding-removal section,
Data table) + stamp → 0.0.14. **Independently audited** (adversarial subagent,
spec+code fresh): **zero drift** in both popup.md and silent-add.md; confirmed
DEFAULT_AFTER_SAVE="stay", `mightbuy:prefs`/`mightbuy:lists` keys, no windows perm,
footer outside ready block
- verified: `pnpm -r typecheck` clean (3 pkgs); web lint clean; extension build
clean (valid MV3, 533B manifest, no new permission). **NOT** verified live:
browser tab behaviour (focus-or-create, bg/fg, My-Mightbuy) — needs manual
unpacked-extension reload in Chrome; can't drive `browser.tabs`/`windows`
headlessly
- pending (unchanged): /api/extract no SSRF/auth/rate-limit; extension
WEB_APP_URL localhost; lists one-per-item (multi-membership open); sharing
browser-local until the backend. NEW pending: extension silent add (backend)
scope: tree b2926076b751cecefedbc1a86e5a6ed5cf2f25e1 (from `git add -A && git write-tree`; last commit 442a894)
## 0.0.13
- feat: **multi-select + bulk actions**. item-card.tsx: whole card clickable via
a stretched overlay `<button>` (`absolute inset-0 z-10`, `aria-label="Open …"`,
`onClick=onEdit` always); checkbox (top-left) + ⋯ raised to **z-20** so they
stay clickable (no nested interactives). **Gmail model** — checkbox is the
dedicated select control, card click always *opens*; NO hidden "select mode".
Card props += `selected` (→ `ring-2 ring-primary`) / `selectionActive` /
`onToggleSelect`. Checkbox visible on `group-hover`, always once
selected/selectionActive
- NEW `ui/checkbox.tsx` (base-ui `@base-ui/react/checkbox`). **GOTCHA**: base-ui
`Checkbox.Root` defaults `display:inline`, which **ignores `width`/`height`**,
so `size-4` was dropped → the box collapsed to ~2px×20px (a visible sliver).
Fix: add `inline-flex items-center justify-center`. Runtime CSS bug — invisible
to typecheck/lint AND to a "does it click?" test; only a rendered-pixel check
caught it (measured `getBoundingClientRect`)
- selection-bar.tsx (NEW): shown when ≥1 selected. "N selected" (`role=status
aria-live`) + **Select all** (visible) + **Add to list** (My shop / each list /
**New list…**→dialog) + **Remove** (confirm) + **Clear** (×). **Status-aware
lifecycle** via `context` prop: shop's `selectionContext` = derived from the
*selected items'* statuses (homogeneous archived→"archived"; homogeneous
bought→"bought"; else "active"). active/mixed → Mark bought + Archive;
archived → **Unarchive**; bought → Restore + Archive. Restore/Unarchive both =
`updateMany {status:'active', boughtAt:undefined}`
- store.ts: **bulk methods** (each ONE write + notify): `updateMany(ids, patch)`
(registers list/collection names via withName/withListName), `removeMany(ids)`,
`markBoughtMany(ids, onBought)`. All on the hook return
- shop.tsx: `selected: Set<string>`; toggleSelect/clearSelection/selectAllVisible;
lifted per-item `markItemBought`/`restoreItem` (shared by card + sheet);
bulkAddToList/MarkBought/Archive/Restore/Remove/CreateListAndAdd; bulk-remove
confirm AlertDialog + new-list Dialog. `changeListScope` also clears selection
- feat: **edit-sheet parity** (edit-item-sheet.tsx): Buy → real `<a>`
(`nativeButton={false}`, was window.open); + Mark bought/Restore (close-after)
+ bottom destructive **Remove from shop** (`onRequestDelete` → shop closes
sheet + opens shared delete confirm). Wired via onMarkBought/onRestore/
onRequestDelete
- feat: **Clear all confirms** (shop.tsx) — AlertDialog quoting total count
- a11y/UX pass (ran vercel-labs **`web-design-guidelines`** skill; installed to
`.agents/`, gitignored): aria-labels on rename / new-list / NameSelect-create /
search inputs; results count `role=status aria-live`; Open-site → real `<a>`;
login email `autoComplete=email`+`inputMode`+`spellCheck=false`; search
`type=search` (+ `[&::-webkit-search-cancel-button]:appearance-none`); image +
product-URL `type=url`; `motion-reduce:` on card hover-scale (item-card +
public card); `width`/`height` on card/sheet/public images
- fix: **cursor-pointer** on interactive controls (Tailwind v4 dropped the
default; the Web Interface Guidelines ruleset has **no cursor rule** so the
skill couldn't flag it). Added to buttonVariants, sidebar menu-button +
SidebarMenuAction, Switch, Checkbox, dropdown-menu items, SelectItem, the card
overlay + search-clear buttons
- feat: **changelog dates hidden in browser** (lib/changelog.ts
`stripChangelogDates`) — /changelog + /changelog/llm strip heading dates
(`— YYYY-MM-DD`), scope `@ …Z`, and bare ISO dates at render. Dates **retained
in the source .md files** (read server-side, never served raw) = the tracked
record. Verified 0 dates in served HTML on both pages
- chore: `.gitignore` += `.agents/` / `skills-lock.json` /
`.claude/skills/web-design-guidelines` (installed-skill artifacts; reproducible
via `npx skills add`)
- E2E (agent-browser): card→sheet; ⋯ works above overlay; checkbox→bar; Select
all (20); New list & add (moved 20); bulk mark bought / remove (confirm);
archive → Archived view → **Unarchive** → restored; checkbox renders 16px +
cursor pointer; both changelog pages 0 dates. typecheck + web lint clean; 0
console errors
- specs: shop.md (clickable card + status-aware multi-select + sheet parity +
Clear-all confirm), data-model.md (bulk methods), changelog.md (date strip),
login.md + share.md re-verified — all independently audited
- pending (unchanged): /api/extract no SSRF/auth/rate-limit; extension
WEB_APP_URL localhost; lists one-per-item (multi-membership open); sharing
browser-local until the backend
scope: tree 97ba85559e14448eb66776963c33664f0be55325 (from `git add -A && git write-tree`; last commit 23c2d97)
## 0.0.12
- feat: **shop search + sort** toolbar (apps/web/src/components/shop.tsx, above
the grid, rendered only when `items.length > 0`). **Search** = case-insensitive
substring over title / brand / siteName / collection / notes (`matchesQuery`);
clear (×) button + "N results" count, both gated on a non-empty `query`.
**Sort** = base-ui Select (`items={SORT_LABELS}`): recent (default) / oldest /
price-asc / price-desc / name → `SORTERS` map. `savedAt` string-compared (ISO,
chronological). `comparePrice(a,b,dir)` returns ±1 for an unpriced item
**without** ×dir, so unpriced items sort **last in both** price directions
- `visible` useMemo now chains `inListScope && matchesFilter && matchesQuery`
then `.sort(SORTERS[sort])` — `.filter` returns a fresh array so the store's
`items` is never sorted in place; deps += `[query, sort]`. `changeListScope`
also `setQuery("")` (a query from another list would otherwise empty the view —
mirrors the existing facet-filter reset). `query`/`sort` are ephemeral
`useState`, **not** persisted to prefs/localStorage
- empty-view copy: `query ? "No items match “…”." : "Nothing here yet."`. New
imports in shop.tsx: `Input`, Select bits, `ArrowUpDown`/`Search`/`X` icons
- E2E (agent-browser, built app): search "sonos" → 1 result (store match);
no-match → "No items match"; clear × → all 20 back; Price high→low descending
(£1,395 / £630 / £450); Name A–Z alphabetical (8BitDo, Aeron, Anker, BEKANT…);
switching list scope clears the search (confirmed after a hard reload — HMR
staleness had masked it mid-session, code was correct). 0 console errors;
typecheck + web lint clean
- spec: shop.md — Search & sort behaviour paragraph, toolbar in UI-anatomy,
search-aware States line, "sort/search not built yet" open-question removed;
**independently audited, 0 discrepancies** (all 8 claims + no-mutation check)
- pending (unchanged): /api/extract no SSRF/auth/rate-limit; extension
WEB_APP_URL hardcoded localhost:3000; lists one-per-item (multi-membership
open); sharing browser-local until the backend
scope: tree 4d2de75f0e70c7b9ec81a153dbada8b4088a7ae6 (from `git add -A && git write-tree`; last commit c41f3d0)
## 0.0.11
- feat: **list rename + delete** (apps/web/src/components/app-sidebar.tsx
`ListRow`). Each named list row (My shop / All lists excluded): hover ⋯ menu
→ **Rename** = inline input (pre-filled + text-selected; Enter/blur commit,
Escape cancel) → store `renameList`; **Delete** = AlertDialog with adaptive
copy (empty / "Its N items will move to My shop — nothing is deleted" / +
"Its public share link will stop working" when shared) → store `deleteList`.
Count badge hides on hover so it doesn't collide with the ⋯ trigger
- store (store.ts): `renameList(old, new): boolean` — updates the ListMeta name
+ **re-points every item** `list===old`→new, **keeps shared/slug** (a public
link survives a rename), re-sorts; returns false on blank/unchanged/collision.
Collision is checked against the **union of meta names ∪ item-referenced
names** (not just `lists`), so an imported shop can't silently merge two
lists. `deleteList(name)` — **non-destructive**: removes the ListMeta and sets
`list: undefined` on its items (→ My shop); the share state dies with the meta
row (its slug stops resolving in `resolveSharedList`). Both added to the hook
return
- reconciliation (shop.tsx `handleRenameList`/`handleDeleteList`): the store
touches only items+lists; the component syncs the other name-keyed state —
`prefs.defaultList` (rename→new name, delete→"") and the view `listScope`
(rename→follows to new name, delete→back to All lists). Success toasts
"Renamed to …" / "Deleted …"
- gotcha (real bug, caught in browser verify — never shipped): a
`DropdownMenuTrigger render={<SidebarMenuAction/>}` **silently breaks** the
menu-item→AlertDialog flow — the menu won't close and the dialog won't open.
`SidebarMenuAction` is a base-ui `useRender` component; composing it as the
trigger double-wraps and swallows the item selection. item-card uses a plain
`<Button>` trigger (works), so ListRow now does too. Rename *appeared* to work
only because it swaps the row for an input (unmounts the menu); Delete (parent
state, menu stays mounted) exposed it. NOT caught by typecheck/lint — only by
actually clicking / the Next.js dev overlay
- E2E (agent-browser, built app): rename Mum→Mummy (its 2 items followed, active
scope followed, sidebar relabelled); delete Dave (dialog "Its 2 items will
move to My shop", My shop count 16→18 as items moved in, Dave row gone, scope
reset to All lists); rename-collision Mummy→"Wedding" rejected (no merge, both
lists intact). 0 console errors; typecheck + web lint clean
- specs: shop.md (ListRow per-list actions paragraph incl. toasts + the
Button-not-SidebarMenuAction NB), data-model.md (`renameList`/`deleteList` API,
the name-referenced/caller-reconciles invariant, collision scope),
open-questions.md "List rename / delete" **resolved**
- pending (unchanged): /api/extract no SSRF/auth/rate-limit; extension
WEB_APP_URL hardcoded localhost:3000; lists one-per-item (multi-membership
still open); delete has no "delete the items too" option; sharing browser-local
until the backend
scope: tree a372194d8828fe81c0cd172ddf71d0ac49d7a60a (from `git add -A && git write-tree`; last commit 1c5b5a3)
## 0.0.10
- feat: **per-list public sharing** + public read-only page. Any list (a named
list or the implicit "My shop") can be toggled shared → non-guessable slug →
`/s/[slug]` page. Decision (with Chris): a simple per-list `shared` toggle, NOT
a mine-vs-gift list *type* (safe because off by default)
- schema/store (apps/web/src/lib/store.ts): `lists: string[]` → **`ListMeta[]`**
(`{name, shared, slug?}`); new optional `StoreState.myShop: ListShare`
(`{shared, slug?}`) holds the implicit primary list's share state (My shop has
no ListMeta row). `PRIMARY_LIST="__my_shop__"` = reserved share-target key,
never stored on items. Migration: `normalize()`/`normalizeLists()` (exported)
coerce legacy `string[]`→`ListMeta[]` on read AND in `replaceAll` (typed via
new `StoreInput` whose `lists` is `unknown`). Same `mightbuy:v2` key — `lists`
reshaped + `myShop` added without a version bump (read merges onto EMPTY)
- store API: new `setListShared(target, shared)` — target = list name |
PRIMARY_LIST; enable mints a FRESH slug (`newSlug` = `slugify(name)` + 8 hex
from `crypto.randomUUID`), disable DROPS the slug (revoke; re-share = new
link). `createList`/`addItem`/`updateItem` register lists via `withListName`
(ListMeta-aware, sorted by name). Pure resolvers (no hook, for the public
page): `resolveSharedList(state, slug)` → `{title, items}|null` (My shop
first, then named by shared slug; **ACTIVE items only**; null for
unknown/revoked); `shareStateFor(state, target)` → ListShare. All exported
- UI: `share-list-dialog.tsx` — Switch "Share this list" (base-ui
`checked`/`onCheckedChange`) → `setListShared`; when shared shows
`{origin}/s/{slug}` in a readonly Input with Copy (navigator.clipboard) +
Open. shop.tsx: **Share** button in header, rendered only when
`listScope.kind` is `primary`|`named` (derives `shareTarget`; hidden on "All
lists"); threads `myShopShared={!!myShop?.shared}` to sidebar. app-sidebar.tsx:
`lists` prop is now `ListMeta[]`; `SharedDot` (🌐 Globe, aria "Shared
publicly") after shared list names + My shop. Name-only consumers use
`lists.map(l=>l.name)` (add-item-dialog, edit-item-sheet ×2, account ×3)
- new route: `apps/web/src/app/s/[slug]/page.tsx` — `'use client'`, `useParams`,
**public (NO auth gate; separate from the Shop auth flow)**, own header chrome
(not the app sidebar). Hydration gate =
`useSyncExternalStore(()=>()=>{}, ()=>true, ()=>false)` → false on
server/first-paint, true after hydration (store snapshot is empty pre-hydration
→ would falsely resolve "not available"). Chosen over a mounted-flag effect to
satisfy the `react-hooks/set-state-in-effect` lint rule. Read-only
`PublicItemCard`; empty→"Nothing in this list yet"; null→"This list isn't
available" (same page for wrong-slug and revoked)
- gotcha: Base UI `Button` defaults `nativeButton=true`; rendering it as an
anchor (`render={<a/>}`) throws a **console error** ("expected a native
<button>…"). Must set **`nativeButton={false}`**. NOT caught by typecheck/lint
— only surfaced in the Next.js dev overlay during browser verify. (Reinforces
the 0.0.9 lesson: verify UI in the real browser, watch the dev-tools issue
badge)
- wireframe limit (by design): `/s/[slug]` resolves against the **current
browser's** localStorage only — genuine cross-person/cross-device sharing
needs the backend (a server route reading the shared list by slug). Renders
correctly for the owner; the flow is fully demonstrable. Consistent with the
parked Supabase hookup
- E2E (agent-browser, built app): Google fake-login → load demo → scope My shop
→ Share → toggle on → slug `my-shop-…` → `/s/…` renders 16 items read-only;
named "Mum" → 2 scoped items; toggle off → old link = "This list isn't
available"; sidebar 🌐 persists across reload; **0 console errors** after the
nativeButton fix. typecheck + web lint clean
- specs: data-model.md (ListMeta entity + store API + migration), shop.md
(Sharing section, Share button, SharedDot, data rows), NEW share.md (public
page) — all updated + **independently audited** (2 adversarial subagents);
account.md re-verified (change was mechanical `l`→`l.name` only, no behaviour
drift). All 4 stamped 0.0.10
- pending: /api/extract still no SSRF guard/auth/rate-limit; extension
WEB_APP_URL hardcoded localhost:3000; lists still one-per-item, no
rename/delete; sharing is browser-local until the backend
scope: tree 572be9354a687b27d91a4f2673e27b5a67889a60 (from `git add -A && git write-tree`; last commit 985306e)
## 0.0.9
- feat: **host-adapter capture layer** — packages/shared/src/hosts.ts. Per-
retailer extraction for sites with no structured data. `HostAdapter`
{matches, fromUrl (pure), dom (DomSpec)}; `ADAPTERS` registry (Amazon only
for now); `getHostAdapter(url)`. Adding a retailer = one entry, no caller
changes
- Amazon adapter: hostname `/(^|\.)amazon\.[a-z][a-z.]+$/`; `fromUrl` → ASIN
from `/dp|/gp/product|/gp/aw/d|/product` (10-char) → `{ sku }` (also flips
looksLikeProduct true on its own); `dom` selectors: `#productTitle`; ordered
buybox price (`.a-offscreen` under `#corePriceDisplay_desktop_feature_div`/
`#corePrice_feature_div`/`#apex_desktop`, then `#price_inside_buybox`/
`#priceblock_ourprice`/`#priceblock_dealprice`/`#sns-base-price .a-offscreen`);
`#bylineInfo` brand (cleaned "Visit the X Store"/"Brand: X"→X); image
`#landingImage`→`#imgTagWrapperId img` (src|data-old-hires)
- shared helpers (hosts.ts): `parsePriceText` ("£21.90"→{price,currency};
£/$/€→GBP/USD/EUR; thousands/decimal + euro "1.234,56"); `fieldsFromDom(DomLike,
spec)`; `fillGaps` (gap-only, never overwrites structured data);
`applyHostUrlFields`; `DomLike`/`ElementLike` interfaces (Document satisfies
structurally). Exported from index.ts
- wiring: content.ts runs adapter AFTER JSON-LD/OG merge, BEFORE generic
h1/document.title fallback; fillGaps(domFields) then fillGaps(fromUrl);
source="dom" when title/price contributed. html.ts (server /api/extract)
applies `applyHostUrlFields` — URL fields only (DOM price needs the live page;
server-fetched Amazon has no price in its HTML)
- fix (real bug, shipped in 0.0.8): **popup blank/crash**. App.tsx used
`browser.storage.local` but wxt.config.ts manifest lacked the `storage`
permission → `browser.storage` undefined → TypeError "reading 'local'" on
mount → blank popup on EVERY page. Fix: `permissions: ['tabs','storage']` +
defensive optional-chaining/try-catch around storage get (useEffect) + set
(addToMightbuy). Remembered-lists is best-effort, must never blank capture
- gotcha: **verify EXTENSION changes by opening the real popup**
(`chrome-extension://<id>/popup.html` against a live tab via
`agent-browser tab new`), not just web-handoff URL params or the
content-script test bridge — that verification gap let the storage crash ship
in 0.0.8. Extension ID (unpacked, path-derived) = mhhpajbmdibphbdcklaehhedbikikabl
- E2E: real built extension on amazon.co.uk/dp/B08T1HR5CS → draft
{title, price:"21.90", currency:"GBP", sku:"B08T1HR5CS", imageUrl,
source:"dom"}; looksLikeProduct=true; popup renders product preview +
"Add to my shop". 17 pure-logic assertions (node ran hosts.ts TS directly)
pass. typecheck + web lint clean. specs audited (content-script/api-extract/
popup): api-extract ACCURATE; other two had minor selector/wording drift,
fixed this wrap
- limits (not bugs): brand via #bylineInfo missed on the test page (best-effort);
server /api/extract can't get Amazon price (not in HTML meta) — extension is
the Amazon path
- action required: **reload the unpacked extension in Chrome** to pick up the
new manifest permission
- pending: /api/extract still no SSRF guard/auth/rate-limit; WEB_APP_URL
hardcoded localhost:3000
scope: tree 7ce79531254f2ae2612db23888cfcf13500ebb3d (from `git add -A && git write-tree`; last commit 101ebff)
## 0.0.8
- feat: **Lists** — a second grouping axis ("who it's for"), orthogonal to
collections ("what kind"). schema: SavedItem += `list?` (packages/shared/
src/types.ts); undefined = primary "My shop" list. One list per item (multi-
membership parked → documents/open-questions.md)
- store (lib/store.ts): StoreState += `lists: string[]` (persists empty lists);
EMPTY += lists:[]; read() now merges parsed onto EMPTY so old mightbuy:v2 shops
get lists:[] (no key bump); addItem/updateItem register item.list via shared
`withName` (renamed from withCollection); new `createList(name)` adds an empty
list. mightbuy:v2 = { items, collections, lists }
- prefs (lib/prefs.ts): += `defaultList: ""` ("" = My shop) — where new saves land
- sidebar (components/app-sidebar.tsx): new `ListScope` type (all|primary|named)
+ Lists group (All lists / My shop / named / inline "+ New list" via NewListRow);
exports `inListScope(item, scope)`. Facets + status groups now computed from
items scoped to the active list. `matchesFilter` unchanged (facet only) —
shop composes inListScope && matchesFilter
- shop (components/shop.tsx): `listScope` state (default {kind:"all"}); switching
scope resets facet filter (changeListScope); header shows "{list} /" prefix for
primary/named; handoff parses `list` param → handoffList → AddItemDialog
initialList; clearAll resets scope + lists:[]; ItemCard showList = kind!=="named"
- item-fields.tsx: extracted shared `NameSelect` (used by both Collection + List);
ItemFormValue += `list`; `showList?` prop (edit sheet passes false)
- add-item-dialog.tsx: EMPTY_FORM += list; seeds list = initialList ?? prefs.defaultList
across all 4 paths (handoff/fetch-ok/fetch-err/manual); save writes list; new
`initialList?` prop
- edit-item-sheet.tsx: quick move-to-list Select up front → updateItem immediately
+ toast (no Save); ItemFields showList={false}; form seeds/saves list
- item-card.tsx: 🎁 list badge bottom-right when showList && item.list
- account/page.tsx: Lists stat (5 tiles, sm:grid-cols-5; count = distinct named
lists + "My shop" if any listless item); Default list select in Saving; import
carries lists ?? []; delete/replaceAll include lists:[]
- extension (entrypoints/popup/App.tsx + App.css): "Add to list" input (plain
element, not nested component — avoids focus loss) with <datalist> of names
remembered in browser.storage.local `mightbuy:lists`; handoff URL += list param
(+ gtin/mpn/sku/availability already present); remembers typed name on save
- demo-items.ts: mk(1)+mk(11) list:"Mum", mk(9)+mk(17) list:"Dave" (2+2)
- docs: new documents/open-questions.md (multi-membership, list rename/delete,
extension↔web list sync — all parked). Specs trued-up + audited (data-model,
web/shop, web/account [was stale at 0.0.1], extension/popup) — all ACCURATE
- verify: E2E in browser — handoff ?list=Dave saved with list:"Dave"; list
scoping filters + counts correct; move/settings render. typecheck + web lint clean
- gotcha: `agent-browser click` doesn't fire the shadcn Dialog save button's React
onClick (portal/synthetic-event quirk); a native el.click() works (20→21). NOT a
product bug — use eval el.click() to drive dialog buttons in browser tests
- pending: /api/extract still no SSRF guard/auth/rate-limit; extension WEB_APP_URL
hardcoded localhost:3000; handoff loses params if signed out
scope: tree ac29a2bdb4b73c06acc63786bb21c708231d998a (from `git add -A && git write-tree`; last commit 4c73d17)
## 0.0.7
- fix (real bug): add-item save() rebuilt SavedItem from form fields only,
**never wrote brand** → no dialog/handoff-saved item ever had a brand (only
hardcoded demo items did). Now brand persisted on all paths
- feat: brand + store are distinct editable fields — ItemFormValue += brand,
store; ItemFields renders Brand|Store row after title; add-item-dialog
seeds (handoff/fetch/error/manual) + saves brand→brand, store→siteName
(form.store || hostname); edit-item-sheet seeds/saves both
- fix: add save() also dropped gtin/mpn/sku/availability/description (find-
other-sellers match keys). Now carried via draftExtras state (set in
handoff+fetch seeds, spread into SavedItem). Extension handoff += gtin/mpn/
sku/availability params (popup App.tsx builds, shop.tsx parses)
- feat: edit sheet redesigned buy-first — title heading, brand·store, large
price+date, primary "Buy at {store} →" (window.open item.url; "View
product" if no store) + "Find other sellers" (onFindSellers → shop closes
edit, opens sellers sheet w/ item), editing in collapsible <details> "Edit
details" (full ItemFields + Save). onFindSellers prop on EditItemSheet
- audit: both docs accurate; no in-scope bugs. gtin/sku carry-through was the
auditor's flagged pre-existing gap, fixed same wrap
- E2E: Nike handoff → brand "Nike"+store "nike.com" prefilled → saved → Nike
in Brands sidebar; gtin/sku/availability round-trip verified
scope: tree fb6a1bd53641690b0f34dee622096e1c416c947b
## 0.0.6
- feat: category keywords expanded — CATEGORY_KEYWORDS now 18 categories /
~832 keywords (was 13/~130). New: Pets, Office & Stationery, Automotive,
Food & Drink, Music. Singular forms (s? matches plural); multi-word anchors
for collisions ("dog collar","car seat","midi keyboard","highlighter pen",
"track pump"). Verified: 0 within-list dupes, 0 cross-category shared
keywords, 20/20 real-product classification test passed
- feat: changelog page files-touched disclosure — lib/changelog.ts
filesTouchedByVersion() shells to git (execFileSync, cwd=REPO_ROOT):
diffs each `v<semver>:` commit vs previous (first vs EMPTY_TREE
4b825dc…), excludes pnpm-lock; page.tsx splits md by /^## /m into
per-version <section>s, native <details> per version (files &&
files.length guard). Human page only, dev-only (git). 0.0.2 has no
disclosure (folded into combined v0.0.1+v0.0.2 commit)
- skill: session-wrap scope marker simplified to tree-hash only (commit hash
inside its own commit is circular) — committed 3f9aa9b, folded here
- audit: docs-clean (changelog.md spec updated for files feature;
auto-categorisation.md 18-cat count); code-review found no bugs (note:
s?-only plural misses irregular -es/-ies on multi-word keywords, negligible)
scope: tree 9caed46973e88d667b14bbf8f0ef336060954430
## 0.0.5
- feat: /categories page (apps/web/src/app/categories/page.tsx) renders
CATEGORY_KEYWORDS live from @mightbuy/shared (one Card/category) — cannot
drift; footer link "How categories work" (site-footer.tsx)
- feat: Uncategorised — ShopFilter union += {type:"uncategorised"}, matchesFilter
returns !item.collection (active-only); app-sidebar FacetGroup gains
`trailing` prop, Categories group ends with Uncategorised row when
uncategorised.length>0 (null when 0 → guard hides cleanly); item-fields
NO_COLLECTION label "None"→"Uncategorised"; shop heading/FILTER_TITLE
- feat: looksLikeProduct(draft) in shared (extract.ts) — true if
source==="json-ld" || price|gtin|sku|mpn. Popup shows "not a product" state
+ "Save it anyway →" ghost fallback when false (bbc.co.uk). INVARIANT:
trusts source==="json-ld" ⇒ Product node found (productFromJsonLd null for
other @types + mergeExtractions only stamps source on non-empty layers) —
comment in extract.ts; add hasProductNode flag if that ever changes
- known: looksLikeProduct false-negative on OG-only-no-price real products
(mainstream/client-rendered retailers) — deliberate, Save-anyway covers it
- audit: first fully-clean wrap (4 specs accurate, 2 non-bug code notes
hardened: invariant comment + OG-only known-behaviour)
scope: tree 001eb1b10f4582e09ef49af0c2b9a54f8e82c505 (commit: this wrap = v0.0.5)
## 0.0.4
- feat: auto-categorisation stage 1+keyword mapping — shared/src/categorise.ts
(CATEGORY_KEYWORDS 13 canonical cats; suggestCategory(draft, collections):
weights category×3/title×2/brand+site×1, \b keyword s?\b matching, ties →
first-listed; user-collection override on shared whole words ≥2 chars)
- schema: ProductDraft += category (raw signal: JSON-LD Product.category
string|Thing|array via categoryString, else BreadcrumbList joined " > "
Home-dropped via breadcrumbFromJsonLd — backfills ONLY when a product node
exists). Persisted on SavedItem via add-dialog rawCategory state
- feat: dialog pre-selects suggestion (only when prefs.defaultCollection
empty) + ✨ Suggested badge (shows while form.collection === suggested);
ItemFields collectionHint prop + `options` list surfaces suggested-but-new
names in the Select; extension handoff += category param (popup App.tsx,
shop.tsx parser)
- fix (audit): plural keywords removed (s? suffix double-counted "shoes" et
al); greedy substring override → whole-word match; suggested-badge
mislabelling on HTTP-error/manual fallbacks (setSuggested(null));
suggestedCollection setState-in-helper refactored to pure
computeSuggestion + explicit setSuggested at call sites
- E2E: adidas SL 72 → category "Shoes" → suggestion "Footwear" verified via
extension handoff AND direct /?add=…&category=Shoes URL post-fix
- pending: LLM classification at backend era (auto-categorisation.md TODOs:
prompt, per-(title,collections) caching, correction feedback)
scope: tree 1c0685c4b54d76969fdf241469e80d0e09c789a4 = commit ce59881
## 0.0.3
- feat: extension MVP — content script (apps/extension/entrypoints/content.ts)
extracts from live DOM via @mightbuy/shared (JSON-LD → meta → h1/title),
message {type:"mightbuy:extract"}; test bridge: html[data-mightbuy-ready],
event "mightbuy-extract-request" → html[data-mightbuy-result]. Popup
(entrypoints/popup/App.tsx, plain CSS) previews + hands off to
localhost:3000/?add=1&url&title&price¤cy&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 += ' (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)