Commit graph

3548 commits

Author SHA1 Message Date
Till JS
7e6fb5b6d1 docs: surface i18n validator stack + format helper convention
- CLAUDE.md: validate:all section now lists every individual check
  (turbo / pg-schema / theme-{variables,utilities,parity} /
  i18n-{parity,hardcoded,keys} / crypto / encrypted-tools) instead of
  the stale "three invariant checks" line.
- .claude/guidelines/sveltekit-web.md: new "i18n" section with the
  hardcoded-strings rule (use $_), the parity/missing-key gates, and
  the formatDate/formatNumber/getDateFnsLocale convention vs.
  toLocaleDateString('de-DE').
- .claude/GUIDELINES.md: index gains "i18n" tag for the SvelteKit page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 12:07:35 +02:00
Till JS
01e6b9f044 fix(scope): undo accidental current-user.svelte.ts rename
A stray rename of current-user.ts → current-user.svelte.ts got swept
into the previous writing M5-expansion commit by lint-staged's stash/
restore step. The migration to a runes-bearing .svelte.ts variant
hadn't yet been completed (34 importers still use the old path), so the
result was a broken build until those importers were updated.

Renames the file back to current-user.ts and switches the one import
that had been pre-emptively rewritten (use-scoped-live-query.svelte.ts)
back to the bare path. The runes migration can land cleanly later.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 11:46:50 +02:00
Till JS
26e25b7694 feat(writing): M5 expansion — kontext, goal, me-image references
Three more reference kinds the resolver previously stubbed out are now
fully wired through the briefing form into the LLM prompt.

- reference-resolver: three new resolveX functions.
  * Kontext is a singleton per space (the picker uses a sentinel
    targetId; the resolver ignores it and reads via scopedForModule
    + first non-deleted row). Decrypts content and trims to budget.
  * Goal reads from companionGoals (plaintext today) and surfaces
    title + description + status + current/target so the model can
    tie the draft into the user's actual progress.
  * MeImage reads from the space-scoped meImages table; encrypts
    label + tags. Hands the model a textual descriptor (kind / label /
    tags) since the binary blob can't help prose generation.

- ReferencePicker: three new kind-tabs (🗂 Kontext, 🎯 Ziel, 🖼 Bild).
  Kontext renders as a single-click "Kontext-Dokument verknüpfen" entry
  if the space has one (with /kontext deep-link otherwise). Goals
  active-first, then archived/done. Me-images render with thumbnail +
  label + tags. Live-resolved chips via labelFor() for all three.

- i18n baseline bumped by one for ReferencePicker (the new
  "Kontext-Dokument verknüpfen" string is intentional, in line with
  the rest of the picker's existing German labels).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 11:43:12 +02:00
Till JS
fbbadc91f0 feat(calendar): M8.3 — calendar pilot for unlisted-share end-to-end
Wires Calendar through the M8.1+M8.2 backbone: flipping an event to
'unlisted' now publishes a server-side snapshot, the visible link in
the DetailView/EventDetailModal opens a real /share/[token] page, and
recipients can download an .ics file for their own calendar.

Changes:
- lib/data/unlisted/resolvers.ts (new):
    buildUnlistedBlob(collection, recordId) dispatcher.
    buildEventBlob: load LocalEvent + linked TimeBlock, decrypt
    client-side, return { title, location, startTime, endTime,
    isAllDay, timezone }. Description, reminders, tagIds, calendarId,
    color stay out of the blob — sensitive context the user didn't
    consent to share by flipping a single flag.
- modules/calendar/types: CalendarEvent gains `unlistedToken: string`
  (empty string when no active token). timeBlockToCalendarEvent
  forwards from LocalEvent. Draft-event scaffold initializes empty.
- modules/calendar/stores/events:
    setVisibility now coordinates with mana-api. Flip-to-unlisted:
      build blob -> publishUnlistedSnapshot -> store server-issued
      token in patch.unlistedToken -> commit local update. If the
      server call fails, no local change happens (no drift).
    Flip-from-unlisted: revoke server snapshot first, then clear
      local token + commit visibility change.
    deleteEvent: revoke active unlisted snapshot before tombstoning,
      so the share-link dies in lock-step with the local delete.
    updateEvent + updateSingleInstance fire-and-forget
      refreshUnlistedSnapshot(id) so the published blob tracks any
      whitelist-field edits. Failures log; the next successful
      refresh heals.
    New regenerateUnlistedToken(id): revoke + republish in one call,
      returns the fresh token. Powers the "Neu erzeugen" UI.
- routes/share/[token]/+layout.svelte: minimal anonymous chrome —
  no app nav, no auth, no Dexie. Light/dark via prefers-color-scheme.
  Footer carries "Geteilt via Mana" + signup CTA.
- routes/share/[token]/+page.server.ts: SSR loader. Fetches
  /api/v1/unlisted/public/:token, dispatches 404/410 cleanly,
  sets Cache-Control: private, max-age=60 + X-Robots-Tag: noindex.
- routes/share/[token]/+page.svelte: dispatcher; renders
  SharedEventView for collection='events', stub message otherwise.
- modules/calendar/SharedEventView.svelte: standalone public render —
  big date, location, "Zum eigenen Kalender hinzufügen" .ics link,
  optional expiry note. OG/Twitter meta tags for WhatsApp/Slack
  preview embedding. Uses $derived everywhere so prop updates
  propagate through reactive recompute.
- routes/share/[token]/ical/+server.ts: RFC 5545 builder. No npm
  library — small enough to inline. Escapes per spec, CRLF endings,
  DTSTART/DTEND swap between VALUE=DATE and UTC depending on isAllDay.
  Wrong-collection requests get 400.
- modules/calendar/views/DetailView (Workbench) + components/
  EventDetailModal (/calendar route): SharedLinkControls dropped in
  below the visibility row when event.visibility === 'unlisted'
  AND event.unlistedToken AND shareUrl computed. The URL is built
  client-side via buildShareUrl(window.location.origin, token) so it
  stays in sync with whichever host the editor is open on.

Verified:
- pnpm check (web): 7541 files, 0 errors, 0 warnings
- pnpm test calendar + website: 26/26
- typecheck of new resolver, store hooks, SSR loader, iCal builder

Manual test path:
1. Open /calendar event in Detail view, flip Sichtbarkeit -> "Per Link"
2. Server publishes snapshot, Dexie record gets the server token
3. SharedLinkControls appear with copy + regenerate + revoke buttons
4. Open the URL in incognito → SSR fetches snapshot, renders
   SharedEventView with date / location / .ics download
5. Edit the event title back in the main app → snapshot auto-refreshes
   (refreshUnlistedSnapshot fires after updateEvent succeeds)
6. Flip back to "Bereich" → snapshot revoked server-side; subsequent
   incognito reloads return 410 Gone

Next: M8.4 — same wiring for Library + Places. Uses the same
infra (resolvers dispatcher, share dispatcher) — just adds two new
buildXBlob functions, two SharedXView components, and the store
hooks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 11:40:53 +02:00
Till JS
8b9fbd2e1c feat(scripts): validate:i18n-hardcoded — ratcheting baseline check
Stoppt das Wachsen des 1877-String-Backlogs hardgecodeter deutscher
User-facing Strings in .svelte Files. Per-file Count vs. committed
Baseline; Datei darf NIE über ihrer Baseline liegen, neue Files müssen
mit 0 Verstößen starten.

- Erkennt: placeholder/title/aria-label/label/alt mit Umlauten,
  Text-Content `>Großbuchstabe…<` (ohne Interpolation).
- Aktuelle Baseline: 1877 Verstöße in 428 Files; jeder Fix ratchet't
  den erlaubten Wert nach unten.
- Lokales Update nach gewolltem Wachstum: `pnpm run validate:i18n-hardcoded -- --update`.
- In validate:all + CI verdrahtet.
- Drift-Test bestätigt: ein zusätzlicher umlaut-Placeholder lässt die
  Datei "2 (was 1, +1)" failen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 11:33:24 +02:00
Till JS
d8feef1149 fix(scope): bridge active-space / user changes to Dexie liveQuery
Modules mounted before the active-space bootstrap finished rendered
empty on first paint and stayed empty until some unrelated Dexie
write happened to wake the querier up. Reproducible in wardrobe:
open the module, the face-ref banner stays visible (even though
face-ref is set), the garment grid is empty; create a new garment
and suddenly the existing ones appear alongside it.

Root cause: Dexie's `liveQuery` only re-runs when a Dexie table it
read during evaluation is written to. `getInScopeSpaceIds()` reads
from plain Svelte `$state` (active-space.svelte.ts `active` +
current-user.ts `currentUserId`), which is invisible to Dexie's
change tracker. So:

1. User opens /wardrobe.
2. First querier runs. getActiveSpaceId() is still null (bootstrap
   hasn't resolved yet). getInScopeSpaceIds() returns
   [`_personal:guest`].
3. Existing rows are stamped `_personal:<userId>` or a real
   space-id — no match. Filter emits [].
4. Bootstrap resolves, setActiveSpace fires, `active = realSpace`.
   Svelte $state assignment: invisible to Dexie.
5. liveQuery never re-runs; user sees empty until a subsequent
   Dexie write forces a re-evaluation.

Fix: add `_scopeCursor`, a single-row infra table, as a Dexie
proxy for the scope-state signal.

- `data/scope/cursor.ts`: new module with two exports.
  `bumpScopeCursor()` writes `{id:'active', bumpedAt}` to the
  cursor. `touchScopeCursor()` reads from it, fire-and-forget —
  the Dexie read registers the liveQuery subscription during
  querier execution.
- Dexie v45 registers `_scopeCursor: 'id'`. NOT in SYNC_APP_MAP:
  it's a client-side liveness signal, not user data, so the
  creating-hook loop ignores it and no pending-change rows are
  generated.
- `scopedTable` / `scopedGet` / `scopedAnd` in `scoped-db.ts` call
  `touchScopeCursor()` on every invocation. Since those run inside
  each querier's body, liveQuery picks up the subscription for free.
- `setActiveSpace` (direct + both loadActiveSpace branches) calls
  `bumpScopeCursor()` after the `$state` update.
- `setCurrentUserId` in current-user.ts calls `bumpScopeCursor()`
  via dynamic import so the module stays leaf-level (no eager
  Dexie dep in test envs that mock fake-indexeddb differently).

Net effect: every scope change triggers one Dexie write to an
infra table, every liveQuery that went through `scopedForModule`
sees the write and re-evaluates with the fresh
`getInScopeSpaceIds()`. The first-mount race that left wardrobe
stuck on an empty list is gone.

Existing 14 scope regression tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 11:32:32 +02:00
Till JS
3f438cf882 refactor(i18n): replace hand-maintained locale list with import.meta.glob
Bisher war für jeden Namespace ein eigener `import(\`./locales/X/\${lang}.json\`)`
Eintrag in index.ts + ein spiegelndes Destructuring — 35 Namespaces,
zwei hand-synchronisierte Listen, jeder neue Namespace erforderte Edits.
Leicht zu vergessen, lautlos fehlerhaft.

Vite's import.meta.glob übernimmt das jetzt: alle ./locales/*/*.json
werden automatisch für den gewünschten Lang registriert, Namespace aus
dem Pfad abgeleitet. Neuer Namespace = JSON-Folder droppen, Ende.

- index.ts: 118 Zeilen → 15 Zeilen.
- svelte-check 0/0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:21:43 +02:00
Till JS
e794e0ca57 feat(i18n): locale-aware formatters, migrate hardcoded de-DE call-sites
Bisher pinnten 185+ call-sites die UI-Zahlen/Datumsausgabe hart auf
Deutsch ("de" / "de-DE" an toLocaleDate{,Time}String, Intl.*Format,
date-fns locale), unabhängig von der aktiven Sprache. EN-User sahen
dadurch deutsche Datums-/Zahlenformate mitten im englischen UI.

- $lib/i18n/format.ts (neu): formatDate / formatTime / formatDateTime
  / formatNumber / formatCurrency / getDateFnsLocale. Alle lesen die
  aktive Locale aus svelte-i18n's locale-store und mappen de→de-DE
  etc. für Intl.
- Codemod: 119 Direktaufrufe in 79 Files migriert (.toLocaleDateString
  / .toLocaleTimeString / new Intl.{Number,DateTime}Format). Erkennt
  new Date() / Number()-Receiver zum Disambiguieren von
  .toLocaleString('de-DE').
- date-fns: 19 Files auf getDateFnsLocale() umgestellt; hardcoded
  `import { de } from 'date-fns/locale'` entfernt.
- Skipped (Collision): 14 Files hatten lokale format*-Wrapper; diese
  bleiben vorerst als gesonderte Folge-Refactorings stehen. Ca. 58
  deep-gekapselte Aufrufe im .toLocaleString/.toLocaleDateString-Idiom
  sind über diese Wrapper noch zu migrieren.
- svelte-check: 0 Errors / 0 Warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:20:11 +02:00
Till JS
364522db87 feat(comic): image-model picker — OpenAI + Nano Banana wählbar
Comic nutzte bisher 'openai/gpt-image-2' hartcodiert auf drei Ebenen
(generate-panel.ts, comic.generatePanel MCP-Tool, generate_comic_panel
AI-Tool). Wardrobe hat seit dem Nano-Banana-Commit einen
TryOnModelPicker mit drei Optionen — Comic spiegelt das jetzt 1:1.

Wählbar in allen drei Editoren (PanelEditor, BatchPanelEditor,
StoryboardSuggester):
- openai/gpt-image-2 (Default) — OpenAI GPT-image Standard
- google/gemini-3-pro-image-preview — Nano Banana Pro, hohe
  Konsistenz, teurer
- google/gemini-3.1-flash-image-preview — Nano Banana 2, neuestes,
  schnell, günstig

Implementierung:
- api/generate-panel.ts: PanelModel Union + DEFAULT_PANEL_MODEL +
  model? Param auf RunPanelGenerateParams + im HTTP-Body
  weitergereicht (vorher hart 'openai/gpt-image-2').
- components/PanelModelPicker.svelte: neue Komponente, Stil/Markup
  identisch zu TryOnModelPicker für Muskel-Memory über beide Flows.
- components/PanelEditor.svelte: `let model = $state(DEFAULT_PANEL_MODEL)`
  + Picker oberhalb der Qualität-/Format-Leiste + model im
  runPanelGenerate-Call.
- components/BatchPanelEditor.svelte: gleiche Änderung — ein Model
  pro Batch (nicht pro Row) damit der Batch konsistent rendert.
- components/StoryboardSuggester.svelte: gleiches Pattern; der
  Picker landet zwischen "Panel manuell"-Button und dem
  Qualität/Format-Block.
- packages/mana-tool-registry/src/modules/comic.ts: generatePanel
  Input-Schema bekommt model mit zod.enum() + default; im Body
  wird input.model durchgereicht.
- packages/shared-ai/src/tools/schemas.ts: generate_comic_panel
  bekommt Parameter 'model' optional mit gleicher Enum-Liste.
- apps/mana/apps/web/src/lib/modules/comic/tools.ts: isValidModel
  Guard + Parameter-Validierung; model an runPanelGenerate.

Keine Story-Level-Persistierung — model bleibt lokaler State pro
Editor-Mount. Eine model-Spalte auf comicStories würde Migration
brauchen und die Wahl ist eh ad-hoc pro Panel/Batch.

Plan-Doc (§2.1) dokumentiert die Entscheidung + die drei Optionen.

107 shared-ai tests weiter grün. check + validate:all clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:19:40 +02:00
Till JS
5501f472ae feat(shared-privacy): M8.2 — unlisted-client + SharedLinkControls
Second milestone of the unlisted-share rollout. Backend endpoints
from M8.1 are now callable from the client, and a reusable
SharedLinkControls component is available for the detail views that
wire up in M8.3/M8.4.

Scope: shared primitives only. No module store integrates them yet —
that's the next step per module.

Changes:
- @mana/shared-privacy/unlisted-client.ts:
    publishUnlistedSnapshot(opts) → { token, url }
      Idempotent per (collection, recordId) — server reuses token on
      re-publish, so store code can call on every edit without caring
      whether it's first publish or refresh.
    revokeUnlistedSnapshot(opts)
      Idempotent — resolves silently even on { revoked: 0 }.
    buildShareUrl(origin, token)
      Convenience for UIs that already know the token.
    UnlistedApiError
      Thrown on non-2xx. Carries { status, code } so callers can
      distinguish 400 COLLECTION_NOT_ALLOWED vs 410 REVOKED vs
      500 UNKNOWN.
- @mana/shared-privacy/SharedLinkControls.svelte:
    Dumb presentational component. Props: token, url, expiresAt,
    onRegenerate, onRevoke, onExpiryChange (optional), disabled.
    Renders URL + copy, regenerate with confirm dialog, revoke,
    optional datetime-local expiry picker, debug token fingerprint.
    Clipboard-API fallback to prompt() for unsecure origins.
    QR-code button deferred to M8.5 polish.
- Exports added to index.ts: functions, error class, both types,
  SharedLinkControls component.
- 10 new unit tests (25 total): publish URL shape, headers, body,
  expiresAt serialisation, 4xx/5xx handling, trailing-slash
  trimming on apiUrl, revoke idempotence, buildShareUrl join.

Verified:
- pnpm --filter @mana/shared-privacy test: 25/25 green
- pnpm --filter @mana/shared-privacy check: 0 errors
- pnpm --filter @mana/web check: 7531 files, 0 errors

Next: M8.3 — wire Calendar through the new client.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:18:56 +02:00
Till JS
c1d643ffb5 feat(writing): register Writing as a Workbench card
Writing is now pickable from the Workbench AppPagePicker and can run
side-by-side with the other module cards. Previously only reachable via
direct navigation to /writing.

- app-registry: registerApp({ id: 'writing', icon: NotePencil, color:
  '#0ea5e9', list: ListView }) with a "Neuer Draft" context-menu action
  that dispatches mana:quick-action (same convention as library/notes/
  quiz — the event channel is the standard cross-card hook).
- writing ListView: onMount listener for the mana:quick-action event
  flips showCreate=true when { app:'writing', action:'new' } fires, so
  the kebab menu → "Neuer Draft" opens the inline BriefingForm instead
  of being a no-op.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:15:25 +02:00
Till JS
92bee0d71a feat(unlisted): M8.1 — backend foundation for shareable-link snapshots
First milestone of the unlisted-share rollout plan (docs/plans/
unlisted-sharing.md). Adds the server-side infrastructure that backs
`visibility='unlisted'` — previously the flag was stamped locally but
led nowhere. After this commit, a token points at an actual snapshot
the SSR share-page will render (M8.3+).

Scope: backend only. No client-side publish/revoke calls yet, no
share-route, no UI. That lands in M8.2/M8.3. Anyone hitting the
endpoints manually with curl can exercise the full publish-fetch-
revoke cycle.

Changes:
- New pgSchema `unlisted` with table `snapshots`:
    token (pk, 32-char base64url)
    user_id, space_id, collection, record_id, blob (jsonb)
    created_at, updated_at, expires_at (nullable), revoked_at
  Partial unique index on (user_id, collection, record_id) WHERE
  revoked_at IS NULL so one record has at most one active token.
  Partial btree on expires_at for the cron-cleanup path.
- Hand-authored SQL migration `apps/api/drizzle/unlisted/0000_init.sql`
  (manual-apply per the repo's feedback_api_hand_authored_migrations
  memory). Already applied to the local mana_platform.
- Drizzle schema `apps/api/src/modules/unlisted/schema.ts`. All id
  fields are `text` not uuid — Better-Auth nanoids aren't UUIDs, same
  trap we hit with the website module's publish bug.
- mana-api module `apps/api/src/modules/unlisted/`:
    POST   /api/v1/unlisted/:collection/:recordId (auth)
      Body: { spaceId, blob, expiresAt? }. Re-publish reuses the
      existing active token (by (user,collection,record) lookup); a
      revoke-then-republish mints a fresh token row. Response includes
      a fully-qualified share URL built from Origin/Referer/env.
    DELETE /api/v1/unlisted/:collection/:recordId (auth)
      Soft-revoke. Idempotent — already-revoked returns
      { revoked: 0 } cleanly so client stores can call it
      unconditionally on setVisibility-away.
    GET    /api/v1/unlisted/public/:token (public)
      Rate-limited 20/min/token + 60/min/ip so token enumeration is
      impractical. 404 for unknown, 410 Gone for revoked or expired.
      Cache-Control: private, max-age=60 + X-Robots-Tag: noindex for
      SEO isolation. Returns { token, collection, blob, createdAt,
      updatedAt, expiresAt }.
- ALLOWED_COLLECTIONS hardcoded allowlist in POST handler
  (events, libraryEntries, places — the M8.3+M8.4 scope). Unknown
  collection -> 400 COLLECTION_NOT_ALLOWED. Keeps the schema honest
  about what the server accepts.
- drizzle.config extended to include the new schema in managed
  migrations.
- index.ts wires unlistedPublicRoutes pre-auth (before
  authMiddleware) and unlistedRoutes post-auth.

Verified:
- Migration applied to mana_platform — `unlisted.snapshots` exists
  with both partial indexes.
- pnpm run type-check (api): clean
- pnpm run validate:all: theme-tokens, theme-parity, crypto-registry,
  encrypted-tools all green
- URL build uses Origin/Referer before the env fallback so dev
  (http://localhost:5173) and prod (https://mana.how) both work
  without env churn.

Next: M8.2 — shared-privacy client helper + SharedLinkControls
component.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:12:13 +02:00
Till JS
6f37e00bf4 feat(comic): AI_TOOL_CATALOG bridge — webapp-runner kann jetzt Comics
Macht den Comic-Autor-Template (M6) auch im Web-App-Mission-Runner
nutzbar. Bisher war der Template nur über persona-runner/Claude
Desktop sinnvoll, weil die comic.*-Tools nur im mana-tool-registry
(MCP) lagen. Jetzt kennt die AI Workbench drei neue Tools und der
Template-Policy-Map trägt beide Naming-Konventionen.

AI_TOOL_CATALOG-Einträge (packages/shared-ai/src/tools/schemas.ts):
- list_comic_stories (auto) — filter style?/favoriteOnly?/limit?
- create_comic_story (propose) — title + style + optional
  description/storyContext/tags. Character-Refs werden vom Executor
  automatisch aus meImages primary face-ref + body-ref gezogen,
  also muss der Planner keine mediaIds kennen.
- generate_comic_panel (propose) — storyId + panelPrompt + optional
  caption/dialogue + quality. Kostet Credits.

Executors (apps/mana/apps/web/src/lib/modules/comic/tools.ts):
- list: scopedForModule pull + decrypt + filter + sort newest.
- create: resolveCharacterMediaIds() scannt meImagesTable für das
  aktive Space, nimmt face-ref+body-ref. Fehler wenn kein Face
  hinterlegt ("Lade eines in /profile/me-images hoch"). Delegiert
  an comicStoriesStore.createStory — gleiche encryption/event-
  pipeline wie StoryForm.
- generate: lädt Story decrypted, delegiert an runPanelGenerate
  (identischer Pfad wie PanelEditor in der UI), liefert
  panelIndex + imageUrl zurück.

Registrierung in data/tools/init.ts (registerTools(comicTools)).

Template-Policy (comic-author.ts) jetzt bi-lingual: snake_case
(AI_TOOL_CATALOG) UND dot-case (MCP) nebeneinander in tools-Map.
So gilt die Intent-Policy konsistent egal welche Runner-Oberfläche
das Tool nennt — auto für list_comic_stories / comic.listStories,
propose für create_comic_story / comic.createStory /
generate_comic_panel / comic.generatePanel / comic.reorderPanels.

apps/mana/CLAUDE.md Tool-Coverage-Tabelle bekommt eine Comic-Zeile.

Tool-Count jetzt 75→78, Module 22→23. 107 shared-ai tests
weiter grün. check + validate:all clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:49:24 +02:00
Till JS
6545498dc2 feat(writing): agent.defaultWritingStyleId — M8 persona-linkage follow-up
Agents can now pin a default writing style. When an AI-actor runs
`create_draft` without an explicit styleId, the tool resolves to the
agent's `defaultWritingStyleId` so e.g. a "Marketing-Agent" always
drafts in the Corporate-Tone style and a "Memoir-Agent" in Memoir.

- @mana/shared-ai: optional `defaultWritingStyleId?: string` added to
  the Agent interface (plaintext FK, format `preset:<id>` or a custom
  WritingStyle uuid). No migration — existing rows stay undefined and
  the fallback path no-ops for them.
- ai-agents store: field threaded through CreateAgentInput + AgentPatch
  + the create function's copy-list. `updateAgent` already deep-clones
  the patch so nothing else to change there.
- ai-agents ListView: new "Writing" section in the agent detail panel
  with a StylePicker (reuses the writing module's component — Vorlagen
  + Meine Stile optgroups). Empty = kein Default.
- writing/tools.ts: `resolveAgentDefaultStyle()` reads the current
  actor, guards `isAiActor`, loads the agent row, and returns its
  defaultWritingStyleId. Wired into `create_draft` as a fallback when
  `params.styleId` is missing. User-invoked calls skip the lookup — a
  human omitting styleId means "ad-hoc, no style", not "my default".
  `generate_draft_content` needs no change because the draft's styleId
  is already set at create time.

107 shared-ai tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:36:20 +02:00
Till JS
e7398b2dee polish(wardrobe): make Try-On CTA the dominant element + shrink secondary actions
Feedback from the latest screenshot: on the dark workbench theme the
earlier "lift + shadow" polish was too subtle — the Try-On button
read as equal weight to "Heute getragen" / "Archivieren" / "Löschen",
so it didn't telegraph "this is THE action on the page". The
primary-tinted shadow I used (shadow-primary/20) didn't have enough
contrast against the dark card.

Stronger visual hierarchy:

- CTA bumps to rounded-xl + px-6 py-4 + text-lg + font-semibold (was
  rounded-lg + px-5 py-3.5 + text-base + font-medium).
- At rest: neutral shadow-lg + primary/40 ring-2 so the button has a
  visible glow on both dark and light themes (neutral shadow works
  regardless of bg contrast; the primary ring carries the accent).
- Hover: lift -translate-y-0.5 + shadow-xl + ring-4 at primary/50.
- Active-press: back to baseline (translate-y-0, shadow-md, ring-2)
  for tactile feedback.
- Sparkle icon 18 → 20; gap tightens to 2.5.
- Applied identically to both GarmentTryOnButton + TryOnButton so
  solo and outfit surfaces share the same CTA weight.

Secondary-action row collapses from three equal full-width buttons
into one labelled button + two icon-only buttons:

- "Heute getragen" keeps the full label and takes the remaining row
  width — it's the frequent positive action.
- "Archivieren" + "Löschen" shrink to 36×36 icon buttons with
  tooltips (aria-label + title) carrying the full label. Archive
  uses the primary-tinted hover; Löschen keeps its destructive-red
  hover.

Net effect: the Try-On CTA is now clearly the loudest element on the
page, Heute-getragen sits cleanly below as a secondary action, and
Archive/Löschen recede to minimal icon-only controls — no more
visual competition with the primary CTA.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:35:18 +02:00
Till JS
91ae58f2af feat(comic): M6 — Comic-Autor persona-template
Neuer Eintrag in der Template-Galerie unter /agents/templates:
Comic-Autor nimmt einen Tagebuch-Eintrag, eine Notiz oder ein
Library-Review und verwandelt ihn in eine kurze Panel-Folge —
4 Panels Default, Sprechblasen + Captions direkt im Bild durch
gpt-image-2.

Policy-Layout:
- comic.listStories / journal.* / notes.* / library.* / kontext /
  goals → auto. Der Agent darf frei stöbern, ohne den User für
  jeden Read anzunerven.
- comic.createStory / comic.generatePanel / comic.reorderPanels →
  propose. Jedes Write muss der User bestätigen; besonders
  generatePanel, das pro Call 3-25 Credits kostet.
- Baseline: alle propose-fähigen Tools aus AI_TOOL_CATALOG kriegen
  propose (seed wie im Recherche-Agent) — Cross-Module-Schreibungen
  die der Agent eventuell vornimmt (z.B. create_note für eine
  Sidecar-Zusammenfassung) landen so als Vorschlag, nicht als
  Blitz-Ausführung.
- defaultForAi: propose — sicher per Default.

System-Prompt gibt dem Agent eine klare Rolle: Text lesen, Stil
wählen nach Ton (comic/manga/cartoon/graphic-novel/webtoon), 4
Panels mit prompt+caption?+dialogue? vorschlagen, Protagonist ist
immer der User. "Humor wenn der User es leicht nimmt, ernst wenn
er es ernst nimmt. Nie urteilen." Ton-Hinweis zu englischen vs.
deutschen Dialog-Texten (Englisch rendert stabiler).

Szene öffnet Comic + Journal + ai-missions + ai-workbench nebeneinander.

Eine paused Starter-Mission "Comic aus einem Tagebuch-Eintrag" mit
Concept-Markdown-Vorlage (Eintrag / Stil / Panel-Anzahl / Ton).

Die comic.*-Tools leben in mana-tool-registry (MCP) und sind noch
NICHT im AI_TOOL_CATALOG — dieser Template ist primär für
persona-runner/Claude-Desktop-Seite nutzbar, bis die Workbench-
Integration separat folgt.

107 shared-ai tests weiter grün.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:34:55 +02:00
Till JS
0ee3b145f0 polish(wardrobe): unify hover vocabulary + lift the Try-On CTA
The garment detail page used three different hover dialects — the
model picker reacted with a primary-tinted bg + border (feels like a
button), the secondary action buttons had a plain muted-grey hover,
the edit pencil was invisible until hover, and the hero photo was
entirely static. Result: the model picker was the only place that
telegraphed "click me". Everything else felt flat.

Align on one vocabulary across the page: primary-tinted border +
primary/5 bg on hover for anything interactive.

- Hero photo is now a `<button>` that opens the existing ImageLightbox
  with the garment's full-res mana-media URL (synthesised as a minimal
  picture.Image — prompt=name, no model/dims/date noise). Hover adds
  the primary-tinted border + a subtle shadow-md + a 1% scale on the
  `<img>` for depth.
- Edit pencil becomes a labelled button "Bearbeiten" with the same
  primary hover. No more hover-to-discover — editing reads as a
  first-class action.
- "Heute getragen", "Archivieren" drop the plain muted hover for the
  primary-tinted one. "Löschen" keeps its destructive-red tint but
  adds border-error/50 on hover so it feels as interactive as the
  others.

Try-On CTA now reads as the most important action:
- rounded-lg + px-5 py-3.5 + text-base + font-semibold (was
  rounded-md + px-4 py-2 + text-sm + font-medium).
- shadow-md shadow-primary/20 at rest → shadow-lg shadow-primary/30
  on hover, combined with -translate-y-0.5 for a subtle lift.
- active:translate-y-0 + shadow-sm makes the press feel tactile.
- Sparkle icon bumped 16 → 18, spinner likewise.

Applied to both GarmentTryOnButton (solo) and TryOnButton (outfit)
so the two surfaces share CTA weight.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:31:11 +02:00
Till JS
201a085872 feat(scripts): validate:i18n-parity — lock locale key-sets in CI
Neuer Validator im Stil von validate:theme-parity. Scannt
apps/mana/apps/web/src/lib/i18n/locales/<namespace>/<locale>.json
und failt hart, sobald ein Locale-File vom kanonischen DE-Key-Set
abweicht (fehlende oder überzählige Keys).

- DE ist canonical weil fallbackLocale='de' in i18n/index.ts. Missing
  keys führten zu mixed-language UI, extra keys sind tote Altlasten.
- In validate:all verdrahtet — CI failt ab sofort bei neuem Drift.
- Smoke-Test: 35 namespaces × 5 locales, 2724 canonical keys clean.
- Failure-Test bestätigt: künstlicher extra-key in apps/it.json führt
  zu exit 1 mit klarer Fehlermeldung.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:30:35 +02:00
Till JS
9c503b7982 i18n: strip dead legacy keys + fill IT/FR/ES calendar translations
Vor-Audit: 4 Namespaces mit Drift (calendar, dashboard, memoro, times).
Nach diesem Commit: alle 35 Namespaces × 5 Locales in voller Parität.

Tote Keys entfernt (0 Call-Sites laut grep über src/):
- memoro/{it,fr,es}.json: app_slider.* (16 Keys) — wurde längst in
  den app_slider/-Namespace migriert.
- calendar/*.json: sync.*, sharing.* (aus allen 5), auth.*, event.save,
  event.delete, event.cancel (aus en/it/fr/es — DE war schon sauber),
  calendar.common.* (aus it/fr/es) — alles duplizierte common-Strings.
- times/{de,en}.json: auth.* (6 Keys) — nur dort als Dupe geblieben.

Echte Lücken gefüllt:
- calendar/{it,fr,es}.json: 15 Keys (views.weekNumber, views.moreEvents,
  calendar.draftEvent/hideSidebar/showSidebar, event.changeStartTime/
  EndTime, errors.{load,create,update,delete}Events, success.event*,
  a11y.createEventOn/slotTime) — waren in DE/EN vorhanden, in IT/FR/ES
  via fallbackLocale auf Deutsch zurückgefallen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:28:10 +02:00
Till JS
05b2209232 polish(wardrobe): garment-detail cosmetic pass + slug-cleanup on upload
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>
2026-04-24 16:24:07 +02:00
Till JS
87b567eec9 i18n: fix IT/FR/ES parity gaps in dashboard + memoro
- 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>
2026-04-24 16:19:59 +02:00
Till JS
d49ad239d9 feat(writing): M8 — AI tools exposed through the shared catalog
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>
2026-04-24 16:19:30 +02:00
Till JS
cc51f0b4b9 chore(env): rotate GEMINI_API_KEY to the key that other Mana services use
Google returned `API_KEY_INVALID · API key expired` on the first
Wardrobe Nano Banana Try-On. The expired key (AIzaSyBR9iP74h...) had
been in .env.development for food image analysis and was carried over
to the new picture/generate-with-reference google/ branch.

Three other AIza keys live in the repo:
- AIzaSyCD50Yosl... — mana-llm/.env + .env.secrets (GOOGLE_API_KEY)
- AIzaSyC_-hPWpV... — .env.development PLANTA_GEMINI_API_KEY
- AIzaSyA0rTThrA... — .env.secrets + mana-research/.env + mana-auth/.env
                      (GOOGLE_GENAI_API_KEY), the one used by every
                      currently-running Gemini consumer.

Adopting the last one for GEMINI_API_KEY brings the whole monorepo
onto the same active credential. Probed `GET
/v1beta/models?key=…` against the new value → HTTP 200. `pnpm
setup:env` regenerated `apps/api/.env`; mana-api restarted to pick
up the fresh env.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 16:12:27 +02:00
Till JS
0dadc17771 feat(writing): M10 — publish hooks (export + save-as-article)
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>
2026-04-24 16:07:39 +02:00
Till JS
6432ef7e6b feat(comic): M4 — AI-Storyboard aus Cross-Modul-Text
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>
2026-04-24 16:06:03 +02:00
Till JS
8a882a3760 feat(wardrobe,picture): Google Nano Banana as a Try-On option
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>
2026-04-24 16:04:21 +02:00
Till JS
90915b7879 fix(visibility): promote picker to labeled rows across remaining modules
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>
2026-04-24 16:03:48 +02:00
Till JS
f2275f752d feat(writing): M11 — adopt the unified visibility system
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>
2026-04-24 16:01:05 +02:00
Till JS
ed8ec98572 feat(workbench): app picker searches English aliases + IT/FR/ES i18n
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>
2026-04-24 16:00:27 +02:00
Till JS
63f51799b0 feat(comic): M3 — Batch-Panel-Generierung (2–4 parallel)
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>
2026-04-24 15:59:17 +02:00
Till JS
bfa923dc22 feat(writing): M5 — cross-module references in the briefing
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>
2026-04-24 15:53:35 +02:00
Till JS
8c43c119ea fix(calendar): promote visibility picker to a labeled prop-row
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>
2026-04-24 15:52:59 +02:00
Till JS
98a68afc74 fix(calendar): visibility picker also in the Workbench DetailView
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>
2026-04-24 15:49:48 +02:00
Till JS
3551652612 feat(comic): M2 — UI + Single-Panel-Generierung
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>
2026-04-24 15:42:27 +02:00
Till JS
19e0f33665 feat(writing): M6 — selection-refinement tools
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>
2026-04-24 15:37:04 +02:00
Till JS
27c1860f82 feat(comic): M1 — Datenschicht + Modul-Registrierung
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>
2026-04-24 15:29:51 +02:00
Till JS
1c82a374fe feat(writing): M4 — style system with presets + custom styles
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>
2026-04-24 15:20:06 +02:00
Till JS
d725a8df8b feat(writing): M3 — one-shot prose generation via mana-llm
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>
2026-04-24 15:11:48 +02:00
Till JS
3c3b2ebbc7 feat(writing): M1+M2 — new Ghostwriter module with manual draft CRUD
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>
2026-04-24 14:59:56 +02:00
Till JS
259f6fb316 fix(shared-privacy): default all new records to 'space', not 'private'
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>
2026-04-24 14:46:48 +02:00
Till JS
800fc9ae5a refactor(picture,wardrobe): extract ImageLightbox, use in garment detail
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>
2026-04-24 14:37:58 +02:00
Till JS
9fbdc14869 feat(wardrobe,picture): symmetric wardrobeGarmentId FK + garment try-on strips
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>
2026-04-24 14:16:40 +02:00
Till JS
218cf45005 feat(wardrobe): M5.c — outfits adopt the unified visibility system
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>
2026-04-24 14:08:32 +02:00
Till JS
0e0d48acec feat(recipes): M5.b — recipes adopt the unified visibility system
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>
2026-04-24 14:04:14 +02:00
Till JS
2af2a4d5c0 feat(places): M5.a — places adopt the unified visibility system
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>
2026-04-24 13:59:15 +02:00
Till JS
0cebb2411e fix(wardrobe): prompt Ganzkörperfoto instead of Portrait for full try-on
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>
2026-04-24 13:56:29 +02:00
Till JS
95e85bdffd feat(goals): M4.c — goals adopt the unified visibility system
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>
2026-04-24 02:41:27 +02:00
Till JS
015a2c18ee feat(todo): M4.b — tasks adopt the unified visibility system
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>
2026-04-24 02:37:32 +02:00
Till JS
ac44d51363 feat(calendar): M4.a — events adopt the unified visibility system
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>
2026-04-24 02:32:25 +02:00
Till JS
0e9f574dfb feat(picture): M3 — boards adopt the unified visibility system (soft)
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>
2026-04-24 02:23:56 +02:00