User reported the picker was missing in the Workbench's inline calendar
detail view. I'd only patched EventDetailModal.svelte (used by the
/calendar route), not views/DetailView.svelte which the app-registry
loads for the Workbench card overlay.
Same pattern as the other fixes: import VisibilityPicker + type,
handleVisibilityChange → eventsStore.setVisibility, place the compact
picker in the title-row next to the title input.
Added a small .title-row flex style so title input and picker sit
side-by-side without overlap. `:global(.title-input)` used because the
class is shared with other DetailViews' overrides.
Lesson: any module with BOTH a route-level detail component AND a
views/DetailView.svelte registered for the Workbench needs the picker
in both. Checked other shipped modules:
- todo: views/DetailView already had it (correct)
- places: views/DetailView already had it (correct)
- library: opens in a sub-route, not inline Workbench — views/
DetailView covers both (correct)
- wardrobe: opens in a sub-route /wardrobe/outfit/[id] — covers both
- picture: no Workbench detail view registered, only list
- goals/recipes: inline-on-card, no separate detail view
Only calendar had the split, now fixed.
Verified:
- pnpm check (web): 7515 files, 0 errors
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Die Datenschicht aus M1 wird jetzt durch UI + gpt-image-2-Flow
benutzbar. Nutzer legt eine Story an (Titel, Stil, Protagonist) und
generiert Panels einzeln über PanelEditor — jeder Panel-Call nutzt
die story-weite Referenz-Liste (face + optional body + optional
Kostüme) plus den stil-spezifischen Prompt-Prefix aus styles.ts.
- `api/generate-panel.ts` → `runPanelGenerate()` wrappt
`/picture/generate-with-reference` analog zu wardrobe/try-on,
schreibt picture.images mit `comicStoryId` + `comicPanelIndex`
Back-Refs und appendet via `comicStoriesStore.appendPanel`. Größe
defaultet auf 1024×1024 (Quadrat) bzw. 1024×1536 für Webtoon.
- Form-Komponenten: `StylePicker` (5 Presets als Radio-Tiles),
`CharacterPicker` (face-ref Pflicht, body-ref + bis 3
Wardrobe-Kostüme optional), `StoryForm` (Titel + Stil + Picker +
optionaler Kontext).
- Panel-Komponenten: `PanelCard` (Bild + Caption/Dialog-Sidecar),
`PanelStrip` (responsives Grid 2-4 Spalten), `PanelEditor`
(inline-Sheet mit Prompt + Caption + Dialog + Quality/Format +
Generate-Button; zeigt Credits vorher, warnt ab 8 Panels, cappt
bei 12).
- `StoryCard` rendert Cover aus `panelImageIds[0]` via neuer
`usePanelImage`-Query, mit Style-Badge und Favorit-Heart.
- `ListView`: Grid + "+ Neue Story"-CTA, Face-Ref-Hinweis wenn
fehlt, leeres Empty-State-Board.
- `DetailView`: Meta-Card mit VisibilityPicker + Favorit +
Archive/Delete, PanelStrip, "+ Panel"-CTA öffnet PanelEditor
inline. Panel-Remove entfernt aus panelImageIds + panelMeta, die
picture.images-Row bleibt (Final-Delete im Picture-Modul).
- Routes: `/comic` (ListView), `/comic/new` (StoryForm) und
`/comic/[id]` (DetailView mit {#key id} Re-Mount wie wardrobe).
- i18n: comic-Label in de.json + en.json für RoutePage-Header.
- queries: `usePanelImage(id)` Helper für Cover + Panel-Rendering
(comic-intern, nicht ins Picture-Modul eingemischt).
Sprechblasen/Captions werden gpt-image-2 per Prompt übergeben und
direkt ins Bild gerendert — kein SVG-Overlay. Englische Texte
rendern stabiler (UI-Hinweis).
Testet per `pnpm run check` + `validate:all` sauber, 5 Encryption-
Tests weiterhin grün.
Kein Batch-Mode (M3), kein AI-Storyboard (M4), keine MCP-Tools (M5).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Users can now select a passage in the editor and refine it in place via
five targeted operations instead of re-generating the whole draft:
Shorten, Expand, Change tone, Rewrite (freeform instruction), Translate.
- Five selection-specific prompt builders in utils/prompt-builder.ts.
Each forbids preamble / quoting / explanation so the output is a drop-
in replacement for the selected text. Style context is injected when
present so refinements stay on-voice.
- generations.store.refineSelection() sizes the token budget to the
selection (selectionWords * 4 + 200), runs at temperature 0.4 for
consistency, and records the attempt as a LocalGeneration with kind
'selection-*' + inputSelection range regardless of whether the user
accepts — every refine-attempt stays auditable.
- applyRefinement() commits the replacement to the current version's
content (not a new version; in-place per the plan) and back-links the
generation via outputVersionId so later audits can trace each edit.
- SelectionToolbar appears above the editor when the user has a non-
empty selection; Tone + Translate expand to pickers, Rewrite to a
text input.
- RefinementPanel shows original + refined side-by-side with Übernehmen /
Noch mal / Verwerfen. Running and failed states get their own chrome.
- VersionEditor tracks textarea selection via select/mouseup/keyup and
reports {start, end, text} via onselect. New `forceContent` prop
nonce lets the parent swap the editor's local text after an apply or
undo without breaking the debouncing layer.
- One-step undo: "↶ Rückgängig: <tool>" button surfaces briefly after
an accepted refinement and restores the pre-refinement content via
draftsStore.updateVersionContent. Kicking off a new refinement
clears the undo target.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Neues Comic-Modul: aus Text-Inputs (Journal / Notes / Writing / Library
/ Calendar) entsteht ein mehrseitiger Comic, generiert mit gpt-image-2
über die bestehende /picture/generate-with-reference-Route. Plan in
docs/plans/comic-module.md (M1–M5 + optional M6–M8).
M1 schafft die Datenschicht ohne UI:
- Dexie v44 `comicStories` (space-scoped, Indices createdAt/style/
isFavorite/isArchived). Story hält `panelImageIds: string[]` und
`panelMeta: Record<panelImageId, {caption, dialogue, promptUsed,
sourceInput?}>` — Panels selbst sind picture.images-Rows mit
comicStoryId + comicPanelIndex Back-Refs.
- Fünf Stil-Presets (comic / manga / cartoon / graphic-novel / webtoon)
mit Prompt-Prefix-Templates in styles.ts; composePanelPrompt webt
Stil + Panel-Prompt + Caption + Dialog zusammen. Sprechblasen
werden von gpt-image-2 direkt ins Bild gerendert — kein SVG-Overlay.
- Encryption-Registry-Eintrag: title / description / storyContext /
tags / panelMeta als JSON-Blob. Struktur (id, style, character-
MediaIds, panelImageIds, Flags, visibility) bleibt plaintext.
- Module-Registry registriert appId='comic', verifyMediaOwnership auf
der /picture/generate-with-reference-Route akzeptiert jetzt
['me', 'wardrobe', 'comic'] — 'comic'-Slot ist reserviert für M6+
Anchor-/Backdrop-Uploads.
- Space-Allowlist: comic in brand (Marken-Storys), club (Vereins-
geschichte), family (Kinder-Abenteuer), team (Release-Comics),
practice (Patienten-Aufklärung). Personal via '*'-Sentinel.
- mana-apps.ts Eintrag mit comic-Icon (Sprechblase + Lightning-Bolt,
f97316→dc2626 Gradient). Lokal tier='guest' mit LOCAL TIER PATCH-
Comment wie Wardrobe, canonical ist 'beta'.
Visibility-System von Anfang an adopted (setVisibility-Methode im
Store, unlistedToken-Generierung inklusive). appendPanel() als
Vorarbeit für M2 bereits da, ohne Aufrufer.
5 Encryption-Roundtrip-Tests grün (panelMeta nested JSON, leeres
panelMeta, partielle panelMeta ohne sourceInput, null-description).
pnpm run check + validate:all sauber (207 Dexie-Tabellen klassifiziert,
comicStories unter den 106 encrypted).
Kein UI, keine Panel-Generierung, keine MCP-Tools — alles M2/M3/M5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Styles are selectable in the briefing and flow into the prompt builder
so "LinkedIn-Post" and "Akademisch" produce visibly different drafts
from the same brief.
- StylePicker.svelte: dropdown in BriefingForm grouped into Vorlagen
(9 presets from presets/styles.ts) and Meine Stile (user custom rows).
Emits an opaque id — `preset:<id>` for presets or a uuid for customs —
so selecting a preset requires no Dexie write.
- generations store: loadStyle() now resolves both prefix shapes. The
prompt builder already honoured both preset.principles and
row.extractedPrinciples, so no prompt changes needed.
- /writing/styles view: grid of presets (read-only dashed cards) plus
a user section with create / edit / delete for custom styles.
- StyleForm.svelte: M4 supports source='custom-description' (name +
freeform prose the LLM reads verbatim). Sample-trained and
self-trained sources come in M4.1.
- DetailView surfaces the active style as a 🎨-chip next to the
briefing preview; ListView gets a "🎨 Stile" link to the management
route.
Styles are optional — existing drafts with styleId=null keep their
previous behaviour, and the LinkedIn/Hemingway/etc. presets are a zero-
friction on-ramp before users bother writing a custom one.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Server:
- New llmText() helper in apps/api/src/lib/llm.ts for plain-text
(non-streaming) completions with token-usage reporting.
- POST /api/v1/writing/generations (Hono + requireTier('beta'))
accepts system+user prompts, forwards to mana-llm (default model
ollama/gemma3:4b), returns raw output + model + tokenUsage. The
endpoint is stateless — draft/version bookkeeping is entirely
client-side so the same route serves refinement calls later.
Client:
- writing/api.ts — Bearer-authed fetch client (follows the food/
news-research pattern).
- writing/utils/prompt-builder.ts — pure builder turning a briefing
(+ optional style preset / extracted principles) into a system+user
pair. Forbids preamble / sign-off / meta commentary so the output is
ready to paste into a version.
- writing/stores/generations.svelte.ts — orchestrates the full flow:
queued → running → call → new LocalDraftVersion → pointer flip →
succeeded. On failure leaves the current version untouched with the
error on the generation record. Emits WritingDraftGenerationStarted /
WritingDraftVersionCreated / WritingDraftGenerationFailed events.
UI:
- Generate button in DetailView.svelte (label flips "Generate" / "Neu
generieren" based on whether the draft already has content).
- GenerationStatus.svelte strip surfaces queued / running / failed with
model + duration badges; succeeded generations auto-disappear because
the new version is already live via the currentVersionId pointer.
M3 is synchronous and non-streaming by design. M7 adds mission-based
long-form with streaming + outline stage + reference injection. M6 will
reuse the same /generations endpoint for selection-refinement prompts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
M1 (skeleton):
- Module `writing` registered: 4 Dexie tables (writingDrafts,
writingDraftVersions, writingGenerations, writingStyles) in v43,
encrypted via typed registry entries, space-scoped via the Dexie hook.
- App entry in mana-apps.ts (sky-cyan #0ea5e9, LOCAL TIER PATCH guest),
fountain-pen icon in app-icons.ts.
- Plan: docs/plans/writing-module.md — 12 milestones, Ghostwriter-first
with Canvas deferred to M9, Picture-pattern analogue (Draft + Version
+ Generation), 9 preset styles, Space-Kontext-as-default.
M2 (manual CRUD):
- drafts store: createDraft (atomic draft + initial v1), updateBriefing,
setStatus, toggleFavorite, deleteDraft (cascade soft-delete versions),
updateVersionContent (live edit), createCheckpointVersion,
restoreVersion (pointer flip, non-destructive), setVisibility.
- styles store: createStyle, updateStyle, upsertExtractedPrinciples,
setSpaceDefault (exclusive flip), deleteStyle.
- queries: useAllDrafts, useDraft, useVersionsForDraft,
useCurrentVersionForDraft (follows the pointer so restoreVersion shows
up in the editor), useGenerationsForDraft, useAllStyles + helpers.
- UI: KindTabs (shows only kinds with drafts), StatusBadge, StatusFilter,
DraftCard (<button> for a11y), BriefingForm (topic/kind/audience/tone/
length/language/extra), VersionEditor (500ms debounce + onBlur flush),
VersionHistory (restore button per version).
- Routes: /writing list + /writing/draft/[id] with {#key id} remounting.
User flow: create draft from briefing → land in detail view → type →
autosave → "Als Checkpoint speichern" for a new version → restore any
older version from the history panel. No AI yet; M3 wires mana-llm for
short-form generation and M7 switches to mana-ai missions for long-form.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Regression reported in testing: tasks and calendar events created via
the Workbench homepage widgets appeared there but vanished from their
respective module sub-routes (/todo, /calendar).
Root cause: my M4.b + M4.a shipped `defaultVisibilityFor('personal') →
'private'` based on the original plan ("personal space default is
private"). That collides with the pre-existing 2-tier visibility filter
in `apps/mana/apps/web/src/lib/data/scope/visibility.ts`, which treats
'private' records as "only the authorId sees them, even inside the
same space". Its applyVisibility() drops any 'private' record whose
authorId doesn't exactly match getCurrentUserId() — and the homepage-
widget cross-app queries in cross-app-queries.ts don't run that filter
while /todo/useAllTasks() does, creating the asymmetry the user saw.
Why the match can fail in practice: during auth bootstrap,
getEffectiveUserId() returns the 'guest' sentinel (which the Dexie
creating-hook stamps onto authorId), while getCurrentUserId() can
already resolve to the real user id by the time /todo's query runs.
authorId='guest' !== currentUserId=<real> → record filtered out.
Fix: defaultVisibilityFor() now returns 'space' regardless of space
type. Rationale:
- In a personal space there's exactly one member, so 'space' and
'private' are effectively equivalent — both mean "only the owner
sees it".
- In a multi-member space, 'space' is the desired default (otherwise
every collaborative record would need a manual toggle).
- 'private' becomes an *active* user decision for drafts in shared
spaces — click the VisibilityPicker to enable it.
- The parameter is retained (as `_spaceType`) for forward-compat so
future space types can differentiate without touching call sites.
Impact on shipped modules: all 8 consumers (Library, Picture,
Calendar, Todo, Goals, Places, Recipes, Wardrobe) call
defaultVisibilityFor(activeSpace.type) at create time — they inherit
the fix automatically. No store edits required.
Existing records with visibility='private' from the testing window
stay as they are; user can flip them to 'Bereich' via the
VisibilityPicker, or reset the local Dexie to pick up the new default.
Plan doc updated with the full rationale (docs/plans/
visibility-system.md §Entscheidung).
Verified:
- pnpm test @mana/shared-privacy: 15/15 (defaults.test.ts updated)
- pnpm check (web): 7464 files, 0 errors
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Picture.ListView's full-screen image modal (~70 lines of inline
markup) grew a second caller: the new Anproben-Strip on the
wardrobe garment detail page. Linking to `target="_blank"` was a
placeholder — the user expects the same inline viewer Picture uses.
Extract the lightbox into $lib/modules/picture/components/ImageLightbox.svelte.
Picture keeps ownership because the component speaks prompt/model/dims
vocabulary against `picture.types.Image`. Module-specific controls go
through an `actions` snippet so each caller wires only what makes
sense:
- Picture ListView renders Favorit + Archivieren in its action slot
(unchanged behaviour, shorter file).
- Wardrobe DetailGarmentView renders a single "In Picture öffnen"
deep-link — Wardrobe doesn't own Favorit/Archiv semantics, the
user navigates to Picture for those. Keeps the back-ref clean:
every generated image lives in Picture, Wardrobe just previews.
Base lightbox handles:
- Fixed overlay with backdrop click-to-close
- Escape key to close
- Image + prompt + model + dimensions + date
- Default Schließen button
- Fallback icon when publicUrl is missing
No logic change for existing users of Picture; one fewer dead
target="_blank" tab for Wardrobe.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Solo-garment Try-Ons had no back-reference into Wardrobe — the
generated image landed in Picture.images with wardrobeOutfitId=null
and was unfindable from the garment detail page. The M4.1 comment
called it "deliberate — a standalone preview, not an outfit"; in
practice users open the garment detail expecting to see past tries.
Drop the asymmetry. Picture stays the single source of truth for
every AI-generated image; Wardrobe references by FK, not heuristic.
- Dexie v42: index `images.wardrobeOutfitId` (was unindexed, the
existing outfit query fell back to a scan) and add the symmetric
`images.wardrobeGarmentId` column + index. Fresh start — not live,
no migration of old rows, undefined on existing rows is the correct
"no back-ref" semantics.
- LocalImage / Image gain `wardrobeGarmentId?: string | null` in the
picture module's types + converter. Invariant: at most one of
`wardrobeOutfitId` / `wardrobeGarmentId` set per row (solo try-ons
write the garment id, outfit try-ons write the outfit id).
- runGarmentTryOn stamps `wardrobeGarmentId: garment.id` on the new
Picture row. runOutfitTryOn unchanged — it already wrote
wardrobeOutfitId for the symmetric outfit path.
- New queries in wardrobe/queries.ts:
- `useGarmentSoloTryOns(garmentId)` — live-queries Picture for
rows tagged with this garment's id.
- `useOutfitsContainingGarment(garmentId)` — live-queries
wardrobeOutfits whose `garmentIds[]` includes the garment, for
the cross-outfit context strip.
- DetailGarmentView gets two new sections under the existing meta/
action stack:
1. "Anproben" — solo-try-on thumbnails (click → full image in a
new tab; proper lightbox can reuse Picture's modal later).
2. "In Outfits" — outfits containing this garment, each rendering
its own `lastTryOn` snapshot as the thumb, click → outfit
detail. Reuses the outfit row's cached snapshot so no extra
image lookup.
Crypto registry unchanged: `images` only encrypts prompt +
negativePrompt; the new FK stays plaintext like wardrobeOutfitId.
mana-sync is field-level-generic — no schema work needed, the new
column syncs as a plain field.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Eighth consumer of @mana/shared-privacy. Wardrobe outfits now carry a
VisibilityLevel flipped via <VisibilityPicker compact> in the outfit
detail page; the wardrobe.outfits embed powers the style-portfolio
use-case on the owner's website.
Scope: outfits only, not individual garments. Outfits are the composite
unit users curate for public presentation (an outfit is an intentional
composition; a single garment rarely is). Garments inherit their outfit
visibility implicitly — a public outfit reveals the look, the garment
pieces behind it stay private at the record level.
Changes:
- wardrobe/types: visibility + unlistedToken + visibilityChangedAt +
visibilityChangedBy on LocalWardrobeOutfit; Outfit (UI) requires
visibility; toOutfit converter forwards with 'space' fallback
- wardrobe/stores/outfits: createOutfit stamps
defaultVisibilityFor(activeSpace.type); new setVisibility(id, level)
mints/clears the unlisted token on the transition boundary and emits
cross-module VisibilityChanged
- wardrobe/views/DetailOutfitView: <VisibilityPicker compact> in the
metadata header row, left of the favourite/edit icons — keeps the
action rail tight while making exposure state glanceable
website embed:
- website-blocks/moduleEmbed/schema: 'wardrobe.outfits' added to
EmbedSourceSchema
- website/embeds: resolveWardrobeOutfits gates hard on
canEmbedOnWebsite, filters archived + deleted, optional isFavorite /
tagIds filters, favourites-first then newest. Inlines title +
occasion/season meta + the lastTryOn.imageUrl (the AI-generated
wearing shot). Description, garment details, and internal tag labels
stay out of the public snapshot
Verified:
- pnpm check (web): 7450 files, 0 errors
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Seventh consumer of @mana/shared-privacy. Recipes now carry a
VisibilityLevel; the recipes.recipes embed powers "my cookbook" /
"tested recipes" sections on the owner's website.
Changes:
- recipes/types: visibility + unlistedToken + visibilityChangedAt +
visibilityChangedBy on LocalRecipe; Recipe (UI) requires visibility
- recipes/queries: toRecipe forwards visibility with 'space' fallback
- recipes/stores/recipes: createRecipe stamps
defaultVisibilityFor(activeSpace.type); duplicateRecipe resets to
the space default (copies don't inherit public status — same rule
as picture boards); new setVisibility(id, level) emits
cross-module VisibilityChanged
- recipes/ListView: <VisibilityPicker> as the first row of the
detail-panel when a card is expanded. Recipes has no dedicated
detail route so inline-expand is the canonical surface
website embed:
- website-blocks/moduleEmbed/schema: 'recipes.recipes' added to
EmbedSourceSchema
- website/embeds: resolveRecipes gates hard on canEmbedOnWebsite,
optional isFavorite + tagIds filters, favourites-first then newest,
inlines { title, subtitle ('30 Min · 4 Port.'), imageUrl }.
Ingredients + steps + internal tag labels stay out of the snapshot —
the embed is a teaser; full recipes are a later M8 unlisted-page
feature.
Verified:
- pnpm check (web): 7450 files, 0 errors
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sixth consumer of @mana/shared-privacy. Places now carry a VisibilityLevel
flipped via <VisibilityPicker> in the Places DetailView; the new
places.places embed powers "my favourite cafes" / "rehearsal rooms" /
"gyms I train at" sections on the owner's website.
Changes:
- places/types: visibility + unlistedToken + visibilityChangedAt +
visibilityChangedBy on LocalPlace; Place (UI type) requires visibility
- places/queries: toPlace forwards visibility with 'space' fallback for
legacy rows
- places/stores/places: createPlace stamps
defaultVisibilityFor(activeSpace.type); new setVisibility(id, level)
mints/clears the unlisted token on the transition boundary and emits
cross-module VisibilityChanged
- places/views/DetailView: <VisibilityPicker> as the first field-row,
above Kategorie
website embed:
- website-blocks/moduleEmbed/schema: 'places.places' added to
EmbedSourceSchema; filter docstring describes the places-specific
reuse of existing kind/isFavorite/tagIds filter fields
- website/embeds: resolvePlaces gates hard on canEmbedOnWebsite,
applies optional kind (→ PlaceCategory) / isFavorite / tagIds
filters, sorts favourites-first then alphabetical.
Privacy: Whitelist (title + address only). Latitude/longitude are
explicitly NOT inlined — 10m precision of a home or workplace can
identify someone, and silently publishing coords on a visibility flip
would be the classic leak the design was built to prevent (plan §2).
Verified:
- pnpm check (web): 7450 files, 0 errors
Next: M5.b — Events (socialEvents), Recipes, Wardrobe-Outfits, Habits,
Quiz, Invoices-Clients. Same pattern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The non-accessory Try-On prompts started with "Fotorealistisches
Portrait von mir", and both gpt-image-1 and gpt-image-2 read that as
a photographic framing hint ("head-and-shoulders crop") rather than a
general "picture of a person". Result: even with body-ref supplied
and a 1024×1536 portrait canvas, the model rendered a headshot and
ignored the body reference.
Keep "Portrait" only for the accessory path (brille/schmuck/hut) —
there the tight head framing is what we actually want for legibility.
Full-garment/outfit paths now say "Fotorealistisches Ganzkörperfoto
von mir im/in <garment> … stehend, von Kopf bis Fuß sichtbar" which
reliably biases the model to full-length framing.
Applies to both runGarmentTryOn (single-garment) and runOutfitTryOn
(outfit) — they share the same framing mistake.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fifth consumer of @mana/shared-privacy, completing the M4 trio
(Calendar + Todo + Goals). Goals live under $lib/companion/goals/
(legacy path, pre-rename to 'ai') instead of the standard /modules/
tree, so the adoption lands in its own commit.
Enables the "public progress page" use case — a fitness / learning /
build-in-public goal with its current-period progress inlined on the
owner's website, rendered as "4 / 5 · Woche".
Changes:
- companion/goals/types: visibility + unlistedToken +
visibilityChangedAt + visibilityChangedBy on LocalGoal (LocalGoal
doubles as the UI type here, no separate plaintext variant)
- companion/goals/store: createFromTemplate and create both stamp
defaultVisibilityFor(activeSpace.type) at insert; new
setVisibility(id, level) mints/clears the unlisted token on the
transition boundary and emits cross-module VisibilityChanged
- modules/goals/ListView: <VisibilityPicker compact> on each active
goal card, sitting between the title and the pause button (goals
have no dedicated detail view — list-inline is the natural spot)
website embed:
- website-blocks/moduleEmbed/schema: 'goals.goals' added to
EmbedSourceSchema; filter docstring describes the active-vs-
completed split that power users can use to section their progress
page
- website/embeds: resolveGoals gates hard on canEmbedOnWebsite,
filters by optional status ('active' | 'completed' | 'paused' |
'abandoned'), sorts active-first then by target descending so
milestone goals land on top. Inlined EmbedItem is whitelist-only —
title + compact progress line like "4 / 5 · Woche". Description,
metric configuration (event types, filter fields), and internal
tracking state stay out of the snapshot; the goal's implementation
detail leaks what the user is measuring, not just the milestone
Verified:
- pnpm check (web): 7450 files, 0 errors
- pnpm test goals + website: 29/29
- pnpm run validate:all green
M4 is done. Next: M5 — Places + Events + Recipes + Habits + Quiz +
Wardrobe + Invoices-Clients. Same pattern, one module at a time.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fourth consumer of @mana/shared-privacy. Tasks now carry a
VisibilityLevel flipped via <VisibilityPicker> in the Todo DetailView;
a new todo.tasks embed source powers the "public roadmap" use-case
(mark a handful of tasks public, drop the embed on the Website).
Changes:
- todo/types: visibility + unlistedToken + visibilityChangedAt +
visibilityChangedBy on LocalTask; Task (UI type) requires visibility
- todo/queries: toTask forwards visibility with 'space' fallback for
legacy rows (pre-M4.b records have no field set; Dexie hook stamped
'space' since spaces-foundation v28)
- todo/stores/tasks: createTask stamps
defaultVisibilityFor(activeSpace.type); new setVisibility(id, level)
mints/clears the unlisted token on the transition boundary and
emits cross-module VisibilityChanged
- todo/views/DetailView: <VisibilityPicker> dropped in as the first
prop-row above Priorität so the user sees exposure state at a glance
whenever they open a task
website embed:
- website-blocks/moduleEmbed/schema: 'todo.tasks' added to
EmbedSourceSchema; filter docstring explains the todo-specific shape
(status + tagIds for the typical "shipped items with #public" filter)
- website/embeds: resolveTodoTasks gates hard on canEmbedOnWebsite,
maps the optional status filter ('completed' → isCompleted=true),
joins the N:N taskTags table for the optional tagIds filter, sorts
newest-first with id as stable tiebreaker. Inlined EmbedItem is
whitelist-only — title + status label ('Erledigt' / 'In Arbeit').
Description, subtasks, LLM-labels, due-dates, and project
memberships stay out of the public snapshot (per plan §2 redaction
policy)
Verified:
- pnpm check (web): 7450 files, 0 errors
- pnpm test todo + website: 38/38
Next: M4.c — Goals. Lives under $lib/companion/goals/ (not in the
standard /modules/ tree), so the adoption path is slightly different
and gets its own commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Third consumer of @mana/shared-privacy. Calendar events now carry a
VisibilityLevel the owner flips from the EventDetailModal via
<VisibilityPicker>; a new calendar.events embed source lets the user
drop a moduleEmbed block on their website that pulls their public
events in.
This unblocks concrete use-cases the Website-Builder audit surfaced:
band tour dates, public workshops, public rehearsals on a team-space
website, meeting-with-the-host pages.
Changes:
- calendar/types: visibility + unlistedToken + visibilityChangedAt +
visibilityChangedBy on LocalEvent; CalendarEvent (UI type) requires
visibility. timeBlockToCalendarEvent forwards the field; cross-module
TimeBlocks (tasks, habits, time entries) without an owning
LocalEvent fall back to 'space' so they stay off the public embed
- calendar/stores/events: createEvent stamps
defaultVisibilityFor(activeSpace.type); createDraftEvent seeds a
'private' draft until the user explicitly opts in; new
setVisibility(id, level) mints/clears the unlisted token on the
transition boundary and emits cross-module VisibilityChanged
- calendar/components/EventDetailModal: <VisibilityPicker compact>
sits in the modal-actions row left of copy/edit/delete
website embed:
- website-blocks/moduleEmbed/schema: EmbedSourceSchema adds
'calendar.events'; the filter shape gains optional `upcomingDays`
(1-365) and `tagIds` (up to 16). Old filters (isFavorite/status/kind)
remain — each source uses only its own subset
- website/embeds: resolveCalendarEvents gates hard on
canEmbedOnWebsite(event.visibility ?? 'private'), joins each event
to its LocalTimeBlock for the real start/end, applies the optional
upcomingDays window and tag-id AND-filter, sorts upcoming-first with
id as stable tiebreaker
Redaction is whitelist-per-design (plan §2): the inlined snapshot
carries only title, formatted date range, and location — NOT
description, reminders, tag labels, or the guest list. Fields that
typically hold private context stay out of the public blob regardless
of the visibility toggle.
Verified:
- pnpm check (web): 7450 files, 0 errors
- pnpm test calendar + website: 26/26
- pnpm run validate:all green
Next: M4.b — Todo, M4.c — Goals. Same pattern; split out because
goals lives under $lib/companion/goals/ with its own structure and
Todo has a complex view-column/filter surface that warrants its own PR.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second consumer of @mana/shared-privacy. Picture boards now carry a
VisibilityLevel the owner flips from the board detail page via
<VisibilityPicker>; the website embed resolver gates hard on
canEmbedOnWebsite. This unblocks the picture.board embed — it had been
effectively dead because the legacy `isPublic` bool had no UI toggle
and thus stayed false for every row in practice.
Soft migration (per the repo's soft-first/hard-follow-up rule). The
legacy `isPublic` field is marked @deprecated on both LocalBoard and
LocalImage but kept on the record so any reader that slipped through
the grep still sees sane data. Converters fall back to
`isPublic === true ? 'public' : 'private'` when visibility is missing,
so legacy rows (pre-M3) route through the new gate with the same
intent. Hard follow-up drops the field in a later PR once callers are
clean.
Changes:
- picture/types: visibility + unlistedToken + visibilityChangedAt +
visibilityChangedBy on LocalImage and LocalBoard; Image and Board
(plaintext UI types) expose `visibility: VisibilityLevel` as a
required field
- picture/queries: toImage + toBoard forward visibility with the
legacy-isPublic fallback described above
- picture/stores/boards: createBoard stamps
defaultVisibilityFor(activeSpace.type) instead of isPublic: false;
duplicateBoard resets the clone to the space default (a copy of a
public board does NOT auto-publish); new setVisibility(id, level)
mints/clears the unlisted token on the transition boundary and
emits the cross-module VisibilityChanged event
- picture/collections: PICTURE_GUEST_SEED demo board starts with
visibility: 'private'
- picture/ListView + routes/picture/generate + wardrobe/try-on:
constructed LocalImage seeds set `visibility: 'private'` instead
of `isPublic: false`
- website/embeds: resolvePictureBoard replaces the hard-coded
isPublic check with canEmbedOnWebsite, reading visibility with the
legacy fallback. Error message points users at the picture module's
new picker
- routes/picture/board/[id]: VisibilityPicker mounted in the header
toolbar, left of the edit/delete buttons, wired through
handleVisibilityChange → boardsStore.setVisibility
Not in this PR:
- Image-level visibility picker UI (record field is ready; no UI
control yet — boards currently govern public exposure, per-image
visibility is a later refinement if anyone asks)
- Hard drop of the legacy isPublic column (M3.1 follow-up once a
soak confirms nothing reads the old field)
Verified:
- pnpm check (web): 7450 files, 0 errors, 0 warnings
- pnpm test picture + website + library: 23/23
- pnpm run validate:all: theme-tokens, theme-parity, crypto-registry,
encrypted-tools all green
Next: M4 — Calendar + Todo + Goals. New embed resolvers + new
moduleEmbed source values.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First consumer of @mana/shared-privacy. Library entries now carry an
explicit VisibilityLevel the owner can flip from the detail view via
<VisibilityPicker>; embed resolver gates hard on canEmbedOnWebsite so
only entries the user marked 'public' appear on published websites.
Replaces the M1/old flow — the library embed used to pass-through
`filter.isFavorite` as a weak proxy for "show on my site". That filter
still works as an additional user-facing filter, but it can no longer
override the visibility gate (fixes a real leak: a favourited private
book would have ended up on the public snapshot).
Changes:
- @mana/shared-privacy added to the web-app's dependency list
- LocalLibraryEntry + LibraryEntry gain visibility / unlistedToken /
visibilityChangedAt / visibilityChangedBy fields. Legacy rows
(pre-migration) fall back to 'space' via the toLibraryEntry
converter — matches the Dexie hook's existing structural default
and maps to the space-foundation semantics unchanged
- libraryEntriesStore.createEntry stamps defaultVisibilityFor(active
space.type) explicitly so personal-space entries default to
'private' instead of the generic 'space' fallback
- libraryEntriesStore.setVisibility(id, level): flips the field,
mints/clears the unlisted token on the transition boundary, emits
the cross-module VisibilityChanged domain event
- Event catalog registers VisibilityChanged with the payload type
re-exported from @mana/shared-privacy (kept under a dedicated
"Visibility (Cross-Module)" section — this is the first of many
modules that will emit it)
- Library DetailView header gains the <VisibilityPicker> next to the
kind-pill, so "who sees this?" is visible at a glance
- embeds.ts resolveLibraryEntries replaces its favourite-proxy gate
with canEmbedOnWebsite. User filters (kind/status/favorite) still
stack on top but cannot relax the visibility requirement
- ListView's inline-create EntryForm seed ships with
visibility: 'private' so the type asserts cleanly and the preview
entry matches the safe default
No schema migration needed — the visibility column already exists on
every space-scoped Dexie record (Spaces-Foundation v28). The Dexie
hook's 'space' default still fires for rows the library store doesn't
pre-populate (e.g. legacy paths); setVisibility and createEntry now
own the intent.
What's verified:
- pnpm check (web): 7450 files, 0 errors, 0 warnings
- pnpm test library + website: 23/23 passing
- @mana/shared-privacy: 15/15 passing (re-ran after the dep pull)
- pnpm run validate:all: theme-tokens, theme-parity, crypto-registry,
encrypted-tools all green
Next in the rollout: M3 Picture (swap the picture.board isPublic
flag for visibility and update the board embed to use
canEmbedOnWebsite). See docs/plans/visibility-system.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Scaffold the unified visibility/privacy layer introduced by docs/plans/
visibility-system.md. No module adopts it yet — this is the foundation
PR (M1). Module rollout lands in follow-ups starting with Library (M2).
What ships:
- @mana/shared-privacy package
- VisibilityLevel enum ('private' | 'space' | 'unlisted' | 'public')
- VisibilityLevelSchema + UnlistedTokenSchema (zod)
- defaultVisibilityFor(spaceType): personal → private, else → space
- predicates: canEmbedOnWebsite, isReachableByLink,
isVisibleToSpaceMember, canAiAccessCrossUser (always false in P1)
- generateUnlistedToken() — 32-char base64url, CSPRNG, ~192 bits
- VISIBILITY_METADATA: German labels + descriptions + phosphor icon
names so non-UI surfaces (audit logs, CLI) label levels consistently
- <VisibilityPicker> svelte component: compact lock/globe trigger with
4-option menu, full descriptions, optional compact + disabledLevels
- VisibilityChangedPayload type for the domain-event catalog (consumer
registers it when the first module adopts the system)
- .claude/guidelines/visibility.md — step-by-step for module authors
(schema migrations + store wiring + picker placement + embed resolver +
legacy isPublic migration), with a pre-PR checklist
- Plan-doc "Offene Fragen" section rewritten as "Designentscheidungen"
with the seven resolutions the user approved
- CLAUDE.md: shared-privacy listed in the packages table; visibility.md
listed in the guidelines table
- 15 unit tests covering predicates (one-and-only-one 'public' for
embed; phase-1 AI always-deny), defaults (personal vs multi-member,
null fallback), token uniqueness + schema round-trip
Key constraints honored:
- `visibility` stays plaintext (NOT added to the encryption registry)
so RLS predicates and publish resolvers can read it without the user's
master key
- Publish flow remains "decrypt client-side, inline plaintext into
snapshot" — the pattern picture.board already uses in embeds.ts
- Deny-by-default everywhere (personal default = private; unknown space
type defaults to private; cross-user AI always false)
Not in this PR (per plan):
- No schema migrations in any module (M2–M6)
- No RLS predicate updates (arrives with M2)
- No /settings/privacy overview (M7)
- No unlisted share routes (M8)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
gpt-image-1 answered the last Try-On attempt with
invalid_image_file: Invalid image file or mode for image 2
because one of the references (face/body/garment) was in a format or
color mode OpenAI's edits endpoint rejects — typical culprits are
HEIC from iPhones, CMYK JPEG, palette-mode PNG, APNG, or JPEG with an
ICC profile gpt-image-1 doesn't honour. mana-media stores originals
verbatim so whatever the user uploaded is what we were forwarding.
Route the references through mana-media's existing on-the-fly
/transform endpoint (format=png, w/h=1024, fit=inside) which pipes
the buffer through sharp server-side. One call per ref, all run in
parallel, same latency budget as before. Output is guaranteed
- PNG / RGB (or RGBA if the source had alpha, which gpt-image-1 accepts),
- no more than 1024 px on the longest side → well under OpenAI's
4 MB/image cap,
- aspect-ratio-preserving (fit=inside) so a portrait body photo
doesn't get squished into a square.
New helper `getMediaBufferAsPng(mediaId, longestSide)` in lib/media.ts
encapsulates the transform-URL build. The Try-On path in the picture
route now uses it instead of `getMediaBuffer`; all Blob filenames
pin to `.png` since the buffer is already normalized.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two interlocking fixes driven by a production lockout incident.
## Bug that motivated this
A fresh schema-drift column (auth.users.onboarding_completed_at) made
every Better Auth query crash with Postgres 42703. The /login wrapper
swallowed the non-2xx and mapped it onto a generic "401 Invalid
credentials" AND bumped the password lockout counter — so 5 legit
login attempts against a broken DB would have locked every real user
out of their own account. Same wrapper pattern on /register, /refresh,
/reset-password etc. The 30-minute hunt ended in a one-off repro
script that finally surfaced the real Postgres error.
The user-facing passkey button additionally returned generic 404s on
every login-page mount because the route wasn't registered (the DB
schema existed, the Better Auth plugin wasn't wired).
## Phase 1 — Error classification (services/mana-auth/src/lib/auth-errors)
- 19-code AuthErrorCode taxonomy (INVALID_CREDENTIALS, EMAIL_NOT_VERIFIED,
ACCOUNT_LOCKED, SERVICE_UNAVAILABLE, PASSKEY_VERIFICATION_FAILED, …)
- classifyFromResponse/classifyFromError handle: Better Auth APIError
(duck-typed on `name === 'APIError'`), Postgres errors (23505 unique,
42703/08xxx → infra), ZodError, fetch/ECONNREFUSED network errors,
bare Error, unknown.
- respondWithError routes the structured response, logs at the right
level, fires the correct security event, and CRITICALLY only bumps
the lockout counter for actual credential failures — SERVICE_UNAVAILABLE
and INTERNAL never touch lockout.
- All 12 endpoints in routes/auth.ts refactored (/login, /register,
/logout, /session-to-token, /refresh, /validate, /forgot-password,
/reset-password, /resend-verification, /profile GET+POST,
/change-email, /change-password, /account DELETE).
- Fixed pre-existing auth.api.forgetPassword typo (→ requestPasswordReset).
- shared-logger + requestLogger middleware wired in index.ts; all
console.* calls in the service removed.
## Phase 2 — Passkey end-to-end (@better-auth/passkey 1.6+)
- sql/007_passkey_bootstrap.sql: idempotent schema alignment —
friendly_name→name, +aaguid, transports jsonb→text, +method column
on login_attempts.
- better-auth.config.ts: passkey plugin wired with rpID/rpName/origin
from new webauthn config section. rpID defaults to mana.how in prod
(from COOKIE_DOMAIN), localhost in dev.
- routes/passkeys.ts: 7 wrapper endpoints (capability probe,
register/options+verify, authenticate/options+verify with JWT mint,
list, delete, rename). Each routes errors through the classifier;
authenticate/verify promotes generic INVALID_CREDENTIALS to
PASSKEY_VERIFICATION_FAILED.
- PasskeyRateLimitService: in-memory per-IP (options: 20/min) and
per-credential (verify: 10 failures/min → 5 min cooldown) buckets.
Deliberately separate from the password lockout — different factor,
different blast radius.
- Client: authService.getPasskeyCapability() async probe, memoised per
session. authStore.passkeyAvailable reactive state. LoginPage gates
on === true so a slow probe doesn't flash the button in.
- AuthResult grew a code: AuthErrorCode field; handleAuthError in
shared-auth prefers the server envelope over the legacy message
heuristics.
## Tests
- 30 unit tests for the classifier covering every branch (including
the exact Postgres 42703 shape that started this).
- 9 unit tests for the rate limiter.
- 14 integration tests for the auth routes — the regression test
explicitly asserts "upstream 500 → 503 + zero lockout bumps".
- 101 tests pass, 0 fail, 30 pre-existing skips unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
OpenAI started gating gpt-image-2 behind per-organization verification
(platform.openai.com/settings/organization/general → Verify Organization,
propagation up to 15 min). Unverified orgs get:
"Your organization must be verified to use the model gpt-image-2"
Keeps Try-On broken until the user completes that manual step. Since
the edits endpoint is identical across gpt-image-1 and gpt-image-2
(same image[] multi-ref, same size/quality/n params), detect that
specific rejection and retry once with gpt-image-1.
- buildFormData(modelName) + callOpenAiEdits(modelName) extracted so
the retry is a one-line re-invoke with the fallback model instead
of a duplicated fetch block.
- needsGptImage1Fallback() matches /verified to use the model/i in
the error body AND checks the attempted model was actually
gpt-image-2 — an explicit openai/gpt-image-1 request stays on 1.
- Response now reports `model: openai/${modelUsed}` so the
picture.images row records whichever model actually produced the
image (matters for future re-generation / audit).
Credits unchanged: our flat 3/10/25-per-quality tariff applies to all
openai/* paths. Slight over-charge for the gpt-image-1 fallback until
the user verifies, then gpt-image-2 takes over automatically.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The try-on path POST'd N reference images as repeated `image` fields in
the multipart body. OpenAI's edits endpoint answers that with
`duplicate_parameter: Duplicate parameter: 'image'. You provided
multiple values for this parameter, whereas only one is allowed. If
you are trying to provide a list of values, use the array syntax
instead e.g. 'image[]=<value>'.`
Switch to the array-syntax field name `image[]`, which OpenAI accepts
for cardinality ≥ 1 (no branching needed for the single-ref case).
Also surface the underlying error from the three 502 branches
(ownership-check, media-fetch, OpenAI call) into both the server log
(structured console.error with refIds + openai body) and the response
`detail` field. The client's callGenerateWithReference now prepends
`detail` to the thrown message so the user sees the concrete reason
in-module instead of a generic "Try-On fehlgeschlagen (502)".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wardrobe landed with requiredTier: 'beta' — correct for production,
but in the local dev loop the user's account is 'standard' and
clicking into /wardrobe/garment/[id] or /wardrobe/compose gets blocked
by the AuthGate with "Dein Zugang: Standard · Benötigt: Beta".
Flip to 'guest' to match the existing local tier-patch pattern that
already covers 55 of the 63 apps (see git log for earlier patches,
and memory note `project_tier_patch_resolved`).
Marked inline with `// LOCAL TIER PATCH — revert to 'beta' before
release` so the pre-release grep sweep finds it alongside the others.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The GridView opened with a big welcome card ("Kleiderschrank ·
Fotografiere Kleidungsstücke …") followed by the category tabs and
the grid, with the upload zone tucked at the very bottom. In the
narrow workbench card this pushed every actionable element below the
fold on first open — the user had to scroll past an empty state to
find "Kleidungsstück hochladen".
Match the pattern profile/ListView and other mature modules use:
- Welcome + category-pick hint move into help-content.ts under the
`wardrobe` key. registerApp auto-attaches it, so the (?) icon in
the ModuleShell header now renders an overlay with the description,
features list, and tips.
- Upload zone moves up to sit directly under the category tabs —
always visible, reflecting the active category in its label.
- Empty-state text updates to point at the zone above instead of the
(now-removed) "Hinzufügen" button.
- Active-space hint becomes a small footer line, only rendered in
non-personal spaces where the per-Space wardrobe split actually
matters.
No data-layer or store changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Write up the design for a repo-wide visibility layer before building. Today
the state is fragmented: 7 modules carry ad-hoc isPublic booleans (picture,
cards, presi, memoro, times, broadcast.audience, uload.tags) with
inconsistent semantics and mostly no UI; the majority of modules (library,
calendar, todo, places, events, recipes, goals, habits, quiz, wardrobe,
invoices-clients, …) have nothing. Spaces only carry member permissions,
no public tier. The existing encryption layer (27 encrypted tables) is not
a blocker — embeds.ts already demonstrates "decrypt client-side, inline
plaintext into the publish snapshot".
Design:
- @mana/shared-privacy package with `VisibilityLevel = 'private' | 'space'
| 'unlisted' | 'public'`, a `<VisibilityPicker>` svelte component, and
predicate helpers (canEmbedOnWebsite, isVisibleToSpaceMember, …)
- Per-record `visibility text not null default 'private'` on public-capable
tables only; `unlistedToken`, `visibilityChangedAt`, `visibilityChangedBy`
alongside. Field stays plaintext so RLS + publish resolvers can read it
without the user's master key
- Default-per-space-type: personal → private, team/club → space. Never
public/unlisted by default
- Embed resolvers gate hard on `canEmbedOnWebsite`; user filters (tags,
status, date window) stack on top, never replace
- RLS predicate extended: `space_member OR visibility='public' OR
(visibility='unlisted' AND unlisted_token matches header)`
Rollout (soft-first / hard-follow-up per existing migration convention):
M1 shared package · M2 library (pilot) · M3 picture (replaces isPublic) ·
M4 calendar + todo + goals · M5 places/events/recipes/habits/quiz/wardrobe
/invoices · M6 legacy-flag consolidation · M7 /settings/privacy overview
+ kill-switch · M8 (optional) unlisted share links.
Out of scope: per-user sharing, field-level visibility, visibility
cascading, time-boxed public, search-indexing by default. Documented
explicitly so the first implementer doesn't reopen these.
No code yet — waiting on user go-ahead before starting M1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
setPrimary(id, 'face-ref') ran two sequential setPrimaryInTx writes on
the same row — one for face-ref, then a "silent twin" for avatar. But
primaryFor is a single-value column, so the second write clobbered
the first. Every fresh face upload ended up with primaryFor='avatar'
and useImageByPrimary('face-ref') returned null forever: wardrobe's
try-on banner stayed, TryOn was hard-blocked, picture's reference
picker showed nothing. Latent since M2.5 (e2b5ac38c).
Drop the silent twin. Keep face-ref as the single source of truth for
both the reference-face used by generators and the avatar that syncs
to Better-Auth. syncAvatarToAuth now reads face-ref first and falls
back to the legacy primaryFor='avatar' row (written by
migrateLegacyAvatarIfNeeded for pre-M2.5 users). deleteMeImage's
avatar-relevance check widens the same way.
Plus a one-shot repair bootstrap for users (incl. local dev sessions)
whose Dexie already carries silent-twin-victim rows. Runs on mount of
wardrobe/ListView AND profile/MeImagesView, guarded by a
per-user localStorage flag. Distinguishes legitimate legacy-avatar
rows (mediaId 'legacy-avatar:<uid>') from victims (any other mediaId)
and flips the newest victim back to primaryFor='face-ref', clearing
any older duplicates.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The face-ref banner vanished silently the moment the Dexie write
landed — the user had to open /profile/me-images to verify the upload
actually worked. Reported as "musste dann in profil reinklicken um es
zu sehen".
Three phases now: prompt → uploading → success.
- uploading: "Wird hochgeladen…" label on the zone + a small pill with
SpinnerGap in the top-right corner. Zone is disabled so drops don't
queue a second upload.
- success: banner swaps to a confirmation card with the newly-saved
thumbnail, a CheckCircle tick, and the next-step nudge ("Perfekt —
als nächstes lädst du unten dein erstes Kleidungsstück hoch"). The
border switches from dashed to solid with a soft primary tint so
the state change is unmistakable. Fades out after 2.5s (or when the
user hits "Schließen") at which point the face$ live-query has
already flipped `face` non-null, so the banner stays unmounted.
- Banner uses svelte/transition fade on mount/unmount for graceful
entry/exit instead of popping in and out.
The .spinner class is nested under .face-banner :global(.spinner)
because it travels through the <SpinnerGap> Phosphor component —
Svelte's scoped CSS can't reach child components without :global().
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a per-session history stack for the website editor — Cmd+Z / Cmd+Shift+Z,
plus ↶ / ↷ buttons in a small toolbar above the canvas. Scoped to a single
page's editing session: cleared on page switch and unmount. No persistence
across reloads, no cross-device replay.
Covers block-level ops: add, update props, delete, move up/down. Each
mutation records a (undo, redo) pair so both directions are replayable;
a fresh action branches off the redo timeline.
Architecture:
- history.svelte.ts — session-only stack exposed via Svelte context. The
wrapped methods (addBlock, updateBlockProps, …) call through to
blocksStore and push the inverse pair onto the stack. limit=100 to bound
memory; past the cap the oldest entry is dropped.
- blocksStore primitives — restoreBlock(snapshot), setBlockProps(id, full),
setBlockOrder(id, order). Needed because redo of add wants the exact id
back (so selection references stay valid), and redo of reorder wants the
numeric order, not a new fractional-index insert.
- reorderBlock now emits WebsiteBlockUpdated with fields:['order'] — fixes
an audit finding that order changes were silently skipping the event log.
- BlockInspector reads the history from context and routes all four of its
mutations through it; falls back to the raw store if no history is
mounted (keeps the inspector reusable).
UX choices:
- Undo/Redo is suppressed when focus is in an INPUT/TEXTAREA/contenteditable
so the browser's native text-undo wins inside form fields.
- Toolbar buttons show the pending label in the tooltip ("Rückgängig:
Text-Block ändern") so users see what Cmd+Z will actually revert.
- Page switch clears the stack because undoing across pages would step
into a block the user can no longer see — confusing and error-prone.
Why session-only (not event-log based): Before-snapshots in _events
would bloat the store (prop updates with embedded images easily hit
100KB/row) and cross-device undo isn't a real use case for a single-user
editor. See the thread that preceded this commit for the full tradeoff
between session-stack, event-log replay, and a hybrid persist layer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Dockerfile's per-app COPY list hadn't been updated after website-
blocks was added as a workspace dep. Docker's build context excludes
any package the Dockerfile doesn't explicitly bring in, so `pnpm
install` bailed with ERR_PNPM_WORKSPACE_PKG_NOT_FOUND for
@mana/website-blocks and the whole build tree (incl. mana-auth built
in parallel) got cancelled.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fetchWithAuth called mana-auth's /api/v1/auth/profile without
credentials: 'include'. In production both hosts sit under *.mana.how
with the shared auth cookie, so the session rode along regardless —
but in dev (5173 → 3001) the cookie was dropped, and the server's
auth.api.updateUser threw because it couldn't identify the user.
serviceErrorHandler then masked it as a generic 500.
The failure was silent at the call site because syncAvatarToAuth()
wraps the POST in try/catch — but every face-ref primary claim logged
"[profile] syncing avatar to Better Auth failed" and left
auth.users.image out of sync. Surfaced now because wardrobe's new
inline face upload claims face-ref reliably.
Matches credentials: 'include' used everywhere in
packages/shared-auth/src/core/authService.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Missing-reference states in wardrobe and picture used to render a deep-
link to /profile/me-images and nothing else. Leaving an outfit detail
(or worse, a workbench card) to upload a face photo and coming back is
jarring, and the cross-navigation loses tab state in the carousel.
Switch to inline upload at the three existing gate-points. Each site
calls a new pipeline helper that encapsulates the orchestration the
profile page's ingestFiles() loop already did — kept minimal: no new
components, no requirement-mode abstraction, no shared "gate" wrapper.
If a fourth call-site appears (memoro avatar, MCP me.* tool) we can
promote to a shared component then.
- profile/api/me-images.ts: new ingestMeImageFile(file, { kind,
claimSlot?, autoAiReference? }) that runs readImageDimensions →
uploadMeImageFile → meImagesStore.createMeImage → optional
setPrimary. MeImagesView.ingestFiles now delegates to it (same
behaviour, 30 fewer lines).
- wardrobe/ListView: face-ref banner with MeImageUploadZone when
useImageByPrimary('face-ref') is empty. Banner auto-hides via
liveQuery once the slot is claimed. Body-ref is deferred to the
detail button to avoid a two-upload wall on first open.
- wardrobe/TryOnButton + GarmentTryOnButton: the missing-refs block
now renders one MeImageUploadZone per missing slot (face and/or
body depending on accessoryOnly), claiming the right primary slot
on drop. The /profile/me-images link stays as a secondary "manage"
CTA for the full pool.
- picture/ReferenceImagePicker: empty-pool state swaps the deep-link
for an inline upload with autoAiReference=true — the user entered
this picker explicitly to feed references into the generator, so
opting in here is contextual consent. Everywhere else,
/profile/me-images's opt-in-per-image remains the default.
No schema changes, no new dependencies. Types, svelte-check, and both
theme validators pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a "Onboarding erneut durchlaufen" row in Settings → Allgemein
that calls onboardingStatus.reset() and goto('/onboarding/name'). The
guard picks it up on next load so the user lands on Screen 1 again,
with their previous name prefilled (from authStore.user.name) and
their theme preserved (it's saved as userSettings state independently
of the onboarding flag).
Doesn't touch the /welcome page — it stays as the public landing for
pre-signup visitors. Analytics events deferred until we have a broader
funnel-tracking pass on the onboarding flow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two usability fixes for the website editor — the preview was cramped
between two sidebars inside the default max-w-7xl layout shell.
Layout:
- (app) layout: detect the editor route and skip the max-w-7xl clamp
+ horizontal padding, so the editor gets the full viewport width
Editor shell:
- Replace the two fixed sidebars (16rem left + 20rem right = 36rem) with
one 18rem tabbed sidebar on the right — nets ~18rem (~288px) of extra
canvas room on a 1440px display
- Tabs: Seiten (site meta + PageList), Einfügen (InsertPalette), Block
(BlockInspector with the move/delete controls)
- Selecting a block auto-switches to the Block tab (via untrack-guarded
$effect so changing the tab manually doesn't fight the selection)
- Switching pages resets selection + returns to the Seiten tab
- Empty-page hint points to the Einfügen tab
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- packages/shared-branding/onboarding-templates.ts:
* 7 templates: Alltag / Arbeit / Health / Sport / Lernen / Entdecken
/ Erinnern — each with a phosphor icon name, German name/desc and
an ordered moduleIds list
* resolveModulesForTemplates() — deduplicates the union of selected
templates' modules (priority-ordered) and caps at 8 (2×4 grid)
- packages/shared-branding/onboarding-templates.spec.ts: 10 tests
covering order preservation, dedup-across-templates, cap honouring,
unknown-id tolerance
- /onboarding/templates/+page.svelte:
* Multi-select grid of 7 tiles (checkmark + primary border when on)
* Finish handler: runs resolveModulesForTemplates → creates a new
"Zuhause" scene with those apps → onboardingStatus.markComplete()
→ navigates to /
* Skip still marks complete (no scene — user lands on DEFAULT_HOME_APPS)
* Prefills selection from onboardingFlow store so back-nav is stable
With this, the 3-screen flow runs end-to-end for a new user:
signup → /onboarding/name → /look → /templates → / with a curated
home scene.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- onboarding-flow.svelte.ts: tiny ephemeral store that bridges
freshly-typed values between screens (needed because authStore.user
is JWT-derived and won't reflect PATCH /me/profile until next token
mint — Screen 2's greeting would otherwise show the stale empty name)
- name screen now writes into the flow store on submit and on skip
- /onboarding/look/+page.svelte:
* "Hi {name}, wähle deinen Look" greeting — falls back to JWT name,
email local-part, or "dir"
* Hell/Dunkel/System mode toggle
* 8 theme variants (lume/nature/stone/ocean/sunset/midnight/rose/
lavender), live preview with gradient, instant-apply on click
* Back button to Screen 1, Next to /onboarding/templates
No server write here — `theme.setVariant` / `theme.setMode` already
sync via userSettings into mana-auth.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- PATCH /api/v1/me/profile in mana-auth (name, image with 1–80 char
validation) — powers the Screen-1 save
- (app)/+layout.svelte:
* isOnboarding derived from pathname
* handleAuthReady loads onboardingStatus, redirects brand-new users
to /onboarding/name (fire-and-forget so sync/data-layer init keeps
running in parallel)
* chrome (PillNav, wallpaper, bottom-stack) hidden in onboarding mode;
AuthGate still wraps so the flow enforces authentication
- /onboarding/+layout.svelte: full-viewport shell with progress dots
(1/3, 2/3, 3/3) and a skip-all that marks the flow complete and
sends the user home
- /onboarding/+page.svelte: redirects bare entry to /onboarding/name
- /onboarding/name/+page.svelte: text input (1–40 chars), Enter = Weiter,
skip falls back to email local-part so Screen 2's greeting is never
empty
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- auth.users: new nullable `onboarding_completed_at` column
- new /api/v1/me/onboarding routes: GET, POST /complete, PATCH /reset
- onboardingStatus Svelte store in the web app that reads/writes via
those endpoints (no JWT claim so completing the flow takes effect
without a token re-mint)
- docs/plans/onboarding-flow.md adjusted: no backfill (launch without
existing users), better-auth `name` clarified, 7 templates including
"Arbeit" confirmed
Foundation for the 3-screen first-login flow (Name → Look → Templates).
No UI and no route guard yet — those ship in M2 when the redirect target
actually exists. Schema change is a pure column-add, applied via
`pnpm --filter @mana/auth db:push`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
blocksStore.reorderBlock already existed but was never wired into the
editor, so sections could only be created or deleted — not moved. Adds
two arrows in the BlockInspector header next to the delete button.
- blocksStore: moveBlockUp / moveBlockDown helpers that look up the
block's siblings (same page + parent), compute the fractional index
that swaps with the neighbour, and delegate to reorderBlock
- BlockInspector: up / down buttons with disabled state at the bounds,
plus siblings prop driving canMoveUp / canMoveDown
- EditorView: derives selectedSiblings (same-parent blocks on the
current page) and passes them down
Works for top-level sections and for children inside containers,
since siblings are scoped to (pageId, parentBlockId).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ListView wrapped itself in `mx-auto max-w-5xl p-4 sm:p-6`, which is the
route-page idiom. Problem: wherever the component renders, that wrapper
is wrong.
- In the workbench homepage the AppPage card is a ~480px-wide ModuleShell
with its own padding; max-w-5xl is a no-op, but the extra p-4/sm:p-6
stacks on the shell's header padding and the inner GridView hero
padding, pushing content off-centre and wasting vertical space.
- On /wardrobe the (app)/+layout already supplies
`mx-auto max-w-7xl px-3 sm:px-6 lg:px-8`, so ListView was doubling the
centring and tripling the padding.
Replace the Tailwind wrapper with a scoped `.wardrobe-root` using theme
tokens (hsl(var(--color-border)) etc.) and `container-type: inline-size`
— same pattern picture/ListView uses to adapt to its shell. Tabs move
into a scoped .wardrobe-tab style so the active underline uses a real
`border-bottom` instead of an absolute span (cleaner and no overflow
clipping on narrow cards).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
published_by, created_by, and space_id were declared as uuid, but
Mana user + space ids are Better-Auth nanoids stored as text. The
insert into website.published_snapshots raised `invalid input syntax
for type uuid` and Hono swallowed it as a generic 500.
Changes:
- schema.ts: uuid -> text on the three columns
- 0003_fix_id_types.sql: ALTER COLUMN on existing installs
- publish.ts: replace UUID regex on X-Mana-Space with a nanoid-shaped
check (it was silently nulling valid space ids)
- publish.ts: log + return the actual error message on the 500 path
so the next unhandled failure is visible instead of opaque
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Kleiderschrank module shipped end-to-end (M1–M5 + M4.1) but was
never surfaced on the workbench homepage — it was reachable only via
direct /wardrobe URLs. This adds the tile so users can add it to a
scene and open it from the launcher like every other module.
- apps.ts: registerApp({ id: 'wardrobe', name: 'Kleiderschrank',
color: #e11d48, icon: CoatHanger }) — list view loads
$lib/modules/wardrobe/ListView.svelte (tab switcher Kleidung /
Outfits). Detail routes stay SvelteKit-based
(/wardrobe/garment/[id], /wardrobe/outfit/[id],
/wardrobe/compose/[[outfitId]]) so the workbench only needs the
root list slot.
- categories.ts: wardrobe → 'creative' (next to picture, library,
playground, quiz).
Color matches the shared-branding entry in mana-apps.ts. Icon is the
phosphor CoatHanger (there is no bare "Hanger" in phosphor-svelte).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
getInScopeSpaceIds() used getCurrentUserId() (null for guests), so
guest-created rows stamped `_personal:guest` by the write hook
became invisible — empty scene, "App hinzufügen" silently no-op'd
because activeSceneIdState resolved to null.
Switch to getEffectiveUserId() so the read filter always matches
what the hook stamps. Four regression tests cover guest-only,
signed-in-no-space, non-personal active space, and personal-sentinel-
is-active collapsing to a single id.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
83 new tests across 5 files — pure-logic, fast, run on every
push. Caught one real bug + motivated one small refactor.
Coverage:
- apps/mana/.../website/constants.test.ts (8): isValidSlug + RESERVED_SLUGS
+ isValidPath. Caught the 1-char-slug bug (regex allowed length 1;
UI + plan say min 2). Fixed the regex in both the webapp and the
mirrored server list.
- apps/mana/.../website/publish.test.ts extended (8 total): adds
self-parent cycle, 3-level nesting, all-orphans, empty-input cases
on top of the original determinism + orphan-drop tests.
- apps/mana/.../website/templates.test.ts (7): parameterised over each
of the 4 bundled templates — clone produces fresh UUIDs, page +
block counts match, navConfig populated. Plus unknown-template and
duplicate-slug rejection. Container-nesting is punted to the smoke
test (none of the bundled templates use columns yet).
- packages/website-blocks/src/schemas.test.ts (38): every block
(11) + sanity-checks (defaults satisfy own schema, enum + length
bounds, required fields). Pure Zod — no Svelte runtime needed.
- packages/website-blocks/src/themes/themes.test.ts (12): preset
parity, resolveTheme overrides, themeCssVars output format +
heading-font fallback.
- apps/api/src/modules/website/reserved-slugs.test.ts (10): mirror of
the client tests for the server SSOT, plus new hostname validation
cases (.mana.how reservation, length, malformed edges).
Refactor:
- apps/api/src/modules/website/reserved-slugs.ts now owns
isValidHostname + RESERVED_HOSTNAMES. domains.ts imports them.
Pure functions live next to the other pure validators; easier to
test + share.
All 83 new tests green. Web-app svelte-check + apps/api type-check
both clean. Existing publish.test.ts / website-blocks tests still
pass (the monorepo-wide count is now well above 83 — these are
the new ones from this commit).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the M3 sub-agent loop. Both webapp consumers of runPlannerLoop
now expose the `task` tool to their planner LLM and route matching
calls to a session-bound sub-agent handler.
Pattern (identical in both files):
1. Hoist the regular tool dispatcher into a local `dispatchTool`
so both the main loop AND the sub-agent executor can share it.
The parent's guardrail, executor, actor attribution, and
domain-event emission happen exactly once — sub-agent tool
calls route through the same function.
2. Build a per-session taskHandler via createTaskToolHandler()
with parentDepth=0 (sub-agents themselves refuse to recurse)
and model=google/gemini-2.5-flash-lite (cheap tier —
sub-agents are summarisation-heavy, no reason to burn primary
budget on them).
3. toolsWithTask = [...regular tools, TASK_TOOL_SCHEMA].
4. onToolCall branches on `call.name === TASK_TOOL_NAME` →
taskHandler.handle; else dispatchTool. Both return
ToolResult, loop doesn't care which route was taken.
Companion:
- parentTools = AI_TOOL_CATALOG (full catalog)
- Token tracking via taskHandler.cumulativeUsage() available if
we later want to attribute sub-agent tokens to a companion-
session counter
Mission runner:
- parentTools = availableTools (agent-policy-filtered)
- Sub-agent inherits the same filter — a research sub-agent in a
mission that already had policy:deny on `list_events` still
can't see `list_events`, defense-in-depth
- runToolCall still gets aiActor → sub-agent tool executions are
attributed to the same mission/iteration as the parent
mana-ai deliberately NOT wired: its onToolCall is a no-op recorder
(plans get staged, executed client-side on sync). Sub-agents there
would produce no value since the sub-agent couldn't execute tools
either, just plan. When the tool-registry fully absorbs AI_TOOL_CATALOG
(Personas-plan M4), mana-ai will get sub-agent support in that same
migration.
No new tests — shared-ai's 107 tests cover the primitive + handler
exhaustively. Existing 31 companion+mission tests remain green;
svelte-check clean across 7427 files.
Completes M3. runPlannerLoop now has Claude-Code's four big patterns:
policy-gate (M1) / reminder-channel (M1) / parallel-reads (M1) /
compactor (M2) / sub-agents (M3).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Exposes runSubAgent() as a tool the planner LLM can call natively,
matching Claude Code's `Task` tool shape: { subagent_type, description,
prompt } -> single-string summary.
New exports from @mana/shared-ai:
- TASK_TOOL_NAME = 'task'
- TASK_TOOL_SCHEMA — ToolSchema ready to drop into a runPlannerLoop
`tools` array. subagent_type enum = research|plan|general;
description+prompt required; defaultPolicy: 'auto' (control-flow,
not a user-data write).
- createTaskToolHandler(opts) — factory returning:
- handle(call): structured ToolResult with the sub-agent's
summary as message + data {subAgentType, toolsCalled,
rounds, stopReason, usage}
- cumulativeUsage(): rolled-up TokenUsage across all sub-agent
invocations — parent budget accounting reads from here
- invocationCount(): metric-ready counter
Why not in mana-tool-registry: `task` is a loop-internal control-flow
primitive, not a user-data operation. Registry is for habits/notes/etc.
where MCP exposure and space-scoping matter. task never touches mana-
sync and never crosses the MCP boundary.
Recursion guard is defense-in-depth: the primitive throws
SubAgentRecursionError, this handler catches parentDepth >=
MAX_SUB_AGENT_DEPTH up front and returns a structured ToolResult
instead so the LLM sees it as regular tool-feedback.
Exceptions from the sub-agent (provider down, network) get wrapped
as `{ success: false, message: 'Sub-agent failed: ...' }`. The parent
loop's round continues.
14 new tests covering schema shape, recursion rejection, argument
validation (4 cases), happy path with tool dispatch, cumulative
usage tracking across multiple invocations, exception wrapping,
and parent-dispatcher routing.
107 shared-ai tests green total (was 93).
M3.3 consumer wiring follows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
M5 of docs/plans/wardrobe-module.md — exposes the Wardrobe feature
through the shared tool-registry so MCP clients (Claude Desktop)
and the mana-ai mission runner can browse, compose, and try on
outfits alongside the built-in UI. Follows the pattern M5 of the
me-images plan established in packages/mana-tool-registry/src/
modules/me.ts — encrypted reads via mana-sync pull + client-side
filter on `row.spaceId === ctx.spaceId`, writes via pushInsert
with encryptRecordFields, HTTP proxy for the try-on endpoint.
Four tools in packages/mana-tool-registry/src/modules/wardrobe.ts:
- wardrobe.listGarments(category?, tags?, limit?) — read. Pulls
wardrobeGarments from mana-sync, filters to the active space,
decrypts name/brand/color/size/material/tags/notes, applies
optional category + intersection-tag filters, caps at 200 rows
(50 default). Archived + soft-deleted items excluded.
- wardrobe.listOutfits(occasion?, favoriteOnly?, limit?) — read.
Same shape, filters by occasion (closed enum, plaintext —
unencrypted filter) and favorite. garmentIds arrive plaintext
so the agent can immediately resolve them via listGarments when
it needs more than ids.
- wardrobe.createOutfit({ name, garmentIds, occasion?, tags?,
description? }) — write. Encrypts name/description/tags, pushes
an insert tagged with ctx.spaceId. No cross-space validation of
the garmentIds — the calling agent is expected to have called
listGarments first; dangling refs surface visually in the UI
rather than as a hard server error.
- wardrobe.tryOn({ outfitId, prompt?, accessoryOnly?, quality? }) —
write (consumes credits). Biggest tool of the set: pulls the
outfit, its garments, and the caller's meImages in three
separate mana-sync pulls, resolves the primary face-ref +
body-ref, auto-detects accessoryOnly from garment categories
(FACE_ONLY_CATEGORIES: accessory/glasses/jewelry/hat), composes
refs respecting the 8-slot server cap, composes a default DE
prompt from the outfit name + occasion, and proxies to
/api/v1/picture/generate-with-reference with the user's JWT.
Returns the resulting image's URL + mediaId + prompt + model.
Deliberately does NOT persist a picture.images row or update
outfit.lastTryOn from the tool — those live on the client's
imagesStore / wardrobeOutfitsStore and doing them server-side
would race with a user who's also looking at the outfit page.
Agents use tryOn as a preview/inspection primitive; the user
commits from the UI.
Types: 'wardrobe' added to the ModuleId union. registerWardrobeTools
wired into registerAllModules — mana-mcp's createMcpServerForUser
iterates the registry and exposes any user-space tool automatically.
Credit model: quality defaults to 'medium' (10 credits per render),
same tarif as text-to-image generation. The agent pays for the
generation out of the calling user's credit balance via the
standard validateCredits/consumeCredits chain on the server endpoint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New packages/shared-ai/src/planner/sub-agent.ts implementing the
"one level deep, fresh messages, restricted tools, single-string
return" sub-agent contract from Claude Code's KN5/I2A launcher.
Four invariants enforced at the primitive level:
1. FRESH messages[] — parent's history never leaks in. The sub-agent
only sees its own system prompt + the task description. Hundreds
of scanned files stay inside the sub-agent.
2. RESTRICTED tool-whitelist — parent's full catalog is filtered
per SubAgentType ('research' = auto-policy only, 'general' =
everything, 'plan' = auto-policy + 3-round cap). Custom filter
overrides the type default.
3. SINGLE RETURN VALUE — sub-agent returns summary:string for
the parent to render as task-tool-result. Individual tool calls
stay in rawResult for debug capture but never cross the boundary.
4. ONE LEVEL DEEP — MAX_SUB_AGENT_DEPTH = 1. parentDepth >= 1 throws
SubAgentRecursionError; the consumer task-tool handler will
also check, this is defense-in-depth.
Model is required (no default) — routing to a cheaper tier like the
compactor does is an explicit decision, not a sneaky default.
Belt-and-suspenders wrapper on onToolCall rejects any tool call
whose name isn't in the whitelist, even if the LLM fabricates one.
14 new tests covering recursion guard, tool filtering per type,
custom filter, whitelist rejection, fresh-messages isolation, usage
roll-up, default summary on max-rounds, type-specific system prompt,
system-prompt override, and end-to-end tool-call -> result -> summary.
93 shared-ai tests green total (was 79).
M3.2 (task tool in registry) and M3.3 (consumer wiring) follow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>