Four small UI tweaks that came out of reviewing the garment-detail
screenshot against the workbench chrome:
1. Duplicate "Kleiderschrank" label — the ModuleShell header above
DetailGarmentView already renders a back-arrow and the app title.
The inner `<nav>` with a second arrow + text was rendering it all
a second time. Drop the inner breadcrumb; ArrowLeft import along
with it.
2. Raw SKU-slug as default garment name — the old
`stripExt(file.name)` produced labels like
`17390-gestreiftes-herren-t-shirt-aus-baumwolle-17390-2-w`. New
`prettifyUploadName` helper:
- drops the extension
- replaces `-`/`_` with spaces
- strips pure-digit tokens of length ≥ 4 (SKU shape) but keeps
short alphanumerics like `4xl` / `w38`
- title-cases each remaining word, rebuilding hyphens
(`t-shirt` → `T-Shirt`, `v-neck` → `V-Neck`)
- clamps to 80 chars on a word boundary
GridView's ingestFiles now passes the prettified name into the
createGarment write. User still edits on the detail page for
anything that needs nuance.
3. Two-line CTA with Credits subtitle. The button used to read
`Anprobieren · 10 Credits` on one line; on a narrow workbench
card the mittelpunkt between label and cost was visually thin
and read like a strikethrough. Split into a main label + small
opacity-75 subtitle so the credit figure is clearly secondary
info, not a decorated part of the CTA text. Applied to both
GarmentTryOnButton and TryOnButton.
4. Redundant microcopy under section headers — "Einzelstück auf dir
gerendert" under ANPROBEN and "Komposition öffnen" under IN
OUTFITS repeated what the section title and the clickable cards
already signalled. Remove both.
No behaviour changes, no schema, no API.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- dashboard: +5 Einträge pro Sprache für die beiden neuen Widgets
activity_feed + articles_unread.
- memoro: +1 Eintrag pro Sprache für memo.load_more.
Damit sind dashboard (111) und memoro auf gleichem Stand wie DE/EN.
Verbleibende Drift (app_slider-Legacy-Keys in memoro IT/FR/ES,
common/auth-Legacy in calendar/times) ist strukturell und bleibt
einem Folge-Cleanup vorbehalten.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Writing is now programmatically accessible from the foreground mission
runner, personas, and Claude Desktop / MCP. Eight tools land:
Auto (read-only):
- list_drafts — filtered by kind/status + word-count summary
- get_draft — briefing + current version body, ready for reading
- list_writing_styles — 9 presets + user customs, ids usable in create_draft
Propose (human approval per agent policy):
- create_draft — briefing only, no generation yet
- generate_draft_content — wraps generationsStore.startDraftGeneration;
writes a new LocalDraftVersion + pointer flip
- refine_draft_selection — wraps refineSelection + applyRefinement in
one call; operations: shorten/expand/tone/
rewrite/translate with op-specific params
- set_draft_status — draft/refining/complete/published
- save_draft_as_article — hand-off to articlesStore.saveFromExtracted
with internal://writing/<id> as originalUrl,
records publishedTo + emits WritingDraftPublished
Schemas live in @mana/shared-ai/src/tools/schemas.ts (the SSOT that the
web-app policy layer + mana-ai planner derive from). Executors live in
modules/writing/tools.ts and delegate to the existing stores so the
encryption + event pipeline runs once regardless of who called the tool.
Registration added to data/tools/init.ts.
107 shared-ai tests still pass. CLAUDE.md tool-coverage table bumped:
67→75 tools, 21→22 modules.
Not in M8 (deferred): agent.defaultWritingStyleId linkage (needs a
Persona schema change + runner wiring), mana-tool-registry Zod specs
(add when a non-web MCP client needs richer validation).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Writing can now leave the module. Four outbound paths in this milestone:
Markdown to clipboard, plain text to clipboard, .md file download, and
a browser-native print / PDF. Plus a first cross-module hand-off: save
as a read-later article.
- utils/export.ts: pure helpers — draftToMarkdown (title as H1 + body),
draftToPlainText, downloadFile via synthetic anchor + blob URL,
fileStem (NFKD-normalise + slug-ify the title), and a
copyTextToClipboard wrapper that falls back to document.execCommand
for http contexts that can't touch navigator.clipboard.
- draftsStore.recordPublish(draftId, module, targetId) — idempotent
per (module, targetId), appends to draft.publishedTo with a
publishedAt timestamp, emits WritingDraftPublished so the Workbench
timeline picks up the hand-off.
- ExportMenu.svelte: dropdown next to Generate / Checkpoint with five
items. "Als Artikel speichern" calls articlesStore.saveFromExtracted
with originalUrl='internal://writing/<draftId>' as both a dedupe-safe
identifier and a back-reference to the source draft, then records
the publish and navigates to the new article.
- DetailView surfaces draft.publishedTo as green chips under the meta
row, with a click-through to the articles reader. Only 'articles' has
a landing page today; website / presi / mail / social-relay chips
render the label without a link (they arrive with their own
milestones).
Not in M10: website-block publishing (M-website), presi import
(M-presi), mail draft creation. Those each need per-target block /
slide / message shapes that exceed this commit's scope.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User wählt einen bestehenden Text (Tagebuch-Eintrag, Notiz oder
Bibliotheks-Review), das Modell schlägt eine geordnete
Panel-Sequenz vor (prompt + optional caption + dialogue pro Panel),
der User prüft/editiert und feuert Batch-Gen mit sourceInput-
Tagging — damit wird `useStoriesByInput` später cross-referenzieren
können ("Welche Comics sind aus diesem Journal-Eintrag entstanden?").
Backend:
- POST /api/v1/comic/storyboard (Hono route) nimmt style +
sourceText + panelCount (+ optional storyContext / sourceModule)
und ruft llmJson() mit einem response_format=json_object-Prompt
an mana-llm. System-Prompt instruiert das Modell auf eine exakte
{panels: [{prompt, caption?, dialogue?}]}-Shape, Rules wie
"keine Style-Instruktionen" (kommen aus dem Story-Prefix
downstream) und "kein Panel-Nummerieren".
- Defense-in-depth Coerce auf der Response: Panel ohne prompt
wird gefiltert, Strings werden gecappt (caption/dialogue 200,
prompt 800), Zahl der Panels auf panelCount geclampt.
- Model via COMIC_STORYBOARD_MODEL env var überschreibbar;
Default ollama/gemma3:4b wie writing (lokal + billig).
- Beide Erfolgs- und Fehler-Pfade mit logger.info /
logger.error + userId + sourceModule für Observability.
- Route registriert in apps/api/src/index.ts als /api/v1/comic.
Client:
- api/storyboard.ts: suggestPanels({style, sourceText, panelCount,
storyContext?, sourceModule?}) — thin fetch-Wrapper + Error-Messaging
für 402 / 502 / no-panels-Responses.
- ReferenceInputPicker: Tabs über Journal / Notizen / Bibliothek
(die drei inhalts-dichtesten Quellen), pro Tab Live-Query +
Suche + Entry-Liste. Click emittiert {module, entryId, label,
sourceText} — label ist der Display-Name für die
"Gequellt aus…"-Chip, sourceText ist bereits decrypted (Queries
liefern plaintext zurück). Bibliotheks-Einträge ohne Review
sind disabled (kein Text = nichts zu rendern).
- StoryboardSuggester: 4-Schritt-Flow (pick-source →
generating-plan → review-plan → rendering). Schritt 3 ist der
eigentliche Editor: jede Claude-Zeile ist editierbar (Prompt,
Caption, Dialog) mit Trash-Button; Quality + Format-Toggle
teilen sich M3-Batch-Style. "Generieren" ruft parallel
runPanelGenerate() via Promise.allSettled mit
sourceInput={module, entryId} im panelMeta, alle Panels gehen
durch den identischen M2-HTTP-Pfad.
- DetailView bekommt einen dritten Editor-Modus "ai" neben
"single" und "batch" — eine Sparkle-Button-CTA öffnet den
Suggester.
Kein Writing-Draft / Calendar-Event-Input in dieser Runde —
Drafts brauchen Version-Chain-Resolve, Events sind meist zu dünn
an Prosa. Follow-up wenn gewünscht (rein additiv: Tab + Hook).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add Google's Gemini image edit family (Nano Banana) as a user-
selectable model for Wardrobe Try-On next to the existing OpenAI
path. Three concrete choices now expose themselves in the Solo and
Outfit Try-On buttons:
- openai/gpt-image-2 (default, falls back to gpt-image-1
server-side when the org isn't
verified)
- google/gemini-3-pro-image-preview (Nano Banana Pro — premium
identity / character consistency)
- google/gemini-3.1-flash-image-preview (Nano Banana 2 — newest,
fast, cheapest)
All three accept multi-image refs (face + body + garment) through
the same /api/v1/picture/generate-with-reference endpoint; the only
differences are the provider-specific request/response shape and
the model-id routing.
Server (apps/api/src/modules/picture/routes.ts):
- Guard now accepts `openai/*` and `google/*` prefixes and rejects
everything else as "not supported for edits". Each provider's key
is validated separately so missing GEMINI_API_KEY doesn't break
OpenAI calls and vice versa.
- New `callGeminiEdits(modelName)` helper mirrors the shape of
callOpenAiEdits: encodes the normalized PNG refs as base64
inline_data parts, POSTs to
generativelanguage.googleapis.com/v1beta/models/{model}:generateContent
with responseModalities=["TEXT","IMAGE"] and imageConfig
(aspectRatio + imageSize), pulls the generated image out of
candidates[].content.parts[].inlineData.
- Our internal size strings map cleanly: 1024x1024 → 1:1 / 1K,
1024x1536 → 2:3 / 1K, 1536x1024 → 3:2 / 1K. Gemini 1K is enough
for the thumbnail sizes Wardrobe renders; going higher bloats
payload without visible gain.
- creditsFor() gains a google/ branch proportional to upstream
pricing (pro ≈ 18, 3.1-flash ≈ 6, 2.5-flash ≈ 5).
- Response `model` reports `${provider}/${modelUsed}` so the picture
row's model metadata is accurate across providers.
Client (apps/mana/apps/web/src/lib/modules/wardrobe):
- api/try-on.ts: export `TryOnModel` union + `DEFAULT_TRY_ON_MODEL`.
RunGarmentTryOnParams / RunOutfitTryOnParams gain an optional
`model` field, threaded through `callGenerateWithReference`.
- components/TryOnModelPicker.svelte: new segmented control, three
options with label + one-line hint. Grid-auto-fits so it reflows
on the narrow workbench card.
- components/GarmentTryOnButton.svelte + TryOnButton.svelte: both
mount the picker above the Sparkle CTA. `estimatedCredits` on the
button label updates live when the user switches model so the
cost signal matches what the server will actually charge.
Env (scripts/generate-env.mjs): GEMINI_API_KEY and GOOGLE_API_KEY
now propagate from the root `.env.development` into `apps/api/.env`
so mana-api can pick them up at boot. The route reads GEMINI_API_KEY
with GOOGLE_API_KEY as fallback, matching how mana-llm ships today.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pattern established in fix(calendar, 8c43c119e) — the picker reads as
a first-class property when it sits in its own row with a 'Sichtbarkeit'
label instead of being tucked behind a compact icon. Applying the same
treatment everywhere the picker was still using the compact variant.
Changes:
- library/views/DetailView: move the picker out of the meta-top-row
(kind-pill + picker cluster) and into the existing <dl class="details">
block as a first dt/dd pair. Keeps the kind-pill standalone and gives
visibility equal weight to the other structured details. Removes the
now-orphaned .meta-top-row CSS rule
- wardrobe/views/DetailOutfitView: remove the compact picker from the
header action cluster (it was competing with favourite/edit buttons);
replace with a 'Sichtbarkeit' label + full picker in its own flex row
directly below the header, above description
- calendar/components/EventDetailModal: remove the compact picker from
the modal-actions row (copy/edit/delete chevron area). Add a new
detail-row at the top of event-details with a 'Sichtbarkeit' label
(new .detail-label CSS rule, mirrors .detail-icon layout for visual
consistency with Time/Location/etc rows)
Picture board detail stays as-is: the picker already renders with its
label visible (non-compact) in the header flex, and the Board-Detail
page has no prop-row-style content list to slot it into — inline in
the header is the right spot there.
Verified:
- pnpm check (web): 7520 files, 0 errors, 0 warnings
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The backing for visibility was already in place from M2 (draft.visibility
stamped on create via defaultVisibilityFor, draftsStore.setVisibility
mints/clears unlistedToken and emits VisibilityChanged), so M11 is just
the UI step that puts it in front of the user.
- <VisibilityPicker> from @mana/shared-privacy sits in the meta-row of
DetailView, mirroring the library pattern. onChange calls
draftsStore.setVisibility — no new store method needed.
- Draft type + toDraft converter now surface `unlistedToken` so the UI
can render a share row when visibility === 'unlisted'. Token is
displayed verbatim + "Kopieren"-button because the public read-URL
for drafts ships with M10 (Publish-Hooks); a tooltip makes that
explicit so the user doesn't expect a working link yet.
With this, Writing is now consistent with the Library / Picture /
Calendar / Todo / Goals / Places / Recipes / Wardrobe pilot group.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Die Suche im AppPagePicker matcht jetzt gegen DE + EN + Fallback-Name +
id-Slug, damit englische Begriffe immer greifen — auch wenn die UI in
Deutsch läuft ("cal" → Kalender, "weather" → Wetter).
- AppPagePicker: statische Imports von apps/de.json + apps/en.json,
neue searchHaystack-Funktion, Filter über Haystack statt nur
displayName.
- apps/{it,fr,es}.json: +42 Einträge pro Sprache, alle 78 registrierten
Module sind jetzt nativ übersetzt. Vorher fiel IT/FR/ES für neuere
Module via fallbackLocale auf Deutsch zurück (z.B. "Wetter" im
italienischen Menü).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dem DetailView einen zweiten Editor-Modus spendiert: neben dem
Einzel-Button ("+ Panel") gibt es jetzt einen "+ Batch"-Button, der
`BatchPanelEditor` öffnet. Der Batch-Editor zeigt 2-4 Prompt-Cards
(prompt + caption + dialog pro Zeile, dynamisch per + / trash),
feuert alle Zeilen parallel über `Promise.allSettled` an
`runPanelGenerate`, und rendert pro Zeile Live-Status-Chips
(pending / ok / error) plus Retry-Button bei Fehlern.
- Parallel statt seriell, damit OpenAI-Latenz nicht N× addiert wird.
`Promise.allSettled` isoliert jeden Call, ein 402-Credits-Fehler
auf Zeile 2 bricht Zeilen 3-4 nicht ab.
- Nach erfolgreichem Submit werden die erfolgreichen Zeilen
verworfen; fehlgeschlagene bleiben mit ihrem Error-Text + Retry-
Chip stehen, sodass der User korrigieren oder nochmal abschicken
kann ohne neu zu tippen.
- Credit-Total wird vor Submit angezeigt (Quality × filledRows).
Story-Room (MAX_PANELS_PER_STORY − panelCount) clampt die
sichtbaren Zeilen — Batch wird abgelehnt wenn die Story am
12-Panel-Hard-Cap ist.
- Shared style (Stilprefix) + character refs werden identisch zum
Einzel-Flow aus der Story gezogen; jede Batch-Zeile geht durch
den gleichen `runPanelGenerate`-Pfad wie M2, also kein
Divergenz-Risiko.
Kein AI-Storyboard (M4), keine MCP-Tools (M5).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drafts can now pull in saved articles, notes, library entries, and raw
URLs as prompt context. This is the Writing module's main differentiator
against standalone LLM chat: the user's own SSOT flows straight into the
ghostwriter without copy-paste.
- utils/reference-resolver.ts: resolveReference() per kind (article,
note, library, url) via scopedGet + decryptRecords + module type
converter. Each ref truncates to MAX_CHARS_PER_REF=1500 (with a
"[… gekürzt …]" marker); resolveReferences() caps the aggregate at
MAX_TOTAL_REFERENCE_CHARS=8000 and drops extras rather than slicing
mid-sentence. Deleted or missing refs silently fall out.
- prompt-builder: buildDraftPrompt() takes resolvedReferences and
renders them as a "--- Quellen ---" block in the user message with
[Quelle N] headers + optional "Kontext:" lines (the user's own
per-ref note). System prompt gets a sentence instructing the model
to paraphrase from the sources and not fabricate facts when a source
has nothing useful.
- generations store: startDraftGeneration resolves references in
parallel before building the prompt. No changes to the refineSelection
path — M5 keeps selection-refinement context-free on purpose.
- UI: ReferencePicker.svelte inline in the BriefingForm with four kind
tabs (Artikel / Notiz / Library / URL). Searchable lists per kind for
module refs (max 20 visible, debounced); URL kind takes a url + an
optional context note. ReferenceChip.svelte pills render live-
resolved titles; parent resolves labels via the module queries. Hard
cap at 6 references per draft.
- Scope limits: kontext / goal / me-image refs are on the roadmap but
deliberately skipped in M5 — they require different resolution paths
(singletons, structured metadata, image descriptors) that would
sprawl this commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User feedback: the picker tucked behind the compact icon in the title
row was easy to miss. The other prop-rows carry an icon + field so a
full row reads as "this is an editable property".
Moves the picker into its own prop-row with a "Sichtbarkeit" label,
matching the pattern todo + places already use. Uses the non-compact
variant so the current level (lock / users / link / globe + word) is
readable at a glance.
prop-row--labeled modifier carries the label-text style (muted, fixed
5rem min-width so the picker aligns with the other rows).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>