Skip to content

Outfit extraction, try-on, and admin cost tracking - #1

Open
jcr-byte wants to merge 61 commits into
mainfrom
feature/scan-outfit-extraction
Open

Outfit extraction, try-on, and admin cost tracking#1
jcr-byte wants to merge 61 commits into
mainfrom
feature/scan-outfit-extraction

Conversation

@jcr-byte

Copy link
Copy Markdown
Owner

Summary

Rolls up the work on feature/scan-outfit-extraction (38 commits ahead of main). The main threads:

  • Outfit extraction from worn photos — unified "Add a piece" flow that classifies a photo as a product shot or a worn outfit, extracts every garment from worn photos, dedupes against the existing closet, and generates flat-lay product images for new items.
  • Try-on rendering — composites an outfit onto the user's body photo via the OpenAI Responses API in background mode, lifting the render quality ceiling that the old synchronous path imposed.
  • Admin dashboard — admin-gated /admin route with a user list and a financials page pricing every image generation from its persisted token breakdown.
  • Navigation + layout — migrated to file-based routing, replaced the header bar with a collapsible sidebar rail.
  • Schema cleanup — dropped unused vector embedding columns and unused clothing item fields; added storage RLS policies and usage-tracking tables.
  • README — reset to an empty section skeleton, to be rewritten.

Test plan

  • npm test — 275 tests across 20 files, all passing.

🤖 Generated with Claude Code

jcr-byte and others added 30 commits July 15, 2026 13:32
Users can upload a full-body photo and have GPT-4o vision detect the
garments they're wearing, then review/edit the results before adding
them to their closet.

- New /api/clothing/extract route: auth-gated GPT-4o vision call with a
  strict JSON schema locking category to the ClothingCategory union.
- New ScanOutfitModal: upload -> analyze -> editable review list ->
  batch insert, reusing the existing embeddings flow.
- Closet: add a "Scan Outfit" entry card next to the add button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Document Vitest commands, the rule to run the suite after changes, and
judgment-based guidance on when new/regression tests are warranted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The tests had drifted out of sync with three recent changes, leaving
16 of 116 tests failing across 8 of 9 files:

- Client lib was reorganized into core/clothing/outfits subfolders;
  updated all stale import and vi.mock paths in the api tests.
- generateItemEmbeddings now derives v_text from an AI-generated image
  description; extended the OpenAI mock with chat.completions and
  rewrote the embeddings tests around the image-only signature.
- calculateOutfitScore now splits similarity into image/text components
  and adds wear-count and favorite scores; updated ScoredItem assertions.
- generateCompleteOutfit reordered its queries and the category cap went
  3 -> 10; fixed the existing-outfit mock shape and query ordering.

All 184 tests now pass. No product source changed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a deterministic "enhance photo" step that cleans up clothing photos
for a consistent catalog look: background removal via BiRefNet on Replicate,
then sharp-based normalization (trim, auto-levels, center on a neutral canvas
at a fixed size). Never alters the garment's shape or colors.

- app/lib/server/enhanceImage.ts: removeBackground + normalizeGarmentImage +
  enhanceGarmentImage pipeline, with a robust Replicate output-URL extractor.
- app/api/clothing/enhance/route.ts: auth'd route returning the enhanced image
  as a base64 data URL.
- AddClothingModal: "Enhance photo" action with an Original/Enhanced toggle;
  the active variant is what gets uploaded.
- next.config: mark sharp as a server-external package so its native binary
  isn't bundled (was 500'ing the route).
- Tests for the pipeline (real sharp) and route (auth/validation/error paths).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Apply the Claude Design closet UI to the closet page and its two modals,
adapting the mock to the real data model — it showed a star rating, which
has no backing field, so wear_count and is_favorite stand in.

- Closet: editorial header, search, category filter chips, and garment
  cards with an Unworn badge, plus loading/empty/no-match states.
- Add shared primitives (Dialog, Input, Select, Button) mirroring the
  design system so both modals share one styling source.
- ClothingItemModal: a direct editor per the design, replacing the
  view/edit toggle and collapsible sections. Keeps the fields the mock
  omits so existing data stays editable.
- AddClothingModal: no manual input fields. Dropping a photo runs
  analysis and tier-1 enhancement together, then shows a read-only
  summary to confirm. Failed analysis is fatal (nothing to fall back on);
  failed enhancement just keeps the original photo.
- Add /api/clothing/analyze for single-garment extraction. The existing
  extract route is prompted for a person wearing an outfit and returns
  many items, so it was the wrong shape to reuse.

Also mirror the remote storage.objects RLS policies into a migration.
storage.objects has RLS enabled by default, and the earlier schema dump
only covered the public schema, so a fresh local stack came up with
buckets but no storage policies and every upload was denied.

Note: size, price, purchase date, tags and notes are no longer captured
when adding — the AI can't see them. They remain editable from the item.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The tl-* scale had drifted from the design system: every value was an
approximation, and brand was a different hue entirely (teal #026873 where
the palette specifies emerald #0B6E4F). public/branding/brand-guide.html
already matched tokens/colors.css exactly, so the Tailwind config was the
sole outlier.

Correct the tokens to their exact values and add the three colors the app
was missing: clay (the palette's single accent), its clay-soft wash, and
on-brand for text on emerald. Favourite states move to clay, which the
design reserves for them, rather than brand green; primary buttons and
active chips use on-brand instead of hardcoded white. Shadows pick up the
design's green-biased rgba(8,53,41), plus the modal step.

Keep hex rather than CSS variables so the existing /90 and /15 opacity
modifiers keep working; channel triplets would be needed otherwise.

Both modals and the primitives already used the tl-* scale, so the token
fix propagates without touching them. Destructive actions stay red: the
palette maps --alert to clay, but that would collide with the favourite
state now using it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Outfit recommendations now score on color cohesion, wear count, and
favorites alone. The CLIP image embedding and the text embedding of the
AI-generated item description are dropped, along with the AI description
itself, which nothing else read.

Item eligibility now keys off palette_hsl instead of v_image, since
compareColors needs a Vibrant swatch to score anything at all.

The migration also drops search_clothing_by_image/search_clothing_by_text,
which had no callers, and their ivfflat indexes go with the columns. Deploy
this code before pushing the migration: the currently deployed app still
writes both columns.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The local stack serves storage over http from 127.0.0.1:54321, which
matched neither the *.supabase.co nor the placehold.co pattern, so real
uploads in local dev failed to render. Seeded rows were unaffected because
they point at placehold.co.

Gated to NODE_ENV=development: next/image fetches server-side, so a
loopback entry in production would let the deployed server be pointed at
its own localhost.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Delete the top Header component and relocate its controls: a floating
hamburger button on the page opens the sidebar, and the sign-in/account
controls now live in a sidebar auth footer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the fixed 1180px max-width on the closet main with a full-width
container and even horizontal padding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Center the photo on the left and give the details column its own
scroll region so the image stays put while the user scrolls details.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the 12 placehold.co placeholder items with 24 real wardrobe
photos. Images are seeded into the clothing-images bucket on db reset
via objects_path in config.toml (files under supabase/seed-storage map
to the bucket root at users/<test-user>/clothing/). seed.sql now points
image_url/image_path at the local storage host, and the 3 outfits and
style profile are rebuilt around the new casual/streetwear items.

Photos are downscaled to 1200px/JPEG q75 (~14 MB total) so the seed
stays committable in plain git without LFS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rebuild the "Your looks" page from the imported design: editorial header,
outfit cards rendered from real garment photos, a Generate composer
(occasion/vibe/build-around picker), and an outfit detail dialog.

Wire Generate to the real backend end-to-end: seed a base item from the
closet, call /api/outfits/recommendations, name it via
/api/outfits/generate-name (with a fallback), and persist with createOutfit.

Fix the local seed so generation produces full outfits: lowercase the
clothing categories (the recommender keys OUTFIT_RULES on lowercase) and
seed palette_hsl (Vibrant swatch) on every item, which the color-cohesion
scorer requires. Add a tl-shimmer keyframe for the generating skeleton.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Render a saved outfit onto the user's own photo. Users store a full-body
photo on their Profile (Supabase user_metadata + private user-images
bucket); the Outfits detail dialog's "Try it on" then composites that photo
with the outfit's garment images via OpenAI gpt-image-2 and saves the result
as the outfit image.

- New POST /api/outfits/try-on: auth, RLS-safe body-photo download, garment
  fetch, gpt-image-2 image edit. Normalizes every input to sRGB PNG via sharp
  so CMYK/16-bit/HEIC/mode-mismatch inputs don't trip the image-edit endpoint.
- Profile page for uploading/replacing/removing the body photo.
- bodyPhotoUrl/bodyPhotoPath on User; refresh auth context on USER_UPDATED.
- Route tests covering auth, validation, garment cap, and error paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The try-on flow failed at several points; this addresses the request and
the save:

- Re-encode inputs to downscaled WebP instead of full-res lossless PNG.
  The old path produced a ~36MB multipart upload that OpenAI dropped
  mid-stream (UND_ERR_SOCKET); WebP at 1536px cuts it to ~1.5MB.
- Set maxRetries: 0 on images.edit so a rejected upload fails on the
  first attempt instead of the SDK re-sending the whole body 3x.
- Render at 1024x1024/low so generation finishes (~34s) under a ~66s
  idle-connection timeout on the outbound path; high/1024x1536 hung past
  it and the connection was dropped. Streaming is noted as future work.
- Raise maxDuration to 120s and add per-stage timing/size logging to the
  route for diagnosing slow or failed generations.
- Add storage RLS policies for the outfit-images bucket (INSERT/SELECT/
  UPDATE/DELETE), scoped to the owning outfit via public.outfits. Without
  them, saving the render was denied ("new row violates row-level
  security policy").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Scanned items were inserted with no image at all, which left them without
a photo in the closet and silently broke embedding generation — the
embeddings route derives everything from image_url.

Each detected garment now gets a flat-lay product image generated from the
scan photo via gpt-image-2 images.edit. Grounding on the real photo (edit,
not generate) keeps the garment's actual color and pattern; generating from
the text description alone would invent a plausible garment that isn't the
user's. Generation runs one item per request at concurrency 2 — batching
them into a single route would blow past the ~66s idle-connection timeout
documented on the try-on route. A failed generation still saves the item,
just without a photo, and embeddings are skipped for those.

Standardize on WebP for stored images. It is already the interchange format
toWebpUpload sends to OpenAI, it is ~13% smaller than JPEG here, and it has
an alpha channel — so preserving the BiRefNet cutout's transparency later
is a one-line change rather than a format migration. The raw PNG the API
returns is 2.97MB vs 0.42MB as WebP, large enough that local storage-api
was timing out on the upload. This conversion cannot be delegated to
output_format: 'webp' — gpt-image-2 silently ignores that value and returns
PNG regardless (openai/openai-node#1850).

Add the missing clothing-images storage policies. The bucket had RLS
enabled and no policies, so every write was denied. Same gap already fixed
for user-images and outfit-images; this affected the manual AddClothingModal
upload path too, not just scanning.

Extract toWebpUpload/makeTimer from the try-on route now that they have a
second caller, and keep buildPrompt in lib rather than the route file —
Next.js only permits route files to export known handlers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Scans no longer catalogue watches, jewelry, belts, hats, bags, socks, or
underwear. These are poorly visible in outfit photos, rarely worth a
generated product image, and not what recommendations reason about — and
every one detected costs an image generation.

Enforced in two layers: SKIP rules in the system prompt (which is what
avoids the generation cost, since undetected items never reach it) and a
category filter on the response as a backstop. The excluded categories stay
in the JSON schema enum deliberately — the schema is strict, so removing
them would force the model to file a watch under some other category rather
than omit it, turning a droppable item into a mislabeled one. Both
categories remain valid for manual entry; this only governs what a scan
pulls automatically.

Encode the source photo at two scales instead of one. Vision analysis stays
at 1024px: gpt-4o bills by image tile and extra pixels don't help it tell a
denim jacket from a wool coat. Generation moves to 1536px/q0.92, because a
garment occupies a fraction of a full-body shot and that crop is what limits
pattern and texture fidelity. 1536 is the ceiling that actually reaches
OpenAI — toWebpUpload resizes to MAX_EDGE server side, so larger inputs are
discarded before the call. Both copies share a single decode, which is the
expensive part on a phone photo.

Add extract route tests, which had none.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
node-vibrant decodes through Jimp 0.22, which has no WebP decoder, so
getColorPalette threw "Unsupported MIME type: image/webp" for every image
the app stores — both /api/clothing/enhance and /api/clothing/product-image
re-encode to WebP before upload.

The failure was silent at three layers: generateItemEmbeddings swallowed it
into a console.error, the embeddings route returned an unread 500, and the
callers fire-and-forget with .catch(console.error). Net effect was that every
scanned item had palette_hsl = null, which excludes it from outfit generation
entirely (recommendations.ts filters on the column and hard-errors when a base
item lacks a palette).

Decode to PNG with sharp — already a dependency — and pass the buffer rather
than the URL.

The existing suite mocked getPalette wholesale, so it asserted the mock rather
than the decoder and could never have caught this. Add a suite that runs real
WebP/JPEG/PNG bytes through the unmocked pipeline; all four cases fail without
this fix.

Existing items keep palette_hsl = null and still need a backfill via
PUT /api/clothing/embeddings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Consolidate garment adding into a single "Add a piece" entry point and
deduplicate scanned outfits against the closet:

- analyze: classify each photo as 'product' (single garment, catalogued
  here) or 'worn' (handed off to /extract); worn photos return early
  instead of 422-ing on empty single-item fields.
- extract: load the user's closet and pass it to the model as a numbered
  text inventory so it can flag detected garments the user already owns,
  returning each as duplicateOf (id + name) or null.
- AddClothingModal: absorb the scan/outfit-review flow (product vs worn),
  pre-untick flagged duplicates, and surface them via a per-item badge
  and a header summary.
- Closet: drop the standalone "Scan outfit" button/modal now that the Add
  modal handles both paths.
- Remove the now-dead ScanOutfitModal.
- Tests for analyze photoType classification and extract dedup mapping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The "Add a piece" worn-outfit scan used the uploaded full-body photo only
transiently — to detect garments and generate per-garment flat-lays — then
discarded it. Flat-lays carry no information about how a garment drapes on a
body, so outfit try-on had to guess fit, length, and silhouette.

Persist the worn photo and link every item scanned from it, then feed those
photos into try-on generation as fit references:

- New worn_photos table + clothing_item_worn_photos join (RLS auth.uid() =
  user_id, cascade deletes). One photo per scan, shared by its items, via a
  join so an item can accumulate multiple references over time.
- New wornPhotos client module: uploadWornPhoto (private user-images bucket,
  users/<uid>/worn/), createWornPhotoForItems, getWornPhotoPathsForItems.
- AddClothingModal links the worn photo to inserted items (best-effort).
- try-on route accepts wornReferencePaths, downloads up to MAX_WORN_REFS from
  user-images via the authenticated client, appends them as fit references,
  and instructs the model to use them only for drape/length/fit — never pose
  or identity. Outfits.handleTryOn passes the linked paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire the existing deleteOutfit client mutation into the outfit detail
dialog. A Delete button sits beside "Try it on" and swaps in an inline
confirmation before removing the outfit and its stored image. On success
the outfit is dropped from the grid, itemsMap, and the "New" badge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Try-on was pinned to low quality at 1024x1024 because a non-streaming edit
left the outbound connection to OpenAI idle while the model worked, and it was
dropped at a ~66s idle timeout. Stream the edit with partial_images instead:
intermediate frames keep bytes flowing so the connection never goes idle, and
only the final image_edit.completed frame is kept.

Streaming clears the idle timeout but reveals a harder ~90s total request cap
on the outbound path (the remote closes the socket regardless of activity), so
render at medium/1024x1536 — a clear step up from low/1024x1024 that still
finishes well inside the cap — rather than high, which measured ~95s and died
at the ceiling. Render settings are isolated in TRYON_* constants; maxDuration
raised to 300s and per-partial timing is logged to place any future timeout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Capture the usage object OpenAI returns on the streamed
image_edit.completed frame and persist it per render in a new
try_on_generations table (input/output/total tokens plus the
text/image breakdown, model, size, quality, and image input count).

Recording is best-effort: the insert is gated on usage being present and
wrapped in try/catch, so a DB failure logs a warning but never turns a
paid, successful render into an error response.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The image routes emitted ~15-20 console lines per try-on (per-stage
timers, per-garment, per-partial-frame, per-encode) plus started/finished
tracing on the embeddings/recommendations/swap routes. Rework makeTimer
so stages accumulate silently and a single time.summary() emits one line
with the total, stage breakdown, and key scalars; drop the per-garment,
per-partial, and per-encode logs and the request-tracing pairs. Error and
warn logging is unchanged. Keeps enough signal to spot a render creeping
toward the outbound hard cap without the noise or a debug flag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
RequireAuth passed an onSuccess handler that AuthModalProps does not
declare, which failed the type check. The callback was identical to
onClose and AuthModal never invoked it, so removing it is a no-op.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the single-route SPA page switcher with real Next.js routes.
Closet/Outfits/Profile/Settings now live under an (app) route group
whose layout holds the sidebar chrome and RequireAuth gate; the root
route redirects to /closet. Sidebar navigates via <Link> + usePathname
instead of currentPage/onPageChange props.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Introduce an admin role backed by Supabase app_metadata (not user-editable,
so trustworthy server-side). hasAdminRole is the shared source of truth;
requireAdmin guards server routes, RequireAdmin gates the client UI, and the
User type/AuthContext expose isAdmin. Add the /admin route (placeholder
dashboard) under the (app) group and an admin-only sidebar link. The local
seed test account is granted the role so /admin is reachable in dev.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an admin-only GET /api/admin/users route (service-role, guarded by
requireAdmin) that returns each user's email, join date, last sign-in,
admin flag, and clothing-item count, rendered as a table on /admin.

Move /admin out of the (app) route group to a top-level route with its
own layout and AdminSidebar (Users nav, back-to-app, logout), and drop
the admin link from the main sidebar. Auth + admin gating now live in
the admin layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds an /admin/financials route listing every try-on generation with a
dollar cost computed from its persisted token breakdown, plus totals.

- app/lib/pricing.ts: shared, testable token pricing (per-class rates
  with a per-model table and default fallback)
- app/api/admin/financials/route.ts: admin-gated GET, service-role read
  of all generations (bypassing per-user RLS) with resolved emails
- FinancialsList component + page, and a Financials sidebar nav item

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
jcr-byte and others added 30 commits July 21, 2026 11:44
Drop these four fields across the stack: types, create/update
mutations, the add and detail modals, the analyze/extract AI routes and
their prompts/schemas, and the product-image prompt. Also trim the
now-removed columns from the embeddings and generate-name selects and
the AI outfit search filter.

Adds a migration dropping the columns from clothing_items and updates
seed.sql accordingly. Outfit tags/price are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extend the try_on_generations log to cover clothing-item (product)
image renders, not just try-ons, so both are priced on the admin
financials page. Adds a generation_type discriminator ('try_on' vs
'product_image') and records token usage from the product-image route.

Also record the token/cost shape for try-on renders that now run
through the Responses API (background mode) rather than images.edit:
new api/image_model/cached_input_tokens/reasoning_tokens columns and a
Responses-aware pricing path, selected per-row by the api discriminator.
Existing rows default to the images 3-class shape, so all columns are
additive and nullable.

Bumps openai to ^6.48.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Keep the sidebar as an icon rail when closed instead of hiding it
- Make "Your looks" fill full page width and match the closet heading
- Remove the occasion tag overlay from outfit cards
- Match closet page horizontal padding to the outfits page

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wrap each icon in a fixed-width centering span and use consistent
padding so icons stay in place when the sidebar expands or collapses,
instead of shifting between centered and left-aligned.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drop the tier-1 enhancement step that ran a Replicate BiRefNet
background-removal call on every "Add a piece" upload. It was the
slowest part of the single-item path (10-30s+, cold-start prone) and
was wasted entirely on the worn-outfit path, where the result was
discarded but still blocked extraction.

Uploads now save the user's original photo as-is. Deletes the enhance
route, the enhanceImage server util, their tests, and the replicate
dependency; strips the enhancement wiring and Original/Enhanced toggle
from AddClothingModal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Clear the README down to headings, empty code fences, and placeholder
comments so the content can be rewritten from scratch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the empty section skeleton with a full draft covering what
ThreadLens does, the stack and why each piece is there, the architecture
and its trust-boundary conventions, local setup against the Supabase
sandbox, and test coverage with its deliberate gaps.

Placeholders remain for the demo link, a screenshot, and contact details.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The clothing detail modal is always mounted, so its state survived
between openings. The displayed photo came from `imagePreview` state
seeded in a `useEffect`, which runs after paint — opening a new item
painted the previously viewed item's image for a frame before the
effect corrected it.

Compute the rendered source at render time from the current item,
keeping state only for user-driven changes (a freshly picked file or a
removal). Form seeding moves out of the effect into a render-time reset
keyed on the active item id, so the text fields cannot go stale either.
As a side effect, favouriting an item no longer wipes in-progress edits.

Apply the same derivation to the outfit modal, whose edit-mode preview
had a milder version of the bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RESUME-CHECKLIST.md is a personal working doc tracking what needs to
land before the project is portfolio-ready. It's scratch, not part of
the project's documentation, so keep it out of the tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`next build` failed on six lint errors. Fixing them with real types
rather than suppressions turned up a latent bug: mapSupabaseUser took
`any`, which hid that Supabase types User.email as optional (phone-only
signups) while our User.email is a required string.

Replace the two `updateData: any` objects with explicit update
interfaces, which also documents the distinction the callers rely on --
null clears a column, undefined leaves it alone.

The remaining errors were not ours to fix. Two thirds came from
supabase/.temp, minified output the Supabase CLI writes on `supabase
start`, so ignore it in ESLint as it already is in git. The rest are
`any` on deliberately partial test doubles; spelling those types out in
full would assert the shape of the mocks instead of the behavior under
test, so scope that rule to a warning under __tests__.

Build passes, 275 tests green, lint reports 0 errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The single-piece review showed the analysis read-only, so a wrong
category or a missing brand meant re-editing the item after saving.
It also had no duplicate check, so re-photographing something already
in the closet added it twice with nothing to warn the user.

Make every field editable in that review, matching the worn-outfit
review, and give the analyze route the same closet dedup the extract
route already does: the wardrobe goes to the model as a numbered
inventory and the returned index resolves back to a real row. Unlike
the scan path this keeps accessories and undergarments in the
inventory, since a single product shot may well be a watch or a belt.
Dedup degrades to null rather than failing the analysis when the
closet query errors or no client is available.

Carry the matched piece's image_url through on both paths so the
warning can show the item instead of only naming it. The URL stays
out of the prompt — it is dead tokens with nothing to match on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A garment uploaded on its own was stored as the raw camera-roll photo,
so the closet grid mixed generated flat-lays from the outfit scan with
whatever background the user happened to shoot against.

Run the same generation on the single-piece save. The user's own photo
becomes the fallback rather than the default: a failed render costs the
studio look, not the photo. This path now runs through the same
generating/saving stages as the scan, so the wait shows progress and
the modal can't be closed mid-generation.

buildPrompt takes the source of the photo, because the worn-shot
wording is actively wrong for a standalone one — telling the model to
remove a person the photo doesn't contain invites it to reinterpret the
scene. The product variant drops those lines and strips background,
hanger, mannequin, and props instead.

Along the way: extract the shared generate-and-upload helper both save
paths now use, and decode the upload once into both scales in
processPhoto rather than again in extractOutfit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adding a piece held the user at a spinner for the ~30s a render takes,
and a scanned outfit multiplied that by the number of garments. The
work has no reason to block the add: the row can exist without an
image and pick one up later.

Insert with image_url null, hand the renders to the closet page, and
close. The closet shows a "Creating photo" tile in the image slot for
items still rendering and swaps in each image as it lands, so a piece
is usable — searchable, filterable, openable — the moment it is added.

The pipeline moves to lib/client/clothing/productImages.ts because it
has to outlive the modal that starts it. Embeddings move with it: they
derive everything from image_url, so they can only run once the image
exists. Everything there is best-effort by design — the row is already
in the closet, so a failed render is a degraded item, not a failed add.

Renders are client-side, so a reload before they finish abandons them
and those items keep the empty placeholder. Nothing records that an
image was expected; making that survivable needs a status column on
clothing_items and is left for a follow-up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Swap the browser confirm() for a ConfirmDialog primitive that matches the
rest of the UI: it names the piece, spells out that the photo and wear
history go with it, and disables itself while the delete is in flight.

Dialog now tracks a stack of open dialogs so Escape only closes the
topmost one -- otherwise dismissing the confirmation would also close the
item editor behind it. Closet's delete throws on failure instead of
alert()-ing, letting the modal surface the error inline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replace the 24 old wardrobe items with the nine product shots that make up
the current local test wardrobe, and swap their JPGs for the matching webp
files in seed-storage.

The three seeded outfits referenced only the removed items, so rebuild them
from the new pieces. Palette swatches are hand-tuned rather than copied from
node-vibrant: the analyzer read the black derbies as olive-grey and the dark
jeans as beige, which skews the recommendation engine's colour scoring.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Settings was a "Coming Soon" stub and Profile was a page with one card on
it. Replace both with a tabbed settings page whose first (and for now only)
tab, Account, carries the sign-in details and the body photo.

The tabs come from a TABS array that drives both the rail and the panel, so
a new section is one entry with a label, icon, blurb, and render function.
The rail sits left on desktop and becomes a scrollable row on mobile.

Profile is retired rather than left as an empty shell: /profile redirects to
/settings so existing links keep working, and it drops out of the sidebar.
The two try-on errors that pointed users at "your profile" now name Settings
-> Account instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Settings is account plumbing, not a place you browse to like the closet or
outfits, so it belongs with the auth controls at the foot of the rail rather
than in the main nav. It keeps the nav item styling and active state.

It now renders only when signed in, alongside Logout -- /settings requires
auth anyway, so offering it beside Sign In would only lead to a blank page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The X on the photo dropped it instantly with no way back short of
cancelling the whole edit. Route it through the same ConfirmDialog the
delete flow uses, naming the piece and saying the removal only lands when
the changes are saved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The single-piece review has had an editable Brand field all along, but the
worn-outfit review -- where a scan hands back several pieces at once -- had
no brand at all, in the form or in the insert. A scan is told never to guess
a brand off a photo, so the only way one gets recorded is if the user types
it, and there was nowhere to do that short of editing each piece again after
saving.

Carry a brand string on each review item, start it empty, and give it an
input beside Category, matching the single-piece layout and evening out that
two-column grid. It saves as null when left blank.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The detail dialog sizes the photo to a 290px column, which is enough to
recognise a garment but not to look at one -- weave, print scale, and wear
are all lost at that size, and there was no way to see more of it.

Click the photo to open it on its own over a near-black backdrop, at
whatever size the viewport allows. It is an ImageLightbox rather than a
Dialog: a Dialog's card, padding, and max width all fight the image, and
here the garment should be the only thing lit. Backdrop, corner button, and
Escape close it; a click on the photo itself does not, so inspecting it
can't dismiss it by accident.

Dialog's Escape and scroll-lock handling moves into a useOverlayLayer hook
that both share, so the lightbox joins the same stack -- Escape over an open
editor closes the photo and leaves the editor be.

The affordance is a caption that fades up from the foot of the photo on
hover, not a zoom cursor: it sits over a soft gradient so it stays legible
on pale garments, and it never covers the piece.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Neither field earns its place right now: size is typed by hand and never
read by anything, and material was a field the user had to check on a garment
they had already photographed. Take both off the add and edit forms, the
types, and the create/update payloads, and drop material from the closet
search haystack.

The columns stay on clothing_items, values and all. Nothing is lost, and
bringing either field back is UI work rather than a migration -- which is
also why the embeddings route still reads material: existing wardrobes keep
whatever search text they already had.

Material is still detected by /analyze and /extract and handed to
buildPrompt in the same request. It is never shown or stored, but a flat-lay
render reconstructs a garment from a photo and reads better for knowing the
fabric, so the descriptor rides along as a render hint. Both review types say
so where the field is declared.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signing in happens on /login now — the editorial screen from the design
system: the closet rack photo full-bleed, a paper wash over its right
third, and the form sitting in the calm part of the frame.

RequireAuth sends anyone without a session there, carrying where they
were headed as ?next= so they land on it afterwards rather than the
closet. That retires AuthModal entirely; the sidebar's "Sign In" is now
just a link.

Two controls on the design are wired up for real rather than left as
decoration. "Keep me signed in" chooses where the Supabase session is
stored — localStorage as before when it is checked, sessionStorage when
it is not, so the session dies with the tab. An absent preference means
"keep", so nobody signed in today gets logged out by this. "Forgot
password?" sends the reset mail, and the link back renders a
set-a-new-password state on the same page, since otherwise it would drop
people into the app with nothing to do.

The design's "Continue with Google" is left out — no OAuth provider is
configured, so the button would have gone nowhere. Its footnote became
"private to your account": we send photos to OpenAI, so the original
"read on-device" would have been a promise we don't keep.

Button and TextInput gained a size prop for the taller controls this
page wants. Both default to what they rendered before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The layout is mirrored: the form moves to the left third and the closet
rack photo reads from the right. The image itself is flipped with
scale-x rather than recropped, so the same framing survives — the crop
that sat at 38% now lands at 62% — and the paper wash is mirrored to
match by turning its gradient from 265deg to 95deg.

The panel goes from 400px to 480px. A sign-in form at 400px leaves the
inputs and the button narrower than the headline they sit under, and the
extra width costs nothing on a screen with a full-bleed photo behind it.
The blurb's measure widens with it, from 25em to 28em, so the paragraph
fills the panel instead of stopping short at the old width.

The wash's stops move out with the panel (26/52/74% to 32/58/78%).
Without that, the right edge of the wider form drifts into the part of
the gradient that has already faded, and the text there loses about a
third of the paper behind it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Generate used to be a single panel that wrote an outfit the moment you
pressed the button: pick a vibe and an occasion, and whatever came back
was already in the grid under a name you never saw. The composer now
opens on a route -- styled by Thread Lens, or picked by hand -- and both
routes land on the same result screen, where the pieces can be swapped
and the name edited. Nothing is written until Save.

Anchors now anchor. The old flow used the first "build around" piece as
the recommendation seed and silently dropped the rest; every anchor
survives into the look, keeps its slot against the engine's suggestion,
and is marked "Yours". composeOutfitPieces holds that rule on its own so
it can be tested without the modal around it: anchors first, one piece
per slot after, and two anchors sharing a slot both kept rather than one
of them quietly lost.

Swap asks /api/outfits/recommendations/swap for the next-best match in
that slot, accumulating exclusions so repeat taps keep moving. That
endpoint has existed unused since the old AI modal was retired. Two
pieces can't go through it -- a hand-picked outfit has no base item, and
buildOutfitFromBase can't re-suggest the category it was seeded from --
so those take the next unused closet piece in the slot instead.

Occasion chips are gone in favour of the free-text note the design asks
for, so generated outfits now carry tags: [vibe] and the note as their
description, and no occasion. The note cannot steer generation: the
engine takes a base item and exclusions, nothing else. It is saved with
the outfit and passed to the namer, and the field says so rather than
implying it shapes the result.

The rationale states what actually happened -- which piece the look was
built out from, and that the rest is matched on colour with a nudge
toward pieces you wear and favourite, which is the weighting in
calculateOutfitScore. There is deliberately no match score: the only
number available mixes colour, wear count, and favourites, is not
returned by the API, and means nothing for a piece you swapped in
yourself.

Dialog gained padded and panelClassName so a panel can own its padding,
which this one needs for a footer bar that runs to the edge. Tag and
SparkleIcon moved into primitives now that two files draw them. The
in-grid "styling a look" card is gone, since generation happens inside
the modal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Uploading a macOS screenshot as a profile photo failed with a 400 and
"Invalid key". The name looks ordinary -- "Image 8-19-26 at 7.50 PM.jpeg"
-- but the space before the meridiem is U+202F, a narrow no-break space,
and storage validates keys against a regex whose \w is ASCII-only. The
character is invisible in the filename and fatal in a key.

uploadImageToBucket built the key straight from file.name, so the same
400 was waiting for any garment photo named with an accent, an em dash, a
curly quote, or an emoji. It now runs both the generated name and any
caller-supplied fileName through toStorageKeySafe, which is the one place
every bucket upload passes through -- profile, clothing, outfit, and worn
photos are all covered without touching their call sites.

Accents survive as their base letters rather than becoming separators,
and spaces become dashes even though they are legal in a key, so a path
reads the same in a URL as it does on disk.

The regression test asserts against the validation regex copied out of
the running storage-api container rather than one written from memory,
and checks the raw name fails it before checking the sanitised one
passes -- so it fails without this fix, for the reason the upload did.

Existing objects are untouched. Nothing was ever stored under a bad key:
those uploads 400'd instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The try-on spinner only lived in the detail dialog, so closing it left no
sign the render was still running. Track which outfit is rendering rather
than a bare boolean and draw the same overlay on its grid thumbnail.

The finished render also gets the hover-to-zoom affordance a piece's photo
already has, reusing ImageLightbox so it stacks above the dialog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The try-on renders come back 1024x1536, but every place we drew one used
a 3:4 frame with object-cover, so the crop quietly ate the head and the
feet -- the two parts of a full-body render worth looking at. The card
thumbnail and the detail dialog now use a 2:3 frame with object-contain,
which is the render's own ratio, so the image fits exactly rather than
letterboxing. A tl-sunk ground sits behind it for any outfit whose photo
is some other shape. The fullscreen lightbox already fit the whole image.

The card caption also drifted from the closet's: a serif name and one
mono meta line, against the closet's semibold name over a mono detail
and a mono stat. It now follows the closet -- season takes the slot a
piece gives its brand, and the piece count joins the wear count on the
stat line -- so the two grids read as the same object seen twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The closet thumbnails and the item modal both letterboxed the photo
inside a white square, so every card showed more background than
garment. Both now cover their frame, and the frame itself is tinted so
nothing white shows through on the empty and rendering states.

The modal's photo was tall and narrow, which cropped the sides off
anything wider than it. Its column is wider and square now, so only
genuinely landscape shots lose an edge — and "view full size" still
shows the piece whole.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The grid card no longer prints the season under the name. It was the
least useful line on a thumbnail whose job is to show the look; the
season still reads in the detail panel alongside the occasion.

Inside that panel, the pieces were a row of name tags. They are photo
tiles now, named underneath, and the fetch no longer stops at four —
a look shows every garment it is made of.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The detail panel laid the pieces out in a three-wide grid, so a look
with more than three garments grew a second row and pushed the panel
taller. The pieces now sit in one row that scrolls sideways, snapping
to each tile, and the panel keeps its height whatever the look holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant