feat(wardrobe): solo-garment try-on + plan-doc status updates (M4.1)

Closes the one checklist item M4 left for later — "TryOnButton auf
DetailGarmentView (mit impliziten 'Solo-Outfit')". A user can now open
a single garment's detail page, see "An mir anprobieren · 10 Credits",
and get an inline preview of themselves wearing just that one item
(or just that accessory, for glasses/jewelry/hat/accessory).

Client:
- api/try-on.ts: extracts a shared callGenerateWithReference() helper
  and a dimsForSize() utility from runOutfitTryOn so the new
  runGarmentTryOn can share the HTTP-error matrix + picture.images
  row shape without a refactor of the outfit path.
- runGarmentTryOn({ garment, faceRefMediaId, bodyRefMediaId?, prompt?,
  quality? }): auto-detects accessoryOnly from the garment's category
  (FACE_ONLY_CATEGORIES), composes the DE default prompt ("im/in
  <Name>", "mit <Name>" für Accessoires), writes a picture.images row
  with wardrobeOutfitId=null so it doesn't pollute any outfit's
  try-on history. Does NOT update any outfit.lastTryOn — it's a
  standalone preview, on purpose.
- GarmentTryOnButton.svelte: thinner sibling of TryOnButton. Same
  three states (ready / missing-refs / loading), same non-personal-
  space disclaimer. Extra: inline preview panel showing the last
  rendered result, with a link to the Picture gallery ("Gefunden in
  der Picture-Galerie als normale Generierung.").
- DetailGarmentView now puts the try-on action above the existing
  wear-tracking button. Try-on is the more engaging action for this
  page; demoting "heute getragen" to a secondary-styled button
  respects that without removing it.

Plan docs:
- docs/plans/wardrobe-module.md — rewrites the Status block to M1-M5
  with actual commit hashes, and checks off the per-milestone task
  lists. Adds a new M4.1 block for solo-garment try-on.
- docs/plans/me-images-and-reference-generation.md — adds the v40
  space-scope migration (cb9a9bb42) as its own row in the commit
  table, with a pointer to the sub-plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 21:14:35 +02:00
parent f20ace0358
commit e0820331b0
5 changed files with 402 additions and 99 deletions

View file

@ -19,6 +19,77 @@ import { wardrobeOutfitsStore } from '../stores/outfits.svelte';
import { FACE_ONLY_CATEGORIES } from '../types'; import { FACE_ONLY_CATEGORIES } from '../types';
import type { Garment, GarmentCategory, Outfit } from '../types'; import type { Garment, GarmentCategory, Outfit } from '../types';
/** Shared low-level POST to /generate-with-reference. Returns the first
* generated image's URL + mediaId + prompt + model outfit and solo
* variants both go through here to keep the HTTP error matrix identical.
*/
async function callGenerateWithReference(opts: {
prompt: string;
referenceMediaIds: string[];
quality: 'low' | 'medium' | 'high';
size: TryOnSize;
}): Promise<{ imageUrl: string; mediaId: string; prompt: string; model: string }> {
const token = await authStore.getValidToken();
const res = await fetch(`${getManaApiUrl()}/api/v1/picture/generate-with-reference`, {
method: 'POST',
headers: {
'content-type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
prompt: opts.prompt,
referenceMediaIds: opts.referenceMediaIds,
model: 'openai/gpt-image-2',
quality: opts.quality,
size: opts.size,
n: 1,
}),
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as {
error?: string;
required?: number;
missing?: string[];
};
if (res.status === 402) {
throw new Error(`Nicht genug Credits (${body.required ?? '?'} erforderlich)`);
}
if (res.status === 404) {
throw new Error(
'Ein oder mehrere Referenzbilder sind im Server-Ownership-Check durchgefallen — vermutlich sind Face/Body noch nicht in diesem Space hochgeladen.'
);
}
throw new Error(body.error ?? `Try-On fehlgeschlagen (${res.status})`);
}
const data = (await res.json()) as {
images?: Array<{ imageUrl: string; mediaId?: string }>;
imageUrl?: string;
mediaId?: string;
prompt: string;
model: string;
};
const first =
(data.images && data.images[0]) ??
(data.imageUrl ? { imageUrl: data.imageUrl, mediaId: data.mediaId } : null);
if (!first?.imageUrl || !first.mediaId) {
throw new Error('Keine Bilder zurückgegeben');
}
return {
imageUrl: first.imageUrl,
mediaId: first.mediaId,
prompt: data.prompt,
model: data.model,
};
}
function dimsForSize(size: TryOnSize): { width: number; height: number } {
if (size === '1024x1536') return { width: 1024, height: 1536 };
if (size === '1536x1024') return { width: 1536, height: 1024 };
return { width: 1024, height: 1024 };
}
export type TryOnSize = '1024x1024' | '1536x1024' | '1024x1536'; export type TryOnSize = '1024x1024' | '1536x1024' | '1024x1536';
export interface RunOutfitTryOnParams { export interface RunOutfitTryOnParams {
@ -86,73 +157,31 @@ export async function runOutfitTryOn(params: RunOutfitTryOnParams): Promise<RunT
referenceMediaIds.push(id); referenceMediaIds.push(id);
} }
const token = await authStore.getValidToken(); const result = await callGenerateWithReference({
const res = await fetch(`${getManaApiUrl()}/api/v1/picture/generate-with-reference`, { prompt: effectivePrompt,
method: 'POST', referenceMediaIds,
headers: { quality: quality ?? 'medium',
'content-type': 'application/json', size: effectiveSize,
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
prompt: effectivePrompt,
referenceMediaIds,
model: 'openai/gpt-image-2',
quality: quality ?? 'medium',
size: effectiveSize,
n: 1,
}),
}); });
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as {
error?: string;
required?: number;
missing?: string[];
};
if (res.status === 402) {
throw new Error(`Nicht genug Credits (${body.required ?? '?'} erforderlich)`);
}
if (res.status === 404) {
throw new Error(
'Ein oder mehrere Referenzbilder sind im Server-Ownership-Check durchgefallen — vermutlich sind Face/Body noch nicht in diesem Space hochgeladen.'
);
}
throw new Error(body.error ?? `Try-On fehlgeschlagen (${res.status})`);
}
const data = (await res.json()) as {
images?: Array<{ imageUrl: string; mediaId?: string; thumbnailUrl?: string }>;
imageUrl?: string;
mediaId?: string;
thumbnailUrl?: string;
prompt: string;
model: string;
referenceMediaIds?: string[];
};
const first =
(data.images && data.images[0]) ??
(data.imageUrl
? { imageUrl: data.imageUrl, mediaId: data.mediaId, thumbnailUrl: data.thumbnailUrl }
: null);
if (!first) throw new Error('Keine Bilder zurückgegeben');
const now = new Date().toISOString(); const now = new Date().toISOString();
const localImageId = crypto.randomUUID(); const localImageId = crypto.randomUUID();
const dims = dimsForSize(effectiveSize);
// Persist the generated image to the Picture gallery + tag it with // Persist the generated image to the Picture gallery + tag it with
// the outfit's wardrobeOutfitId so the outfit detail's Try-On strip // the outfit's wardrobeOutfitId so the outfit detail's Try-On strip
// picks it up via the useOutfitTryOns liveQuery. // picks it up via the useOutfitTryOns liveQuery.
await imagesStore.insert({ await imagesStore.insert({
id: localImageId, id: localImageId,
prompt: data.prompt, prompt: result.prompt,
negativePrompt: null, negativePrompt: null,
model: data.model, model: result.model,
publicUrl: first.imageUrl, publicUrl: result.imageUrl,
storagePath: first.mediaId ?? first.imageUrl, storagePath: result.mediaId,
filename: `wardrobe-tryon-${Date.now()}.png`, filename: `wardrobe-tryon-${Date.now()}.png`,
format: 'png', format: 'png',
width: effectiveSize === '1024x1536' ? 1024 : effectiveSize === '1536x1024' ? 1536 : 1024, width: dims.width,
height: effectiveSize === '1024x1536' ? 1536 : effectiveSize === '1536x1024' ? 1024 : 1024, height: dims.height,
isPublic: false, isPublic: false,
isFavorite: false, isFavorite: false,
downloadCount: 0, downloadCount: 0,
@ -168,16 +197,110 @@ export async function runOutfitTryOn(params: RunOutfitTryOnParams): Promise<RunT
// live-query round-trip. // live-query round-trip.
await wardrobeOutfitsStore.setLastTryOn(outfit.id, { await wardrobeOutfitsStore.setLastTryOn(outfit.id, {
imageId: localImageId, imageId: localImageId,
imageUrl: first.imageUrl, imageUrl: result.imageUrl,
createdAt: now, createdAt: now,
prompt: data.prompt, prompt: result.prompt,
model: data.model, model: result.model,
}); });
return { return {
imageId: localImageId, imageId: localImageId,
imageUrl: first.imageUrl, imageUrl: result.imageUrl,
prompt: data.prompt, prompt: result.prompt,
model: data.model, model: result.model,
};
}
// ─── Solo-Garment Try-On ─────────────────────────────────────────
export interface RunGarmentTryOnParams {
garment: Garment;
faceRefMediaId: string;
/** Null for accessory categories (glasses/jewelry/hat/accessory) the
* category check happens here, callers don't need to pre-filter. */
bodyRefMediaId?: string | null;
prompt?: string;
quality?: 'low' | 'medium' | 'high';
}
/** True iff the garment category implies a face-only render. Exposed so
* the button can decide whether body-ref is required. */
export function isAccessoryGarment(garment: Garment): boolean {
return FACE_ONLY_CATEGORIES.has(garment.category as GarmentCategory);
}
function composeGarmentPrompt(garment: Garment, accessoryOnly: boolean): string {
if (accessoryOnly) {
return `Fotorealistisches Portrait von mir mit ${garment.name}, frontal, studio-Licht, neutraler Hintergrund, Fokus auf dem Accessoire`;
}
return `Fotorealistisches Portrait von mir im/in ${garment.name}, natürliches Licht, neutraler Hintergrund`;
}
/**
* Single-garment try-on "nur diese Brille auf mein Gesicht" / "wie
* sähe dieses Hemd an mir aus". Writes a picture.images row WITHOUT a
* wardrobeOutfitId back-reference (it's not an outfit) and does NOT
* update any outfit's lastTryOn. The Picture gallery picks it up like
* any other generated image.
*
* Plan follow-up to docs/plans/wardrobe-module.md M4.
*/
export async function runGarmentTryOn(params: RunGarmentTryOnParams): Promise<RunTryOnResult> {
const { garment, faceRefMediaId, bodyRefMediaId, prompt, quality } = params;
const garmentMediaId = garment.mediaIds[0];
if (!garmentMediaId) {
throw new Error('Dieses Kleidungsstück hat kein Foto.');
}
const accessoryOnly = isAccessoryGarment(garment);
const effectiveSize: TryOnSize = accessoryOnly ? '1024x1024' : '1024x1536';
const effectivePrompt = prompt?.trim() || composeGarmentPrompt(garment, accessoryOnly);
const referenceMediaIds: string[] = [faceRefMediaId];
if (!accessoryOnly && bodyRefMediaId) referenceMediaIds.push(bodyRefMediaId);
referenceMediaIds.push(garmentMediaId);
const result = await callGenerateWithReference({
prompt: effectivePrompt,
referenceMediaIds,
quality: quality ?? 'medium',
size: effectiveSize,
});
const now = new Date().toISOString();
const localImageId = crypto.randomUUID();
const dims = dimsForSize(effectiveSize);
await imagesStore.insert({
id: localImageId,
prompt: result.prompt,
negativePrompt: null,
model: result.model,
publicUrl: result.imageUrl,
storagePath: result.mediaId,
filename: `wardrobe-garment-tryon-${Date.now()}.png`,
format: 'png',
width: dims.width,
height: dims.height,
isPublic: false,
isFavorite: false,
downloadCount: 0,
generationMode: 'reference',
referenceImageIds: referenceMediaIds,
// Deliberately null — this is a standalone preview, not an outfit.
// If users later compose outfits from these, the outfit-level
// try-on writes its own picture.images row with the wardrobeOutfitId
// set; no retroactive linking from the garment row.
wardrobeOutfitId: null,
createdAt: now,
updatedAt: now,
});
return {
imageId: localImageId,
imageUrl: result.imageUrl,
prompt: result.prompt,
model: result.model,
}; };
} }

View file

@ -0,0 +1,148 @@
<!--
Single-garment try-on action for the garment detail page. Thinner
sibling of TryOnButton — no outfit context, no occasion hint. Still
handles the three states (ready / missing refs / loading) and
disclaimer in non-personal spaces.
Plan follow-up: docs/plans/wardrobe-module.md M4 called out solo-
garment try-on ("mit impliziten 'Solo-Outfit'"); the runGarmentTryOn
helper writes a picture.images row WITHOUT a wardrobeOutfitId, so
the result lives in the Picture gallery but doesn't pollute any
outfit's try-on history.
-->
<script lang="ts">
import { Sparkle, UserCircle, Info } from '@mana/shared-icons';
import { getActiveSpace } from '$lib/data/scope';
import { useImageByPrimary } from '$lib/modules/profile/queries';
import { isAccessoryGarment, runGarmentTryOn } from '../api/try-on';
import type { Garment } from '../types';
interface Props {
garment: Garment;
}
let { garment }: Props = $props();
const face$ = useImageByPrimary('face-ref');
const body$ = useImageByPrimary('body-ref');
const activeSpace = $derived(getActiveSpace());
const face = $derived(face$.value);
const body = $derived(body$.value);
const accessoryOnly = $derived(isAccessoryGarment(garment));
const missingFace = $derived(!face);
const missingBody = $derived(!accessoryOnly && !body);
const hasPhoto = $derived((garment.mediaIds?.length ?? 0) > 0);
const canTryOn = $derived(!missingFace && !missingBody && hasPhoto);
let running = $state(false);
let error = $state<string | null>(null);
let lastResultUrl = $state<string | null>(null);
const estimatedCredits = 10;
async function handleClick() {
if (!face || (!accessoryOnly && !body)) return;
running = true;
error = null;
lastResultUrl = null;
try {
const result = await runGarmentTryOn({
garment,
faceRefMediaId: face.mediaId,
bodyRefMediaId: accessoryOnly ? null : (body?.mediaId ?? null),
});
lastResultUrl = result.imageUrl;
} catch (err) {
error = err instanceof Error ? err.message : 'Try-On fehlgeschlagen';
} finally {
running = false;
}
}
</script>
{#if !hasPhoto}
<p class="text-xs text-muted-foreground">
Lade erst ein Foto hoch, um dieses Stück an dir zu visualisieren.
</p>
{:else if missingFace || missingBody}
<div
class="flex items-start gap-3 rounded-xl border border-dashed border-border bg-background/50 p-4 text-sm text-muted-foreground"
>
<UserCircle size={18} weight="regular" class="mt-0.5 flex-shrink-0 text-primary" />
<div class="space-y-1">
<p class="text-foreground">Lade erst Referenzbilder hoch, um das Stück an dir zu sehen.</p>
<p class="text-xs">
Solo-Try-On braucht ein {accessoryOnly
? 'Gesichtsbild'
: 'Gesichts- und ein Ganzkörperbild'}
in diesem Space. Öffne dafür
<a href="/profile/me-images" class="font-medium text-primary hover:underline">
Meine Bilder
</a>.
</p>
</div>
</div>
{:else}
<div class="space-y-2">
<button
type="button"
onclick={handleClick}
disabled={running || !canTryOn}
class="flex w-full items-center justify-center gap-2 rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if running}
<div
class="h-4 w-4 animate-spin rounded-full border-2 border-current border-r-transparent"
></div>
Rendere…
{:else}
<Sparkle size={16} weight="fill" />
An mir anprobieren · {estimatedCredits} Credits
{/if}
</button>
{#if accessoryOnly}
<p class="flex items-center gap-1.5 text-xs text-muted-foreground">
<Info size={12} weight="regular" class="flex-shrink-0" />
Accessoire-Modus — nur das Gesicht wird gerendert (spart Credits).
</p>
{/if}
{#if activeSpace && activeSpace.type !== 'personal'}
<p class="flex items-start gap-1.5 text-xs text-muted-foreground">
<Info size={12} weight="regular" class="mt-0.5 flex-shrink-0" />
<span>
Try-On nutzt deine Referenzbilder aus diesem Space
<strong class="text-foreground">({activeSpace.name})</strong>, nicht aus Persönlich.
</span>
</p>
{/if}
{#if error}
<div
class="rounded-md border border-error/30 bg-error/10 px-3 py-2 text-sm text-error"
role="alert"
>
{error}
</div>
{/if}
{#if lastResultUrl}
<div class="space-y-1.5 rounded-xl border border-border bg-card p-3">
<p class="text-xs font-medium uppercase tracking-wider text-muted-foreground">Ergebnis</p>
<img
src={lastResultUrl}
alt="Try-On"
class="w-full rounded-md border border-border bg-muted"
/>
<p class="text-xs text-muted-foreground">
Gefunden in der
<a href="/picture" class="font-medium text-primary hover:underline">Picture-Galerie</a>
als normale Generierung.
</p>
</div>
{/if}
</div>
{/if}

View file

@ -12,6 +12,7 @@
import { garmentPhotoUrl } from '../api/media-url'; import { garmentPhotoUrl } from '../api/media-url';
import { CATEGORY_LABELS } from '../constants'; import { CATEGORY_LABELS } from '../constants';
import GarmentForm from '../components/GarmentForm.svelte'; import GarmentForm from '../components/GarmentForm.svelte';
import GarmentTryOnButton from '../components/GarmentTryOnButton.svelte';
interface Props { interface Props {
id: string; id: string;
@ -184,14 +185,17 @@
{/if} {/if}
</div> </div>
<!-- Primary action: heute getragen --> <!-- Try-on — "wie sähe das an mir aus" -->
<GarmentTryOnButton {garment} />
<!-- Wear-tracking -->
<button <button
type="button" type="button"
onclick={handleMarkWorn} onclick={handleMarkWorn}
disabled={markingWorn} disabled={markingWorn}
class="flex w-full items-center justify-center gap-2 rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50" class="flex w-full items-center justify-center gap-2 rounded-md border border-border bg-background px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-50"
> >
<CheckCircle size={16} weight="fill" /> <CheckCircle size={14} />
{markingWorn ? 'Gespeichert…' : 'Heute getragen'} {markingWorn ? 'Gespeichert…' : 'Heute getragen'}
</button> </button>

View file

@ -14,6 +14,7 @@ Commits (teils durch parallele Sessions in Commits mit anderer Attribution gelan
| M4 Reference-Picker | in `d087b4744` | `ReferenceImagePicker.svelte`, Model-Auto-Switch, Endpoint-Routing, `generationMode`+`referenceImageIds` auf `LocalImage` | | M4 Reference-Picker | in `d087b4744` | `ReferenceImagePicker.svelte`, Model-Auto-Switch, Endpoint-Routing, `generationMode`+`referenceImageIds` auf `LocalImage` |
| M2.5 Avatar-Migration | `e2b5ac38c` | One-shot `migration/legacy-avatar.ts`, Autosync face-ref→avatar→`auth.users.image`, EditProfileModal-Cleanup | | M2.5 Avatar-Migration | `e2b5ac38c` | One-shot `migration/legacy-avatar.ts`, Autosync face-ref→avatar→`auth.users.image`, EditProfileModal-Cleanup |
| M5 MCP-Tools | `fc635f983` | `packages/mana-tool-registry/src/modules/me.ts` — zwei Tools, auto-registriert | | M5 MCP-Tools | `fc635f983` | `packages/mana-tool-registry/src/modules/me.ts` — zwei Tools, auto-registriert |
| Space-Scope-Migration | `cb9a9bb42` | `meImages` aus `USER_LEVEL_TABLES` ausgetragen, Dexie v40 retro-stampt `spaceId = _personal:<uid>`-Sentinel + `authorId` + `visibility`, drops `userId`. Queries/Store/MCP-Tool filtern jetzt auf aktiven Space. `auth.users.image` bleibt an Personal-Space primary-avatar gekoppelt — andere Spaces haben ihren eigenen lokalen Avatar. Sub-Plan: `docs/plans/me-images-space-scope-migration.md` |
## Offen (noch nicht angefangen) ## Offen (noch nicht angefangen)

View file

@ -1,8 +1,27 @@
# Wardrobe — Module Plan # Wardrobe — Module Plan
## Status (2026-04-23) ## Status (2026-04-23, Stand nach M5)
Greenfield. Das Fundament (meImages + reference-based image generation) ist komplett verfügbar — siehe `docs/plans/me-images-and-reference-generation.md` Stand M1-M5. Dieses Modul konsumiert es. **M1M5 SHIPPED** — Feature ist end-to-end benutzbar. Nutzer pflegt Garments + Outfits pro Space (alle sechs Space-Typen), komponiert über den Composer, rendert Try-On-Vorschauen via OpenAI `gpt-image-2`-Edits, und kann dasselbe via MCP-Tools an Personas/Agents delegieren. Solo-Garment-Try-On ("nur diese Brille anprobieren") als Follow-up in M4.1.
| Milestone | Commit | Inhalt |
|---|---|---|
| M1 Datenschicht | `4fc9d6c59` | Dexie v41 `wardrobeGarments` + `wardrobeOutfits` (space-scoped), Types/Collections/Queries/Stores, module-registry, space-allowlist in allen 6 Typen, `/api/v1/wardrobe/garments/upload`, `MAX_REFERENCE_IMAGES` Cap 4→8, `picture.images.wardrobeOutfitId` Back-Ref |
| M2 Garments-UI | `5a49bcbf0` | `/wardrobe` Route, CategoryTabs, GarmentCard, GarmentForm, `/wardrobe/garment/[id]`, Drag-Drop-Upload, edit/archive/delete flows, Active-Space-Badge |
| M3 Outfits-Composer | `2b89bf795` | `/wardrobe/compose/[[outfitId]]` Composer (click-to-add, garment-library left, editor right), OutfitsView-Tab, OutfitCard (try-on cover → garment collage fallback), `/wardrobe/outfit/[id]` Detail |
| M4 Try-On | `d56ad396d` | `runOutfitTryOn` + `TryOnButton` auf DetailOutfitView, Accessoire-Modus-Detection, Empty-State bei fehlenden Referenzen, non-personal-Space-Hinweis; `verifyMediaOwnership` erweitert auf `['me','wardrobe']` |
| M5 MCP-Tools | `7e3f53f8a` (+ `66b7e08df` für types/index) | `wardrobe.listGarments` / `.listOutfits` / `.createOutfit` / `.tryOn` in `packages/mana-tool-registry/src/modules/wardrobe.ts`, registered in `registerAllModules` |
**Fundament konsumiert:** me-images M1-M5 (siehe `docs/plans/me-images-and-reference-generation.md`) — Space-scoped `meImages` (v40), `/api/v1/picture/generate-with-reference` (gpt-image-2 via `/v1/images/edits`), `useImageByPrimary('face-ref'|'body-ref')`.
## Offen (nach M5)
- **M4.1 Solo-Garment-Try-On**`runGarmentTryOn()` + `GarmentTryOnButton` auf DetailGarmentView. Render eines einzelnen Kleidungsstücks ohne Outfit-Kontext (z.B. Brille an mir ausprobieren). Ergebnis landet in `picture.images` ohne `wardrobeOutfitId`-Back-Ref. Implementiert als Plan-Follow-up.
- **M6 Persona-Template "Stil-Coach"** (~0.5 Tag, optional) — neuer Eintrag unter `/agents/templates` mit auto-policy für `wardrobe.list*` + `me.listReferenceImages`, propose-policy für `wardrobe.createOutfit` + `wardrobe.tryOn`. Seed-Prompt: "Du bist der persönliche Stil-Coach. Schlage Outfits aus dem vorhandenen Kleiderschrank vor, basierend auf Kontext (Kalender-Event, Wetter, Nutzer-Stimmung). Nie kritisch, nie body-urteilend."
- **M7 "Heute trage ich…"-Tiefe** (~0.5 Tag, optional) — "heute getragen"-Button auf OutfitCard + Stats-Widget ("am häufigsten getragen", "lange nicht mehr angehabt"). Quick-Log-Button ist bereits in DetailGarmentView drin; fehlt nur Card-Ebene + Stats.
- **M8 Context-basierte Outfit-Mission** (mehrere Tage, optional) — mana-ai Mission-Template "Outfit des Tages": liest calendar + wetter + wardrobe, erzeugt 3 Vorschläge als Proposals. Workbench-Widget "Heute anziehen" als Card.
- **Multi-Variant-Rendering** (n=2/4) im TryOnButton — Picker-UI "Zeig mir 3 Looks" statt 1-Klick-1-Bild.
- **Multi-Foto pro Garment**`mediaIds: string[]` ist vorbereitet (Primary `[0]`); UI rendert aktuell nur Primary. Detail-Strip für alternate Views (front/back/detail) wäre die nächste Ausbau-Stufe.
## Ziel ## Ziel
@ -275,44 +294,52 @@ Vier Tools, alle user-space. Pattern ist 1:1 an `me.ts` aus M5 angelehnt:
## Milestones ## Milestones
- **M1 — Datenschicht & Backend-Cap** (~11.5 Tage) - **M1 — Datenschicht & Backend-Cap** ✅ SHIPPED `4fc9d6c59`
- [ ] Dexie v39: `wardrobeGarments` + `wardrobeOutfits` mit Indices (space-scoped, also Compound-Index auf `[spaceId+...]` für die hot path Queries) - [x] Dexie v41 (nicht v39 — me-images-space-migration hat v40 belegt): `wardrobeGarments` + `wardrobeOutfits` mit Indices (space-scoped, kein Compound-Index nötig — `scopedTable` filtert in-memory)
- [ ] Types + Encryption-Registry + Collections + Queries (via `scopedForModule<>`, *nicht* in `USER_LEVEL_TABLES` — volle Space-Scope-Behandlung) - [x] Types + Encryption-Registry (`name/brand/color/size/material/tags/notes` für Garments, `name/description/tags` für Outfits) + Collections + Queries via `scopedForModule<>`, *nicht* in `USER_LEVEL_TABLES`
- [ ] Stores (garments, outfits) - [x] Stores (garments, outfits) mit Domain-Events (WardrobeGarmentAdded, WardrobeOutfitCreated, WardrobeOutfitTryOn, etc.)
- [ ] `module.config.ts` registriert `appId='wardrobe'` - [x] `module.config.ts` registriert `appId='wardrobe'`
- [ ] `wardrobe` in *alle* sechs Space-Typen der Allowlist (`personal`, `brand`, `club`, `family`, `team`, `practice`) - [x] `wardrobe` in *alle* sechs Space-Typen der Allowlist
- [ ] `MAX_REFERENCE_IMAGES` Cap auf 8 (`apps/api/src/modules/picture/routes.ts`) mit Comment + ClientCap im Generator - [x] `MAX_REFERENCE_IMAGES` Cap auf 8 (`apps/api/src/modules/picture/routes.ts`) + Client-Default in `ReferenceImagePicker.svelte`
- [ ] Neuer `POST /api/v1/wardrobe/garments/upload`-Endpoint + Route-Registrierung - [x] `POST /api/v1/wardrobe/garments/upload`-Endpoint + Route-Registrierung
- [ ] `wardrobeOutfitId`-Feld auf `LocalImage` + `toImage`-Converter - [x] `wardrobeOutfitId`-Feld auf `LocalImage` + `toImage`-Converter
- **M2 — Garments-Grundlayer** (~11.5 Tage) - **M2 — Garments-Grundlayer** ✅ SHIPPED `5a49bcbf0`
- [ ] Route `/wardrobe` mit `RoutePage` - [x] Route `/wardrobe` mit `RoutePage`
- [ ] `CategoryTabs`, `GarmentCard`, `GarmentUploadZone`, `GarmentForm` - [x] `CategoryTabs`, `GarmentCard`, `GarmentForm`; Upload-Zone reuses `MeImageUploadZone` (cross-module import, purely presentational)
- [ ] Multi-File-Upload pro Kategorie (wie me-images) - [x] Multi-File-Upload, aktive Kategorie bestimmt den default-Kind für neue Drops
- [ ] Detailseite `/wardrobe/garment/[id]` — Foto, Metadaten, "heute getragen"-Button (incrementiert `wearCount`) - [x] Detailseite `/wardrobe/garment/[id]` — Foto, Metadaten, "heute getragen"-Button
- [ ] Archive / Delete / Edit flows - [x] Archive / Delete / Edit flows
- [x] Active-Space-Badge im Intro-Card
- **M3 — Outfits-Composer** (~11.5 Tage) - **M3 — Outfits-Composer** ✅ SHIPPED `2b89bf795`
- [ ] Route `/wardrobe/compose/[[outfitId]]` - [x] Route `/wardrobe/compose/[[outfitId]]`
- [ ] Drag-drop-Leiste mit Garments (nach Kategorie gruppiert) - [x] Zwei-Spalten-Composer mit Garment-Library (nach Kategorie gruppiert) + Outfit-Editor. Click-to-Add statt Drag-Drop (keyboard-accessible, 100% workflow)
- [ ] Outfit-Preview-Kachel rechts (Stapel der Garment-Thumbnails) - [x] Outfit-Preview-Chips mit Hover-× zum Entfernen
- [ ] Create/Edit an dieselbe Route, `[[outfitId]]` optional - [x] Create/Edit an dieselbe Route, `[[outfitId]]` optional; `{#key outfitId ?? 'new'}` für sauberen Re-Mount
- [ ] Detailseite `/wardrobe/outfit/[id]` - [x] Detailseite `/wardrobe/outfit/[id]` mit Metadata-Card + Komposition-Grid + Try-On-Verlauf-Strip
- [ ] `OutfitsView` als zweiter Tab im Root - [x] `OutfitsView` als zweiter Tab in ListView mit "+ Neues Outfit"-CTA
- **M4 — Try-On-Integration** (~1 Tag) - **M4 — Try-On-Integration** ✅ SHIPPED `d56ad396d`
- [ ] `runTryOn(outfit, prompt?)` in `api/try-on.ts` — composed die reference-Liste aus *des Nutzers eigenen* `useImageByPrimary('face-ref' | 'body-ref')` + garment-mediaIds (auch in non-personal Spaces), ruft `/generate-with-reference` - [x] `runOutfitTryOn` in `api/try-on.ts` composed die reference-Liste aus aktivem Space's face-ref + body-ref + garment-mediaIds, ruft `/generate-with-reference`
- [ ] `accessoryOnly`-Preset für `glasses`/`jewelry`/`hat` — nur face-ref, quadratisches Format - [x] `accessoryOnly`-Modus auto-detectiert aus `FACE_ONLY_CATEGORIES` — nur face-ref, 1024×1024 Format
- [ ] `TryOnButton.svelte` auf DetailOutfitView + auf DetailGarmentView (mit impliziten "Solo-Outfit") - [x] `TryOnButton` auf DetailOutfitView (DetailGarmentView folgt in M4.1)
- [ ] Nach Erfolg: `picture.images.wardrobeOutfitId` setzen + `lastTryOn`-Snapshot aufs Outfit - [x] Nach Erfolg: `picture.images.wardrobeOutfitId` + `lastTryOn`-Snapshot aufs Outfit
- [ ] Empty-State wenn `primaryFace` oder `primaryFullbody` fehlen → Link zu `/profile/me-images` - [x] Empty-State bei fehlenden Referenzen → Link zu `/profile/me-images`
- [ ] In Non-Personal-Spaces (`brand`/`club`/`family`/`team`/`practice`): Hinweis "Du siehst dich selbst im Outfit — Try-On nutzt deine persönlichen Referenzbilder, nicht die des Spaces" (Subject ist user-global, siehe Entscheidung #6) - [x] Non-Personal-Space-Hinweis ("Try-On nutzt deine Referenzbilder aus diesem Space"); Family-Space-Sonderhinweis
- [ ] Try-On-History als horizontaler Strip in DetailOutfitView - [x] Try-On-Verlauf-Strip via `useOutfitTryOns` (bereits in M3 angelegt, füllt sich nach erstem Render auto)
- [x] Server-side `verifyMediaOwnership` auf `['me','wardrobe']` erweitert
- **M5 — MCP-Tools** (~0.5 Tag) - **M4.1 — Solo-Garment-Try-On** ✅ SHIPPED *(folgender Commit)*
- [ ] `packages/mana-tool-registry/src/modules/wardrobe.ts` mit den 4 Tools - [x] `runGarmentTryOn` in `api/try-on.ts` — Single-Garment als "impliziter Solo-Outfit"; `wardrobeOutfitId=null` auf der erzeugten `picture.images`-Row
- [ ] `'wardrobe'` zum `ModuleId`-Union - [x] `GarmentTryOnButton` auf DetailGarmentView mit Inline-Preview des zuletzt erzeugten Bildes
- [ ] `registerWardrobeTools()` in `registerAllModules()` - [x] Gemeinsamer `callGenerateWithReference`-Helper refactored aus `runOutfitTryOn`
- [x] `isAccessoryGarment(garment)` Helper für face-only Detection
- **M5 — MCP-Tools** ✅ SHIPPED `7e3f53f8a` (+ `66b7e08df` für types/index)
- [x] `packages/mana-tool-registry/src/modules/wardrobe.ts` mit 4 Tools: listGarments, listOutfits, createOutfit, tryOn
- [x] `'wardrobe'` im `ModuleId`-Union
- [x] `registerWardrobeTools()` in `registerAllModules()` — MCP exponiert automatisch
- **M6 — Persona-Templates** (~0.5 Tag, optional) - **M6 — Persona-Templates** (~0.5 Tag, optional)
- [ ] Persona-Template "Stil-Coach": auto-Policy für `wardrobe.list*` + `me.listReferenceImages`, propose-Policy für `wardrobe.createOutfit` + `wardrobe.tryOn` - [ ] Persona-Template "Stil-Coach": auto-Policy für `wardrobe.list*` + `me.listReferenceImages`, propose-Policy für `wardrobe.createOutfit` + `wardrobe.tryOn`