Commit graph

3505 commits

Author SHA1 Message Date
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
Till JS
d5ae2f19b4 feat(library): M2 — adopt unified visibility system as the pilot module
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>
2026-04-24 02:08:29 +02:00
Till JS
49935c9628 feat(shared-privacy): M1 — visibility foundation package
Scaffold the unified visibility/privacy layer introduced by docs/plans/
visibility-system.md. No module adopts it yet — this is the foundation
PR (M1). Module rollout lands in follow-ups starting with Library (M2).

What ships:
- @mana/shared-privacy package
  - VisibilityLevel enum ('private' | 'space' | 'unlisted' | 'public')
  - VisibilityLevelSchema + UnlistedTokenSchema (zod)
  - defaultVisibilityFor(spaceType): personal → private, else → space
  - predicates: canEmbedOnWebsite, isReachableByLink,
    isVisibleToSpaceMember, canAiAccessCrossUser (always false in P1)
  - generateUnlistedToken() — 32-char base64url, CSPRNG, ~192 bits
  - VISIBILITY_METADATA: German labels + descriptions + phosphor icon
    names so non-UI surfaces (audit logs, CLI) label levels consistently
  - <VisibilityPicker> svelte component: compact lock/globe trigger with
    4-option menu, full descriptions, optional compact + disabledLevels
- VisibilityChangedPayload type for the domain-event catalog (consumer
  registers it when the first module adopts the system)
- .claude/guidelines/visibility.md — step-by-step for module authors
  (schema migrations + store wiring + picker placement + embed resolver +
  legacy isPublic migration), with a pre-PR checklist
- Plan-doc "Offene Fragen" section rewritten as "Designentscheidungen"
  with the seven resolutions the user approved
- CLAUDE.md: shared-privacy listed in the packages table; visibility.md
  listed in the guidelines table
- 15 unit tests covering predicates (one-and-only-one 'public' for
  embed; phase-1 AI always-deny), defaults (personal vs multi-member,
  null fallback), token uniqueness + schema round-trip

Key constraints honored:
- `visibility` stays plaintext (NOT added to the encryption registry)
  so RLS predicates and publish resolvers can read it without the user's
  master key
- Publish flow remains "decrypt client-side, inline plaintext into
  snapshot" — the pattern picture.board already uses in embeds.ts
- Deny-by-default everywhere (personal default = private; unknown space
  type defaults to private; cross-user AI always false)

Not in this PR (per plan):
- No schema migrations in any module (M2–M6)
- No RLS predicate updates (arrives with M2)
- No /settings/privacy overview (M7)
- No unlisted share routes (M8)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:59:11 +02:00
Till JS
91fd88e77d fix(picture): normalize Try-On refs to clean RGB PNG before OpenAI call
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>
2026-04-24 01:55:00 +02:00
Till JS
e66654068f feat(auth): error-classification layer + passkey end-to-end
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>
2026-04-24 01:52:51 +02:00
Till JS
b204958007 feat(picture): fall back to gpt-image-1 when gpt-image-2 org-unverified
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>
2026-04-24 01:50:21 +02:00
Till JS
15beddeda9 fix(picture): use image[] array syntax for multi-ref gpt-image-2 edits
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>
2026-04-24 01:43:01 +02:00
Till JS
f9ca6ca44b chore(branding): drop wardrobe tier to guest for local testing [LOCAL PATCH]
Wardrobe landed with requiredTier: 'beta' — correct for production,
but in the local dev loop the user's account is 'standard' and
clicking into /wardrobe/garment/[id] or /wardrobe/compose gets blocked
by the AuthGate with "Dein Zugang: Standard · Benötigt: Beta".

Flip to 'guest' to match the existing local tier-patch pattern that
already covers 55 of the 63 apps (see git log for earlier patches,
and memory note `project_tier_patch_resolved`).

Marked inline with `// LOCAL TIER PATCH — revert to 'beta' before
release` so the pre-release grep sweep finds it alongside the others.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:31:28 +02:00
Till JS
2b5a7b1a46 refactor(wardrobe): lift upload zone to top, move intro behind help icon
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>
2026-04-24 00:09:56 +02:00
Till JS
ca2809da89 docs(plans): visibility-system — unified 4-tier privacy model (planning)
Write up the design for a repo-wide visibility layer before building. Today
the state is fragmented: 7 modules carry ad-hoc isPublic booleans (picture,
cards, presi, memoro, times, broadcast.audience, uload.tags) with
inconsistent semantics and mostly no UI; the majority of modules (library,
calendar, todo, places, events, recipes, goals, habits, quiz, wardrobe,
invoices-clients, …) have nothing. Spaces only carry member permissions,
no public tier. The existing encryption layer (27 encrypted tables) is not
a blocker — embeds.ts already demonstrates "decrypt client-side, inline
plaintext into the publish snapshot".

Design:
- @mana/shared-privacy package with `VisibilityLevel = 'private' | 'space'
  | 'unlisted' | 'public'`, a `<VisibilityPicker>` svelte component, and
  predicate helpers (canEmbedOnWebsite, isVisibleToSpaceMember, …)
- Per-record `visibility text not null default 'private'` on public-capable
  tables only; `unlistedToken`, `visibilityChangedAt`, `visibilityChangedBy`
  alongside. Field stays plaintext so RLS + publish resolvers can read it
  without the user's master key
- Default-per-space-type: personal → private, team/club → space. Never
  public/unlisted by default
- Embed resolvers gate hard on `canEmbedOnWebsite`; user filters (tags,
  status, date window) stack on top, never replace
- RLS predicate extended: `space_member OR visibility='public' OR
  (visibility='unlisted' AND unlisted_token matches header)`

Rollout (soft-first / hard-follow-up per existing migration convention):
M1 shared package · M2 library (pilot) · M3 picture (replaces isPublic) ·
M4 calendar + todo + goals · M5 places/events/recipes/habits/quiz/wardrobe
/invoices · M6 legacy-flag consolidation · M7 /settings/privacy overview
+ kill-switch · M8 (optional) unlisted share links.

Out of scope: per-user sharing, field-level visibility, visibility
cascading, time-boxed public, search-indexing by default. Documented
explicitly so the first implementer doesn't reopen these.

No code yet — waiting on user go-ahead before starting M1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 23:50:17 +02:00
Till JS
4093b91a34 fix(profile): setPrimary no longer overwrites face-ref with avatar
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>
2026-04-23 23:38:59 +02:00
Till JS
62267f3d3e feat(wardrobe): upload feedback + success confirmation on face-ref banner
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>
2026-04-23 23:29:03 +02:00
Till JS
81c0d8cfc0 feat(website): session-scoped undo/redo for the editor
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>
2026-04-23 23:21:42 +02:00
Till JS
4f40fdafae fix(mana-web): copy packages/website-blocks into the build context
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>
2026-04-23 23:19:02 +02:00
Till JS
00ddd1e4ea fix(api/profile): send credentials cross-origin so Better-Auth updateUser works
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>
2026-04-23 23:16:43 +02:00
Till JS
aeba23f772 feat(profile,wardrobe,picture): inline me-image upload instead of deep-link
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>
2026-04-23 23:08:13 +02:00
Till JS
bb8e7c207e feat(onboarding): M5 — settings re-trigger
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>
2026-04-23 23:05:26 +02:00
Till JS
aa7909076c feat(website): full-bleed editor + unified sidebar with Seiten/Einfügen/Block tabs
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>
2026-04-23 23:03:19 +02:00
Till JS
1198d01263 feat(onboarding): M4 — Screen 3 (Templates) + finish handler
- 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>
2026-04-23 23:03:00 +02:00
Till JS
d1ac8a6ea9 feat(onboarding): M3 — Screen 2 (Look — theme mode + variant)
- 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>
2026-04-23 22:58:47 +02:00
Till JS
5aecf8b90d feat(onboarding): M2 — route guard + shell + Screen 1 (name)
- 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>
2026-04-23 22:49:52 +02:00
Till JS
5a92e1168b feat(onboarding): M1 — data model + endpoints + client store
- 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>
2026-04-23 22:24:49 +02:00
Till JS
bdd4e05446 feat(website): reorder blocks via up/down arrows in inspector
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>
2026-04-23 22:24:00 +02:00
Till JS
25314200b2 fix(wardrobe): strip route-idiom wrapper from ListView so it fits both shells
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>
2026-04-23 21:53:19 +02:00
Till JS
c404db5b6e fix(website): publish failed with uuid type error on Better-Auth ids
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>
2026-04-23 21:43:57 +02:00
Till JS
645993db01 feat(webapp): register wardrobe in workbench app-registry
The Kleiderschrank module shipped end-to-end (M1–M5 + M4.1) but was
never surfaced on the workbench homepage — it was reachable only via
direct /wardrobe URLs. This adds the tile so users can add it to a
scene and open it from the launcher like every other module.

- apps.ts: registerApp({ id: 'wardrobe', name: 'Kleiderschrank',
  color: #e11d48, icon: CoatHanger }) — list view loads
  $lib/modules/wardrobe/ListView.svelte (tab switcher Kleidung /
  Outfits). Detail routes stay SvelteKit-based
  (/wardrobe/garment/[id], /wardrobe/outfit/[id],
  /wardrobe/compose/[[outfitId]]) so the workbench only needs the
  root list slot.
- categories.ts: wardrobe → 'creative' (next to picture, library,
  playground, quiz).

Color matches the shared-branding entry in mana-apps.ts. Icon is the
phosphor CoatHanger (there is no bare "Hanger" in phosphor-svelte).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 21:32:13 +02:00
Till JS
36c427d17e fix(scope): align scope filter with guest-mode write hook
getInScopeSpaceIds() used getCurrentUserId() (null for guests), so
guest-created rows stamped `_personal:guest` by the write hook
became invisible — empty scene, "App hinzufügen" silently no-op'd
because activeSceneIdState resolved to null.

Switch to getEffectiveUserId() so the read filter always matches
what the hook stamps. Four regression tests cover guest-only,
signed-in-no-space, non-personal active space, and personal-sentinel-
is-active collapsing to a single id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 21:25:29 +02:00
Till JS
e0820331b0 feat(wardrobe): solo-garment try-on + plan-doc status updates (M4.1)
Closes the one checklist item M4 left for later — "TryOnButton auf
DetailGarmentView (mit impliziten 'Solo-Outfit')". A user can now open
a single garment's detail page, see "An mir anprobieren · 10 Credits",
and get an inline preview of themselves wearing just that one item
(or just that accessory, for glasses/jewelry/hat/accessory).

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 21:14:35 +02:00
Till JS
f20ace0358 test(website): broad automated coverage across the builder surface
83 new tests across 5 files — pure-logic, fast, run on every
push. Caught one real bug + motivated one small refactor.

Coverage:

- apps/mana/.../website/constants.test.ts (8): isValidSlug + RESERVED_SLUGS
  + isValidPath. Caught the 1-char-slug bug (regex allowed length 1;
  UI + plan say min 2). Fixed the regex in both the webapp and the
  mirrored server list.
- apps/mana/.../website/publish.test.ts extended (8 total): adds
  self-parent cycle, 3-level nesting, all-orphans, empty-input cases
  on top of the original determinism + orphan-drop tests.
- apps/mana/.../website/templates.test.ts (7): parameterised over each
  of the 4 bundled templates — clone produces fresh UUIDs, page +
  block counts match, navConfig populated. Plus unknown-template and
  duplicate-slug rejection. Container-nesting is punted to the smoke
  test (none of the bundled templates use columns yet).
- packages/website-blocks/src/schemas.test.ts (38): every block
  (11) + sanity-checks (defaults satisfy own schema, enum + length
  bounds, required fields). Pure Zod — no Svelte runtime needed.
- packages/website-blocks/src/themes/themes.test.ts (12): preset
  parity, resolveTheme overrides, themeCssVars output format +
  heading-font fallback.
- apps/api/src/modules/website/reserved-slugs.test.ts (10): mirror of
  the client tests for the server SSOT, plus new hostname validation
  cases (.mana.how reservation, length, malformed edges).

Refactor:

- apps/api/src/modules/website/reserved-slugs.ts now owns
  isValidHostname + RESERVED_HOSTNAMES. domains.ts imports them.
  Pure functions live next to the other pure validators; easier to
  test + share.

All 83 new tests green. Web-app svelte-check + apps/api type-check
both clean. Existing publish.test.ts / website-blocks tests still
pass (the monorepo-wide count is now well above 83 — these are
the new ones from this commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 21:07:40 +02:00
Till JS
66bfcb3996 feat(webapp): wire task tool into Companion chat + Mission runner (M3.3)
Closes the M3 sub-agent loop. Both webapp consumers of runPlannerLoop
now expose the `task` tool to their planner LLM and route matching
calls to a session-bound sub-agent handler.

Pattern (identical in both files):

  1. Hoist the regular tool dispatcher into a local `dispatchTool`
     so both the main loop AND the sub-agent executor can share it.
     The parent's guardrail, executor, actor attribution, and
     domain-event emission happen exactly once — sub-agent tool
     calls route through the same function.

  2. Build a per-session taskHandler via createTaskToolHandler()
     with parentDepth=0 (sub-agents themselves refuse to recurse)
     and model=google/gemini-2.5-flash-lite (cheap tier —
     sub-agents are summarisation-heavy, no reason to burn primary
     budget on them).

  3. toolsWithTask = [...regular tools, TASK_TOOL_SCHEMA].

  4. onToolCall branches on `call.name === TASK_TOOL_NAME` →
     taskHandler.handle; else dispatchTool. Both return
     ToolResult, loop doesn't care which route was taken.

Companion:
  - parentTools = AI_TOOL_CATALOG (full catalog)
  - Token tracking via taskHandler.cumulativeUsage() available if
    we later want to attribute sub-agent tokens to a companion-
    session counter

Mission runner:
  - parentTools = availableTools (agent-policy-filtered)
  - Sub-agent inherits the same filter — a research sub-agent in a
    mission that already had policy:deny on `list_events` still
    can't see `list_events`, defense-in-depth
  - runToolCall still gets aiActor → sub-agent tool executions are
    attributed to the same mission/iteration as the parent

mana-ai deliberately NOT wired: its onToolCall is a no-op recorder
(plans get staged, executed client-side on sync). Sub-agents there
would produce no value since the sub-agent couldn't execute tools
either, just plan. When the tool-registry fully absorbs AI_TOOL_CATALOG
(Personas-plan M4), mana-ai will get sub-agent support in that same
migration.

No new tests — shared-ai's 107 tests cover the primitive + handler
exhaustively. Existing 31 companion+mission tests remain green;
svelte-check clean across 7427 files.

Completes M3. runPlannerLoop now has Claude-Code's four big patterns:
policy-gate (M1) / reminder-channel (M1) / parallel-reads (M1) /
compactor (M2) / sub-agents (M3).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 19:14:36 +02:00
Till JS
101af462a8 feat(shared-ai): LLM-facing task tool wrapper for runSubAgent (M3.2)
Exposes runSubAgent() as a tool the planner LLM can call natively,
matching Claude Code's `Task` tool shape: { subagent_type, description,
prompt } -> single-string summary.

New exports from @mana/shared-ai:

  - TASK_TOOL_NAME = 'task'
  - TASK_TOOL_SCHEMA — ToolSchema ready to drop into a runPlannerLoop
    `tools` array. subagent_type enum = research|plan|general;
    description+prompt required; defaultPolicy: 'auto' (control-flow,
    not a user-data write).
  - createTaskToolHandler(opts) — factory returning:
      - handle(call): structured ToolResult with the sub-agent's
        summary as message + data {subAgentType, toolsCalled,
        rounds, stopReason, usage}
      - cumulativeUsage(): rolled-up TokenUsage across all sub-agent
        invocations — parent budget accounting reads from here
      - invocationCount(): metric-ready counter

Why not in mana-tool-registry: `task` is a loop-internal control-flow
primitive, not a user-data operation. Registry is for habits/notes/etc.
where MCP exposure and space-scoping matter. task never touches mana-
sync and never crosses the MCP boundary.

Recursion guard is defense-in-depth: the primitive throws
SubAgentRecursionError, this handler catches parentDepth >=
MAX_SUB_AGENT_DEPTH up front and returns a structured ToolResult
instead so the LLM sees it as regular tool-feedback.

Exceptions from the sub-agent (provider down, network) get wrapped
as `{ success: false, message: 'Sub-agent failed: ...' }`. The parent
loop's round continues.

14 new tests covering schema shape, recursion rejection, argument
validation (4 cases), happy path with tool dispatch, cumulative
usage tracking across multiple invocations, exception wrapping,
and parent-dispatcher routing.

107 shared-ai tests green total (was 93).

M3.3 consumer wiring follows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 19:05:09 +02:00
Till JS
7e3f53f8a5 feat(tool-registry): wardrobe.* MCP tools — listGarments/listOutfits/createOutfit/tryOn (M5)
M5 of docs/plans/wardrobe-module.md — exposes the Wardrobe feature
through the shared tool-registry so MCP clients (Claude Desktop)
and the mana-ai mission runner can browse, compose, and try on
outfits alongside the built-in UI. Follows the pattern M5 of the
me-images plan established in packages/mana-tool-registry/src/
modules/me.ts — encrypted reads via mana-sync pull + client-side
filter on `row.spaceId === ctx.spaceId`, writes via pushInsert
with encryptRecordFields, HTTP proxy for the try-on endpoint.

Four tools in packages/mana-tool-registry/src/modules/wardrobe.ts:

- wardrobe.listGarments(category?, tags?, limit?) — read. Pulls
  wardrobeGarments from mana-sync, filters to the active space,
  decrypts name/brand/color/size/material/tags/notes, applies
  optional category + intersection-tag filters, caps at 200 rows
  (50 default). Archived + soft-deleted items excluded.

- wardrobe.listOutfits(occasion?, favoriteOnly?, limit?) — read.
  Same shape, filters by occasion (closed enum, plaintext —
  unencrypted filter) and favorite. garmentIds arrive plaintext
  so the agent can immediately resolve them via listGarments when
  it needs more than ids.

- wardrobe.createOutfit({ name, garmentIds, occasion?, tags?,
  description? }) — write. Encrypts name/description/tags, pushes
  an insert tagged with ctx.spaceId. No cross-space validation of
  the garmentIds — the calling agent is expected to have called
  listGarments first; dangling refs surface visually in the UI
  rather than as a hard server error.

- wardrobe.tryOn({ outfitId, prompt?, accessoryOnly?, quality? }) —
  write (consumes credits). Biggest tool of the set: pulls the
  outfit, its garments, and the caller's meImages in three
  separate mana-sync pulls, resolves the primary face-ref +
  body-ref, auto-detects accessoryOnly from garment categories
  (FACE_ONLY_CATEGORIES: accessory/glasses/jewelry/hat), composes
  refs respecting the 8-slot server cap, composes a default DE
  prompt from the outfit name + occasion, and proxies to
  /api/v1/picture/generate-with-reference with the user's JWT.
  Returns the resulting image's URL + mediaId + prompt + model.

  Deliberately does NOT persist a picture.images row or update
  outfit.lastTryOn from the tool — those live on the client's
  imagesStore / wardrobeOutfitsStore and doing them server-side
  would race with a user who's also looking at the outfit page.
  Agents use tryOn as a preview/inspection primitive; the user
  commits from the UI.

Types: 'wardrobe' added to the ModuleId union. registerWardrobeTools
wired into registerAllModules — mana-mcp's createMcpServerForUser
iterates the registry and exposes any user-space tool automatically.

Credit model: quality defaults to 'medium' (10 credits per render),
same tarif as text-to-image generation. The agent pays for the
generation out of the calling user's credit balance via the
standard validateCredits/consumeCredits chain on the server endpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:59:31 +02:00
Till JS
66b7e08df2 feat(shared-ai): runSubAgent() primitive — Claude-Code I2A pattern (M3.1)
New packages/shared-ai/src/planner/sub-agent.ts implementing the
"one level deep, fresh messages, restricted tools, single-string
return" sub-agent contract from Claude Code's KN5/I2A launcher.

Four invariants enforced at the primitive level:

  1. FRESH messages[] — parent's history never leaks in. The sub-agent
     only sees its own system prompt + the task description. Hundreds
     of scanned files stay inside the sub-agent.
  2. RESTRICTED tool-whitelist — parent's full catalog is filtered
     per SubAgentType ('research' = auto-policy only, 'general' =
     everything, 'plan' = auto-policy + 3-round cap). Custom filter
     overrides the type default.
  3. SINGLE RETURN VALUE — sub-agent returns summary:string for
     the parent to render as task-tool-result. Individual tool calls
     stay in rawResult for debug capture but never cross the boundary.
  4. ONE LEVEL DEEP — MAX_SUB_AGENT_DEPTH = 1. parentDepth >= 1 throws
     SubAgentRecursionError; the consumer task-tool handler will
     also check, this is defense-in-depth.

Model is required (no default) — routing to a cheaper tier like the
compactor does is an explicit decision, not a sneaky default.

Belt-and-suspenders wrapper on onToolCall rejects any tool call
whose name isn't in the whitelist, even if the LLM fabricates one.

14 new tests covering recursion guard, tool filtering per type,
custom filter, whitelist rejection, fresh-messages isolation, usage
roll-up, default summary on max-rounds, type-specific system prompt,
system-prompt override, and end-to-end tool-call -> result -> summary.

93 shared-ai tests green total (was 79).

M3.2 (task tool in registry) and M3.3 (consumer wiring) follow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:59:05 +02:00
Till JS
d56ad396d8 feat(wardrobe,picture): try-on integration — outfit → OpenAI edit (M4)
M4 of docs/plans/wardrobe-module.md — the loop closes. A user with at
least a face-ref in the active space can click "Anprobieren" on an
outfit detail page; the client composes a reference call against the
existing M3 `/generate-with-reference` endpoint, persists the result
into the Picture gallery with a `wardrobeOutfitId` back-reference,
and pins a `lastTryOn` snapshot on the outfit so its card instantly
shows the AI preview next time.

Server side — picture/routes.ts:
- verifyMediaOwnership now accepts `apps: string | readonly string[]`.
  Under the hood it runs one list() per app-tag and unions the owned
  set before the missing-id check. Preserves the 500-row per-app
  sanity cap. Single-tag callers unchanged — it's an additive widen.
- Picture /generate-with-reference passes `['me', 'wardrobe']` so
  face/body portraits (me-images) and garment photos (wardrobe) can
  ride in the same referenceMediaIds array. Anything outside those
  two tags still 404s — no expansion of the trust surface.

Client side — wardrobe/api/try-on.ts:
- `runOutfitTryOn({ outfit, garments, faceRefMediaId, bodyRefMediaId?, ... })`
  composes the ref list (face → body → up to 6 garments, respecting
  the 8-slot server cap), picks portrait 1024x1536 by default (or
  1024x1024 in accessory-only mode), and POSTs with
  `model='openai/gpt-image-2'`, `quality='medium'`, `n=1`. One render
  per click; multi-variant is a future Generator-style extension.
- Default prompts are composed in DE from the outfit meta (name +
  occasion); callers can override via `prompt`. Accessory-only mode
  uses a tighter studio-portrait phrasing since the fullbody ref is
  dropped there.
- `isAccessoryOnlyOutfit()` helper — iff every garment is in
  FACE_ONLY_CATEGORIES, skip body-ref and render square. Covers the
  Brille-Try-On headline use case.
- On success: inserts a `picture.images` row with generationMode=
  'reference', referenceImageIds, and wardrobeOutfitId set; then
  calls wardrobeOutfitsStore.setLastTryOn() with imageId + imageUrl
  so OutfitCard + DetailOutfitView immediately flip to the AI cover.

TryOnButton — wardrobe/components/TryOnButton.svelte:
- Three states: ready (click to render), missing-references (shows
  UserCircle + link to /profile/me-images, with the right hint for
  accessory-only vs. fullbody), loading (spinner).
- Credit estimate on the button (10c medium quality).
- Hints: accessory-only, too-many-garments (>6, over server cap),
  and non-personal-space disclosure — the family-space case gets its
  own sentence since "Try-On rendert dich, nicht dein Kind" is
  non-obvious.
- Reads face-ref/body-ref via useImageByPrimary (space-scoped after
  the v40 meImages migration — brand/club/family spaces need their
  own references uploaded).

UI wiring:
- DetailOutfitView replaces the M3 stub button with <TryOnButton/>.
  The existing "Try-On Verlauf"-Strip already reads
  `useOutfitTryOns(outfit.id)` which filters `picture.images` by
  wardrobeOutfitId — it lights up automatically on first render.

Not in M4 (punted to follow-ups):
- Solo-garment try-on on DetailGarmentView ("nur diese Brille auf
  mein Gesicht"). Plan called it out as optional; the outfit flow
  already covers it when the outfit contains only that one garment.
- Multi-variant rendering (n=2/4). Usable "show me 3 looks" needs a
  picker UI on top, not just a param bump.
- Quality + prompt override in the button. A power-user panel can
  come later; default medium + auto-prompt keeps M4's click-to-try-on
  one-tap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:52:57 +02:00
Till JS
2b89bf7955 feat(wardrobe): outfits composer + detail page + tab switcher (M3)
M3 of docs/plans/wardrobe-module.md — layers outfit composition on top
of M2's garment grid. Users can now combine their garments into named
outfits, see them in a second tab under /wardrobe, open a per-outfit
detail page, and edit via the same composer route.

Routes:
- /wardrobe/compose — empty composer, creates a new outfit
- /wardrobe/compose/[outfitId] — composer pre-populated with an
  existing outfit, saves back into it (SvelteKit optional-param
  `[[outfitId]]` folder name). Both wrap OutfitComposer in
  `{#key outfitId ?? 'new'}` so create→edit navigation cleanly
  re-mounts with the right initial state.
- /wardrobe/outfit/[id] — outfit detail; wrapped in `{#key id}`
  for the same reason as the garment detail route.

Components:
- OutfitCard — grid tile. Cover precedence: lastTryOn.imageUrl
  (M4 payload) → 2×2 garment-thumbnail collage → empty state.
  Shows name + "<n> Stücke · <occasion>" line + favorite heart
  overlay when set.
- OutfitComposer — two-column editor. Left: garments grouped by
  category with +/✓ overlay toggles and a scroll container capped
  at 70vh so the right-hand editor doesn't disappear below the
  fold on long libraries. Right: name + description + occasion
  dropdown + season pill-toggles + comma-tags + composition chips
  with hover-× to remove. Click-to-add (no drag-drop — simpler
  mental model, keyboard-accessible for free, 100% of the
  workflow covered).
- OutfitsView — sibling to GridView, renders the outfit grid and
  the "+ Neues Outfit" CTA. Shows a garments-first empty state
  when the user has no clothing at all, an outfit-only empty state
  when they do but haven't composed anything yet.
- DetailOutfitView — cover + metadata card + "Zusammenstellung"
  grid (each garment tile links back to its own detail page).
  Try-On button is a stub for M4 ("kommt bald"); the Try-On
  history strip reads from picture.images via the existing
  useOutfitTryOns query and renders once M4 starts writing those
  back-references.

ListView now toggles between Garments (GridView, default) and
Outfits (OutfitsView) tabs; local state, lost on hard reload,
kept across in-app navigation.

Types: OutfitTryOn gains `imageUrl: string` (mana-media URL cached
alongside the picture.images.id pointer). Needed so the OutfitCard
renders the try-on thumb with one HTTP round-trip instead of a
Dexie→picture.images→mana-media lookup chain. Source of truth
remains the picture.images row; this is just a cache.

No M1 data shape breaks — only additive field on OutfitTryOn and
that type wasn't used anywhere in shipped code yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:45:21 +02:00
Till JS
441f95697b docs(website): smoketest walkthrough + manual-test-backlog entry
The code is shipped (M1–M7) but nothing has run against real
Postgres + mana-sync + mana-media + a browser. This smoke-test doc is
the click-through a human needs to do before we trust the feature in
production.

- docs/plans/website-builder-smoketest.md — 10 scenarios end-to-end
  from migrations + dev-stack through create/publish, block coverage
  (image upload, gallery lightbox, columns container), forms with
  honeypot + rate-limit, moduleEmbed with public-flag enforcement,
  templates + AI tools, subdomain rewrite, custom-domain DNS verify,
  rollback + analytics, metrics + GC script, edge-cases + security.
  Lists bekannte Limits (CF SaaS gap, target-delivery, AiProposalInbox)
  explicitly so the tester knows what NOT to expect.

- docs/optimizable/manual-test-backlog.md — new release-blocker entry
  pointing at the walkthrough. Follows the same format as the Shared
  Space + Data Export entries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:42:42 +02:00
Till JS
507af32bcc docs(plans): spec for per-Space destructive-tools opt-in
Bau-Plan für die M1-Polish-Lücke: heute lehnt evaluatePolicy() jeden
destructive-Call ab weil settingsFor() hardcoded { allowDestructive: [] }
zurückgibt. In POLICY_MODE=enforce würde das alle User von destructive
Tools aussperren. Bisher kein Problem — es gibt keine destructive Tools
in der Registry. Sobald das erste kommt, greift dieser Plan.

Kernentscheidungen:

  - Scope: per-SPACE, nicht per-User. Passt zum Space-scoped-data
    model; ein Admin opt-in'd, alle Members des Spaces profitieren.
  - Authority: Server-authoritative. Nicht in Dexie. JWT-gated
    PUT, Role-Check (nur owner/admin dürfen ändern), RLS.
  - Storage: eigene Tabelle mana_spaces.space_policy_preferences
    plus append-only space_policy_audit für Diff-Tracking. NICHT
    JSON-Column auf spaces — typisiert, indexierbar, mehr Raum für
    spätere per-Space-Rate-Limits etc.
  - Fail-closed: wenn mana-mcp apps-api nicht erreicht, wird
    destructive geblockt. 30s TTL-Cache, kurz genug für Revoke-Speed.
  - Acknowledgement enforced at API: PUT verlangt acknowledged:true
    wenn neue Tools zur Liste. Anti-Click-Through by construction.

Inkludiert:
  - Schema + RLS (Postgres)
  - GET/PUT/audit-GET + interner service-key-GET
  - SpacePolicyClient-Pattern in mana-mcp (wie MasterKeyClient)
  - UI /s/:space/settings/ai-policy mit Audit-Section
  - Metriken-Erweiterung (policy-changes counter)
  - Rollout-Reihenfolge + Tests + offene Fragen

Bau-Trigger: erster PR der ein Tool mit policyHint:'destructive'
in die Registry bringt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:39:56 +02:00
Till JS
4966ca69f0 feat(tool-registry): add mood module (log/today/recent)
Third encrypted module in @mana/tool-registry, brings the registry to
16 tools across 7 modules. Lets the Anna / Sofia / Maya personas
(whose moduleMix puts mood at 20–30 %) actually exercise their
daily-tracking routine when the runner ticks.

Three tools, all encrypted per the web-app registry
(moodEntries: entry<LocalMoodEntry>(['withWhom', 'notes'])):

- mood.log
    Write a mood entry. `level` 1–10, `emotion` + `secondaryEmotions`
    from the taxonomy copied verbatim from apps/mana/.../modules/mood/
    types.ts (keep in sync if new emotions/activities get added). date
    + time default to server-clock now; personas logging
    retrospectively pass them explicitly.

- mood.today
    Return every entry for today (or `{ date }`) sorted by time.
    Multiple entries per day are normal — the web app timelines them.

- mood.recent
    Last N days (default 7), newest first. Useful for
    self-reflection turns like "how has your week been?".

Scope decisions

Calendar was on the shortlist but dropped: `events` writes couple to
`timeBlocks` (a separate table/appId), so one tool call becomes two
sync pushes with a shared transaction concern — worth a careful
session, not a drive-by. Goals dropped because `companionGoals` is
owned by the Companion Brain, not a regular module, and has no clear
mana-sync appId convention. Both candidates for a focused follow-up.

Verified

- `pnpm run validate:all` green (crypto registry 202/202, encrypted-
  tools audit 9/9 including the 3 new mood tools)
- type-check across tool-registry + mcp + runner green
- registerAllModules → 16 tools, 7 modules:
    habits: create/list/update/archive
    journal: add 🔐
    me: listReferenceImages 🔐 / generateWithReference
    mood: log 🔐 / today 🔐 / recent 🔐
    notes: create 🔐 / search 🔐
    spaces: list
    todo: create 🔐 / list 🔐 / complete

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:39:00 +02:00
Till JS
5a49bcbf02 feat(wardrobe): garments UI — /wardrobe + /wardrobe/garment/[id] (M2)
M2 of docs/plans/wardrobe-module.md — the first interactive surface on
top of the M1 data layer. Users can now upload photos, browse their
garment grid filtered by category, and edit/archive/delete individual
items. Outfits (M3) and Try-On (M4) are still placeholders.

Route:
- /wardrobe — grid view with active-space badge in the intro card
  (identical pattern to /profile/me-images since the pool IS per-
  space). Category tabs across the top: "Alle" + eleven categories
  with live counts. Dropping files while a category tab is active
  creates garments with that category preselected; dropping on
  "Alle" defaults to `other` and the user edits on the detail page.
- /wardrobe/garment/[id] — detail view. Renders the primary photo
  + metadata card; a pencil toggles into GarmentForm for inline
  edit. Three actions: "Heute getragen" (bumps wearCount + stamps
  lastWornAt, prominent primary button), Archive, and Delete with
  confirm. The route wraps DetailGarmentView in `{#key id}` so
  navigating between different garments cleanly remounts the
  liveQuery + form state.

Components:
- CategoryTabs — horizontal pill row with per-category count
  badges. Stays compact on mobile via overflow-x-auto.
- GarmentCard — tile with primary photo + name + brand + wear-
  count hint; click navigates to detail.
- GarmentForm — inline edit sheet (name, category, brand, color,
  size, material, tags comma-separated, notes, price+currency).
  Comma→array for tags because that's how most users think about
  them; the store normalizes on save.
- GridView — orchestrates queries, filter tabs, drop zone (reuses
  MeImageUploadZone from profile since it's already generic about
  what "files" mean), and the empty states (no garments at all vs.
  no garments in this category).

Small conveniences:
- api/upload.ts wraps the M1 POST /api/v1/wardrobe/garments/upload
  endpoint with fetchWithAuth; same shape as profile's me-images
  client (mediaId/storagePath/publicUrl/thumbnailUrl).
- api/media-url.ts — tiny mediaId → URL resolver using the same
  inline PUBLIC_MANA_MEDIA_URL pattern wallpaper and invoices/
  pdf/logo already use. Worth a shared helper later but premature
  while three call sites disagree on which variant to default to.
- constants.ts — CATEGORY_ORDER / CATEGORY_LABELS plus
  OCCASION_LABELS and SEASON_LABELS for M3 to pick up.

Svelte 5 note: GarmentForm's `$state(garment.xxx)` initializers
trip the state_referenced_locally check, but the intent is
correct — the parent uses `{#key id}` to remount on navigation,
so the captures are a feature, not a bug. Suppressed per-line
with `svelte-ignore` and a comment pointing at the remount
mechanism.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:37:38 +02:00
Till JS
d518169ce9 feat(website): M7 — observability + analytics + GC + M2-polish
Closes the plan. Prometheus metrics across the website endpoints, a
cookieless analytics block users can opt in to, a read-only orphan-
asset scan script, plus two M2 debts (rollback UI + determinism test).

apps/api:
- New /metrics endpoint (unauth; internal-network only via reverse proxy).
  Scrape with the existing Prometheus config that already covers mana-ai.
- lib/metrics.ts with prom-client Registry and default-metrics prefix
  `mana_api_`. Website-specific counters/histograms:
    website_publish_total{result=success|slug_taken|invalid|error}
    website_publish_duration_seconds (Histogram)
    website_submissions_total{result=received|spam|rate_limit|not_found|invalid}
    website_host_resolve_total{result=hit|miss|error}
    website_domain_verify_total{result=verified|failed}
    website_public_reads_total{result=hit|not_found}
    website_public_read_age_seconds (Histogram — age of served snapshot)
- Instrument publish.ts, submit.ts, public-routes.ts, domains.ts with
  .inc() calls on every code path.

packages/website-blocks:
- New `analytics` block: Plausible + Umami support with self-hosted
  script-URL override. Hidden in edit/preview, emits exactly one
  <script> in public mode. No cookies, no PII. Registered in block-
  registry; 11 blocks total now.

apps/api/scripts/gc-website-assets.ts:
- Read-only scan: walks published_snapshots.blob + submissions.payload
  for /api/v1/media/{id}/ references, asks mana-media for items scoped
  to app=website, flags orphans older than 30d. Writes report to
  /tmp/gc-website-assets-<ts>.json. Deletion toggle is a future commit.

apps/mana/apps/web:
- RollbackDialog component + PublishBar integration. Closes the M2
  debt "Rollback funktioniert" (API + store were there; UI was missing).
- publish.test.ts: snapshot determinism + orphan-drop tests. 4/4 pass.

docs:
- observability/website.md: metric reference, PromQL queries, alert
  suggestions, Grafana dashboard pointer.
- plans/website-builder.md: M7 checklist updated (Per-site-stats +
  submission-retention explicitly deferred with reason), shipping log
  table completed with all M1→M7 commits.

Validation:
- apps/mana/apps/web: pnpm check → 0 errors 0 warnings
- apps/api: tsc --noEmit → clean
- website-blocks tsc → clean
- publish.test.ts → 4/4 pass

Note: validate:all's check:crypto fails on unrelated WIP (wardrobe
module's Dexie tables aren't classified yet in encryption-registry).
Pre-existing failure, not introduced by this commit — the pre-commit
lint-staged run does NOT include check:crypto so it doesn't block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:30:49 +02:00
Till JS
4fc9d6c59c feat(wardrobe): module foundation — garments + outfits space-scoped data layer (M1)
M1 of docs/plans/wardrobe-module.md — pure data layer + backend plumbing,
zero UI (that's M2). A user can now hold a digital wardrobe per space:
brand merch, club Trikots, family Kleiderschrank, team Kostüme, practice
Dresscode, and personal closet all live as separate pools under the same
Dexie tables, space-scoped like tags/scenes/agents after Phase 2c.

Data model — two tables, no join:

- wardrobeGarments (Dexie v41): single clothing items / accessories.
  Indexed on `category` + `createdAt` + `isArchived`. Encrypted:
  name/brand/color/size/material/tags/notes. Plaintext: category,
  mediaIds, counters, timestamps — all indexed or structural.
  `mediaIds[0]` is the primary photo used for try-on; additional
  ids are alternate views (back, detail) for M7.

- wardrobeOutfits (Dexie v41): named compositions referencing
  garment ids. Encrypted: name/description/tags. Plaintext:
  garmentIds (FK array), occasion (closed enum — useful for
  undecrypted filtering), season, booleans, lastTryOn snapshot.

- picture.images gains `wardrobeOutfitId?: string | null` as a
  plaintext back-reference. Try-on results land in the Picture
  gallery like any other generation; the outfit detail view
  queries them via this id rather than maintaining a third table.

Space scope:

- `wardrobe` added to all five explicit allowlists in shared-types/
  spaces.ts (personal is wildcard, no edit needed). Each space type
  gets a one-line comment explaining the real-world use case.
- App registry: `wardrobe` entry in shared-branding/mana-apps.ts
  with a rose→fuchsia gradient icon (T-shirt on hanger silhouette),
  color #e11d48, tier 'beta', status 'beta'.
- Module registry: wardrobeModuleConfig imported + appended to
  MODULE_CONFIGS so SYNC_APP_MAP picks it up automatically.

Backend:

- MAX_REFERENCE_IMAGES bumped 4 → 8 in picture/generate-with-
  reference (plus the client-side default in ReferenceImagePicker).
  Justified with a comment: face + body + top + bottom + shoes +
  outerwear + 2 accessories = 8. Cost doesn't scale with ref count
  (OpenAI bills per output), so the bump is a pure capability
  expansion with no credit-side risk.
- New POST /api/v1/wardrobe/garments/upload wraps uploadImageToMedia
  with app='wardrobe'. Registered under /api/v1/wardrobe in index.ts.
  Pattern 1:1 with the profile/me-images/upload endpoint; tier-gating
  falls out of wardrobe NOT being in RESOURCE_MODULES (tier='guest'
  works — consistent with picture's plain CRUD).

Stores emit domain events (WardrobeGarmentAdded, WardrobeOutfitCreated,
WardrobeOutfitTryOn, etc.) so later mana-ai missions can observe
activity without polling.

No UI in this commit. M2 (Garments-Grundlayer) wires the route + grid
+ upload-zone; M3 the Outfit composer; M4 the Try-On integration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:27:37 +02:00
Till JS
f7536bc0b9 feat(shared-ai): route compactor to Haiku-tier model by default (M2.5)
compactHistory() now defaults to DEFAULT_COMPACT_MODEL =
'google/gemini-2.5-flash-lite' when the caller doesn't override. Lite
is ~3–5x cheaper than gemini-2.5-flash with near-identical
summarisation quality — summarisation doesn't need the same tier as
reasoning + tool-calling, and the compactor fires exactly when token
spend is highest, so the cheaper route saves exactly where it matters.

CompactHistoryOptions.model is now optional. All three consumers
(mana-ai tick, webapp Companion, webapp Mission runner) drop their
explicit gemini-2.5-flash override and let the default apply.

This is the pragmatic M2.5: no mana-llm changes. The "tier" abstraction
(X-Model-Tier header, env-routed aliases) from the Claude-Code report
makes sense only once multiple utility tasks need cheaper routing —
topic-detection, classification, command-injection checks. Today only
the compactor wants it, and a model constant is the simplest contract
that works.

2 new tests (default applied + override honoured). 79 shared-ai tests
green, all three consumers type-check clean. One pre-existing unrelated
type error in apps/mana/apps/web/src/lib/modules/wardrobe/queries.ts
(not touched by this commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:26:50 +02:00
Till JS
2769241de3 docs(plans): agent-loop plan reflects M1 shipped + M2 core shipped
Update the plan doc to match reality:

  - Title + intro: "M1 + M2 (core)" instead of just M1.
  - Exit criteria: mark the two achievable ones DONE with commit
    refs; flag POLICY_MODE=enforce soak as ops-blocked; correct the
    parallel-read-speedup criterion that was misformulated (mana-ai
    SERVER_TOOLS are all propose-policy, so parallelisation
    actually kicks in on the webapp side, covered by 54a12ffd5).
  - New M2 section: 5-row status table (M2.1-M2.4 + bonus shipped;
    M2.5 Haiku-tier pending).
  - M2 config table (MANA_AI_COMPACT_MAX_CTX).
  - M2 metrics listed (compactions_triggered_total, compacted_turns).
  - Open polish items: allowDestructive still hardcoded to [].

No code changes. Future sessions reading the plan now see the
actual shipped surface instead of a stale M1-only snapshot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:12:58 +02:00
Till JS
d68dbc9c6d docs(optimizable): add MCP + personas live-smoke to manual-test-backlog
Two entries:

- **MCP gateway + Persona-runner — end-to-end live smoke** (🟠)
    Covers M1+M1.5+M2+M3 commits. Unit tests verified ~2600 LOC at
    the type/shape level, but nothing has ever talked to a real
    Postgres + mana-auth + Anthropic. 11-step recipe walks through
    seed → tick → verify in psql, including the encryption-on-wire
    check (enc:1: prefix in sync_changes, plaintext in web app).

- **Persona visual regression — capture first baselines** (🟡)
    Depends on the smoke run above succeeding (empty personas produce
    meaningless baselines). Eyeball-check step is explicit — the
    first PNG IS the reference, no CI can catch "baseline was wrong".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:12:06 +02:00