Outfit extraction, try-on, and admin cost tracking - #1
Open
jcr-byte wants to merge 61 commits into
Open
Conversation
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Rolls up the work on
feature/scan-outfit-extraction(38 commits ahead ofmain). The main threads:/adminroute with a user list and a financials page pricing every image generation from its persisted token breakdown.Test plan
npm test— 275 tests across 20 files, all passing.🤖 Generated with Claude Code