Commit graph

2213 commits

Author SHA1 Message Date
Till JS
cb9a9bb42e refactor(profile,tool-registry): flip meImages from user-scoped to space-scoped (v40)
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>
2026-04-23 18:09:57 +02:00
Till JS
703ef69ca9 feat(webapp): wire context-window compactor into Companion + Mission runner (M2.4)
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>
2026-04-23 15:33:18 +02:00
Till JS
3eca5ac201 feat(website): M6 — subdomain publish + custom-domain foundation
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>
2026-04-23 15:29:42 +02:00
Till JS
13efae8cd2 feat(website): M5 — AI tools + starter templates
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>
2026-04-23 15:14:45 +02:00
Till JS
9589feb296 fix(infra): mana-web COPY + research URL + Umami version pin
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>
2026-04-23 15:05:47 +02:00
Till JS
e2b5ac38cb feat(profile): migrate auth.users.image into meImages + avatar autosync (M2.5)
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>
2026-04-23 14:38:20 +02:00
Till JS
57be0f61b1 feat(website): M4 — forms + moduleEmbed
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>
2026-04-23 14:36:52 +02:00
Till JS
7a4f8894e1 feat(website): M3 — 5 more blocks, containers, upload, themes
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>
2026-04-23 14:27:49 +02:00
Till JS
d087b4744a chore(observability): scrape mana-mcp at :3069
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>
2026-04-23 14:24:13 +02:00
Till JS
38dc806549 feat(personas): M3.b-d — tick loop + Claude Agent SDK + persistence
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>
2026-04-23 14:16:36 +02:00
Till JS
54a12ffd5c feat(webapp): wire isParallelSafe in Companion chat + Mission runner
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>
2026-04-23 14:11:24 +02:00
Till JS
a64a7e39cf feat(profile): UI for me-images management at /profile/me-images (M2)
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>
2026-04-23 14:01:40 +02:00
Till JS
89258eb451 feat(profile,api): meImages foundation for AI reference generation (M1)
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>
2026-04-23 13:50:53 +02:00
Till JS
32c95a3780 chore(diagnostics): headless prod smoke scripts
Some checks failed
CI / Build mana-sync (push) Has been cancelled
CI / Build mana-notify (push) Has been cancelled
CI / Build mana-api-gateway (push) Has been cancelled
CI / Build mana-crawler (push) Has been cancelled
CI / Build mana-media (push) Has been cancelled
CI / Build mana-credits (push) Has been cancelled
CI / Build mana-web (push) Has been cancelled
CI / Build chat-backend (push) Has been cancelled
CI / Build chat-web (push) Has been cancelled
CI / Build todo-backend (push) Has been cancelled
CI / Build todo-web (push) Has been cancelled
CI / Build calendar-backend (push) Has been cancelled
CI / Build calendar-web (push) Has been cancelled
CI / Build clock-web (push) Has been cancelled
CI / Build contacts-backend (push) Has been cancelled
CI / Build contacts-web (push) Has been cancelled
CI / Build presi-web (push) Has been cancelled
CI / Build storage-backend (push) Has been cancelled
CI / Build storage-web (push) Has been cancelled
CI / Build telegram-stats-bot (push) Has been cancelled
CI / Build food-backend (push) Has been cancelled
CI / Build food-web (push) Has been cancelled
CI / Build skilltree-web (push) Has been cancelled
Docker Validate / Build calendar-web (push) Has been cancelled
Docker Validate / Build quotes-web (push) Has been cancelled
Docker Validate / Build todo-backend (push) Has been cancelled
Docker Validate / Build todo-web (push) Has been cancelled
Docker Validate / Build mana-auth (push) Has been cancelled
Docker Validate / Build mana-sync (push) Has been cancelled
Docker Validate / Build mana-media (push) Has been cancelled
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>
2026-04-23 13:42:52 +02:00
Till JS
16c8818338 feat(mcp): M1+M1.5 MCP gateway + tool-registry + shared-crypto
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>
2026-04-23 13:18:35 +02:00
Till JS
5c08653b19 fix(infra): include shared-ai + shared-rss in mana-api Dockerfile installer
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>
2026-04-23 02:34:22 +02:00
Till JS
2d86c6d429 refactor(picture): unify ListView for carousel + route contexts
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>
2026-04-23 00:38:14 +02:00
Till JS
e21f2145de refactor(routes): wrap every (app) route in RoutePage / ModuleShell
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>
2026-04-23 00:37:53 +02:00
Till JS
3a68a63728 feat(picture,api): GPT-Image-2 image generation
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>
2026-04-23 00:37:15 +02:00
Till JS
13b785b33f refactor(shell): unify card + route chrome into ModuleShell + RoutePage
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>
2026-04-23 00:36:54 +02:00
Till JS
af4fd27769 feat(crypto): restore at-rest encryption sweep (lost to revert)
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>
2026-04-22 22:42:53 +02:00
Till JS
ce5d1f1a2a feat(db): Phase 2c-followup #2 — strip Space-scope fields from user-level tables
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>
2026-04-22 19:13:37 +02:00
Till JS
8c3d6e7bba test(backup): add v2 export/import round-trip + fix cross-account adoption
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>
2026-04-22 18:59:19 +02:00
Till JS
fd1ea47075 feat(backup): client-driven v2 snapshot export, drop server-side backup
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>
2026-04-22 18:46:29 +02:00
Till JS
c31dcdd66c Revert "test(mana-research): fixture-based tests for Gemini poll-response parser"
This reverts commit c413ab7dd3.
2026-04-22 18:43:48 +02:00
Till JS
f4c66241ca feat(db): Phase 2c follow-up — Dexie v35 hard userId drop on data tables
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>
2026-04-22 18:43:09 +02:00
Till JS
c413ab7dd3 test(mana-research): fixture-based tests for Gemini poll-response parser
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>
2026-04-22 18:34:33 +02:00
Till JS
e9b9544ea3 feat(db): Phase 2c — stop stamping userId on data-record writes
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>
2026-04-22 18:27:11 +02:00
Till JS
09e6a8b9df feat(crypto): Phase 2e — flip encryption on for tags/scenes/missions
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>
2026-04-22 18:13:34 +02:00
Till JS
0f8fbb381b feat(settings): Phase 2d.6 — Tag-Presets management UI
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>
2026-04-22 18:10:28 +02:00
Till JS
81a426af28 feat(spaces): Phase 2d.5b — tag-source picker in SpaceCreateDialog
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>
2026-04-22 18:04:59 +02:00
Till JS
4d5a96e21b perf(invoices): lazy-load pdf-lib + swissqrbill, -516 KB on route
/(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>
2026-04-22 18:03:53 +02:00
Till JS
596e5a7424 feat(tag-presets): Phase 2d.5a — applyPresetToSpace + copyTagsBetweenSpaces
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>
2026-04-22 18:02:06 +02:00
Till JS
3b85d7d3d2 chore(bundle): add bundle-size audit + snapshot inventory
scripts/audit-bundle.mjs reads `.svelte-kit/output/client/_app/immutable`
after a prod build and reports:
  - Total size + category breakdown (entry / nodes / chunks / workers /
    assets).
  - Top N JS files with content heuristics (transformers.js, zxcvbn,
    tiptap, pdf-lib, swissqrbill, rrule, suncalc, Phosphor icon paths,
    Vite __vite__mapDeps metadata, etc).
  - Route mapping for `nodes/*.js` by parsing the server manifest's
    `leaf:` entries, so node 118 is identified as /(app)/invoices/[id].
  - ⚠ flag on chunks/ ≥ 200 KB (shared, potentially eager).

Current snapshot (docs/optimizable/bundle-analysis.md):
  entry   92 KB  |  nodes   2.77 MB  |  chunks   5.59 MB
  workers 22.3 MB (ONNX WASM, lazy)  |  total   31.8 MB

Already healthy:
  - 92 KB entry (no critical-path bloat).
  - 22 MB transformers.js WASM is worker-scoped — only fetched on first
    /llm-test or memoro voice use.
  - zxcvbn (1.25 MB combined dict + keyboard graphs) correctly behind a
    dynamic import in PasswordStrength.svelte.

Follow-up opportunities logged:
  1. /invoices/[id] = 534 KB — split swissqrbill + pdf-lib via dynamic
     import.
  2. @mana/shared-icons = 317 + 149 KB SVG path chunks — migrate to
     tree-shakable per-icon imports or lazy-load.
  3. Root (app) layout = 260 KB — check for module bleed into shared
     shell.

Report-only. Run with `pnpm run audit:bundle`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:52:08 +02:00
Till JS
72a5995fa5 feat(articles): M9 workbench homepage — 4-tab shell + QuickAdd + StatsView
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>
2026-04-22 17:50:38 +02:00
Till JS
a36e543e41 feat(agents): Phase 2d.3 — SpaceType-aware default agent bootstrap
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>
2026-04-22 17:40:28 +02:00
Till JS
8a82f3c543 feat(kontext): Phase 2d.2 — kontextDoc is per-Space, not user-singleton
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>
2026-04-22 17:35:23 +02:00
Till JS
219ccd3f2c chore(web): harden pnpm check with --fail-on-warnings
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>
2026-04-22 17:33:50 +02:00
Till JS
35d9e023a6 feat(tag-presets): Phase 2d.1 — CRUD store + encryption for user-level presets
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>
2026-04-22 17:26:39 +02:00
Till JS
52af8c0cec refactor(theming): migrate who semantic colours to theme tokens
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>
2026-04-22 17:19:53 +02:00
Till JS
07e35d79f0 feat(db): Phase 2b — Dexie v34 schema for space-scoped data model
Adds the two small schema changes needed for the space-scoped rollout.
The heavy lifting (userId drop, kontextDoc reshape, store-API pivots)
lives in 2c / 2d — this commit keeps scope surgical to isolate the
Dexie version bump.

1. userTagPresets table — user-level templates for seeding tags into
   newly-created Spaces. Deliberately NOT space-scoped: the preset
   picker runs from ANY Space during new-Space creation, so active-
   space filtering would hide the user's other presets. Indexed on
   userId + isDefault. NOT yet in SYNC_APP_MAP — cross-device sync
   wires up in 2d alongside the CRUD store API.

2. Compound indexes on globalTags + tagGroups:
   - globalTags: [spaceId+sortOrder] (per-Space sorted list) +
     [spaceId+name] (in-Space dedup-by-name)
   - tagGroups:  [spaceId+sortOrder]
   Tags/tagGroups already carry spaceId on every row (v28 migration +
   the creating-hook's ongoing stamping), these indexes simply let
   per-Space queries skip the client-side JS filter that
   scopedForModule does today.

Audit script pass-through: userTagPresets sits on the plaintext
allowlist with a comment flagging that it moves to ENCRYPTION_REGISTRY
in 2d once the store API wraps writes. 196 Dexie tables classified,
type-check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:15:48 +02:00
Till JS
766ad2ea8f feat(crypto): Phase 2a — declare encryption intent for tags/scenes/missions
Preparation step for the space-scoped data model migration (Phase 2b).
Moves globalTags, tagGroups, workbenchScenes, and aiMissions from the
plaintext allowlist into the encryption registry with enabled:false —
so the audit script documents which fields WILL be encrypted without
changing any runtime behaviour.

Fields chosen per design-doc:
- globalTags.name — personal categorization (Therapie, Finanzen-privat)
- tagGroups.name — same
- workbenchScenes.name + description — scene labels often encode
  Space-specific context (Q2-Launch, Urlaub 2026)
- aiMissions.title + conceptMarkdown + objective — all user-typed
  mission config; state/cadence/inputs stay plaintext for the Runner

Deliberately kept plaintext (against my initial suggestion):
- aiAgents.name — registry comment explains: name is the Actor
  displayName cache key for historic attribution. Encrypting would
  show "🤖 [encrypted]" on every past task the agent ever touched.
- globalTags.icon / tagGroups.icon / color — not personal content;
  icon is a visual cue, color is theme metadata

The 2c migration (Dexie v35, flip enabled:true) runs after 2b lands
the schema changes so existing rows get encrypted in one controlled
pass instead of mixing schema + encryption in the same upgrade.

Crypto audit: 195 Dexie tables classified (94 encrypted, 101
plaintext-allowlisted). Type-check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:07:05 +02:00
Till JS
3e09ff66d1 fix(a11y): resolve 30 svelte-check warnings across 8 files
svelte-check now completes clean (0 errors, 0 warnings, 0 files with
problems).

- profile/ContextOverview: 11 click-on-div sites made keyboard-
  accessible with role="button", tabindex="0", and an onActivate helper
  that fires the same handler on Enter/Space. Two <p> wrappers became
  <div> since <p> cannot carry role="button" per ARIA.
- profile/ContextInterview: paginate dots got aria-label + aria-current.
- settings/GeneralSection: toggle button got aria-label +
  aria-pressed.
- events/RegionPicker: radius label associated with range input via
  for/id.
- events/SourceManager: drop unused .source-item.inactive + .inactive-
  badge CSS selectors (dead code).
- research-lab/CompareColumn: local `rating` seed from entry.userRating
  now uses svelte-ignore comment + $effect sync (intentional seed-only
  read, plus prop-update mirror).
- admin/ListView: initialTab prop is deliberately read only at mount;
  svelte-ignore comment documents the intent.
- gifts/redeem: drop unused .animate-fade-in CSS selector.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:00:59 +02:00
Till JS
ea71d3c215 refactor(theming): replace transition-all with specific transitions
Sweep 98 `transition-all` occurrences across 62 files and replace with
targeted Tailwind transition utilities. Motivation:

1. `transition-all` animates every property, including CSS custom-
   property-backed colours. On first paint the vars may not have
   resolved yet, producing the P5 "white-on-white until first
   interaction" rendering bug. The same bug hit food/moodlit ListViews
   in the earlier theme migration.

2. Specific transitions also perform better — no layout-property
   interpolation overhead.

Codemod scripts/migrate-transition-all.mjs classifies each class
attribute by its sibling classes and picks one of:

  - `transition-opacity`                     — icon fade on group-hover
  - `transition-[width]`                     — progress-bar width anim
  - `transition-[transform,colors,box-shadow]` — scaled buttons/cards
  - `transition-[border-color,box-shadow]`   — card hover:border+shadow
  - `transition-colors`                      — default (card/row hover)

91 / 98 auto-classified, 7 hand-migrated:

  - EntryItem              → transition-[box-shadow]      (ring fade)
  - NutritionProgressWidget → transition-[stroke-dashoffset,stroke]
  - OnboardingModal        → transition-[width,background-color]
  - times/reports (3×)     → transition-[width] / -[height] (bar anims)
  - presi/present          → transition-[width,background-color] (dots)

svelte-check clean with 0 errors; validate:all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:57:49 +02:00
Till JS
46c03e6a5b feat(articles): reader UI polish — full-bleed + unified floating toolbar
Reader page is now a proper distraction-free reading surface instead
of a padded card inside the (app) layout.

Layout:
 - .detail-shell breaks out of the (app) layout's padded + max-width
   container via the 100vw + negative-margin-X trick, and additionally
   cancels the vertical padding (<main pt-2> + inner py-2) plus the
   bottom-chrome reservation. The reader theme therefore paints
   edge-to-edge including behind the PillNav. No more island-in-a-sea
   look.
 - Initial theme (light/sepia/dark) mirrors the global Mana theme at
   mount time by checking document.documentElement.classList.dark — so
   opening an article from a dark-mode app no longer flashes a white
   reader. User can still override per-article via the swatches.

Toolbar unification:
 - Old two-bar layout (top: back + typography, bottom: actions) fused
   into one floating pill-bar at the bottom. Three groups divided by
   vertical rules: nav | typography | actions. flex-wrap handles narrow
   screens gracefully.
 - position: fixed + bottom: calc(--bottom-chrome-height + 1rem) so the
   bar floats above Mana's PillNav without overlap. The CSS var comes
   from <main>'s style attribute and cascades even into fixed
   descendants.
 - backdrop-filter: blur(10px) + theme-specific semi-transparent
   background so the bar feels aerial, not docked.
 - Custom CSS tooltips on every button (data-tip attribute + ::after
   pseudo). Replaces the native `title` attribute which has a ~1s delay
   and inherits OS chrome. Tooltip bubble colors adapt to the active
   reader theme. aria-label stays for screen-readers.
 - Active-state swatches get an outline-ring instead of a background-
   swap so the chip color stays visible as a theme-preview.

Spacing:
 - meta-bar margin-top: 1.5rem → 4rem — clearer separation between the
   viewport edge and the article title.
 - ReaderView padding-bottom: 4rem → 14rem — last paragraph no longer
   visually attaches to the floating bar when scrolled to the end;
   there's a proper "you've reached the end" gap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:55:56 +02:00
Till JS
7d6a340b13 refactor(theming): migrate remaining 738 token violations across routes + components
Expand validate-theme-tokens.mjs scope from ListViews only to all
lib/modules/**/*.svelte and routes/(app)/**/*.svelte. Add a second rule
banning the neutral Tailwind palette (gray/slate/zinc/neutral/stone-N)
— these should be theme tokens (bg-card, bg-muted, text-foreground,
text-muted-foreground, border-border) instead.

Apply one-shot codemod (scripts/migrate-theme-tokens.mjs) that
replaces:
  bg-gray-800/900        → bg-card
  bg-gray-600/700        → bg-muted (with opacity preserved)
  border-gray-600..900   → border-border
  text-gray-800/900      → text-foreground
  text-gray-300          → text-foreground/90
  text-gray-400/500/700  → text-muted-foreground
  placeholder-gray-*     → placeholder:text-muted-foreground/60
  bg/border-white/N      → bg-muted/N, border-border/N
  text-white/70-90       → text-foreground
  text-white/40-60       → text-muted-foreground
  text-white/10-30       → text-muted-foreground/70

42 files touched; biggest: presi/deck/[id] (91 subs), uload/analytics
(58), uload/+page (53), presi/+page (47), who/PlayView (35),
skilltree/Edit+AddXpModal (28 each), context/* (115 across 4 pages),
uload/links+tags (50 across 2).

Brand-literal overlays in moodlit/components/mood/{MoodFullscreen,
MoodCard,CreateMoodDialog}.svelte stay unmigrated — they render on
vivid colour gradients. Validator exempts these 3 files from the
white-alpha rule; they still obey the neutral-palette rule.

Result: 527 files pass validate:theme-tokens; svelte-check clean with
0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:42:55 +02:00
Till JS
efe1810b04 feat(articles): browser-HTML bookmarklet + consent-wall detection + auto-save
Three intertwined improvements so the "save an article" flow actually
works on real-world sites, not just bloggy happy-path URLs.

=== Consent-wall detection ===

apps/api/src/modules/articles/routes.ts: the /extract response now
includes `warning: 'probable_consent_wall'` when the extracted text
is both short (<300 words) AND contains cookie-dialog vocabulary
(Cookies zustimmen / cookie consent / Zustimmung / accept all cookies
/ enable javascript / privacy center / Datenschutzeinstellungen). The
server still returns whatever it got so the client can decide; it just
flags it as probably-not-the-article.

Frontend surfaces that warning prominently instead of silently
persisting a "Cookies zustimmen…" blob as the article body.

=== Browser-HTML extract path ===

Server-side: new POST /api/v1/articles/extract/html endpoint accepting
{ url, html }, running @mana/shared-rss's extractFromHtml on the
caller-supplied HTML. 10 MiB payload cap. Same response shape as
/extract, including the consent-wall warning (in case the bookmarklet
fires before the user dismisses the dialog).

Client-side: new extractFromHtml() in api.ts with the same 25s
timeout + typed network-error mapping as extractArticle.

AddUrlForm gains a postMessage handshake: when loaded with
?source=bookmarklet, it posts `mana-ready` to window.opener and
listens one-shot for `mana-html` with { url, html, title } from the
opener's tab. The HTML goes straight to our own /extract/html
endpoint — same-origin, carries the user's auth cookie. No CORS, no
form-submission CSP tango, no cross-origin token smuggling. If
nothing arrives within 30s we surface a clear error instead of
hanging.

Settings page adds a second "browser-HTML" bookmarklet (marked as
"Empfohlen") alongside the legacy URL bookmarklet. New snippet opens
/articles/add?source=bookmarklet in a new tab, waits for mana-ready,
then postMessages the tab's documentElement.outerHTML over. 15s
safety timeout.

This bypasses cookie-consent walls and soft paywalls because the
HTML already comes from the user's own authenticated, consented
browser tab.

=== Auto-save after successful extract ===

Previously every save path had a two-click UX: preview → confirm.
Now on clean extract the preview skips straight to persist + navigate
to the reader. Consent-wall warning is the only fallback that pauses
the flow — the user gets a "Trotzdem speichern" button to opt into
saving a teaser anyway.

Button in the manual input row is renamed "Vorschau abrufen" → "Speichern"
since it's now the commit action, not the inspect action. Loading-block
messaging distinguishes "Server extrahiert…" vs "Speichere in deine
Leseliste… Gleich weiter zum Reader."

Net click count:
  Bookmarklet v1/v2 on working site:  2 clicks → 1 click
  Manual paste:                        2 clicks → 1 click
  Consent-wall fallback:              2 clicks (explicit "Trotzdem")
  Duplicate:                          2 clicks ("Zum gespeicherten
                                        Artikel")

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:29:53 +02:00
Till JS
86c205ffc5 refactor(theming): migrate remaining 12 ListViews to theme tokens
Replace raw white-alpha Tailwind utilities across the last 12 module
ListViews that were flagged by validate-theme-tokens: citycorners,
guides, inventory, memoro, picture, plants, playground, presi,
questions, times, uload, who. Also replace semantic color hex/names
(bg-yellow-500/20, bg-green-400, text-blue-400, bg-teal-600, etc.)
with success/warning/error/primary tokens.

Per-deck brand colors in who/ListView (#a855f7 purple/historical,
#ec4899 pink/women, #f59e0b amber/antiquity, #0ea5e9 blue/inventors)
stay as hex — those are domain semantics, not theme intent.

Wire validate:theme-tokens into validate:all so future regressions
fail the local pre-push gate. All 76 module ListViews now pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:29:43 +02:00
Till JS
a2a43b1d5a refactor(theming): migrate 6 ListViews + ai-missions badges to theme tokens
Replace raw white-alpha Tailwind utilities (text-white/x, bg-white/x,
border-white/x) with canonical theme tokens (text-foreground, bg-muted,
border-border, etc.) in cards, context, food, moodlit, storage, music
ListViews. Replace hardcoded hex badge/dot/phase colors in ai-missions
with success/warning/error/primary tokens.

Fix two transition-all bugs (food:160, moodlit:223) that prevented CSS
custom property colors from resolving on first paint under theme switches.

Add scripts/validate-theme-tokens.mjs to prevent regression; run via
pnpm run validate:theme-tokens. Not yet in validate:all — 12 modules
still use raw white utilities (citycorners, guides, inventory, memoro,
picture, plants, playground, presi, questions, times, uload, who).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:23:55 +02:00
Till JS
43b4570e69 refactor(admin): fuse admin-* cards into one tabbed admin card
User feedback: four separate admin cards (admin-users, admin-system,
admin-user-data + admin) bloated the scene-picker without adding value
— they're one logical power-user surface split four ways. Fuse them
into a single admin card with an internal tab switcher.

- lib/modules/admin/tabs/{Overview,Users,System,UserData}Tab.svelte —
  each tab owns its own data + styles
- lib/modules/admin/ListView.svelte is now a tabbed container: one
  role-guard, one pill-row, deep-linkable via `initialTab` prop
- /admin, /admin/users, /admin/system, /admin/user-data routes pass
  the corresponding initialTab so direct URLs still land on the right
  section
- Delete lib/modules/admin-{users,system,user-data}/ + three
  registerApp entries
- Complexity stays a separate card (different shape — iframe-heavy,
  was already its own card before this batch)

Smoketest: all 5 /admin/* routes respond 200; type-check clean.

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