Commit graph

1677 commits

Author SHA1 Message Date
Till JS
3a3cd126cf feat(mana/web): unified guest-mode prompt in the bottom-stack notification slot
Adds a single, deduped notification mechanism for the moments when the
app needs to nudge a signed-out user toward registration / login —
replacing the silent failures that used to happen when a guest hit a
server-only feature.

──── New: lib/stores/guest-prompt.svelte.ts ────

A mana-web-local singleton with a tiny API:

  guestPrompt.requireAccount(featureKey, message?, actionLabel?)
  guestPrompt.dismiss(id)
  guestPrompt.clear()

Notifications are deduped by featureKey, so three failed LLM calls in
a row don't stack three identical "you need an account" stripes. The
"Anmelden" button uses an injectable navigator (via setGuestPromptNavigator)
so the layout can wire SvelteKit's goto for client-side routing
instead of the default window.location.assign fallback.

──── Hooked into api/base-client.ts ────

fetchWithRetry now short-circuits BEFORE the network call when the
user is unauthenticated — surfaces guestPrompt.requireAccount('api')
and returns "Anmeldung erforderlich" instead of burning the round-trip
to a server that's just going to 401. Saves latency, log noise, and
gives a better German error message than "Sitzung abgelaufen".

When a 401 does come back from the server (token expired mid-session),
fetchWithRetry calls guestPrompt.requireAccount('api:401', ..., 'Neu anmelden')
in addition to the existing return-with-error path.

Both hooks live in one central place so every feature that uses
createApiClient(...) — LLM, subscriptions, profile, credits, events,
gifts, etc. — gets the prompt for free without per-module changes.

──── Rendered in routes/(app)/+layout.svelte ────

The bottom-stack already had a NotificationBar slot for the time-based
guest nudge from createGuestMode (3-min trigger). The new event-driven
prompts merge into the SAME bar via array spread — one stripe, no
visual stacking — so the user only ever sees one band even when both
sources have something to say.

handleAuthReady() also calls guestPrompt.clear() when the user signs
in, so leftover guest prompts don't carry into the authenticated session.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 01:11:54 +02:00
Till JS
752f31bfad fix(mana/web): handle vault-locked race + add guest plaintext fallback
Two related issues in the encryption pipeline that were both surfacing
as silent failures when a user tried to log a mood / write to any
encrypted field shortly after page load or while signed out:

1. Boot-time race
   The layout fires authStore.initialize() and vaultClient.unlock()
   in the same tick. The very first user mutation can land before the
   network round-trip that fetches the master key returns. encryptRecord
   then synchronously sees a null key and throws VaultLockedError —
   surfacing in the UI as "click does nothing" because nothing in the
   call chain catches it.

   Fix: KeyProvider gets a waitForKey(timeoutMs) method.
   MemoryKeyProvider implements it via the existing onChange listener,
   so callers resume as soon as setKey lands. encryptRecord now waits
   up to 2 s before throwing, which converts a near-miss race into a
   transparent millisecond delay.

2. Guest plaintext fallback (Option A in the chat thread)
   Guests have no auth token, so the server vault is unreachable by
   definition. Refusing every encrypted-field write would hide the
   bulk of the app behind a sign-up wall — undesirable for the
   try-before-you-buy local-first flow.

   Fix: encryptRecord now silently no-ops when getCurrentUserId() is
   null, writing plaintext to the local Dexie. guest-migration.ts
   waits for the vault (10 s budget) and then encrypts the registry
   fields per-table BEFORE the re-insert, so the on-disk state after
   sign-in matches "user signed up first, then typed everything".

   If the vault never opens (auth/network failure on /me/encryption-vault),
   migration aborts cleanly — guest data stays put rather than being
   re-inserted as plaintext under the real user id.

UI side: cycles/ListView.svelte wraps every dayLogsStore.logDay call
in a safeLogDay helper that catches VaultLockedError and surfaces a
toast pointing the user at Settings → Sicherheit. Previously the
unhandled rejection from a click handler vanished into the console.

Tests:
- record-helpers.test.ts now stamps a fake current user in beforeEach
  so the new guest-skip doesn't no-op the encryption asserts. The
  "throws when locked" test uses fake timers to flush the 2 s wait
  without sitting on it.
- aes.test.ts: anonymous-class KeyProvider stub gains the new
  waitForKey method to satisfy the interface.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 01:11:21 +02:00
Till JS
1702caa4f7 feat(shared-llm) + fix(mana/web): tiered LLM orchestrator + workbench proxy fix
Bundles two unrelated changes that landed together due to a concurrent
lint-staged race in a multi-session edit. Splitting after the fact would
churn the parallel session's working tree, so the message is amended to
honestly describe both pieces.

──── 1. feat(shared-llm): tiered LLM orchestrator (Phase 1) ────

Replaces the unused NestJS @mana/shared-llm package with a tiered LLM
orchestrator that routes Mana tasks across four user-controlled privacy
tiers:

  none        — deterministic parsers / heuristics, no LLM
  browser     — Gemma 4 E2B via @mana/local-llm (WebGPU, on-device)
  mana-server — services/mana-llm with Ollama (gemma3:4b on Mac Mini)
  cloud       — services/mana-llm with google/* model (Gemini API)

The user picks which tiers Mana is allowed to use. The orchestrator
walks the user's tier list in order, picks the first one that's
available + ready + permitted for the input's content class, and runs
the task. If everything fails, it falls through to a per-task
deterministic runRules() implementation when one is provided.

Package shape moved from NestJS-style (Module/Service/__tests__/) to a
flat browser-package layout (deps are @mana/local-llm + svelte peer).
All NestJS legacy files deleted: __tests__/, interfaces/, types/,
utils/, llm-client*.ts, llm.module.ts, standalone.ts, etc.

Phase 2 (UI work — settings page section, onboarding step, source
badge component, cloud-consent dialog) is a follow-up and does not
block this commit. The orchestrator is fully functional from the
Router tab right now.

──── 2. fix(mana/web): unwrap \$state proxy in workbench-scenes ────

Adding an app to a workbench scene threw DataCloneError. scenesState
is a \$state array, so current.openApps was a Svelte 5 proxy and
spreading it into a new array left proxy entries inside; IndexedDB's
structured clone refuses to serialise those. Snapshot before handing
the array to patchScene / createScene so Dexie sees plain objects.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 01:09:54 +02:00
Till JS
56065c8537 fix(mana/web): unwrap $state proxy in workbench-scenes Dexie writes
Adding an app to a workbench scene threw DataCloneError. scenesState
is a $state array, so current.openApps was a Svelte 5 proxy and
spreading it into a new array left proxy entries inside; IndexedDB's
structured clone refuses to serialise those. Snapshot before handing
the array to patchScene / createScene so Dexie sees plain objects.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 00:44:00 +02:00
Till JS
761851847f Revert "refactor(mana/web): migrate zitare DetailView to theme tokens (P5 phase 1 sample)"
This reverts commit 74140e1154.
2026-04-09 00:36:43 +02:00
Till JS
6fd4655273 fix(questions): unwrap \$state proxy for tags before Dexie write
Creating a new question crashed in mana-sync's pending-change hook
with DataCloneError because the tags array is a Svelte 5 \$state proxy
and IndexedDB / structured-clone can't serialize proxies.

Surfaced while clicking through the deep-research feature end-to-end
in the browser — the form would silently fail to submit with the error
buried in the console.

Use \$state.snapshot() to deep-clone tags into a plain array before
persisting. Other fields are primitives so they're already plain.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 00:31:53 +02:00
Till JS
74140e1154 refactor(mana/web): migrate zitare DetailView to theme tokens (P5 phase 1 sample)
First file in the P5 visual-track consolidation. Replaces hand-rolled
#374151/#9ca3af/#e5e7eb palette + paired :global(.dark) duplications
with var(--color-foreground), var(--color-muted-foreground),
var(--color-border), var(--color-surface-hover) etc. — the same
theme tokens already used by shared-ui Card / PageHeader / FormModal.

Net: 260 → 245 LOC (-15), 7 hand-rolled rules eliminated, all
:global(.dark) selectors gone (theme system handles light/dark via
.dark class on <html>).

Brand colors that should NOT track the theme primary stay hardcoded:
the violet category badge (#8b5cf6) keeps its literal value, the
favorite-active red gets var(--color-error, #ef4444) with fallback.

This is the smallest of the 8 files identified by the P5 audit:
zitare DetailView (7 rules) → cycles → calendar → contacts → todo →
finance → notes → dreams (31 rules). Migration runs file-by-file
with one commit each so visual diffs are easy to review.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 00:29:39 +02:00
Till JS
cc6ebee6f5 fix(csp): allow blob: in script-src so onnxruntime-web Worker can spawn
After jsDelivr was allowlisted, transformers.js progressed one step
further: it successfully fetched the ort-wasm-simd-threaded.asyncify.mjs
loader, then tried to wrap it in a `URL.createObjectURL(new Blob([...]))`
and instantiate the result as a multi-threaded Web Worker. The blob:
URL scheme is treated as its own CSP source by browsers, so the
existing script-src — which only allows 'self', specific HTTPS hosts,
and 'wasm-unsafe-eval' — blocked it.

Add `blob:` to the mana-web scriptSrc list. The blob: scheme always
inherits the document origin (you can't `URL.createObjectURL` a Blob
from another origin), so this allowlists nothing more than our own
runtime-generated worker scripts. It does NOT loosen the protection
against remote-script injection.

Worth knowing for future debugging: when transformers.js or any
WebGPU/onnxruntime-web stack hits "Failed to fetch dynamically
imported module: blob:..." after a successful dynamic import from
a CDN, the next CSP layer is always blob: in script-src.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 23:06:33 +02:00
Till JS
e4e3360ca8 fix(csp): move jsdelivr allowlist to mana-web hooks (Vite SSR cache workaround)
The previous two attempts at allowlisting cdn.jsdelivr.net for
transformers.js's onnxruntime-web loader landed in shared-utils
security-headers.ts. The actual file change was correct (verified by
grep), the commits got pushed, the live security-headers.ts on disk
had the additions — but Vite's SSR module cache for cross-workspace-
package imports kept serving the OLD compiled shared-utils to
hooks.server.ts. Net effect: edits to hooks.server.ts hot-reloaded
fine (proven by the *.hf.co connect-src additions showing up
immediately) while edits to shared-utils/security-headers.ts did not.
A dev server restart should clear it but I'd rather not depend on
manual intervention every time we touch the shared CSP.

Move the jsdelivr allowlist out of the shared default and into
mana-web's hooks.server.ts via the existing scriptSrc + connectSrc
options. hooks.server.ts is in the SvelteKit app's own source tree so
it HMRs reliably, no SSR cache to fight. As a bonus this is also
architecturally cleaner: cdn.jsdelivr.net is only needed by mana-web
because mana-web is the only Mana app that bundles @mana/local-llm —
other apps get a slightly tighter CSP for free.

The pattern to remember: changes to packages/shared-utils that affect
SSR (response headers, server hooks) require either a dev server
restart OR a manual `rm -rf apps/.../node_modules/.vite` to take
effect. Client-side changes hot-reload fine.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 23:03:38 +02:00
Till JS
96023394b5 refactor(mana/web): extract QuickActionsList molecule shared by both widget variants
Two QuickActionsWidget files lived in parallel under different widget
systems — `dashboard/widgets/` (the user-customizable dashboard, i18n
keys, 3 actions: credits/feedback/profile) and `core/widgets/` (the
mana home screen, hardcoded German strings, 5 actions: todo/calendar/
contacts/context/times). The two rendered the same shape character-
for-character: optional emoji-prefixed title + a list of rounded-card
links each with icon + label + description. Only the data and a
slightly different padding/icon sizing differed.

Extract <QuickActionsList> in $lib/components that takes the actions
array directly (consumers resolve i18n before passing in). Both widget
files become thin wrappers — the dashboard one resolves $_(...) keys
and passes the result, the core one passes its hardcoded data with
`compact` set.

LOC: 110 → 102 across the 3 files (-8 net, plus the shared 70-LOC
molecule). Small numerically, but the bigger win is that future
changes to the link layout (hover state, padding, icon style) happen
once instead of twice — and the two widget files no longer accidentally
drift in sizing/spacing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 23:02:09 +02:00
Till JS
243c09d97c refactor(mana/web): extract useItemContextMenu helper for ListView right-click menus
Nine files (8 ListViews + todo's TaskList) reimplemented the same
context-menu state machinery character-for-character: a typed
$state object with visible/x/y/<itemKey>, a handleItemContextMenu
function that calls preventDefault and stuffs the click position
in, and a close handler that resets the entity field.

Extract `useItemContextMenu<T>()` in $lib/data/item-context-menu.svelte
that returns a reactive handle with `.state` (visible/x/y/target),
`.open(e, target)`, and `.close()`. Consumers derive their menu
items from `ctxMenu.state.target` and pass `ctxMenu.close` directly
to <ContextMenu onClose>.

Per file: ~10 LOC of state declaration + handler removed; consumer
items array switches from `ctxMenu.<entity>` to `ctxMenu.state.target`.
Across the 9 files this is ~−90 LOC of pure boilerplate; helper itself
is 50 LOC. Net small (~−40 LOC) but the boilerplate is gone and the
shape is one helper away from being adjustable globally.

Note: shared-ui already exports a `createContextMenuState` factory,
but it's a plain default-value object — not a Svelte 5 reactive
helper. This new wrapper composes with the existing `ContextMenuState<T>`
type from shared-ui rather than replacing it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:55:31 +02:00
Till JS
0af9094096 fix(csp): allow HF XET CDN (cas-bridge.xethub.hf.co) for transformers.js
After the WebLLM → transformers.js migration, the first attempt to load
Gemma 4 E2B in /llm-test was blocked by CSP at the *weight shard*
download step (tokenizer.json got through fine — it lives on
huggingface.co directly). HF has rolled out a new XET-backed CDN for
large model files at cas-bridge.xethub.hf.co, served from
*.xethub.hf.co (the parent zone is hf.co, NOT huggingface.co — so our
existing wildcard `*.huggingface.co` did not cover it).

Open the broader hf.co wildcard (`https://*.hf.co`) so future XET host
rotations don't bite us, plus the explicit cas-bridge.xethub.hf.co
entry for older CSP-strict browsers that want narrower matches first.
The legacy huggingface.co + cdn-lfs.huggingface.co entries stay in place
for repo metadata and any model still on the old LFS path.

Update the comment block above the CSP additions to reflect that the
package now uses transformers.js + ONNX shards rather than the old
WebLLM/MLC path, including a quick map of which HF domain serves what.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:51:50 +02:00
Till JS
63a91e36a2 fix(research): use /v1/chat/completions for mana-llm (not /api/v1/)
End-to-end testing surfaced a 404 from the synth path. mana-llm
(services/mana-llm/src/main.py) mounts the OpenAI-compatible API at
/v1/* — there's no /api prefix.

The first quick-depth e2e run only worked because the planner is
skipped on quick (it just uses the question itself), so llmJson never
fired; only llmStream did, and the streaming path also used the wrong
prefix but the test happened to land before this was caught.

The other apps/api modules (chat, guides, context, traces) all use the
wrong /api/v1/ path too — that's a separate, pre-existing bug to be
addressed in their own commits.

Verified by re-running a standard-depth research run end-to-end against
mana-llm pointed at the GPU server's ollama with gemma3:4b/12b: plan +
retrieve + extract + synth all succeed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:37:07 +02:00
Till JS
048184bef0 fix(macmini): auto-rebuild stale sveltekit-base before per-app web builds
Per-app web Dockerfiles do `FROM sveltekit-base:local` and do NOT re-COPY
packages/shared-* — those packages are baked into the base image. So a
change to packages/shared-utils, packages/shared-ui, etc. only reaches
the live web app if the base image is also rebuilt.

This bit us THREE times on 2026-04-08 alone:
1. CSP fix in shared-utils ('wasm-unsafe-eval') sat unused in production
   for over an hour because every `build-app.sh mana-web` cheerfully
   reused the cached base layer that still contained the old shared-utils.
2. Same problem with the BaseListView export in shared-ui after the
   ListView consolidation refactor — mana-web's build failed because the
   Rollup pass couldn't resolve the new symbol from the stale base.
3. Same shape, different package, repeatedly.

The pattern is identical every time and the manual workaround
(`build-app.sh --base` first) is something you only think to run if you
already know how the layering works. Make the script catch it.

New `is_base_image_stale` helper compares the base image's `Created`
timestamp against the latest git commit touching paths the base image
actually depends on:
  - packages/                       (all shared-* packages baked in)
  - docker/Dockerfile.sveltekit-base
  - pnpm-lock.yaml                  (transitive dep changes)

When building any *-web service, if the image is stale or missing, the
base is rebuilt automatically before the per-app build kicks off, with
the triggering commit's oneline printed for transparency.

Date parsing notes:
- macOS Docker emits the Created field with the local TZ offset
  ("...+02:00"), not Z. We strip the fractional + offset suffix and
  parse the literal local clock time with BSD date (no -u), which is
  what the original timestamp meant on this host. GNU date is the
  fallback for Linux dev boxes and handles the full ISO directly.
- If parsing fails for any reason we conservatively force a rebuild
  rather than risk shipping stale code.

Verified end-to-end against the live Mac Mini's current state earlier
today: image 55s newer than the last packages/ commit at the time →
"fresh, skip" (correct). When the next packages/ commit lands, the
script will see commit_epoch > image_epoch and trigger the base
rebuild automatically.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:34:29 +02:00
Till JS
3dc16bb885 refactor(mana/web): extract addTagId / removeTagIdWithUndo helpers
Four ListViews (calendar, contacts, places, todo) reimplemented the
same drag-drop tag append logic, and four matching DetailViews
reimplemented the same "remove tag with undo toast" logic. Extract
both into pure helpers in $lib/data/tag-mutations.ts that take a
store-agnostic update function — works for the standard tagIds
modules and for todo's metadata.labelIds via tasksStore.updateLabels.

Side win: places/views/DetailView's removeTag had no undo toast
(every other module did). Consolidating fixes the inconsistency.

zitare is the outlier — its drag target is a Quote, but the tags
live on a (possibly-not-yet-existing) Favorite record. Stays custom.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:26:15 +02:00
Till JS
1f26aa4f2f feat(local-llm): swap WebLLM/Qwen for transformers.js + Gemma 4 E2B
Replace the entire @mana/local-llm engine with a transformers.js-based
implementation backed by Google's Gemma 4 E2B (released 2026-04-02).
The external API of LocalLLMEngine — load(), generate(), prompt(),
extractJson(), classify(), onStatusChange(), isSupported() — is
preserved 1:1, so the /llm-test page, the playground module, and the
Svelte 5 reactive bindings in svelte.svelte.ts need no changes
beyond updating the default model key.

Why the engine swap: MLC has not (and as of today still hasn't)
published Gemma 4 builds for WebLLM. The webml-community team and
HuggingFace's onnx-community already have Gemma 4 E2B running in
the browser via transformers.js + WebGPU, with a documented
Gemma4ForConditionalGeneration class shipped in @huggingface/transformers
v4.0.0. Going through the ONNX route gets us the latest Google model
six days after release instead of waiting on MLC compilation.

Trade-offs accepted (discussed before this commit):
- transformers.js is a more generic ONNX runtime, so per-token
  throughput will be ~20-40% lower than WebLLM would deliver for the
  same model size. For a 2B model on a modern WebGPU device that's
  still well above interactive latency.
- The JS bundle gains ~2-3 MB (the ONNX runtime). Negligible compared
  to the 500 MB model download.
- transformers.js v4 is brand new (released alongside Gemma 4) so the
  Gemma4ForConditionalGeneration code path has very little battle
  testing yet. The risk is partially offset by webml-community's
  reference implementation.

What changed file by file:

- packages/local-llm/package.json: drop @mlc-ai/web-llm, add
  @huggingface/transformers ^4.0.0; bump version 0.1.0 → 0.2.0; rewrite
  description.

- packages/local-llm/src/types.ts: add `dtype` field to ModelConfig
  ('fp32' | 'fp16' | 'q8' | 'q4' | 'q4f16') so each model can request
  the quantization that matches its uploaded ONNX shards.

- packages/local-llm/src/models.ts: replace the old Qwen 2.5 + Gemma 2
  registry with a single `gemma-4-e2b` entry pointing at
  onnx-community/gemma-4-E2B-it-ONNX with q4f16 quantization. Future
  models can be added by appending entries — the /llm-test picker
  reads MODELS dynamically and picks them up automatically.

- packages/local-llm/src/cache.ts: replace the WebLLM-specific
  hasModelInCache helper with a generic Cache API probe that looks for
  `https://huggingface.co/{model_id}/resolve/main/tokenizer.json` in
  any open cache. tokenizer.json is small, downloaded first, and
  always present, so its presence is a reliable proxy for "model has
  been loaded before".

- packages/local-llm/src/engine.ts: full rewrite. Internally we now
  hold a transformers.js model + processor pair (created via
  AutoProcessor.from_pretrained + Gemma4ForConditionalGeneration.from_pretrained
  with `device: 'webgpu'`), and translate our LoadingStatus union from
  the library's `progress_callback` shape. generate() applies Gemma's
  chat template via the processor, runs model.generate() with optional
  TextStreamer for streaming, then slices the prompt tokens off the
  output tensor to compute per-call usage. The convenience methods
  (prompt, extractJson, classify) are unchanged because they only call
  generate() under the hood.

- packages/local-llm/src/generate.ts and status.svelte.ts: deleted.
  These were orphaned from a much earlier engine API (referenced
  `getEngine()` / `subscribe()` / `LlmState` symbols that haven't
  existed for a while) and were never re-exported from index.ts —
  they only showed up because `tsc --noEmit` was crawling the src
  tree. Their functionality lives in engine.ts + svelte.svelte.ts now.

- apps/mana/apps/web/package.json: swap the direct dep from
  @mlc-ai/web-llm to @huggingface/transformers. This is the same
  trick we used for the previous adapter-node externals warning —
  having it as a direct dep makes adapter-node's Rollup pass treat
  it as external automatically.

- apps/mana/apps/web/vite.config.ts: swap ssr.external entry from
  @mlc-ai/web-llm to @huggingface/transformers. Add a comment
  explaining the why so the next person doesn't wonder.

- apps/mana/apps/web/src/routes/(app)/llm-test/+page.svelte: change
  the default selectedModel from 'qwen-2.5-1.5b' to 'gemma-4-e2b'.
  All other model display strings come from the MODELS registry, so
  this is the single hard-coded reference that needed updating.

- pnpm-lock.yaml: regenerated. Confirmed @mlc-ai/web-llm is gone (0
  references) and @huggingface/transformers is in (4 references).

CSP: no header changes needed. We already opened connect-src for
huggingface.co + cdn-lfs.huggingface.co + raw.githubusercontent.com
when fixing the WebLLM blockers earlier today, and 'wasm-unsafe-eval'
is already in script-src — both transformers.js (ONNX runtime) and
WebLLM (MLC runtime) need that. If transformers.js spawns its
inference into a Web Worker via a blob URL we may need to add
`worker-src 'self' blob:` once we hit the first runtime test, but
the existing CSP should be enough for the synchronous path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:22:32 +02:00
Till JS
83828e5a44 fix(research): handle zero-hit retrieval — skip empty insert + graceful summary
Smoke-testing /api/v1/research/start with mana-search down surfaced a
crash: drizzle's .values([]) throws "values() must be called with at
least one value", which dropped the run into status='error' even though
the failure is a perfectly normal "no results" case.

Two changes:
- Guard the sources insert behind enriched.length > 0
- If retrieval returns nothing, short-circuit straight to status='done'
  with an explicit German "keine Quellen gefunden" summary instead of
  feeding an empty corpus to the synthesiser

The same path also triggers when every sub-query genuinely returns no
results (very specific question, niche domain) so this isn't just an
ops-failure case.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:19:17 +02:00
Till JS
e82851985b feat(questions): deep-research module — mana-search + mana-llm pipeline
End-to-end deep-research feature for the questions module: a fire-and-
forget orchestrator in apps/api that plans sub-queries with mana-llm,
retrieves sources via mana-search (with optional Readability extraction),
and streams a structured synthesis back to the web app over SSE.

Backend (apps/api/src/modules/research):
- schema.ts: pgSchema('research') with research_results + sources
- orchestrator.ts: three-phase pipeline (plan / retrieve / synthesise)
  with depth-aware config (quick=1×, standard=3×, deep=6× sub-queries)
- pubsub.ts: in-process event bus, single-node, swappable for Redis
- routes.ts: POST /start (202, fire-and-forget), GET /:id/stream (SSE),
  POST /start-sync (test only), GET /:id, GET /:id/sources
- Credit gating via @mana/shared-hono/credits — validate up-front,
  consume best-effort on `done`. Failed runs cost nothing.

Helpers (apps/api/src/lib):
- llm.ts: llmJson() + llmStream() over mana-llm OpenAI-compat API
- search.ts: webSearch() + bulkExtract() over mana-search Go service
- responses.ts: shared errorResponse / listResponse / validationError

Schema deployment:
- drizzle.config.ts (research-scoped) + drizzle/research/0000_init.sql
  hand-authored migration, deployable via psql -f or drizzle-kit push.
- drizzle-kit added as devDep with db:generate / db:push scripts.

Web client (apps/mana/apps/web/src/lib/api/research.ts):
- Typed start() / get() / listSources() / streamProgress(). The stream
  uses fetch + ReadableStream (not EventSource) so we can attach the
  JWT via Authorization header. Special-cases 402 for friendly toast.
- New PUBLIC_MANA_API_URL plumbing in hooks.server.ts + config.ts.

Module store (modules/questions/stores/answers.svelte.ts):
- New write-side store with createManual / startResearch / accept /
  softDelete. startResearch creates an optimistic empty answer, opens
  the SSE stream, debounces token deltas in 100ms batches into the
  encrypted local row, and on `done` replaces the streamed text with
  the parsed { summary, keyPoints, followUps } payload + citations
  resolved against research.sources.id.

Citation rendering (modules/questions/components/AnswerCitations.svelte):
- Tokenises [n] markers in the answer body into clickable pills with
  hover popovers showing title / host / snippet / external link.
- Lazy-loaded via a session-scoped source cache (stores/sources.svelte.ts)
  that deduplicates concurrent fetches.

UI (routes/(app)/questions/[id]/+page.svelte):
- Recherche card with three-state button (start / cancel / re-run),
  animated phase indicator, source counter.
- Confirmation dialog warning about web/LLM transmission since the
  question itself is locally encrypted.
- Toasts for success / error / cancel via @mana/shared-ui/toast.
- Re-run flow soft-deletes prior research-driven answers but keeps
  manual ones intact.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:15:35 +02:00
Till JS
30787e36d2 refactor(mana/web): consolidate DetailView scaffolding into DetailViewShell + useDetailEntity
Every module's inline-editable DetailView reimplemented the same
plumbing: liveQuery → optional decryptRecord → reset on id change
→ focused/confirmDelete state → save-on-blur → deleteWithUndo via
toastStore. Plus ~150 LOC of duplicated scoped CSS for the
.detail-view / .title-input / .properties / .prop-row / .section /
.danger-zone style track.

Extract two pieces:

  - useDetailEntity (svelte runes module, $lib/data/detail-entity.svelte.ts)
    handles the JS plumbing: liveQuery + optional decrypt + reset
    on id change + focused/confirmDelete state + deleteWithUndo.
    Supports a custom `loader` for cross-table joins (events+timeBlocks,
    timeEntries+timeBlocks, tasks+timeBlocks).

  - DetailViewShell ($lib/components/DetailViewShell.svelte)
    handles the visual scaffold: outer flex column with scroll,
    loading/not-found state, body snippet, danger zone with confirm
    flow. Exports the shared field/property/section/meta classes as
    :global so consumer snippets can use them without redefining.

Migrated 16 of the 18 DetailViews. Skipped:
  - zitare: no DB entity (quotes from bundled @zitare/content),
    no edit/delete flow.
  - events: different page shape (centered max-width, edit/view
    modes, eventId via direct prop instead of params, nested guest
    list / RSVP sections).

Side wins:
  - 6 encrypted modules (storage, uload, music, questions, calendar,
    todo) now route their decrypt logic through one path instead of
    six separate `liveQuery + decryptRecord({ ...raw })` variations.
  - times/views/DetailView had the same latent type bug as the
    ListView (reading .date / .startTime / .endTime / .source off
    LocalTimeEntry, which doesn't define them). Now uses toTimeEntry()
    via the loader option for the joined TimeEntry shape.

Net impact: ~3640 LOC removed across the 16 files (~49% reduction),
~510 LOC added for shell + helper. Net ~3130 LOC saved.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:10:42 +02:00
Till JS
c3cb9dd533 refactor(mana/web): consolidate ListView scaffolding into BaseListView
Every workbench-style module ListView reimplemented the same
liveQuery + filter + scroll-area + empty-state shell. Extract a
shared <BaseListView> in @mana/shared-ui (with toolbar/header/
listHeader/item/empty snippets) and migrate the 17 modules whose
list templates fit the workbench tailwind track.

While here:
- migrate DeckCard onto the existing (previously unused) shared
  Card atom from shared-ui/atoms.
- fix a latent type bug in times/ListView: it was reading .date /
  .startTime / .isRunning off LocalTimeEntry, which doesn't define
  them. Now uses the proper joined TimeEntry via toTimeEntry() like
  the rest of the times module.

Modules with their own scoped-CSS layout track (calendar, finance,
contacts, notes, places, todo, photos, habits, automations, dreams,
cycles) and outliers (calc, events, playground, zitare) are left
alone — migrating them would be a visual rewrite, not a structural
shell swap.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 18:40:47 +02:00
Till JS
c5e5963cbe fix(macmini): repair container auto-recovery (broken --env-file path)
Two unrelated bugs in scripts/mac-mini/ensure-containers-running.sh,
both caught while debugging a mana-auth crash loop on 2026-04-08:

1. The recovery path passed --env-file "$PROJECT_ROOT/.env.macmini" to
   docker compose, but that file has never existed on the server — only
   .env does, and compose auto-loads it from the working directory. The
   explicit --env-file silently caused recovered containers to start with
   empty secrets (e.g. blank MANA_AUTH_KEK), which made mana-auth crash
   the moment it came back up. The auto-recovery loop was therefore
   self-defeating: it kept "fixing" auth into the same broken state
   every 5 minutes for hours, with no notification because compose
   exited 0. Drop --env-file entirely and cd into PROJECT_ROOT so
   compose's standard .env discovery applies.

2. mana-infra-minio-init is a one-shot job container that legitimately
   sits in "exited" state after running once. The script flagged it as
   "stuck" every cycle, tried to "recover" it, and spammed the log with
   ERROR lines. Add an explicit ONESHOT_INIT_CONTAINERS allowlist and
   skip those names in both the initial scan and the post-recovery
   verification.

Also tee compose output into the log so future failures actually leave
a breadcrumb instead of disappearing into the void.

Also: bump @mlc-ai/web-llm from a transitive dep (via @mana/local-llm)
to a direct dep of @mana/web. SvelteKit's adapter-node post-build
Rollup pass uses the web app's direct deps as its externals heuristic;
without this entry it warns "@mlc-ai/web-llm ... could not be resolved
- treating it as an external dependency" on every build. Functionally
harmless (the dynamic import in LocalLLMEngine only fires in the
browser), but the warning hid a real adapter-node misconfiguration
that would have bitten us if we'd ever tried to SSR /llm-test.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 18:17:31 +02:00
Till JS
94ab125fbb fix(mana/web): SessionWarning + SuggestionToast into bottom-stack
Two more centred bottom-anchored toasts had the same problem the
EncryptionIntroBanner had in 2a437a586: their own position: fixed
with hardcoded bottom + transform centring put them in stacking
contexts that the QuickInputBar (z-index 90) either covered up
(SessionWarning, z-index 45 → hidden) or sat under (SuggestionToast,
z-index 9999 → covered the input bar instead).

Both moved into .bottom-stack as .bottom-stack-notification children
in (app)/+layout.svelte, with the parent handling positioning and
the components themselves stripped down to in-flow flex items.

- SessionWarning: was a free-floating element inside (app)/+layout
  but outside the bottom-stack — moved into the stack, kept the
  authStore.isAuthenticated gate so it only renders for logged-in
  users
- SuggestionToast: was mounted in the ROOT layout, but its only
  consumer (automationsStore) is an (app)-only module so the toast
  never made sense on auth/landing pages. Moved into (app) bottom-
  stack, removed from root layout

CSS cleanup in both: dropped position: fixed, bottom, left,
transform, max-width, z-index. Slide-up keyframes rewritten to use
translateY only (no more parent-transform-X to fight with).

Stack order in (app)/+layout.svelte from top to bottom now:
  1. EncryptionIntroBanner  (one-time)
  2. NotificationBar         (guest nudge, conditional)
  3. SessionWarning          (auth-only, conditional)
  4. SuggestionToast         (auto-dismissing, conditional)
  5. QuickInputBar
  6. TagStrip
  7. PillNav

Corner-anchored toasts (PwaUpdatePrompt right-12px, SyncConflictToast
right-1rem) intentionally NOT moved — they live in different visual
real estate and don't compete with the centred stack column.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 18:16:55 +02:00
Till JS
2a437a5861 fix(mana/web): EncryptionIntroBanner sits inside the bottom-stack
The one-time encryption intro banner used its own position: fixed at
bottom: 1.5rem with z-index: 60, mounted at the root layout level.
That put it in a stacking context the QuickInputBar covered up — the
search bar visually sat ON TOP of the banner instead of below it,
making the privacy claim half-readable and the dismiss X impossible
to click.

Same fix the guest nudge got in c8ed58b7d: move into the bottom-stack
flex container in (app)/+layout.svelte and let the parent handle
positioning. The banner is now the FIRST child of the stack so it
renders above the guest nudge / QuickInputBar / TagStrip / PillNav
and stays in flow as the stack reflows when nav collapses.

- Removed `<EncryptionIntroBanner />` from root +layout.svelte (it
  doesn't belong above the (app) gate anyway since it self-checks
  isVaultUnlocked() which is always false outside auth context)
- Mounted inside `.bottom-stack` as the first `.bottom-stack-notification`
  child in (app)/+layout.svelte
- Stripped position: fixed / bottom / left / transform / max-width /
  z-index from the banner CSS — now an in-flow flex item with
  width: 100% (the wrapper centres + caps width via the existing
  bottom-stack-notification rules)
- Slide-up animation rewritten to use translateY only since the
  parent no longer transforms the banner

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 18:08:35 +02:00
Till JS
01632dfa49 docs(devlog): voice quick-add pipeline + LLM parsing live in prod
Covers today's voice quick-add session: shared <VoiceCaptureBar>
across five modules, generic /api/v1/voice/{transcribe,parse-task,
parse-habit} endpoints, LLM-driven structure extraction with tag
matching, the mana-llm Ollama routing fix (Colima LAN-range RST
gotcha), gemma3:12b + few-shot prompt iteration, 49 unit tests,
the .env.secrets persistent dev-secret layer, and the two real
bugs found during prod deployment (SvelteKit prod-export
restriction + $env/dynamic/private PUBLIC_-prefix exclusion).

18 commits, ~+1.220 LOC net, voice quick-add now live on mana.how
for todo + habits with full structured extraction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 18:01:21 +02:00
Till JS
4fd5ff3199 feat(local-llm): add Gemma 2 + allow HF/MLC hosts in CSP
WebLLM was blocked by connect-src — model config and weight shards live
on huggingface.co (+ cdn-lfs.* for LFS), and the WebGPU model_lib WASM
comes from raw.githubusercontent.com (binary-mlc-llm-libs). Also wires
Gemma 2 2B/9B into the model registry so /llm-test picks them up.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 18:00:57 +02:00
Till JS
93748c0c9c feat(playground): real LLM playground module backed by mana-llm + saved snippets
The playground route was previously a stub. This turns it into a proper
module:

- A streaming chat surface that talks to mana-llm's OpenAI-compatible
  /v1/chat/completions and /v1/models. The SSE chunk parser is hand-rolled
  in modules/playground/llm.ts (~30 lines) rather than pulling a dep —
  the wire format is straight OpenAI and the playground is the only
  consumer right now. If chat / todo enrichment / cycles insights end up
  hitting the same surface, this lifts cleanly into $lib/data/llm-client.ts.
- A persisted **snippets** store: name + systemPrompt + (model, temperature)
  defaults that the user can pin and reorder. Stateless chat history stays
  out — that's what the chat module is for. Both `name` and `systemPrompt`
  are encrypted (same pattern as notes/dreams), with a registry entry in
  data/crypto/registry.ts and a Dexie schema in data/database.ts.
- Standard module wiring: collections.ts / queries.ts / types.ts /
  stores/snippets.svelte.ts / module.config.ts, registered in
  module-registry.ts alongside the other 30+ modules.
- ListView.svelte and the (app)/playground/+page.svelte route consume
  the new store + the streaming client.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 17:41:18 +02:00
Till JS
d3a1f00072 feat(crypto): roll places + locationLogs into the encryption registry
Phase 8 follow-up. Places carries GDPR-sensitive PII so it gets the same
treatment as the rest of Phase 7+8, with one deliberate carve-out:

- `places` encrypts the user-typed surface (name / description / address)
  but leaves lat/lng plaintext so the proximity matcher in
  tracking.svelte.ts can run during background geolocation logging without
  a vault unlock. The trade-off is documented inline in registry.ts: a
  handful of named POIs is much less sensitive than the full movement
  trail.
- `locationLogs` IS the movement trail, so every coordinate field
  (latitude, longitude, accuracy, altitude, speed, heading) is encrypted.
  Indexed columns (timestamp, placeId, [placeId+timestamp]) stay plaintext
  for the time-range scans in the log view.
- `placeTags` stays out of the registry — pure FK join table, no user
  content, same pattern as manaLinks.

queries.useAllPlaces / useLocationLogs now decrypt before mapping to the
DTO. placesStore.create/update snapshot the plaintext DTO before
encryptRecord mutates the local in place — same pattern as
notes/dreams/contacts. trackingStore.logPosition decrypts the place set
before running the nearest-place match (the lat/lng carve-out means this
still works pre-unlock, but downstream consumers want the decrypted name).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 17:41:00 +02:00
Till JS
a7f3577ee2 fix(voice): set MANA_LLM_URL on mana-web — $env/dynamic/private hides PUBLIC_
The first prod deploy of voice quick-add (3b41b39a3) silently fell
back for every transcript: title=transcript verbatim, dueDate=null,
priority=null, labels=[]. The endpoint code was reaching the
fallback() path even though mana-llm was healthy and reachable from
inside the mana-web container.

Root cause: SvelteKit's $env/dynamic/private explicitly excludes any
env var that starts with the public prefix (default PUBLIC_). The
parse-task code read

  env.MANA_LLM_URL || env.PUBLIC_MANA_LLM_URL || 'http://localhost:3025'

expecting to fall back to PUBLIC_MANA_LLM_URL when MANA_LLM_URL was
unset, but $env/dynamic/private treats PUBLIC_MANA_LLM_URL as if it
didn't exist on the server side. So it always fell through to
http://localhost:3025, which from inside mana-web is nothing,
fetch threw, and coerce returned the fallback shape.

Two fixes:

1. docker-compose.macmini.yml — set MANA_LLM_URL (no prefix) on
   mana-web alongside PUBLIC_MANA_LLM_URL. The PUBLIC_ var is still
   needed for the browser-side playground and status page; the
   private one is what the parse endpoints actually read.

2. parse-task and parse-habit — drop the dead env.PUBLIC_MANA_LLM_URL
   fallback so the next dev who reads the code doesn't think it'd
   ever work. Add a comment explaining the SvelteKit gotcha so the
   next person setting up a new env var doesn't repeat this mistake.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 17:20:03 +02:00
Till JS
3b41b39a32 fix(voice/parse-task): extract helpers to coerce.ts so prod build passes
SvelteKit's production build forbids non-handler exports from a
+server.ts file — dev runs them fine but `pnpm build` errored with
"Invalid export 'coerce' in /api/v1/voice/parse-task" when trying to
deploy mana-web with the new unit tests.

Move ParseResult, fallback, DATE_TRIGGER_PATTERNS,
PRIORITY_TRIGGER_PATTERNS, transcriptMentions, coerce, and extractJson
into a sibling coerce.ts module. The +server.ts file imports from
there and only exports POST, which is the prod build's hard rule.

Tests now import from ./coerce instead of from the route handler,
which also drops the $env/dynamic/private resolution dance from the
test fast path — coerce.test.ts now runs in ~130ms instead of ~400ms
because it pulls in zero SvelteKit runtime.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 17:12:26 +02:00
Till JS
4f6609a595 docs(devlog): backfill daily devlogs for 2026-04-01 through 2026-04-07
Fills the gap between the last entry (2026-03-31) and today. The 2026-04-06
slot is intentionally skipped — no commits that day.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 17:11:10 +02:00
Till JS
0119e48edb test(voice/parse-task): unit tests for coerce + transcriptMentions guards
The deterministic guards in parse-task's coerce() are the load-bearing
defense against gemma3 hallucinating dueDate / priority on bare tasks.
The integration tests against the live LLM cover the happy path
end-to-end, but they go offline as soon as mana-llm is unreachable —
the unit tests cover the guard logic in isolation with synthetic LLM
responses, so a regression in the rules is caught even when the LLM
itself is dark.

22 cases:

- transcriptMentions: substring matching, case-insensitivity, empty
  pattern list, the German + English date words from the few-shot
  examples, and the negative cases ("Mülltonnen rausstellen",
  "Buy milk") that must NOT trigger.

- coerce: fallback shape on garbage input, transcript-as-title when
  the model omits one, time-component stripping ("2026-04-09T14:00:00"
  → "2026-04-09"), malformed dueDate rejection, the dueDate /
  priority hallucination guards (drop when the transcript has no
  trigger word), real-date / real-priority preservation, label
  filtering (cap at 3, drop non-strings, empty array on non-array
  input), invalid priority value rejection.

Helpers exported solely for the tests via a __test object — the
production endpoint goes through buildPrompt + coerce as before.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 17:04:52 +02:00
Till JS
7007140d13 fix(voice): switch to gemma3:12b + few-shot prompt for parse-task
Two related changes that fall out of real end-to-end testing against
the now-working local mana-llm.

1. Default model bumped from gemma3:4b to gemma3:12b for both
   parse-task and parse-habit. The 4b model gets weekday math
   off-by-one ("nächsten Montag" from a Wednesday → 2026-04-14
   instead of 2026-04-13), aggressively shortens titles ("Anna
   anrufen" → "Anrufen"), and frequently paraphrases habit names
   instead of copying verbatim ("Joggen" instead of "Laufen") which
   the verbatim-validation in coerce drops, costing an LLM round-trip
   for nothing. The 12b variant is roughly 10% slower for these
   tiny prompts (~1.1s vs ~1.0s on the GPU box) so the accuracy
   win is essentially free.

2. parse-task prompt rewritten as few-shot. Pure rule descriptions
   were *worse* than simple examples — the long "Rules — read
   carefully" section in the previous prompt actually made the model
   compute next Monday as 2026-04-14 even though a direct "what date
   is next Monday?" prompt to the same model returned 2026-04-13.
   The detailed rules were also priming the model to over-shorten
   titles and over-eagerly tag filler words. Five worked examples
   (including the previously-failing "Anna nächsten Montag anrufen"
   case) plus one novel case ("Mama am Wochenende besuchen") all
   come back correct now, including for the novel one.

The deterministic guards in coerce() are kept as a backstop for the
day the GPU box swaps in a weaker model — they're cheap and don't
hurt the happy path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:59:32 +02:00
Till JS
b505024f7b fix(voice/parse-task): guard against gemma3:4b hallucinating dueDate + priority
Real end-to-end testing against the now-working local mana-llm
surfaced two model behaviours the prompt couldn't talk down:

1. gemma3:4b stamps today's date on every task that doesn't have a
   real time anchor. "Mülltonnen rausstellen" came back with
   dueDate=2026-04-08 and priority=low even though the prompt
   explicitly said "MUST be null when no date is mentioned". After
   typing "Buy milk" the user would silently get a today-due task,
   which is worse than no parsing at all.

2. The model occasionally returns dueDate as a full ISO timestamp
   ("2026-04-09T14:00:00") when the transcript mentions a time. The
   coerce regex previously matched the prefix and let the timestamp
   through unchanged, which then breaks the YYYY-MM-DD-shaped Dexie
   field downstream.

Fix: deterministic post-processing in coerce. The prompt is also
tightened with explicit "ONLY when…" rules but the guards are the
load-bearing change since gemma3:4b ignores prompt restrictions.

- Strict YYYY-MM-DD extraction: a leading-anchor regex match keeps
  only the date prefix even if the model adds a time component.
- DATE_TRIGGER_PATTERNS: substring scan over the original transcript
  for German + English date words. If the LLM returned a dueDate but
  the transcript has zero matches, drop the date — it was a
  hallucination. False positives are preferable to false negatives:
  letting through a fake date is more annoying than suppressing a
  real one the user can re-type.
- PRIORITY_TRIGGER_PATTERNS: same idea for priority. The model thinks
  taxes are inherently urgent; we don't want to inherit that opinion.

The labels field is left noisy on purpose — "müll", "unbedingt",
"erledigen" all come back from a single transcript and only the ones
that fuzzy-match an existing workspace tag end up on the task, so
filtering filler words at this layer would be wasted work.

Verified against five transcripts spanning bare/explicit/relative
date in DE + EN. Real LLM round-trip via http://localhost:5173https://llm.mana.how → ollama gemma3:4b. Local mana-llm now reaches
its Ollama backend after the gpu-proxy routing fix in 7f382138a.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:50:19 +02:00
Till JS
bfeeef7819 chore(matrix): final scrub of stale matrix references
A grep audit after the previous matrix removal commits found a handful
of stragglers in non-runtime files that the earlier sweeps missed:

- services/mana-llm/CLAUDE.md: removed matrix-ollama-bot from the
  consumer-apps diagram and from the related-services table
- services/mana-video-gen/CLAUDE.md: removed "Matrix Bots" integration
  bullet
- packages/notify-client/README.md: removed sendMatrix() doc entry
  (the method itself was already gone in the prior cleanup)
- docker/grafana/dashboards/logs-explorer.json: dropped the "Matrix
  Stack" log row that queried tier="matrix" (would show no data forever)
- docker/grafana/dashboards/master-overview.json: dropped the "Matrix
  Bots" stat panel that counted up{job=~"matrix-.*-bot"}
- apps/mana/apps/landing/src/data/ecosystem-health.json: regenerated via
  scripts/ecosystem-audit.mjs to drop matrix from the app list, icon
  counts, file analytics, top offenders and authGuard missing list
- .gitignore: removed services/matrix-stt-bot/data/ pattern (the
  service itself was deleted long ago)

Production-side stragglers also addressed (not in this commit):
- DROP USER synapse on prod Postgres (the parallel cleanup commit
  2514831a3 dropped DATABASE matrix + DATABASE synapse but left the
  role behind)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:47:54 +02:00
Till JS
029c7973ef feat(mana/web): pass MANA_LLM_API_KEY from voice parse proxies
The /api/v1/voice/parse-task and /api/v1/voice/parse-habit endpoints
forwarded transcripts to mana-llm without an X-API-Key header. This
worked against the local mana-llm container (no auth) but silently
fell back to the no-LLM path when pointed at gpu-llm.mana.how, which
requires an API key — voice quick-add would look like it was running
in degraded mode forever with no signal that auth was the cause.

Now both endpoints read MANA_LLM_API_KEY from the server-side env and
attach it as X-API-Key when present, mirroring the pattern already
used by /api/v1/voice/transcribe for mana-stt. When the var is empty
the header is omitted, so local Docker setups without auth still work.

Plumbing: generate-env.mjs writes MANA_LLM_URL + MANA_LLM_API_KEY into
apps/mana/apps/web/.env, .env.development gets the new keys with empty
defaults, ENVIRONMENT_VARIABLES.md documents the gateway and where to
get a key.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:40:26 +02:00
Till JS
e337243303 test(mana/web): unit tests for voice quick-add matchers + fix habit ranking
Two new test files lock in the matching boundary where free-text LLM
hints meet the user's actual workspace data — that's where bugs hide
silently. Both matchers are now pure-function-shaped (the production
wrappers just feed them Dexie data) so the tests run without
fake-indexeddb or any I/O.

todo: 16 cases for matchLabelsToTagsPure covering exact / case /
diacritic / substring / specificity rules + the "never invent tags"
guarantee.

habits: 11 cases for matchHabitToTranscript including the word-
boundary "Bier vs ausprobiert" false-positive, multi-word matching,
and a real bug the test surfaced on the first run:

  Without specificity ranking, "Tee" would always beat "Grüner Tee"
  because the first matching habit in input order won. The matcher
  now collects all candidates and returns the one with the most
  matched tokens, so multi-word habits beat single-word substrings
  whenever both could fit the transcript.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:37:11 +02:00
Till JS
8e8b6ac65f fix(mana-auth) + chore: rewrite /api/v1/auth/login JWT mint, remove Matrix stack
This commit bundles two unrelated changes that were swept together by an
accidental `git add -A` in another working session. Documented here so the
history reflects what's actually inside.

═══════════════════════════════════════════════════════════════════════
1. fix(mana-auth): /api/v1/auth/login mints JWT via auth.handler instead
   of api.signInEmail
═══════════════════════════════════════════════════════════════════════

Previous attempt (commit 55cc75e7d) tried to fix the broken JWT mint in
/api/v1/auth/login by switching the cookie name from `mana.session_token`
to `__Secure-mana.session_token` for production. That was necessary but
not sufficient: Better Auth's session cookie value isn't just the raw
session token, it's `<token>.<HMAC>` where the HMAC is derived from the
better-auth secret. Reconstructing the cookie from auth.api.signInEmail's
JSON response only gave us the raw token, so /api/auth/token's
get-session middleware still couldn't validate it and the JWT mint kept
silently failing.

Real fix: do the sign-in via auth.handler (the HTTP path) rather than
auth.api.signInEmail (the SDK path). The handler returns a real fetch
Response with a Set-Cookie header containing the fully signed cookie
envelope. We capture that header verbatim and forward it as the cookie
on the /api/auth/token request, which now passes validation and mints
the JWT correctly.

Verified end-to-end on auth.mana.how:

  $ curl -X POST https://auth.mana.how/api/v1/auth/login \
      -d '{"email":"...","password":"..."}'
  {
    "user": {...},
    "token": "<session token>",
    "accessToken": "eyJhbGciOiJFZERTQSI...",   ← real JWT now
    "refreshToken": "<session token>"
  }

Side benefits:
- Email-not-verified path is now handled by checking
  signInResponse.status === 403 directly, no more catching APIError
  with the comment-noted async-stream footgun.
- X-Forwarded-For is forwarded explicitly so Better Auth's rate limiter
  and our security log see the real client IP.
- The leftover catch block now only handles unexpected exceptions
  (network errors etc); the FORBIDDEN-checking logic in it is dead but
  harmless and left in for defense in depth.

═══════════════════════════════════════════════════════════════════════
2. chore: remove the entire self-hosted Matrix stack (Synapse, Element,
   Manalink, mana-matrix-bot)
═══════════════════════════════════════════════════════════════════════

The Matrix subsystem ran parallel to the main Mana product without any
load-bearing integration: the unified web app never imported matrix-js-sdk,
the chat module uses mana-sync (local-first), and mana-matrix-bot's
plugins duplicated features the unified app already ships natively.
Keeping it alive cost a Synapse + Element + matrix-web + bot container
quartet, three Cloudflare routes, an OIDC provider plugin in mana-auth,
and a steady drip of devlog/dependency churn.

Removed:
- apps/matrix (Manalink web + mobile, ~150 files)
- services/mana-matrix-bot (Go bot with ~20 plugins)
- docker/matrix configs (Synapse + Element)
- synapse/element-web/matrix-web/mana-matrix-bot services in
  docker-compose.macmini.yml
- matrix.mana.how/element.mana.how/link.mana.how Cloudflare tunnel routes
- OIDC provider plugin + matrix-synapse trustedClient + matrixUserLinks
  table from mana-auth (oauth_* schema definitions also removed)
- MatrixService import path in mana-media (importFromMatrix endpoint)
- Matrix notification channel in mana-notify (worker, metrics, config,
  channel_type enum, MatrixOptions handler)
- Matrix entries from shared-branding (mana-apps + app-icons),
  notify-client, the i18n bundle, the observatory map, the credits
  app-label list, the landing footer/apps page, the prometheus + alerts
  + promtail tier mappings, and the matrix-related deploy paths in
  cd-macmini.yml + ci.yml

Devlog/manascore/blueprint entries that mention Matrix are left intact
as historical record. The oauth_* + matrix_user_links Postgres tables
stay on existing prod databases — code can no longer write to them, drop
them in a follow-up migration if you want them gone for real.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:32:13 +02:00
Till JS
4eb5dfe4a0 feat(mana/web): named workbench scenes (Home, Deep Work, …)
Users can now define multiple named layouts of the workbench homepage and
switch between them. Each scene holds its own openApps list with per-app
window state (minimized / maximized / size). Scene list syncs cross-device
via mana-sync; the active scene id is per-device (localStorage) so device
A doesn't pull device B into a different scene.

- new `workbenchScenes` Dexie table, registered in manaCoreConfig
- `workbenchScenesStore` (Dexie liveQuery) with scene CRUD + per-scene app
  mutations; auto-seeds a default "Home" scene on first run
- SceneTabs pill bar above the carousel with dnd reorder + context menu
  (rename / duplicate / delete); SceneRenameDialog and a reusable
  ConfirmDialog for the destructive path
- workbench +page.svelte refactored to delegate all openApps mutations to
  the store; the carousel itself is unchanged

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:25:13 +02:00
Till JS
d37483a0f9 feat(todo): map LLM topic hints to existing workspace tags
The parse-task endpoint already returns free-text label hints from the
LLM ("steuern", "haushalt", …). Now the todo store fuzzy-matches each
hint against the user's existing tags via tagCollection and assigns
the matched IDs to the task's metadata.labelIds.

Match policy is intentionally conservative:
- Normalize via NFD strip + lowercase + collapsed whitespace
- Exact normalized match wins
- Substring fallback only for ≥3 char strings (avoids "ab" hitting
  every tag containing "ab")
- Never auto-creates a tag — even if the LLM is sure, an unknown topic
  silently drops, because auto-creating would clutter the user's tag
  list with one-off duplicates from voice transcripts

Both flows pick this up: voice always (transcripts almost always carry
topic hints) and typed only when there's structured payoff, same
asymmetry as before — typed quick-add now also enriches when the LLM
just finds a tag match without a date or priority.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:21:32 +02:00
Till JS
d8da11a4ff feat(todo): typed quick-add gets the same LLM enrichment as voice
Press Enter on "Steuererklärung morgen 14 Uhr hoch" and the task lands
instantly with your exact text as the title — then a background pass
through /api/v1/voice/parse-task swaps in dueDate + priority once
mana-llm answers. The title only gets rewritten when the LLM actually
finds structured info (dueDate or priority); for plain titles like
"Mülltonnen rausstellen" the typed text is left alone, since silently
"cleaning up" perfectly fine input is more annoying than helpful.

Pulled the parse + STT-then-parse plumbing apart so both flows share
parseTaskText() and only differ in policy: voice always applies the
LLM title (raw transcripts are noisy), typed only when there's
structured payoff.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:12:17 +02:00
Till JS
c32a5a57de feat(todo): LLM-parse spoken tasks into title + dueDate + priority
The previous voice quick-add dumped the whole transcript into the task
title — fine for "Steuererklärung" but useless for "Steuererklärung
morgen 14 Uhr hoch", which should land as title="Steuererklärung",
dueDate=tomorrow, priority="high".

New endpoint /api/v1/voice/parse-task posts the transcript to mana-llm
(gemma3:4b, temperature 0) with a tight system prompt that asks for
strict JSON: { title, dueDate, priority, labels }. The endpoint coerces
the response back into the typed shape and falls through to
{ title: transcript, … } whenever anything goes wrong — mana-llm down,
JSON garbled, network timeout. Voice quick-add must never fail harder
than typed quick-add, so the fallback path is the rule, not the
exception.

Labels come back from the LLM as free-text topic hints and don't yet
map to the workspace's tag IDs — fuzzy matching against existing tags
is a follow-up.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:08:09 +02:00
Till JS
b48c9ff80f refactor(mana/web): migrate dreams + memoro to /api/v1/voice/transcribe
The per-module /api/v1/memoro/transcribe and /api/v1/dreams/transcribe
endpoints were literal copies that proxied to mana-stt. Now that the
generic /api/v1/voice/transcribe endpoint exists (added with notes),
point both stores at it and delete the duplicates. -200 LOC, one place
to update STT auth or response shape from now on.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:05:49 +02:00
Till JS
b841a24e73 feat(todo): voice quick-add in workbench ListView via shared <VoiceCaptureBar>
Speak a task and it lands in the list as a placeholder while mana-stt
transcribes it; the title swaps in once the transcript returns.

No date/priority/label parsing yet — that's a follow-up that needs an
LLM pass over the transcript. For now the whole transcript becomes the
task title and the user can edit inline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:01:50 +02:00
Till JS
9b3d7c7325 feat(notes): voice capture in workbench ListView via shared <VoiceCaptureBar>
Drop a mic into Notes — record, transcribe through the new generic
/api/v1/voice/transcribe proxy (mana-stt), then write the result back
into the placeholder note. The first transcript line becomes the title
when it fits in 80 chars, otherwise a generic 'Sprachnotiz' label.

The inline editor refreshes from the live note while the placeholder
'…' content is still on screen, so a transcript that arrives a moment
after the editor opens shows up automatically without overwriting
anything the user has typed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:59:42 +02:00
Till JS
079cc39dbc refactor(mana/web): extract shared <VoiceCaptureBar> for module voice capture
Dreams and Memoro had two literal copies of the MediaRecorder boilerplate
plus parallel mic-button markup, error UI, and requireAuth gating. Lift
the recorder + bar into $lib/components/voice and add it to the memoro
workbench ListView (which had no mic at all). New voice-capture features
just drop in <VoiceCaptureBar> with idleLabel/feature/reason/onComplete.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:51:22 +02:00
Till JS
45958ad885 feat(mana/web): global requireAuth() gate for guest-blocked features
The unified Mana app runs most modules in a "guest mode": you can
open a module, look around, type a quick note, etc. without an
account. But anything that touches an *encrypted* table (dreams
voice capture, memoro recordings, notes, todo, calendar events, …)
needs the user to be logged in — the encryption vault only unlocks
against a Mana Auth session, and writing to those tables without
it throws `VaultLockedError` at the very last step of the action.

Before this commit, every entry point into an encryption-required
action would silently let the guest go through the whole flow
(record audio, wait for transcription, open the dexie write) and
then explode with a stack-trace error. The user lost work and
didn't know why. The dreams voice capture flow surfaced this
during the 2026-04-08 STT debugging session.

The fix is a global imperative gate: `requireAuth({ feature, reason })`.
Call sites await it before the action; it returns immediately if the
user is already authenticated, otherwise pops a global modal that
asks the guest to log in or cancel. Promise-based, so callers
decide what to do with `false` (silent abort, restore state, own
toast).

  $lib/auth/require-auth.svelte.ts          new — store + helper
  $lib/components/auth/AuthRequiredModal.svelte  new — global modal
  routes/+layout.svelte                     mount the modal once
  packages/shared-utils/src/analytics.ts    new ManaEvents.featureBlockedByAuth
                                            event for conversion tracking

Wired into the two voice-capture entry points that actually exhibited
the bug:

  modules/dreams/ListView.svelte  → feature: 'dreams-voice-capture'
  routes/(app)/memoro/+page.svelte → feature: 'memoro-voice-capture'

Both gate on `requireAuth()` BEFORE the mic permission request, so
guests see the friendly "Konto erforderlich" modal instead of
recording → transcribing → crashing.

Design choices documented in detail in the require-auth.svelte.ts
header comment:
  - Imperative function (not a button wrapper component) so it
    works in event handlers, store actions, keyboard shortcuts,
    drag-drop handlers — anywhere async code runs.
  - Single global modal mounted once in the root layout, no
    portal/z-index gymnastics; two simultaneous prompts replace
    each other (the most recent one wins).
  - Checks `authStore.isAuthenticated`, not vault-unlocked state —
    the user-facing concept is "I need an account", not "I need
    a working encryption vault". Vault-unlock failures (network
    error etc.) are a separate bug class with their own UX.
  - The modal navigates to `/login?next=<current path>` so the
    user lands back on the same page after logging in. The
    Promise resolves `false` on navigation; the user re-clicks
    the original button after coming back, and the second click
    sees `isAuthenticated === true` and proceeds without a modal.
    Re-triggering the original action across a navigation cycle
    would require restoring half-recorded mic state — not worth
    the complexity, and the second click is a clean UX.

How to wire a new entry point (4 lines):

    import { requireAuth } from '$lib/auth/require-auth.svelte';

    async function handleCreateThing() {
      const ok = await requireAuth({
        feature: 'create-thing',
        reason: 'Things werden verschlüsselt gespeichert. Dafür brauchst du ein Mana-Konto.',
      });
      if (!ok) return;
      // ...existing logic
    }

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:36:38 +02:00
Till JS
2b4494628e fix(mana/web): unblock voice capture — permissions policy, notification mount, dev SW
Three independent bugs that conspired to make the dreams + memoro mic
buttons completely unusable in production AND in dev. Each one alone
would have been the only blocker; they layered on top of each other so
fixing the top one just exposed the next.

1. Permissions-Policy header blocked the microphone API entirely.
   `packages/shared-utils/src/security-headers.ts` set
   `microphone=()` which means "no origin, including self, may use
   the microphone". `getUserMedia()` throws a `Permissions policy
   violation` and the browser never even shows the permission
   dialog — no amount of OS / browser / site settings can override
   it because the policy blocks the API at the document level.
   Fix: change to `microphone=(self)` so mana.how itself can use
   the API. Camera stays disallowed (no module needs it).

2. Notification permission was requested at layout mount time.
   `(app)/+layout.svelte` called
   `notificationService.requestPermission()` from `onMount()`. Modern
   browsers require permission requests to come from a user gesture
   — calling it without one queues the prompt until the next click.
   That meant the user's FIRST click on any button (in this case the
   dreams "Traum sprechen" mic button) showed the queued notifications
   prompt instead of the action they actually clicked. Worse,
   `getUserMedia()` was then silently dropped because Chrome only
   shows one permission dialog at a time.
   Fix: remove the mount-time call entirely. Notification permission
   must be requested from a button the user explicitly clicks
   ("Benachrichtigungen aktivieren" toggle in Settings or first time
   a reminder is created) — the reminder scheduler still runs without
   permission, it just won't fire OS notifications until granted.

3. vite-plugin-pwa registered a service worker in dev that cached
   the old layout chunks across reloads, so the fix for #2 was
   invisible until the user manually unregistered the SW in DevTools.
   `vite-plugin-pwa` defaults `devEnabled: true`, which is a
   well-known footgun for fast iteration. Production still gets the
   full SW (this only flips dev). The 2026-04-08 mic-button hunt
   took an extra hour for exactly this reason.
   Fix: pass `devEnabled: false` to createPWAConfig in vite.config.ts.

Verified: in a fresh incognito tab on `localhost:5173/`, opening the
Dreams app in the workbench and clicking the mic button now shows the
microphone permission dialog directly (no notifications hijack), and
recording → transcription works end-to-end against the production
mana-stt service on the GPU box.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:36:03 +02:00
Till JS
abe0a21966 refactor(auth-ui): tighten LoginPage UX, a11y, and dead code
Some checks are pending
CI / Build mana-crawler (push) Blocked by required conditions
CI / Build mana-media (push) Blocked by required conditions
CI / Build mana-credits (push) Blocked by required conditions
CI / Build mana-web (push) Blocked by required conditions
CI / Build chat-backend (push) Blocked by required conditions
CI / Build chat-web (push) Blocked by required conditions
CI / Build todo-backend (push) Blocked by required conditions
CI / Build todo-web (push) Blocked by required conditions
CI / Build calendar-backend (push) Blocked by required conditions
CI / Build calendar-web (push) Blocked by required conditions
CI / Build clock-web (push) Blocked by required conditions
CI / Build contacts-backend (push) Blocked by required conditions
CI / Build contacts-web (push) Blocked by required conditions
CI / Build presi-web (push) Blocked by required conditions
CI / Build storage-backend (push) Blocked by required conditions
CI / Build storage-web (push) Blocked by required conditions
CI / Build telegram-stats-bot (push) Blocked by required conditions
CI / Build nutriphi-backend (push) Blocked by required conditions
CI / Build nutriphi-web (push) Blocked by required conditions
CI / Build skilltree-web (push) Blocked by required conditions
CI / Build mana-matrix-bot (Go) (push) Blocked by required conditions
Docker Validate / Validate Dockerfiles (push) Waiting to run
Docker Validate / Build calendar-web (push) Blocked by required conditions
Docker Validate / Build todo-backend (push) Blocked by required conditions
Docker Validate / Build todo-web (push) Blocked by required conditions
Docker Validate / Build zitare-web (push) Blocked by required conditions
Docker Validate / Build mana-auth (push) Blocked by required conditions
Docker Validate / Build mana-sync (push) Blocked by required conditions
Docker Validate / Build mana-media (push) Blocked by required conditions
Mirror to Forgejo / Push to Forgejo (push) Waiting to run
LoginPage cleanup:
- Drop dev pre-fill credentials and the secret logo-as-button trick
- Remove duplicate in-component theme toggle; accept isDark as a prop and let the (auth) layout's global theme toggle drive it
- Move passkey CTA below the password form so the primary flow stays primary
- Remove the dead "Angemeldet bleiben" checkbox (was bound but never forwarded to onSignIn)
- Fix the skip-to-form link to use sr-only/focus:not-sr-only so it only appears on keyboard focus
- Fix the "oder" divider to render its before/after hairlines by setting an explicit color on the parent
- Wire focus-visible outlines on all interactive controls
- Bump 0.6 → 0.75 opacity on subtitle text for AA contrast
- Drop opacity-60 from the headerControls wrapper

Robustness:
- Track all setTimeout IDs in a Set and clear them in an effect cleanup so navigation away doesn't fire stale callbacks (success redirects, error shake, focus restore)
- Replace (result as any) casts with the new typed AuthResult fields
- New resolveErrorCode() helper prefers result.errorCode and falls back to legacy string matching, so rate-limit / account-lock detection survives i18n
- WebAuthn Conditional UI: on mount, if PublicKeyCredential.isConditionalMediationAvailable(), call onSignInWithPasskey({ conditional: true }) so passkeys appear inline in the email autofill dropdown
- Extract the dismissible success-banner markup into a {#snippet successBanner} and reuse it for the verified / verification-sent / magic-link-sent cases (~50 lines of duplicate JSX out)

Page wrappers:
- login/+page.svelte passes isDark={theme.isDark} so the in-app theme store drives both layouts
- register/+page.svelte wraps trackGuestConversion() in queueMicrotask + try/catch so analytics can never block the success redirect
- Drop the dead baseSignupCredits={25} prop from register/+page.svelte (RegisterPage never accepted it)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:41:19 +02:00
Till JS
a3a47459c6 docs(audit): file-bytes encryption implementation plan + audit roll-up
Two changes:

1. New BACKLOG_FILE_BYTES_ENCRYPTION.md captures everything I'd
   want to know if I were picking up the file-bytes encryption
   work cold in 6 months. ~370 lines, sits next to
   DATA_LAYER_AUDIT.md for discoverability.

   Sections:
   - TL;DR + status (deferred, no production impact yet)
   - Goal + non-goals
   - Threat model delta table (mode-by-mode)
   - Architecture: write path with ASCII flow diagram
   - Architecture: read path with ASCII flow diagram
   - The six hard parts:
     1. Web Crypto AES-GCM doesn't stream → chunked-AEAD wrapper
     2. Multipart uploads need coordinated chunking (S3 5 MB minimum
        vs. our 1 MB AES-GCM chunks)
     3. Resumable uploads + key persistence (new _pendingUploads
        table for the in-flight content key)
     4. No more server-side thumbnails (three options, recommended:
        client-side resize before upload)
     5. Sharing complicates the trust model (URL-fragment key
        sharing, recommended; Mega.nz / Cryptpad pattern)
     6. Migration of existing plaintext files (lazy on-read,
        recommended)
   - Schema delta (sql + Dexie additions)
   - File map (~2200 LoC across 9 new files + 3 touched)
   - Testing strategy (unit + integration + e2e per layer)
   - Out-of-scope items explicitly listed
   - Decision criteria for when to actually do this
   - Five open questions for whoever picks it up
   - Cross-references to related files

   The doc is opinionated where I have a defensible recommendation
   and explicit about uncertainty where I don't.

2. DATA_LAYER_AUDIT.md updates:

   - Backlog "Offen" item #1 (File-Bytes-Encryption) now points
     directly at the new plan doc with a one-line teaser.
   - Backlog "Abgeschlossen" gains a row C for the Conflict
     Visualization UI shipped in ed8ab4483 (was still listed as
     open from the previous audit roll-up).
   - List renumbered: Conflict-UI dropped from "Offen", remaining
     items shifted up.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:17:15 +02:00
Till JS
c8ed58b7d1 fix(mana,ui): integrate guest nudge into bottom stack + theme it
The "Gefällt es dir?" guest nudge was a free-floating fixed element at
bottom: 10rem, so it didn't follow the bottom-stack when the PillNav was
collapsed. Move it inside .bottom-stack as the first child so it shares
the stack's reflow.

NotificationBar now uses the elevation system (--color-surface-elevated,
--color-border-strong, --color-foreground) instead of hardcoded rgba so
it adapts to all themes. Bumped the CTA button (shadow + hover lift) and
container (stronger border, layered shadow) to be more visible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:13:05 +02:00