getInScopeSpaceIds() used getCurrentUserId() (null for guests), so
guest-created rows stamped `_personal:guest` by the write hook
became invisible — empty scene, "App hinzufügen" silently no-op'd
because activeSceneIdState resolved to null.
Switch to getEffectiveUserId() so the read filter always matches
what the hook stamps. Four regression tests cover guest-only,
signed-in-no-space, non-personal active space, and personal-sentinel-
is-active collapsing to a single id.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the one checklist item M4 left for later — "TryOnButton auf
DetailGarmentView (mit impliziten 'Solo-Outfit')". A user can now open
a single garment's detail page, see "An mir anprobieren · 10 Credits",
and get an inline preview of themselves wearing just that one item
(or just that accessory, for glasses/jewelry/hat/accessory).
Client:
- api/try-on.ts: extracts a shared callGenerateWithReference() helper
and a dimsForSize() utility from runOutfitTryOn so the new
runGarmentTryOn can share the HTTP-error matrix + picture.images
row shape without a refactor of the outfit path.
- runGarmentTryOn({ garment, faceRefMediaId, bodyRefMediaId?, prompt?,
quality? }): auto-detects accessoryOnly from the garment's category
(FACE_ONLY_CATEGORIES), composes the DE default prompt ("im/in
<Name>", "mit <Name>" für Accessoires), writes a picture.images row
with wardrobeOutfitId=null so it doesn't pollute any outfit's
try-on history. Does NOT update any outfit.lastTryOn — it's a
standalone preview, on purpose.
- GarmentTryOnButton.svelte: thinner sibling of TryOnButton. Same
three states (ready / missing-refs / loading), same non-personal-
space disclaimer. Extra: inline preview panel showing the last
rendered result, with a link to the Picture gallery ("Gefunden in
der Picture-Galerie als normale Generierung.").
- DetailGarmentView now puts the try-on action above the existing
wear-tracking button. Try-on is the more engaging action for this
page; demoting "heute getragen" to a secondary-styled button
respects that without removing it.
Plan docs:
- docs/plans/wardrobe-module.md — rewrites the Status block to M1-M5
with actual commit hashes, and checks off the per-milestone task
lists. Adds a new M4.1 block for solo-garment try-on.
- docs/plans/me-images-and-reference-generation.md — adds the v40
space-scope migration (cb9a9bb42) as its own row in the commit
table, with a pointer to the sub-plan.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
83 new tests across 5 files — pure-logic, fast, run on every
push. Caught one real bug + motivated one small refactor.
Coverage:
- apps/mana/.../website/constants.test.ts (8): isValidSlug + RESERVED_SLUGS
+ isValidPath. Caught the 1-char-slug bug (regex allowed length 1;
UI + plan say min 2). Fixed the regex in both the webapp and the
mirrored server list.
- apps/mana/.../website/publish.test.ts extended (8 total): adds
self-parent cycle, 3-level nesting, all-orphans, empty-input cases
on top of the original determinism + orphan-drop tests.
- apps/mana/.../website/templates.test.ts (7): parameterised over each
of the 4 bundled templates — clone produces fresh UUIDs, page +
block counts match, navConfig populated. Plus unknown-template and
duplicate-slug rejection. Container-nesting is punted to the smoke
test (none of the bundled templates use columns yet).
- packages/website-blocks/src/schemas.test.ts (38): every block
(11) + sanity-checks (defaults satisfy own schema, enum + length
bounds, required fields). Pure Zod — no Svelte runtime needed.
- packages/website-blocks/src/themes/themes.test.ts (12): preset
parity, resolveTheme overrides, themeCssVars output format +
heading-font fallback.
- apps/api/src/modules/website/reserved-slugs.test.ts (10): mirror of
the client tests for the server SSOT, plus new hostname validation
cases (.mana.how reservation, length, malformed edges).
Refactor:
- apps/api/src/modules/website/reserved-slugs.ts now owns
isValidHostname + RESERVED_HOSTNAMES. domains.ts imports them.
Pure functions live next to the other pure validators; easier to
test + share.
All 83 new tests green. Web-app svelte-check + apps/api type-check
both clean. Existing publish.test.ts / website-blocks tests still
pass (the monorepo-wide count is now well above 83 — these are
the new ones from this commit).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the M3 sub-agent loop. Both webapp consumers of runPlannerLoop
now expose the `task` tool to their planner LLM and route matching
calls to a session-bound sub-agent handler.
Pattern (identical in both files):
1. Hoist the regular tool dispatcher into a local `dispatchTool`
so both the main loop AND the sub-agent executor can share it.
The parent's guardrail, executor, actor attribution, and
domain-event emission happen exactly once — sub-agent tool
calls route through the same function.
2. Build a per-session taskHandler via createTaskToolHandler()
with parentDepth=0 (sub-agents themselves refuse to recurse)
and model=google/gemini-2.5-flash-lite (cheap tier —
sub-agents are summarisation-heavy, no reason to burn primary
budget on them).
3. toolsWithTask = [...regular tools, TASK_TOOL_SCHEMA].
4. onToolCall branches on `call.name === TASK_TOOL_NAME` →
taskHandler.handle; else dispatchTool. Both return
ToolResult, loop doesn't care which route was taken.
Companion:
- parentTools = AI_TOOL_CATALOG (full catalog)
- Token tracking via taskHandler.cumulativeUsage() available if
we later want to attribute sub-agent tokens to a companion-
session counter
Mission runner:
- parentTools = availableTools (agent-policy-filtered)
- Sub-agent inherits the same filter — a research sub-agent in a
mission that already had policy:deny on `list_events` still
can't see `list_events`, defense-in-depth
- runToolCall still gets aiActor → sub-agent tool executions are
attributed to the same mission/iteration as the parent
mana-ai deliberately NOT wired: its onToolCall is a no-op recorder
(plans get staged, executed client-side on sync). Sub-agents there
would produce no value since the sub-agent couldn't execute tools
either, just plan. When the tool-registry fully absorbs AI_TOOL_CATALOG
(Personas-plan M4), mana-ai will get sub-agent support in that same
migration.
No new tests — shared-ai's 107 tests cover the primitive + handler
exhaustively. Existing 31 companion+mission tests remain green;
svelte-check clean across 7427 files.
Completes M3. runPlannerLoop now has Claude-Code's four big patterns:
policy-gate (M1) / reminder-channel (M1) / parallel-reads (M1) /
compactor (M2) / sub-agents (M3).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
Flips `meImages` out of USER_LEVEL_TABLES so it lives under the same
tenancy model as every other data table (tags, scenes, tasks, …).
Precursor to the Wardrobe module, which is space-scoped across all
six space types — leaving meImages user-global would leave an
inconsistency where the Wardrobe catalog is per-space but its
reference input is cross-space, plus a latent privacy leak in shared
spaces (agents in a brand-space would see the owner's entire pool).
Plan: docs/plans/me-images-space-scope-migration.md.
Key decisions:
- Strict scope, no cross-space fallback. Switching into a brand-space
with no uploaded face shows an empty state and links back to
/profile/me-images; it does not quietly reach into the personal-
space pool. Keeps the mental model clean.
- auth.users.image remains pinned to personal-space primary-avatar.
Only a primary change inside personal space triggers the Better
Auth sync; brand/club/family/team/practice primaries stay local.
- Single Dexie v40 upgrade: stamps `spaceId=_personal:<uid>`
sentinel, `authorId=<uid>`, `visibility='space'` on every existing
row and drops the legacy `userId` column. Dexie upgrades block app
startup, so by the time the new code's scopedForModule reads run,
every row is already space-stamped. reconcileSentinels() on the
next active-space bootstrap rewrites `_personal:<uid>` to the real
personal-space id, same path v28 used.
- Legacy-avatar migration (M2.5) now pins its row to
`_personal:<uid>` explicitly — the legacy avatar is the user's
global SSO identity and belongs in the personal space even if the
migration happens to fire while the user is in a brand space.
Code changes:
- types.ts: LocalMeImage gains spaceId/authorId/visibility (all
optional — stamped by hook). Public MeImage exposes spaceId for
queries that want to branch on space type.
- database.ts: meImages out of USER_LEVEL_TABLES; new v40 upgrade
block that stamps sentinels + drops userId in one pass.
- queries.ts: all four hooks (useAllMeImages, useMeImagesByKind,
useReferenceImages, useImageByPrimary) read via scopedForModule.
Scope-switch triggers automatic re-render via the existing
scopedTable filter path.
- stores/me-images.svelte.ts: setPrimaryInTx uses scopedForModule so
a setPrimary in Brand-space never clears Personal-space's holder.
syncAvatarToAuth gates on activeSpace.type==='personal' so non-
personal primary changes don't leak into Better Auth.
createMeImage accepts optional spaceId override — the legacy-
avatar migration uses it, regular uploads let the hook stamp the
active space.
- migration/legacy-avatar.ts: explicitly passes
spaceId=_personal:<uid> to pin the legacy row into personal space.
- MeImagesView.svelte: subtle badge in the intro card shows the
active space ("Persönlich" for personal, space name otherwise) so
users notice when the pool changes on space switch.
- packages/mana-tool-registry/src/modules/me.ts: me.listReferenceImages
filters pulled rows by row.spaceId === ctx.spaceId. mana-sync
returns all spaces the user belongs to; the tool only wants the
active space's subset.
No schema/index change on meImages (non-indexed fields, pool size
small enough for in-memory scopedTable filter). If perf matters
later, adding [spaceId+kind] is a 5-minute follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Symmetrical to 83a4606a9 which wired the compactor into mana-ai. Both
webapp consumers of runPlannerLoop (Companion chat engine, Mission
runner) now pass a compactor that folds the middle of messages into
a <compact-summary> when cumulative token usage hits 92% of
maxContextTokens.
COMPACT_MAX_CTX is a module constant — gemini-2.5-flash's 1M-token
ceiling — not env-wired. Vite builds for the browser and PUBLIC_*
flags are the wrong tool for a value that only matters to the loop
runtime; changing the model means changing the constant alongside the
model reference anyway.
Uses the same LlmClient + model as the planner's own calls. A cheaper
compactor-tier model (Haiku) is the optional M2.5 follow-up and does
not require changing this wiring — only the compactHistory `opts.model`
gets swapped.
Type-check clean (svelte-check 0 errors 0 warnings across 7389 files).
All 31 companion + mission tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SvelteKit hook + new DB table + founder-gated API + UI section. Ships
the code path for public-site routing on {slug}.mana.how and custom
hostnames. Cloudflare SaaS Hostnames integration is stubbed — see
plan §M6 "Offene Enden".
apps/api/src/modules/website:
- schema.ts: new `customDomains` table. Fields: id, site_id, hostname
(unique), status (pending | verifying | verified | failed),
verification_token, dns_target, verified_at.
- drizzle/website/0002_custom_domains.sql: manual migration with
partial unique index on (hostname) WHERE status='verified'.
- domains.ts (new, authenticated + founder-gated via
`requireTier('founder')`): POST/GET/DELETE /sites/:id/domains,
POST /sites/:id/domains/:domainId/verify. Verify runs CNAME + TXT
checks via node:dns/promises with an apex-domain A-record fallback.
Reserved-hostname list prevents users from binding mana.how subdomains.
- public-routes.ts: new GET /public/resolve-host?host= — unauthenticated
resolver used by hooks.server.ts. Returns { slug, siteId } only for
verified bindings tied to a currently-published site.
apps/mana/apps/web/src/hooks.server.ts:
- After the existing https/app-subdomain guards, a new
`resolveWebsiteRewrite()` step rewrites `event.url.pathname`:
{slug}.mana.how/path → /s/{slug}/path (pure string)
custom-host.com/path → /s/{resolved}/path (API call, 60s LRU)
- Browser URL stays on the custom host — this is a server-side rewrite,
not a 302. APP_SUBDOMAINS + RESERVED_WEBSITE_SUBDOMAINS win over
website routing. Localhost and apex mana.how are skipped.
apps/mana/apps/web/src/lib/modules/website:
- domains.ts (new): typed client for list/add/verify/remove. Handles
200 + expected 400 (verification-failed) separately.
- components/DomainsSection.svelte: add-input, per-domain status pill,
DNS-instructions box (CNAME + TXT with copy-to-clipboard), Verify
button. Mounted inside SiteSettingsDialog as its own section — the
existing theme/footer controls stay put.
docs/plans/website-builder.md:
- M6 checklist updated with what shipped vs. ops-gap (CF SaaS).
- `mana-landing-builder` consolidation: DECIDED to keep parallel. Four
reasons in the plan. Revisit-criterion stated.
- Shipping log table seeded with M1→M6 commits.
Validation:
- pnpm run validate:all: 6/6 gates green
- pnpm run check (web): 0 errors, 0 warnings
- apps/api type-check: green
Apply schema with:
psql "$DATABASE_URL" -f apps/api/drizzle/website/0002_custom_domains.sql
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two things:
1. AI tools (9) in the website module — writes go through the standard
proposal flow, reads run auto during planning.
- shared-ai/src/tools/schemas.ts: AI_TOOL_CATALOG entries with
defaultPolicy propose/auto.
- webapp modules/website/tools.ts: execute functions wired to the
existing stores. ModuleTool[] registered in data/tools/init.ts.
- Propose: create_website, apply_website_template, create_website_page,
add_website_block, update_website_block, publish_website
- Auto: list_websites, list_website_pages, list_website_blocks
Server-side mana-tool-registry integration (mana-mcp, mana-ai) is
a M5.x follow-up — webapp flow unblocks the missions-based use case.
2. Starter templates — clone into a fresh site with new UUIDs.
- templates/types.ts: SiteTemplate shape with localId / parentLocalId
so container→child references survive the clone.
- 4 templates: portfolio (4 pages), personal-linktree (1 page, 6 CTAs),
event (3 pages incl. RSVP form), blank (1 empty page). Deferred:
smb-corporate + product-landing (need team/pricing/testimonials
blocks, M6+).
- sitesStore.applyTemplate: walks template, bulk-inserts new rows,
remaps parent refs. Sets navConfig items from template pages.
- TemplatePicker component + /website/new route. Replaces the old
quick-create modal; ListView now links to /new. AppRegistry
context-menu action points there too.
AiProposalInbox integration deferred — the component doesn't exist in
the webapp yet (the plan mentions it aspirationally). defaultPolicy
'propose' is already set so writes stage correctly once the UI catches
up.
Validation:
- pnpm run validate:all: 6/6 gates green
- pnpm run check (web): 0 errors, 0 warnings
- apps/api + packages/shared-ai type-check: green
Plan: docs/plans/website-builder.md (M5 shipped)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three edge-level fixes applied live to the Mac Mini today, now
committed so the canonical state matches:
1. apps/mana/apps/web/Dockerfile: add COPY for @mana/shared-crypto
(added recently as a workspace dep but the Dockerfile missed it,
so pnpm install failed with ERR_PNPM_WORKSPACE_PKG_NOT_FOUND on
every rebuild — same class as the shared-types / shared-ai /
shared-rss fixes earlier today).
2. docker-compose.macmini.yml (mana-web service): set
PUBLIC_MANA_RESEARCH_URL + PUBLIC_MANA_RESEARCH_URL_CLIENT. Without
this pair the SSR-injected window.__PUBLIC_MANA_RESEARCH_URL__ was
empty and research fetches 404'd against the current origin.
3. docker-compose.macmini.yml (umami service): pin image to
postgresql-v2.18.0. The rolling `postgresql-latest` tag jumped to
Umami 3.1.0 (Next.js 16) which crashed the container on every
POST /api/send — browser page loaders hung up to 10s on the
failing tracker request. v2.18.0 is the last known-stable v2;
DB schema is still v2-compatible so the downgrade is clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hard-follow-up to M1's soft Dexie schema landing (plan
docs/plans/me-images-and-reference-generation.md). After this commit
the source of truth for the avatar is meImages(primaryFor='avatar');
auth.users.image becomes a derived mirror that gets pushed back to
Better Auth whenever the primary changes.
Changes:
- New migration/legacy-avatar.ts: one-shot, idempotent bootstrap. On
first visit to /profile/me-images it reads profile.image via
profileService.getProfile() and writes a single meImage with
kind='face', primaryFor='avatar', usage.aiReference=false. The
mediaId is a sentinel `legacy-avatar:<uid>` — the original bytes
never went through mana-media, so verifyMediaOwnership (M3) will
naturally bounce if the user ever flips aiReference on without
re-uploading. Guarded per user via localStorage +
existing-avatar-holder check so reruns are no-ops.
- Store avatar autosync: setPrimary and deleteMeImage now push
meImages(primaryFor='avatar').publicUrl back to
profileService.updateProfile({ image }). The avatar slot is
coupled to face-ref — setting a new face-ref primary also claims
the avatar on the same row, so users don't need a second UI
control to keep their profile picture fresh. Failures are logged
but swallowed; meImages stays authoritative for in-app rendering.
- MeImagesView triggers the migration once on mount.
- EditProfileModal replaces the broken inline avatar upload (the old
POST /api/v1/storage/avatar/upload endpoint never existed in the
unified API) with a read-only preview + a button that closes the
modal and navigates to /profile/me-images. Name + email flows are
untouched.
- profileService.uploadAvatar + AvatarUploadResponse + its test are
deleted (no callers left after the modal rewrite).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds two new block types and the server-side infrastructure for
untrusted input + cross-module data embedding.
Forms:
- packages/website-blocks/src/form: declarative fields (text, email,
tel, url, textarea, number) with required / maxLength / placeholder
per field. Honeypot hidden input in the renderer; public-mode POST
to a same-origin SvelteKit proxy that forwards to mana-api.
- apps/api: website.submissions table (schema.ts + 0001_submissions.sql)
+ POST /public/submit/:siteSlug/:blockId. Loads the current published
snapshot, finds the form block, validates payload against its
declared fields (trim, type check, length cap), rejects honeypot
submissions silently, rate-limits per IP (10 / 5 min) in-memory.
Unknown keys are dropped — clients can only submit declared fields.
- Owner-facing: GET/DELETE /sites/:id/submissions + SubmissionsView
component + /(app)/website/[siteId]/submissions route. Shows
incoming submissions with status pill + payload preview + delete.
- apps/mana/.../routes/s/[siteSlug]/__submit/[blockId]/+server.ts:
same-origin proxy so form posts don't trigger CORS and IP / user-
agent headers are forwarded via SvelteKit's trusted getClientAddress.
M4 first-pass does NOT wire target-module delivery (contacts / notify).
Submissions stay in the inbox until owner-side tool handlers land
(M4.x). `target` enum is intentionally `['inbox']` only for now.
moduleEmbed:
- packages/website-blocks/src/moduleEmbed: source dropdown
(picture.board | library.entries), max-items, layout (grid | list),
optional filter object. The `resolved` field on props is populated at
publish time by the editor-side resolver — public renderer reads it
directly, no Dexie / API round-trip needed.
- apps/mana/.../website/embeds.ts: per-source resolvers. picture.board
enforces `isPublic=true`; library.entries respects filter.isFavorite
/ kind / status so owners can expose a subset (e.g. "my favorites").
- buildSnapshot() walks the tree after assembly and fills in
block.props.resolved for every moduleEmbed. Publish slower, public
visits fast. No cross-service call at render time.
Validation:
- pnpm run validate:all: 6/6 gates green
- pnpm run check (web): 0 errors, 0 warnings
- apps/api type-check: green
Apply Postgres with:
psql "$DATABASE_URL" -f apps/api/drizzle/website/0001_submissions.sql
Plan: docs/plans/website-builder.md (M4 shipped)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Expands the builder from 3 M1 blocks to 8. Containers (columns) and
media blocks (image, gallery) are the structural additions; cta and faq
round out the content coverage.
packages/website-blocks:
- image, cta, faq, columns (container), gallery — each with Zod schema,
renderer (mode-aware for edit/preview/public), and fallback inspector.
- Block type extended with optional `children` + `renderChild` snippet
so containers render their children through the same chrome the
outer renderer provides (click-to-select, public-path tagging).
- themes/: 3 presets (classic light, modern dark, warm) with
`resolveTheme` + `themeCssVars` helpers. Public layout now emits
CSS vars via `style=` on the root; block components read
`var(--wb-primary)` / `var(--wb-bg)` / `var(--wb-fg)` / etc.
- Registry updated; new exports + `./themes` subpath export.
apps/mana/apps/web/src/lib/modules/website:
- upload.ts: multipart POST to mana-media with `app=website` scope,
returns { mediaId, url }. 25 MB cap, non-image rejection client-side.
- components/ImageInspector + GalleryInspector: app-side overrides
wired to upload. Registered via `CUSTOM_INSPECTORS` in BlockInspector
so block.type → app-side inspector, fallback to registry otherwise.
- components/SiteSettingsDialog: theme preset picker + color overrides
for primary/bg/fg + footer text. Mounted from a ⚙ button in the
editor's left pane.
- components/BlockRenderer: rebuilt around a byParent map + recursive
`renderBlock` snippet so container blocks can render their children
through the same click-to-select wrapper as top-level blocks.
- routes/s/[siteSlug]: rename `[[...path]]` → `[...path]` (SvelteKit
treats rest segments as optional automatically — double-bracket form
errored at sync time). +page.svelte renders snapshot trees
recursively so published pages match the editor.
apps/api: unchanged.
Validation:
- pnpm run validate:all: all 6 gates green
- pnpm run check (web): 0 errors, 0 warnings
- apps/api type-check: green
- website-blocks tsc: green
Plan: docs/plans/website-builder.md (M3 block shipped)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pairs with c94ab01c6 which added the real /metrics endpoint. Without a
scrape job the policy_decisions_total counter has nowhere to go and
the soak period is flying blind.
30s interval to match mana-ai. Same job shape as mana-ai — any Grafana
dashboard that auto-discovers services via labels will pick this up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the M3 loop from docs/plans/mana-mcp-and-personas.md. The
runner now picks up due personas, drives them through Claude + MCP
for one simulated turn, collects actions + ratings, and persists
them through service-key internal endpoints in mana-auth.
Internal endpoints (mana-auth, service-key-gated)
- GET /api/v1/internal/personas/due
Returns personas whose tickCadence + lastActiveAt say they're
due. Rules: hourly > 1h, daily > 24h, weekdays > 24h mon-fri.
NULLS FIRST so never-run personas go ahead of stale ones.
- POST /api/v1/internal/personas/:id/actions
Batch ≤ 500. Row ids are deterministic
(`${tickId}-${i}-${toolName}`) + ON CONFLICT DO NOTHING so the
runner can retry a tick without doubling audit rows. Also
bumps personas.last_active_at so the next /due call sees it.
- POST /api/v1/internal/personas/:id/feedback
Batch ≤ 100. Row id is `${tickId}-${module}` — natural key is
one rating per module per tick.
Runner tick pipeline (services/mana-persona-runner/src/runner/)
- claude-session.ts
Two phases per tick. runMainTurn feeds the persona's system
prompt + a German "simulate a day" user prompt to Claude Agent
SDK's query(), with mana-mcp wired in as a streamable-HTTP MCP
server. We iterate the returned AsyncGenerator and extract
tool_use blocks into ActionRows; tool_result with is_error=true
flips the most recent action. runRatingTurn is a fresh query()
with tools:[] asking Claude in character to rate each used
module 1-5 as strict JSON, which we parse with tolerance for
surrounding whitespace / fences. Unparseable output becomes a
synthetic '__parse' feedback row so operators see the failure.
- tick.ts
Orchestrator. Skips if config.paused. Fetches /due, processes
in batches of config.concurrency (Promise.allSettled so one
failure doesn't kill the batch), returns {due, ranSuccessfully,
failed[], durationMs}.
- types.ts
ActionRow and FeedbackRow shapes shared between claude-session
and the internal client; mirrors the mana-auth schema but in
narrow plain TS for the wire.
Runner bootstrap (src/index.ts)
- setInterval(config.tickIntervalMs) starts the tick loop on boot.
tickInFlight guards against overlap when Claude latency > interval.
If MANA_SERVICE_KEY or ANTHROPIC_API_KEY is missing, loop is
disabled with a warn line — /health still works, /diag/login
still works.
- New dev-only POST /diag/tick fires a single tick on demand and
returns the result, so you can verify without waiting 60 s.
- Graceful SIGTERM/SIGINT shutdown clears the interval.
Client
- clients/mana-auth-internal.ts
X-Service-Key client for the three endpoints above. Constructor
throws if serviceKey is empty — fail loud, not silent.
Boot smoke: /health + /diag/tick both return descriptive 500s when
keys are absent, 200/JSON when present. Warning lines show up on
boot for missing keys. Type-check green across mana-auth, tool-
registry, mcp, persona-runner.
End-to-end smoke recipe (docker up → db:push → seed:personas →
diag/tick → psql) documented in
services/mana-persona-runner/CLAUDE.md. That's the M3 exit gate.
M2.d (cross-space family/team memberships) still deferred.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Enables the M1 parallel-reads optimisation on the webapp side. Both
consumers of runPlannerLoop pass an isParallelSafe predicate derived
from the tool catalog:
isParallelSafe: (name) =>
AI_TOOL_CATALOG_BY_NAME.get(name)?.defaultPolicy === 'auto'
Auto-policy tools (list_tasks, get_habits, nutrition_summary, …) run
via Promise.all in batches of 10 when the LLM fans them out in one
round. Propose-policy tools — which surface to the user as Proposal
cards — stay sequential so intent ordering in the inbox is preserved
and pre-execute guardrails can reason about prior-step state.
Tests: 31 existing companion + mission tests pass unchanged; the
parallel path is exercised via the new loop.test.ts cases shipped
with the M1 commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
M2 of docs/plans/me-images-and-reference-generation.md — the Settings
surface that sits on top of the M1 data layer. Users can now upload
a Face and a Fullbody reference into two primary slots, toss extra
references into a grid, and toggle each image's "KI darf nutzen" flag
individually.
Route placement: /profile/me-images (not /settings/me-images as the
plan originally proposed). The repo convention is per-module subroutes
(/todo/settings, /invoices/settings, …) — there is no global /settings
namespace to hang this off. Plan doc updated accordingly.
- MeImageUploadZone: drag-and-drop + file-picker, pattern from
picture/ListView but refactored into a reusable component. Fires
onFiles(File[]) so the parent decides kind + slot.
- MeImageSlotCard: large card for Face / Fullbody primary slots.
When filled it shows the portrait + the image's AI-toggle + delete
+ a compact "Neues Bild setzen" replacement zone. When empty it
collapses into a large drop-zone.
- MeImageTile: grid tile for everything that isn't currently holding
a primary slot — thumbnail, kind badge, Robot-AI-toggle, Star
primary-promotion (only enabled for kinds that map to a slot),
Trash delete.
- MeImagesView: orchestrates queries (useImageByPrimary for each
slot + useAllMeImages for the rest), upload flow (readDimensions →
uploadMeImageFile → store.createMeImage → optional setPrimary in
the same tick), and the three write actions (toggleAi, togglePrimary,
delete). Dropping a file on a slot drop-zone both uploads and claims
the slot, so the old holder automatically falls into the grid.
- Client: profile/api/me-images.ts wraps the M1 endpoint with
authStore.getValidToken() → Bearer header and a small
readImageDimensions helper that exposes natural width/height
synchronously (mana-media reports them later but we want them for
the Dexie row's first write).
- Discoverability: profile ListView "Konto" tab gains a "Meine Bilder"
action button that navigates to the new route with a one-line hint.
Still open (later commits): the hard-migration that rewrites
auth.users.image → meImages(primaryFor='avatar'), the global
aiUsesReferenceImages kill-switch (lives on profile singleton), and
the Picture-generator's Reference picker (M4, rides on top of M3's
backend endpoint).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
M1 of docs/plans/me-images-and-reference-generation.md — a user-owned
pool of reference images (face, fullbody, hands, …) that will back
image generation where the user appears as themselves (outfit try-on,
glasses, portraits) via OpenAI /v1/images/edits. Data layer only in
this commit; UI lands in M2, the edits endpoint in M3.
- Dexie v38: meImages table with id/kind/primaryFor/createdAt indices.
Added to USER_LEVEL_TABLES so the hook stamps userId and skips the
spaceId/authorId/visibility trio (one human = one face across every
Space, not per-Space).
- Encryption registry: label + tags encrypted; kind/primaryFor/usage
stay plaintext because they drive the indexed queries and the
Reference picker's filtering. mediaId/URLs/dimensions are structural.
- Profile module store: createMeImage, updateMeImage,
setAiReferenceEnabled (per-image KI opt-in — plan decision #5),
setPrimary (transactional slot swap — only one row per primary slot),
deleteMeImage. Emits MeImage* domain events.
- Queries: useAllMeImages, useMeImagesByKind, useReferenceImages
(only the rows the user opted in for KI), useImageByPrimary.
- POST /api/v1/profile/me-images/upload: thin wrapper over mana-media
with app='me' as the reference tag. No new MinIO bucket — plan
decision #1 revised after verifying mana-media uses one bucket and
only tags references by app.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two Playwright-based diagnostic scripts for investigating
production-only browser issues that curl can't reproduce:
- scripts/smoke-prod.mjs: loads mana.how like a fresh incognito
tab, waits a configurable budget, reports every console error,
request failure, still-pending request, and slow resource.
- scripts/smoke-prod-load.mjs: measures DOMContentLoaded + load
event timing explicitly. Distinguishes "app interactive" from
"browser tab spinner stops".
Run: `node apps/mana/apps/web/scripts/smoke-prod.mjs`
MANA_URL=https://mana.how/login MANA_WAIT_MS=45000 node ...
Used today to rule out server-side issues in a loader-hang report
that reproduced only in one specific browser profile.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Foundation for autonomous Claude-driven testing. Plan:
docs/plans/mana-mcp-and-personas.md.
New packages
- @mana/tool-registry — schema-first ToolSpec<InputSchema, OutputSchema>
with zod generics, scope ('user-space' | 'admin') and policyHint
('read' | 'write' | 'destructive'). sync-client helpers speak the
mana-sync push/pull protocol directly so RLS and field-level LWW are
preserved. MasterKeyClient fetches per-user MKs via the existing
mana-auth GET /api/v1/me/encryption-vault/key endpoint (JWT-gated,
ZK-aware, already audited) — no new service-key endpoint built.
ZeroKnowledgeUserError surfaced as a typed throw.
- @mana/shared-crypto — AES-GCM-256 primitives extracted from the web
app's $lib/data/crypto/aes.ts so the server-side tool handlers and the
browser produce byte-for-byte identical wire format
(enc:1:{b64(iv)}.{b64(ct)}). Web app aes.ts now re-exports from
shared-crypto — 5 existing importers unchanged, svelte-check stays
green.
New service
- services/mana-mcp (:3069, Bun/Hono) — MCP Streamable HTTP gateway.
JWKS auth against mana-auth, per-user session isolation (session-id
belongs to the user who opened it — cross-user access returns 403),
admin-scoped tools filtered out before registration. MasterKeyClient
cached per process with a 5-minute TTL.
11 tools registered
- habits.{create,list,update,archive}, spaces.list (plaintext, M1)
- todo.{create,list,complete}, notes.{create,search}, journal.add
(encrypted — field lists match
apps/mana/apps/web/src/lib/data/crypto/registry.ts verbatim)
Infra
- Port 3069 added to docs/PORT_SCHEMA.md
- services/mana-mcp/CLAUDE.md with architecture, auth model,
tool-authoring recipe, local smoke-test steps
- Root CLAUDE.md services list updated
Type-check green across shared-crypto, mana-tool-registry, mana-mcp.
svelte-check on apps/mana/apps/web stays at 0 errors / 0 warnings.
Boot smoke verified: /health returns registry.loaded=true, unauthed
/mcp → 401, invalid-JWT /mcp → 401 with descriptive message.
Decisions locked in for later milestones (per plan D1–D10):
- Personas will be real mana-auth users (users.kind='persona'), no
service-key bypass (D1, D2)
- Tool-registry is the SSOT; mana-ai and the legacy
apps/api/src/mcp/server.ts get merged into it in M4 (three current
parallel tool catalogs collapse to one)
- Persona-runner (:3070) will be a separate service using the Claude
Agent SDK + MCP client (D5)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
apps/api/package.json lists @mana/shared-ai and @mana/shared-rss as
workspace deps, but the Dockerfile's builder stage never copied their
source. pnpm silently skipped the symlinks, and bun hit ENOENT on every
articles / ai import at runtime. Same class as 70c62e758 (shared-logger
in mana-auth) and the shared-types fix one commit earlier.
Without this, any push that triggered a mana-api rebuild failed
health-check and cascaded mana-web offline via depends_on.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Merges the feature-rich gallery (search, tag filters, favorites toggle,
view-mode toggles, detail modal) that previously lived in
routes/(app)/picture/+page.svelte INTO modules/picture/ListView.svelte,
and keeps the upload affordances (drag-and-drop, upload button, progress
chips) from the old ListView.
Route shrinks to a 3-liner: <RoutePage appId="picture"><ListView /></RoutePage>.
Responsive behaviour uses CSS container queries (@container inline-size)
on the ListView root. Below ~560px (carousel card width) the search bar,
tag chips and view-mode toggles hide; action-strip buttons drop to
icon-only. Above that breakpoint (route context, ≥~720px up to the
layout's max-w-7xl) everything is visible.
Drag-over handler distinguishes file drags from cross-module drag data
via dataTransfer.types.includes('Files'), so the upload overlay only
appears for real file drops — workbench card-to-card drags pass through
to the wrapping AppPage's dropTarget.
Data source changes from context-based (getContext('allImages')) to
direct Dexie live-queries via ./queries, so the component works in both
the carousel (no layout context) and the route (layout still provides
context for /picture/archive and /picture/board).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every +page.svelte under routes/(app) now renders inside workbench-card
chrome. Before, sub-routes floated directly on the app-shell background
— card-style paper/border/shadow only existed on the homepage carousel,
leaving /library, /notes, /picture, /finance etc. visually disconnected
from the rest of the app.
Coverage:
- 28 SIMPLE routes (single <ListView /> wraps): <RoutePage appId="...">
- 43 top-level main routes: <RoutePage> with preserved internal markup
- 122 sub-routes (/X/[id], /X/new, /X/settings, …): <RoutePage> with
backHref pointing at the parent listing. Title overrides for detail
pages (e.g. "Rechnung", "Deck", "Eintrag").
- Articles tab children (/articles/list, /favorites, /highlights, /stats)
get explicit title overrides ("Artikel · Leseliste", etc.).
A handful of special cases:
- calc/standard: <svelte:window> hoisted outside RoutePage (Svelte forbids
window bindings inside component children).
- agents/templates: {#snippet templateCard} hoisted outside so both {#each}
blocks inside RoutePage can @render it via page-scope lookup.
- citycorners redirect-stubs (add/, locations/[id]/, map/): left unwrapped
— they onMount → goto() with no body to wrap.
- 3 carousel routes (/, /todo, /contacts) keep their PageCarousel wrapping
untouched — they already provide card chrome.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a third provider path to /api/v1/picture/generate that calls OpenAI
gpt-image-2 when model starts with "openai/". Supports n=1..4 batch
generation with character continuity, base64 response decoded server-side
and uploaded to mana-media for dedup + thumbnails. Credit cost scales
by quality (low=3, medium=10, high=25) × n.
Env plumbing:
- scripts/generate-env.mjs: new apps/api/.env stanza propagates
OPENAI_API_KEY + REPLICATE_API_TOKEN from .env.secrets
- .env.macmini.example: documents OPENAI_API_KEY for prod
Frontend /picture/generate: model + quality + aspect-ratio + batch-count
selectors, real fetch with auth, persists each image via imagesStore.insert
(encrypted + synced). Wrapped in ModuleShell variant=fill with back-arrow
to /picture and a live credit badge in the header actions slot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the old PageShell (workbench-only) with a single ModuleShell that
serves both carousel cards (variant=card, width-sized, window actions) and
sub-routes (variant=fill, fills main area, optional back button). RoutePage
wraps ModuleShell with auto-metadata lookup from the app-registry so every
(app)/*/+page.svelte can stay a three-liner.
Drops the dead onMinimize prop-drilling that was declared on PageShell but
never rendered — TodoPage/ContactPage callers cleaned up too.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 2e-followup originally shipped inside c413ab7dd (misattributed to
a test(mana-research) commit via lint-staged race). A later revert
(c31dcdd66) undid that commit, and the re-apply (3a7bc7f1c) only
restored the mana-research test files — dropping this at-rest-sweep
payload. This commit puts it back cleanly with the correct message.
- lib/data/crypto/at-rest-sweep.ts: post-vault-unlock one-shot sweep
that iterates every ENCRYPTION_REGISTRY table with enabled:true
and re-saves every row through encryptRecord(). Per-table
localStorage sentinel for idempotency; change-tracking suppressed
via beginApplyingTables so sync isn't flooded with re-encryption
writes. Fire-and-forget from the caller; idempotent inside each
row (isEncrypted gate in encryptRecord skips already-wrapped
fields).
- routes/+layout.svelte: after vaultClient.unlock() returns
'unlocked', dynamically import the sweep module and fire it. Same
lazy-load pattern the rest of the post-unlock wiring uses.
Plan doc's shipping-log entry stays pointed at c413ab7dd (the
original commit) since that's where the history trail starts, but
this commit is the one currently on main. Both are logged in the
attribution notes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Completes the "no table has both userId AND spaceId" invariant from
the space-scoped plan. Phase 2c-followup v35 cleaned the userId
column off data-record tables; this follow-up cleans the inverse off
user-level singleton tables (userSettings, invoiceSettings, …).
Hook change: user-level tables no longer receive spaceId / authorId /
visibility stamps on new writes. Those three fields are only
meaningful for tenant-scoped data; stamping them on user-level rows
was v28 collateral damage from the blanket migration.
Dexie v36 upgrade: deletes spaceId + authorId + visibility from
every row in the 11 user-level tables. No schema change — these
fields were never indexed on user-level tables, so .stores() stays
untouched.
Safety check before shipping: grep showed zero callers use
scopedTable(<user-level-table>) or .where('spaceId') against these
tables. They're queried directly by userId (via shared-stores or
singleton lookups), so dropping the space columns is a pure cleanup.
After this ships, user-level tables have {userId, …fields} and data
tables have {spaceId, __lastActor, …fields} — the invariant is
truly met app-wide.
Type-check clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The new roundtrip.test.ts uncovered that the importer only stripped
`userId` — after Phase 2c, data tables are scoped by `spaceId`
(sentinel `_personal:<userId>`), so cross-account restores left rows
bound to the source user's personal space and invisible under RLS.
Fix: strip `userId`, `spaceId`, AND `authorId` before bulkPut, so the
Dexie creating-hook re-stamps all three from the current session.
6 new orchestration tests: plain round-trip, scope-filter, cross-
account spaceId adoption, unknown-table skip, sealed round-trip,
wrong-passphrase rejection.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the mana-sync event-stream export (GET /backup/export) with a
fully client-driven `.mana` v2 archive: webapp reads Dexie, decrypts
per-field, packages JSONL + manifest, optionally PBKDF2+AES-GCM seals
with a passphrase.
- New: backup/v2/{format,passphrase,export,import}.ts + format.test.ts
(10 tests: round-trip, sealed path, 3 failure modes incl. wrong-
passphrase vs. tamper distinction).
- UI: ExportImportPanel with module multi-select, optional passphrase,
progress + sealed-file detection — replaces the old backup flow in
Settings → MyData.
- Removes services/mana-sync/internal/backup/ and the corresponding
client helpers + v1 tests. No parallel paths, no legacy shim.
- Why client-driven: zero-knowledge users hold their vault key only
client-side, so a server exporter cannot produce plaintext archives;
GDPR Art. 20 portability is better served by plaintext-by-default.
- Cross-account restore works via re-encryption under the target
vault key (no MK transfer needed).
DATA_LAYER_AUDIT.md §8 rewritten to reflect the new architecture.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Completes the userId cleanup Phase 2c left half-done. The creating-hook
(commit e9b9544ea) stopped stamping userId on new writes, but existing
rows still carried the column from v28 onwards — mixed state. This
migration removes the column from every data-record row and drops the
articles module's userId indexes that are now dead.
v35.stores():
Re-declares articles / articleHighlights / articleTags without
the `userId` index. Other indexes (status, savedAt, isFavorite,
siteName, originalUrl, [articleId+startOffset], [articleId+tagId])
stay identical.
v35.upgrade():
Iterates every SYNC_APP_MAP table that isn't on the USER_LEVEL list,
calls `.modify()` to `delete record.userId` on every row. User-level
tables (userSettings, userContext, newsPreferences, meditate/sleep/
mood/time/invoice/broadcast/wetterSettings, userTagPresets) keep
their userId — their ownership model is user-scoped by design.
The USER_LEVEL set is duplicated inside the upgrade closure because
the hook-registration loop (where the runtime USER_LEVEL_TABLES const
lives) hasn't run yet when the upgrade fires — Dexie applies upgrades
before we call `db.table(...).hook()`.
Public-type converters (tags-local's toTag/toTagGroup, calc's
toCalculation/toSavedFormula) already fall back to 'guest' / '' when
userId is absent, so the field's disappearance doesn't break
downstream reads.
After this ships, the "no table has both userId AND spaceId"
invariant from the plan is truly met on data records. User-level
tables still have both (v28 stamped spaceId onto them) but that's a
separate, lower-priority cleanup.
Type-check clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The real Gemini /v1beta/interactions/:id completed shape bit us once
already during the initial smoke-test (we had OpenAI-style nested
`output.message.content[]` coded; reality is a flat `outputs` array
of thought|text|image items, with url_citations that carry no title
and usage fields named `total_input_tokens` rather than `input_tokens`).
This test pins the parser against a synthetic fixture covering the
cases we saw in the wild plus the failure modes that are hard to
provoke from a live API call:
- status dispatch (queued, in_progress, failed, cancelled, incomplete)
- completed body concatenated across text items, skipping thought/image
- empty/missing `outputs` without crashing
- missing usage
- citations deduped by url, hostname extracted as title
- wrong-type annotations and those without url skipped
- real vertexaisearch redirect URLs Gemini emits
- fallback to url as title when the URL is unparseable
- trimming of leading/trailing whitespace
To make this testable I pulled the completed-branch of
pollGeminiDeepResearch into a standalone parseInteractionResponse
helper — same behaviour, now reachable without mocking global fetch.
Also adds the `test` script to package.json so `pnpm --filter
@mana/research-service test` works.
17 pass / 0 fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The creating-hook now splits its user-stamping behaviour by table:
- USER_LEVEL_TABLES (userSettings, userContext, newsPreferences,
meditateSettings, sleepSettings, moodSettings, timeSettings,
invoiceSettings, broadcastSettings, wetterSettings, userTagPresets)
still get userId stamped — these rows are primarily scoped to the
signed-in user rather than a Space.
- All other sync tables (the ~53 data-record tables) no longer
receive userId on new writes. Attribution is the Actor system's
job (__lastActor + __fieldActors are already stamped on every
write); tenancy is the spaceId column's job (stamped below in the
same hook). Keeping both userId and spaceId on data records was
redundant.
Migration approach — lenient, no Dexie bump: existing rows keep the
userId they were stamped with in v28. New writes don't have it. The
three public type converters that exposed userId (tags-local's
toTag/toTagGroup, calc's toCalculation/toSavedFormula) use a
`?? 'guest'` / `?? ''` fallback, so rows without userId stay
readable. The 16-site codebase audit in phase 2c found no load-
bearing reader: the few sites that reference record.userId are
either one-time migration code (v28/v31/guest-migration), manifest
metadata (backup format — different userId field), or the hook's own
immutability guard.
authorId stamping now derives from effectiveUserId directly instead
of reading objRecord.userId — the previous chain relied on the
userId stamp having just happened, which no longer holds for data
tables.
The "no table has both userId AND spaceId" invariant from the plan
is now partially met: data tables will converge on it as old rows
cycle out. User-level tables still have both but that's by design
(userId = ownership, spaceId = v28 Personal-sentinel carried through
the hook; a future cleanup could drop the spaceId on user-level
tables but it's harmless today).
Tests: 20/20 agents + workbench-scenes pass. Type-check clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Turns on at-rest encryption for the four tables staged in Phase 2a.
New writes now encrypt the user-typed fields; future code paths read
via decryptRecords as normal (the modules already call decrypt on
read, no changes needed).
Flipped:
- globalTags.name — tag names can leak categorization intent
- tagGroups.name — same
- workbenchScenes.name/description — scene labels often encode
Space-specific context
- aiMissions.title/conceptMarkdown/objective — mission configuration
is user-authored
Deliberately unchanged:
- color / icon / groupId / sortOrder / openApps / wallpaper /
scopeTagIds / cadence / state / agentId — all structural, indexed,
or FK data needed for query paths
- agents.name stays plaintext per the prior design note (Actor
displayName cache key)
Migration approach — pre-live lenient: decryptRecords skips values
that aren't encrypted (isEncrypted gate in record-helpers.ts:256), so
existing plaintext rows stay readable after the flip. New writes
encrypt; existing rows get encrypted organically as the user edits
them. No Dexie migration needed. A post-login "encrypt-at-rest
sweep" over pre-existing rows is a follow-up if hard at-rest coverage
is required before launch.
Crypto audit: 196 Dexie tables (95 encrypted, +4 vs 91 before),
101 allowlisted plaintext. Type-check clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the userTagPresets loop: users can now create, set-default,
and delete presets from Settings → Tag-Presets, making the dropdown
in SpaceCreateDialog actually useful (before this, it only showed
"empty" / "copy-current" because no presets existed).
New settings category "Tag-Presets":
- searchIndex.ts: adds the category entry + anchor; sidebar picks it
up automatically since it iterates `categories`.
- TagPresetsSection.svelte: list + create + delete + set-default.
- settings/ListView.svelte: conditional render wiring.
The create flow is deliberately one-click: name the preset, hit
"Aus <activeSpace.name> erstellen", and we snapshot every non-deleted
tag + tagGroup in the active Space into the new preset (with
groupName denormalized so the preset is space-independent). The first
preset automatically becomes the user's default — subsequent ones can
be promoted via the star button.
No full per-entry editor in this commit. If the user wants to tweak a
preset's contents, they create a sibling Space with the preset,
modify tags there, and promote THAT Space's tags to a new preset.
Scope-creep avoidance for a feature whose main value is snapshotting,
not authoring.
Type-check clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the Phase 2d.5a helpers (applyPresetToSpace,
copyTagsBetweenSpaces) into the new-Space UX so users get a familiar
tag taxonomy in every Space they create, without manual re-entry.
The dialog gains a "Tag-Set" dropdown:
- "Leer starten" — new Space starts without any tags
- "Aus <current> kopieren" — clones the user's active Space's
globalTags + tagGroups as a one-shot snapshot (fresh ids, no live
link back to the source)
- <named-preset> — applies a userTagPreset snapshot, creating tagGroups
for each distinct groupName so the user's familiar grouping carries
over
Default pick (when the dropdown first renders):
- If the user has a default preset → that preset
- Else if currently in Personal → "copy-current"
- Else "empty" (safer inside shared Spaces — don't leak Team/Family
taxonomy into a new one by default)
Seeding runs BEFORE the Space activation switches context, so
copyTagsBetweenSpaces still sees the source-Space's tags as
read-scope. Seeding failures are caught and logged but deliberately
non-fatal — the Space is already created, the user can seed later
from inside it.
`<select>` styling piggy-backs on the existing .field input/textarea
rules (extends the shared selector list instead of duplicating).
Type-check + Svelte a11y check clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
/(app)/invoices/[id] route bundle drops from **534 KB → 18.6 KB** by
moving PDF rendering behind dynamic imports.
Changes:
- views/DetailView.svelte: `await import('../pdf/renderer')` inside
renderPdf() + downloadPdf(), cached in a module-local ref.
- components/SendModal.svelte: same for openAndDownload().
- pdf/scor.ts (new): generateSCORReference extracted so the
invoices store can derive a reference string without pulling
swissqrbill/svg + pdf-lib into the list-view bundle.
- pdf/qr-bill.ts: re-exports generateSCORReference from scor.ts
for backward compatibility.
- stores/invoices.svelte.ts: imports from ../pdf/scor (light) instead
of ../pdf/qr-bill (heavy).
- index.ts: drop re-export of the PDF renderer from the module
barrel so `import ... from '$lib/modules/invoices'` never drags
pdf-lib in.
The heavy chunk (pdf-lib + swissqrbill, ~576 KB) now only loads when
a user actually opens an invoice detail — list views, create flow, and
all other routes stay lean.
20/20 qr-bill tests pass; svelte-check clean.
Bonus: scripts/audit-icon-usage.mjs (+ pnpm run audit:icon-usage)
audits @mana/shared-icons imports. Reveals 204 distinct icons across
the codebase, 199 of them at default weight but paying for all 6
Phosphor weights. Biggest offender: app-registry/apps.ts with 69
static icon imports accounting for ~290 KB of the shared 466 KB icon
chunk. Migration path for that is documented in
docs/optimizable/bundle-analysis.md §2 — next session's work.
docs/optimizable/bundle-analysis.md also updated with the root (app)
layout (260 KB) investigation notes (start/stop lifecycle hooks to
defer via idleCallback).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the two seeding helpers the Space-creation flow needs:
- applyPresetToSpace(presetId, targetSpaceId): one-shot-copies a
preset's frozen snapshot as fresh globalTags rows in the target
Space. Creates tagGroups for each distinct groupName so the user's
familiar grouping carries over. Not a live link — renaming the
preset afterwards doesn't rename applied tags.
- copyTagsBetweenSpaces(sourceSpaceId, targetSpaceId): duplicates
every non-deleted tag + tagGroup from one Space into another with
fresh ids. Powers the "copy tags from my current Space" option in
SpaceCreateDialog so solo-Space users don't have to build a named
preset before they inherit their existing taxonomy.
Both helpers explicitly stamp spaceId on every written row so the
write lands in the TARGET Space even while the caller's active-space
context is still the SOURCE Space (SpaceCreateDialog: create Space
→ apply preset → activate → reload). The Dexie creating-hook
normally stamps spaceId from getActiveSpaceId(); pre-populating it
makes the hook's `if undefined/null` guard skip.
Both run inside a single Dexie transaction so a mid-batch failure
doesn't leave a half-seeded Space.
Duck-typed LocalTagShape / LocalTagGroupShape local to this file —
the authoritative types live in @mana/shared-stores but importing
them here would create an awkward data-layer → shared-stores
dependency direction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Articles ist jetzt als Workbench-App in apps.ts registriert
(icon BookOpen, collection 'articles', paramKey 'articleId') und
landet damit im Scene-App-Picker. HomeView/ListView/HighlightsView/
StatsView teilen sich eine neue ArticlesTabShell, die sowohl als
SvelteKit-Route als auch als Workbench-Karte rendert.
Shell (ArticlesTabShell.svelte):
- Top-Bar mit QuickAddInput (URL einfügen + Enter = Save + goto
Reader; kein Preview-Schritt) und Settings-Gear.
- Tab-Leiste darunter: Leseliste | Highlights | Favoriten | Stats.
Leseliste ist Default (initialTab='list').
- Tab-Wechsel läuft intern via $state + Svelte-Context — kritisch
für die Workbench-Karte, wo goto() den User aus der Karte kicken
würde. getArticlesTabContext() aus tab-context.ts gibt tief
verschachtelten Sektionen eine switchTo(tab)-API.
- Padding 1rem 1.25rem auf der Shell selbst — PageShell.page-body
hat null padding, sonst klebt QuickAdd am Card-Rand. Im Route-
Kontext addiert's sich zum (app)-Layout-Padding ohne zu viel.
Tabs:
- Leseliste (list): bestehende ListView mit optionalem
initialFilter-Prop. Continue-Reading-Strip (HomeSectionWeiterlesen
horizontal carousel) erscheint über den Filter-Chips wenn
status='reading'-Artikel existieren und filter ∈ {all, reading}.
Filter-Chips sind einzeilig + horizontal scrollbar mit
scroll-snap-Einrast; inaktive Chips haben jetzt sichtbare
Background-Füllung + Border via color-mix(currentColor) — adaptiv
fürs Theme.
- Highlights (highlights): HighlightsView unverändert (nur der
eigene Header + Zurück-Button raus, liegt jetzt in der Shell).
- Favoriten (favorites): ListView mit initialFilter='favorites' —
Shell-Shortcut auf den Filter.
- Stats (stats): neue StatsView mit Stats-Strip (savedThisWeek,
finishedThisWeek, avg reading time), Highlight-Counter, Top-
Sources und Archiv-Link.
Routes (unter (tabs)-Gruppe):
- /articles → initialTab="list" (Default)
- /articles/list → initialTab="list" (alias)
- /articles/highlights → initialTab="highlights"
- /articles/favorites → initialTab="favorites"
- /articles/stats → initialTab="stats"
Detail/Add/Settings bleiben bewusst ausserhalb — die haben ihren
eigenen Reader/Form-Chrome und sollen die Tab-Leiste nicht zeigen.
Neue Files:
- ArticlesTabShell.svelte (Tab-Host)
- tab-context.ts (Cross-Tab-Switch-Context)
- components/ArticleCard.svelte (shared Card aus ListView extrahiert,
row + compact Varianten)
- components/QuickAddInput.svelte (URL-Input aus HomeView extrahiert)
- components/HomeSectionSources.svelte
- components/HomeSectionStats.svelte
- components/HomeSectionWeiterlesen.svelte
- views/StatsView.svelte
- routes/(app)/articles/(tabs)/{+page,list,highlights,favorites,stats}
Gelöscht:
- HomeView.svelte (Overview-Tab wurde rausgenommen auf User-Feedback)
- HomeSectionFrisch/Highlights/Favorites (durch eigene Tabs ersetzt)
docs/plans/articles-homepage.md dokumentiert den Architektur-Plan,
inklusive der Entscheidung für "eine Card pro Domain, interne Tabs"
statt zwei separater App-Registrierungen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before this commit, the bootstrap created one "Mana" agent per user.
After per-Space migration, every Space needs its own default agent so
Actor attribution shows the right identity and missions land in the
right Space. Three users with Personal + Family + Brand Spaces would
have ended up with three "Mana" agents in the picker — ugly and
confusing.
Now each Space type gets a name that reads naturally:
- personal → "Mana" (keeps legacy name + DEFAULT_AGENT_ID
so historic Actor.displayName on
pre-migration records still renders)
- family → "Familien-Helfer"
- team → "Team-Assistent"
- brand → "Brand-Assistent"
- club → "Verein-Helfer"
- practice → "Praxis-Assistent"
Stable id scheme:
- Personal: DEFAULT_AGENT_ID (legacy coupling with LEGACY_AI_PRINCIPAL)
- Others: `default:<spaceId>` (deterministic, collision-free)
Bootstrap bypasses the regular createAgent path (which enforces
global name-uniqueness) because the same name is legitimately repeated
across multiple Spaces of the same type. Deduplication happens via
getAgent(id) + Dexie's add-or-skip for cross-tab races instead.
ensureDefaultAgent() reads the active Space via getActiveSpace(); when
no Space is loaded yet (pre-bootstrap first boot) it falls back to the
Personal default. The per-Space re-run on onActiveSpaceChanged (Phase
2d.4) picks up the correct agent once loadActiveSpace resolves.
Type-check clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Since Phase 2d.2 of the space-scoped rollout, each Space can have its
own kontextDoc. Before this commit, the module was a user-level
singleton keyed by id='singleton' — which meant Shared/Brand/Family
Spaces saw the user's Personal-Space bio as their AI planner context.
Changes:
- types.ts: relax LocalKontextDoc.id to plain string (was the literal
'singleton'). KONTEXT_SINGLETON_ID stays as an exported const so
legacy Personal-Space rows (stamped before the refactor) are
documented; no longer used at write sites.
- stores/kontext.svelte.ts: ensureDoc() finds the active-Space row via
scopedTable(), creates a fresh UUID row if absent. setContent /
appendContent operate on the found-or-created row's id. Personal-
Space's legacy 'singleton' row keeps rendering because the
`_personal:<userId>` sentinel is inside getInScopeSpaceIds()'s
returned set.
- queries.ts: useKontextDoc() mirrors the same scopedTable filter.
- ai/missions/default-resolvers.ts: kontextIndexer surfaces the active
Space's kontextDoc (not hardcoded 'singleton'). Shared-Spaces without
a doc yet return an empty candidate list, which is the correct
empty-state for the mission-input picker.
Type-check clean. No schema change; relies on v28's existing spaceId
stamping + the creating-hook's ongoing stamp (kontextDoc is in the
kontext module.config).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
svelte-check emits a11y + dead-CSS + Svelte-5 $state warnings that were
previously non-blocking — pre-push only caught hard type errors. The
a11y-30 cleanup commit (3e09ff66d) brought the warning count to 0, so
flipping `--fail-on-warnings` on now makes the checker hold the line:
any new warning fails the pre-push hook that runs `pnpm check`.
Covers: a11y_click_events_have_key_events, a11y_consider_explicit_label,
css_unused_selector, state_referenced_locally, and the other svelte-check
diagnostic categories.
No behaviour change with current codebase (0 warnings); prevents drift
going forward.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First wire-up of the userTagPresets surface from Phase 2b's v34 schema.
This is the store layer only — Space-create UI integration + the
apply-preset-to-space flow land in a follow-up commit alongside the
SpaceCreateDialog changes.
- lib/data/tag-presets/types.ts: LocalUserTagPreset shape + inline
TagPresetEntry + toUserTagPreset converter.
- lib/data/tag-presets/store.svelte.ts: createPreset / updatePreset /
deletePreset / setDefault / appendEntry. Stamps userId explicitly
because userTagPresets is kept out of SYNC_APP_MAP (the Dexie
creating-hook only fires for sync tables). At-most-one-default-per-
user invariant enforced by clearDefaultFlag() before writes that set
isDefault=true.
- lib/data/tag-presets/queries.ts: useUserTagPresets + useDefaultTagPreset
live queries. User-scoped, no active-space filter (presets show from
any Space context).
- crypto: move userTagPresets from plaintext-allowlist to
ENCRYPTION_REGISTRY with fields ['name', 'tags']. AES wrapping handles
the tags array via JSON-stringify, same pattern as food.foods.
Crypto audit: 196 tables (95 encrypted, +1 userTagPresets). Type-check
clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PlayView used Tailwind palette classes for game-status feedback:
bg-emerald-500/10 + text-emerald-300 (won) → bg-success/10 + text-success
bg-amber-500/10 + text-amber-300 (lost) → bg-warning/10 + text-warning
border-red-500/20 + bg-red-500/10 +
text-red-300 (error) → border-error/20 + bg-error/10 + text-error
placeholder-white/30 focus:border-purple-400/50 → placeholder:text-muted-foreground/60 focus:border-primary/50
Semantic status now tracks the theme (errors are red in dark, darker red
in light, etc.) instead of being fixed hex ramps.
The `bg-purple-500` / `bg-purple-500/30` / `hover:bg-purple-600` classes
on the user's chat bubble and submit buttons STAY — purple is the who
module's primary identity colour (historical-deck accent `#a855f7` is
semantically the same hue). Documented in brand-literals.md §who.
Also harden two validators against mid-rename states where git ls-files
returns paths that aren't on disk yet — both now skip unreadable files
instead of crashing the pre-commit hook (caught while migrating who).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>