Commit graph

196 commits

Author SHA1 Message Date
Till JS
5110065ebe feat(mana/web/sync): expose debug info, surface silent push failures
The SYNC_DEBUG.md runbook tries to inspect window.unifiedSync from
DevTools to figure out why pending changes aren't flushing on
mana.how. The script can't work because (a) the unified sync
instance is never exposed on window and (b) the two most likely
failure modes — push for an unknown appId, getToken() returning
null — both `return` silently with no error, no telemetry, no
state change. The pending count climbs and there's nothing in
the console to point at the cause.

This commit makes those failures visible:

push() unknown appId
  When a pending change lands for an appId that isn't in the
  registered channels map (almost always a registry/migration
  drift like renaming an appId without migrating the existing
  pending rows) we now log a warning that names the offending
  appId, lists the known ones for comparison, and emits a
  push:error telemetry event with errorCategory='unknown-appid'.
  The pending rows for that appId would otherwise accumulate
  forever — same symptom as the SYNC_DEBUG report.

push() no token
  getValidToken() can return null if the local exp check failed
  and the refresh-on-online retry didn't yield a new token. This
  was the silent path that was hardest to diagnose: the existing
  health-check telemetry only fires after a successful fetch, so
  there was no signal at all. We now log a warning, set
  channel.lastError = 'no-token', flip status to 'error' and emit
  push:error with errorCategory='no-token'.

sync-telemetry.ts
  Widens the errorCategory union to include 'no-token' and
  'unknown-appid' so the new emits type-check.

getDebugInfo()
  New method on the createUnifiedSync return value. Returns a
  flat, JSON-serializable snapshot of every channel's state
  (status, online, clientId, serverUrl, channels[appId] with
  lastError + timer flags, plus knownAppIds at top level) so the
  SYNC_DEBUG runbook (Schritt C) can compare what the server
  is being asked to sync vs. what's actually sitting in
  _pendingChanges.

(app)/+layout.svelte
  Exposes the live unified-sync instance on window.__unifiedSync
  in the browser. Not a security concern: every method on the
  returned object is also reachable via Dexie + a fresh fetch
  from the same DevTools console, and a malicious user can't
  escalate anything by poking at it. This is the global the
  SYNC_DEBUG Schritt C script needs to exist.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:20:22 +02:00
Till JS
967f938e84 feat(mana/web/body): full i18n + calorie × weight correlation chart
Two complementary improvements that take the body module from "works
in DE/EN" to "works for every Mana user" and surface the highest-
value cross-module integration the merged module unlocks.

i18n — finish the rollout
  it/fr/es JSON files were already present from the initial body
  drop but only had the original copy. Add the new keys introduced
  by the quick-win commits last week:
    - phase.{start,end,startNew}
    - progression
    - routines.{title,start,empty}
    - exercisePicker.{title,pick,search,empty,create}
    - muscle.* (13 muscle group labels)
    - calorieWeight (used by the new chart below)
  de.json + en.json get the calorieWeight key for the new section.
  Translations are real (not machine-default fallbacks) so the
  Body module is now first-class in all five supported locales.

CalorieWeightChart — Body × Nutriphi correlation
  The whole point of having both modules in the same app is being
  able to ask "did the cut work?" without exporting CSVs. This
  component overlays daily calorie intake (summed across nutriphi
  meals) against bodyweight readings over the last 8 weeks, with
  an optional dashed target-weight line driven by the active phase.

  Key design choices:
    - Two y-axes auto-scaled independently (calories left, weight
      right) so a 2000kcal swing and a 1kg swing both stay visible.
    - Days without data are omitted from the path; the line draws
      "M ... L" gaps so a missed weigh-in doesn't show as a hard
      drop to zero.
    - Target-weight overlay only renders when it falls inside the
      visible weight range — clamping it to the edge would create
      a meaningless boundary stripe.
    - Cut-friendly delta colors: weight DOWN is green (you're on
      track), weight UP is red. Calorie deltas use the same scheme
      (down = restriction working).
    - Pure SVG, no chart-lib dependency, same auto-scale primitive
      we already use for WeightChart and ExerciseProgressionChart.

  Cross-module read: new `useNutriphiMealsSince(date)` helper in
  body/queries.ts — lives in body (not nutriphi) because the body
  module owns the integration boundary, and putting the cross-table
  read in one place keeps the import graph from getting circular if
  nutriphi ever wants to reach back.

  The hook decrypts the nutriphi `meals` table (already encrypted at
  rest by the meals registry entry) and projects to a thin
  MealWithNutrition shape for the chart. Decrypt cost on a few
  hundred meal rows is negligible vs. the value of the chart.

  Wired into the body layout as a 7th context (`bodyNutriphiMeals`)
  with `dateNDaysAgo(56)` — 8 weeks covers a typical cut/bulk
  cycle. ListView renders a new "Kalorien × Gewicht" card between
  the Weight section and the Daily Check.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:19:20 +02:00
Till JS
4fab323234 fix(mana/web/news): use client-side API URL + snapshot $state arrays
Onboarding's "Fertig" button was failing with two distinct errors:

1. Feed fetch hit `http://mana-api:3060/api/v1/news/feed` (the SSR-only
   internal Docker hostname) and was blocked by CSP. The news client was
   reading `$env/dynamic/public.PUBLIC_MANA_API_URL`, which on the client
   resolves to whatever the SSR process had — i.e. the internal hostname.
   Switched to the existing `getManaApiUrl()` helper, which on the client
   reads `window.__PUBLIC_MANA_API_URL__` (set from
   `PUBLIC_MANA_API_URL_CLIENT` = `https://mana-api.mana.how`).

2. `completeOnboarding` passed Svelte 5 `$state` proxy arrays directly
   into the preferences store, which then handed them to Dexie's update
   hook → `_pendingChanges.add` → `DataCloneError`. The picked arrays
   are now snapshotted with `$state.snapshot()` at the call site, and
   the store-side setters defensively spread their inputs so any future
   caller is safe by default.
2026-04-09 17:16:44 +02:00
Till JS
59b5114348 fix(mana/web/who): surface guestPrompt on JWT expiry
When the access token had aged out mid-game and the silent refresh
failed (auth.mana.how/api/v1/auth/refresh → 401), the who store
threw a raw "not authenticated" error and the PlayView showed a
gibberish red banner. Confusing because the navbar still shows the
user as logged in — the session cookie is intact, only the JWT is
gone — so the user has no clue what to do.

Match the base-client.ts pattern: when getAccessToken() returns
null OR the upstream returns 401, fire guestPrompt.requireAccount()
to surface the standard "Sitzung abgelaufen, neu anmelden" prompt
in the bottom-bar slot, then throw a German error string so the
inline error banner reads as "Sitzung abgelaufen — bitte neu
anmelden" instead of "not authenticated".

Hit by the developer mid-test on the first end-to-end live game on
production: the chat had been working for ~5 messages, then the
JWT expired and the game appeared to "die" with a cryptic message.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:10:03 +02:00
Till JS
f9b83990c6 refactor(mana/web): consume shared AI Zod schemas via z.infer
Drops the hand-written MealAnalysisResult / AnalyzedFood / NutritionData
interfaces in nutriphi/{api,types}.ts and the IdentifyResult interface
in planta/api.ts. They are now type aliases that re-export the inferred
types from @mana/shared-types — same types the backend validates against
at the boundary, so frontend and backend can no longer drift.

Net result is end-to-end type safety: a field rename in the shared
schema lights up red in both apps/api routes and apps/mana/apps/web
consumers in the same tsc pass. No more interface duplication, no more
manual sync.

Storage shapes (LocalMeal, LocalGoal, LocalFavorite) stay module-local
because they compose the shared NutritionData / AnalyzedFood with
storage-specific BaseRecord fields (id, userId, _fieldTimestamps,
deletedAt, etc.) that have no place in the wire format.

Tests: 29/29 nutriphi + 20/20 planta still green — the shapes are
identical, only the source of the type aliases changed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:00:13 +02:00
Till JS
c2a75bb8e1 feat(shared-types): add Zod schemas for AI structured outputs
Introduces packages/shared-types/src/ai-schemas.ts as the single source
of truth for the wire format between mana-api and the unified Mana app.

Two schemas:
  - MealAnalysisSchema (foods, totalNutrition, description, confidence,
    warnings, suggestions) — consumed by nutriphi /analysis/photo and
    /analysis/text routes
  - PlantIdentificationSchema (scientificName, commonNames, confidence,
    health/watering/light advice, generalTips) — consumed by planta
    /analysis/identify

Both schemas include .describe() annotations on every field. The Vercel
AI SDK passes these through to the model as part of the structured-output
prompt, which materially improves accuracy on Gemini Vision (the model
sees both the field name AND the German-language hint about what to put
there).

Schemas use plain .optional() rather than .nullable() because
generateObject() guides the model with strict schema adherence — it
won't emit JSON null for missing fields, just omit them.

Deps wired up:
  - apps/api: + ai@6, + @ai-sdk/openai-compatible@2, + @mana/shared-types
  - apps/mana/apps/web: + zod (for z.infer of the shared schemas)
  - packages/shared-types: + zod (for the schema definitions themselves)

All three on zod ^3.23 to stay in lockstep with the existing
apps/api zod usage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:59:28 +02:00
Till JS
63e1ef8233 fix(mana/web/who): chat bubble Tailwind classes — v3 → v4 syntax
The NPC reply rendered as a fully-white bubble with invisible
white-on-white text. Three bugs in the message-bubble markup,
all from copy-pasting Tailwind v3 patterns into a v4 codebase:

  1. text-white-90 is not a valid class name in any Tailwind
     version. The opacity goes after a slash: text-white/90.
  2. bg-white + bg-opacity-5 is the v3 pattern. v4 dropped
     bg-opacity-* and folded opacity into the color via
     bg-white/5. Without it the bubble was solid white.
  3. Combining 1 and 2: solid white background + invalid text
     color → text inherited the parent's white → invisible.

Plus a Svelte-specific gotcha: class:bg-emerald-500/10={cond}
doesn't parse because Svelte's class: directive treats `/` as a
token. Use a class={...} string interpolation instead, which is
how the result banner now picks between the won and surrendered
backgrounds.

Also: rewrote the message bubble loop with an explicit
{#if msg.sender === 'user'}/{:else} branch instead of stacking
class:* directives. Less clever, more legible, and dodges the
class: + slash issue at the source.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:56:34 +02:00
Till JS
56130cd3f7 test(mana/web/body): integration tests for bodyStore mutations
11 vitest cases covering the load-bearing parts of bodyStore that
would otherwise rot silently because they only fire on edge paths
(re-tap, phase switch, double-start). Same harness as
nutriphi/mutations.test.ts: fake-indexeddb + a MemoryKeyProvider
seeded with a fresh master key, plus mocks for the browser-only
globals the Dexie hooks reach for (funnel-tracking, triggers,
inline-suggest).

Coverage:

  Encryption (registry round-trip)
    - Exercise: name + notes wrapped, muscleGroup + equipment +
      isPreset stay plaintext for the index/picker layer
    - Set: weight + reps wrapped (numeric values get JSON-stringified
      before encryption), workoutId + exerciseId + order + isWarmup
      stay plaintext

  upsertCheck idempotency
    - Re-tapping the same date updates the existing row instead of
      creating a second one (the bug this guards against would have
      filled bodyChecks with one row per dot-tap on a slow day)
    - Partial updates preserve prior fields when callers pass
      undefined for the others
    - Different dates get different rows

  startPhase auto-close
    - Opening a second phase closes the previous one's endDate
      (so the "active phase" view always sees ≤ 1 open row)
    - endPhase stamps endDate without soft-deleting the row

  startWorkout single-active guard
    - Returns the existing open workout instead of starting a
      second one (would have silently double-tracked sets)
    - After finishWorkout, a fresh start works again

  logSet ordering
    - Assigns sequential order indices within a workout
  deleteWorkout cascade
    - Soft-deletes the workout AND all its sets in one go

All 11 pass against the v2 schema (bodyExercises / bodyWorkouts /
bodySets / bodyChecks / bodyPhases) plus the registry encryption
allowlist landed in the previous body commits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:56:17 +02:00
Till JS
b2f3b313bb feat(mana/web/body): exercise picker, routines, phases, progression chart
Five quick-win UI upgrades that take the body module from "skeleton
ListView" to "actually usable for daily training":

1. ExercisePicker modal (replaces the previous bare <select> in
   WorkoutLogger). Search by name, filter chips per muscle group,
   inline create-new-exercise. The big win is the per-row "Last:
   80kg × 8 · vor 3 Tagen" hint — progressive overload becomes
   "look at the number, add 2.5kg" instead of digging through
   workout history. Recently-trained exercises bubble to the top
   so the picker matches what you actually do most days.

2. RoutineManager. Three seed routines added to BODY_GUEST_SEED
   (Full Body Starter, Upper Day, Lower Day) so a fresh user has
   a one-tap "start" path. Inline form to save custom routines as
   chips of selected exercises. Archive button per routine; edit
   is deferred. Routines start a workout via the existing
   bodyStore.startWorkout({ routineId, title }) shape.

3. PhaseManager replaces the previously read-only header pill with
   a clickable control. Three states: idle (start button), opening
   (kind picker + start/target weight inputs), active (color-coded
   summary card with end button). The auto-close-on-switch logic
   was already in bodyStore.startPhase, so this is pure UI plumbing.

4. ExerciseProgressionChart. Same auto-scaled SVG approach as
   WeightChart but plots best estimated 1RM (Epley) per day for
   one exercise. Falls back to the most-recently-trained exercise
   when no explicit id is pinned, so the chart is never empty on
   first open.

5. New query helpers feeding the above: getLastSetByExercise,
   getE1rmTimeline (collapses multiple working sets in one session
   to the daily best so the chart isn't noisy), and a coarse
   relativeDays formatter for the picker's "vor 3 Tagen" hints.

ListView re-composed: removed the dead phase-pill CSS, added
PhaseManager + RoutineManager + ExerciseProgressionChart sections,
left WorkoutLogger / WeightChart / DailyCheckCard / RecentWorkouts
in place. i18n keys for the new copy added to body/de.json and
body/en.json (it/fr/es fall back to the components' inline default
strings until translated).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:55:51 +02:00
Till JS
77ad48972e fix(mana/web/who): set createdAt + use simple gameId index for messages
Two related bugs that caused user messages to disappear into the
ether: optimistic insert succeeds but neither the user message nor
the NPC reply ever shows up in PlayView, and no errors hit the
console because nothing actually throws.

Bug 1 — createdAt was never set
-------------------------------
The Dexie creating-hook in apps/mana/apps/web/src/lib/data/database.ts
auto-stamps userId and __fieldTimestamps but does NOT auto-stamp
createdAt. Module stores have to set it themselves. Chat gets away
with it because its query uses a simple conversationId index and
the type converter falls back to "now" — but I had the who store
omit createdAt entirely.

Bug 2 — composite index hides rows with undefined createdAt
-----------------------------------------------------------
queries.ts used .where('[gameId+createdAt]').between(...) against
the [gameId+createdAt] composite. Dexie does NOT index rows where
any compound key component is undefined, so even though the insert
succeeded and the row was physically in the table, the range query
returned an empty list. The liveQuery effect re-fired but found
nothing → no UI update. Same issue inside sendMessage's history-
fetch step.

Fix:
  1. Set createdAt explicitly on insert in whoGamesStore (both
     user message and NPC reply, +1ms on the reply so it sorts
     strictly after even when both inserts land in the same ms)
  2. Switch queries to .where('gameId').equals(id) and sort in JS
     — same pattern as chat's useConversationMessages, robust
     against missing createdAt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:41:36 +02:00
Till JS
b2db42bb26 feat(mana/web/body): UI components, route, i18n, dashboard widget
Builds the user-facing surface on top of the data layer landed in the
previous commit. After this commit the Body module is reachable at
/body and surfaces an at-a-glance tile on the customizable dashboard.

Components (lib/modules/body/components/):
  - SetRow — inline editable set with weight/reps/RPE/warmup/delete.
    Local $state mirrors the prop and re-syncs via $effect when the
    parent re-emits the row through liveQuery.
  - WorkoutLogger — active-session console. Groups sets by exercise,
    pre-fills the next-set form from the most recent working set on
    the same exercise so progressive overload is one tap.
  - MeasurementForm — quick-log with type picker; unit auto-follows
    (kg for weight/muscle, % for body fat, cm for circumferences).
  - WeightChart — pure SVG line chart, no chart-lib dependency.
    Auto-scales the y-axis with padding so flat-line periods don't
    collapse to a single horizontal line.
  - DailyCheckCard — 1-5 dot buttons for energy/sleep/soreness/mood,
    upserts to bodyChecks per day so re-tapping overwrites today.
  - RecentWorkouts — finished sessions with set count, total volume,
    duration.

ListView.svelte composes everything into the main view: active
workout console when running (otherwise a "start" CTA), weight
chart + measurement form, today's daily check card, recent
workouts. Phase pill in the header (Cut/Bulk/Maintenance) with
color-coded background.

Route (routes/(app)/body/):
  - +layout.svelte sets seven contexts via the useAllBody*() hooks
    so child pages get observable streams without prop drilling.
  - +page.svelte renders ListView.

i18n (lib/i18n/locales/body/):
  - de/en/it/fr/es JSON files with title, subtitle, workout state,
    measurement.* (10 types), check.* (4 fields), phase.* (4 kinds),
    log/finish/start strings.
  - Registered in lib/i18n/index.ts alongside the other module dicts.

Dashboard widget (lib/modules/body/widgets/BodyStatsWidget.svelte):
  - Surfaces latest weight + delta vs the previous reading, plus
    either the active workout (with today's set count + volume) or
    a "start workout" CTA when idle.
  - Reads bodyMeasurements / bodyWorkouts / bodySets directly via
    liveQuery + decryptRecords (same pattern as NewsUnreadWidget).
  - Wired into widget-registry.ts as 'body-stats', registered in
    types/dashboard.ts WIDGET_REGISTRY with 💪 icon and the new
    'body' requiredBackend tier.
  - Strings added under dashboard.widgets.body_stats.* in all five
    locales.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:28:55 +02:00
Till JS
a412ccc6fb feat(mana/web/body): new module — combined fitness training + body comp tracking
Adds the unified Body module that merges what would otherwise be two
separate apps (fitness + bodylog) into one. The value lives in their
intersection: tracking lifts alongside bodyweight is what enables
real progressive-overload + recomp insights, and shared primitives
(charts, time series, units, photos) avoid duplicating UI surface.

This commit lands only the data layer + module registration so the
follow-up UI / route / dashboard widget can build on a stable
foundation.

Tables (db.version(2), already in place):
  bodyExercises    — exercise library (Squat, Bench, Deadlift, OHP,
                     Row, Pull-Up seeded as presets)
  bodyRoutines     — saved workout templates
  bodyWorkouts     — one logged training session
  bodySets         — set rows inside a workout, indexed [workoutId+order]
  bodyMeasurements — weight + measurements over time, indexed [type+date]
  bodyChecks       — daily energy/sleep/soreness/mood self-rating,
                     upserted per day
  bodyPhases       — cut/bulk/maintenance/recomp phase markers, with
                     auto-close on phase change so the "active phase"
                     view always has at most one open row

Encryption (registry.ts): all 7 tables flipped to enabled. Health
data is GDPR Art. 9 special-category, so user-typed text + the
sensitive numeric fields (weight, reps, value, startWeight,
targetWeight, energy/sleep/soreness/mood) are wrapped. Indexed
columns (ids, FKs, ordering, dates, kind/type/equipment enums)
stay plaintext so the existing query layer keeps working without
decrypt-on-every-row.

Module wiring:
  - bodyModuleConfig added to module-registry.ts
  - Body app entry registered in shared-branding mana-apps.ts
    (red→orange icon to set it apart from the green health-adjacent
    modules and the pink cycles icon)
  - APP_ICONS.body added (dumbbell + heart-pulse hybrid SVG)

Also captures the broader module-ideas brainstorm in
docs/future/MODULE_IDEAS.md and marks fitness + bodylog as merged
into the new body module.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:28:19 +02:00
Till JS
422eb9f61b fix(mana/web/who): log sendMessage failures to console
The PlayView's send() catch sets a local `error` state which renders
as a small banner near the input — easy to miss when the chat area
is the first thing the eye looks at after pressing send. Add an
explicit console.error so the next time something goes wrong end
to end, the actual exception is one DevTools tab away instead of
"my message disappeared and I have no idea why".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:19:19 +02:00
Till JS
bd748b0a14 feat(mana/web/nutriphi): context menu quick-action for "Neue Mahlzeit"
Adds a contextMenuActions entry to the nutriphi registerApp() block
matching the convention todo / calendar / contacts / habits / notes /
dreams / cycles all use: a Plus-icon "Neue Mahlzeit" action that
dispatches a window CustomEvent('mana:quick-action', { app: 'nutriphi',
action: 'new' }).

Note: there is currently no registered listener for mana:quick-action
in the codebase — every existing module dispatches it but nothing
consumes it yet (presumably waiting for a central handler in the
workbench shell). Adding the entry now keeps nutriphi consistent with
the convention so it will light up automatically once the listener
lands.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:14:50 +02:00
Till JS
6124ae9cd6 fix(mana/web): bump Dexie schema to v3 for the who tables
The who module landed with whoGames + whoMessages declared inside
db.version(1). That's wrong: existing browsers (every tester
including the developer's own) already had Dexie persisted at v1
with the OLD schema (no who tables). When the new bundle declared
v1 with a different schema, Dexie refused the schema diff and the
optimistic insert in whoGamesStore.sendMessage silently failed —
neither the user's message nor the server reply appeared in the
PlayView, even though the deck picker and game start worked
(those write whoGames which has the same schema-mismatch issue
but the failure is only visible once a chat starts).

The pre-launch cleanup doc says "edit version(1) directly until
launch", but in practice that bricks every developer's local
state on every additive change. The right rule is: bump the
version for additive table additions even pre-launch — Dexie
handles the additive case cleanly with no upgrade function.

This commit:
  - Removes whoGames + whoMessages from db.version(1)
  - Adds them to a new db.version(3) block (v2 was already taken
    by the bodyExercises / bodyRoutines / etc. body module)
  - Existing IndexedDB databases at v1 or v2 will run the
    additive upgrade automatically on next page load. No data
    loss, no upgrade function needed (no rows to migrate yet).

Also: add a console.error to PlayView's send() catch so future
sendMessage failures actually show up in DevTools instead of
only being visible as a tiny error banner near the input.

Fixes the "ich tippe eine frage und nichts passiert" symptom
the developer hit on the first end-to-end live test of the who
module on production.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:14:39 +02:00
Till JS
5480a8dfdf feat(mana/web/nutriphi): global quick-input adapter for the search bar
Adds nutriphi to the unified quick-input registry so the global search
bar gains meal-aware behaviour whenever the user is on a /nutriphi route.

Adapter contract (mirrors planta / todo / calendar):

  - onSearch: decrypts meals (description is in the encrypted allowlist)
    and substring-matches by description, sorted newest-first, capped at 10
  - onCreate: parses an optional meal-type prefix from the query
    ("frühstück: müsli mit beeren", "snack: apfel", english + ASCII
    variants accepted) and falls back to suggestMealType() based on
    time-of-day when no prefix is given
  - onParseCreate: shows a preview line so users see which meal type
    will be picked before they hit enter

Persistence goes through mealMutations.create — same code path the
workbench card uses, so encryption + sync work for free.

Tests: 13 cases covering parser branches (German + English prefixes,
case insensitivity, time-of-day fallback for the three meal windows,
edge cases like unknown prefixes, far-away colons, empty descriptions
after a prefix). Parser is exported to keep the test independent of
the adapter's network-touching hooks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:14:37 +02:00
Till JS
c9e16243c8 feat(shared-llm): bump mana-server default model to gemma4:e4b
Two surprises came out of "why do we still use Gemma 3 instead of 4":

1. The hardcoded default in ManaServerBackend was `gemma3:4b`, which
   was even smaller than mana-llm's actual server-side default of
   `gemma3:12b`. My initial guess from docs/LOCAL_LLM_MODELS.md was
   conservative.

2. The mana-llm OLLAMA_URL points at host.docker.internal:13434,
   which is NOT the Mac Mini's local Ollama — it's a Python TCP
   forwarder (~/gpu-proxy.py) that proxies to 192.168.178.11:11434
   on the Windows GPU server. So title generation has been running
   on the RTX 3090 the whole time, not on the M4 Metal GPU. The
   Mac Mini's brew-installed ollama 0.15.4 wasn't even being used
   for inference — only as a CLI to inspect the proxied Ollama.

To get to Gemma 4, both Ollama instances needed an upgrade:
  - Mac Mini brew  : 0.15.4 → 0.20.4 (cosmetic, the binary isn't on
                     the inference path; upgraded for consistency)
  - GPU server     : 0.18.2 → 0.20.4 via winget. Required restarting
                     the daemon via the OllamaServe scheduled task
                     that was already configured.

Then `ollama pull gemma4:e4b` on the GPU server (9.6 GB, ~10 min on
the LAN). Verified end-to-end via the proxy with a real chat
completion request to mana-llm — gemma4:e4b answered with a clean
4-word German title for a sample voice memo prompt:

  prompt: "Erstelle einen kurzen 3-Wort Titel für: Es ist ein
           schöner Tag heute am 9. April"
  → "Schöner Tag, neuntes April"

Changes in this commit:

  packages/shared-llm/src/backends/mana-server.ts
    - defaultModel: 'gemma3:4b' → 'gemma4:e4b'
    - Updated docstring to explain why E4B is the right Mana-Server
      tier default: 9.6 GB on disk, 128K context, "Effective 4B"
      arch punches above its weight class for German prompts, and
      the family stays consistent with the browser tier (Gemma 4
      E2B is the smaller sibling) so the source label and prompt
      behavior remain coherent across tiers.

  apps/mana/apps/web/src/lib/modules/memoro/views/DetailView.svelte
    - TITLE_SOURCE_LABELS map updated:
        browser     → "Auf deinem Gerät (Gemma 4 E2B)" (was "(Gemma 4)")
        mana-server → "Mana-Server (Gemma 4 E4B)" (was "(gemma3:4b)")
    - The label now reflects that BOTH the browser and the mana-server
      tier are running Gemma 4 variants, which is more honest than
      the previous mix.

Did NOT change:
  - The Ollama OLLAMA_DEFAULT_MODEL env var in docker-compose.macmini.yml
    (still gemma3:12b). That's the fallback for callers who don't
    specify a model in their request. Our generate-title task always
    sends an explicit model string, so it's unaffected. Bumping the
    global default is a separate decision — it would change behavior
    for the playground module and any other consumer that relies on
    the implicit fallback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:06:33 +02:00
Till JS
df72a92b4c test(mana/web): consistency guard for workbench-registry ↔ MANA_APPS
Adds the test that would have caught the inventar↔inventory drift
months earlier (commit 45790ffbb fixed the actual mismatch). Walks
both directions:

  1. Every workbench-registered app must have a MANA_APPS entry, OR
     be in the WORKBENCH_ONLY allowlist (currently `automations`,
     `playground` — internal devtools we don't want in marketing).

  2. Every MANA_APPS entry must be registered in the workbench, OR
     be in the BRANDING_ONLY allowlist (`mana` itself, standalone
     subdomains like `arcade`, "Coming Soon" placeholders like
     `wisekeep`/`mail`/`events`, and modules whose workbench
     integration is still pending like `guides`/`who`).

Plus a regression guard that fails loudly if anyone reintroduces
`inventar` as an id in either registry.

The point: every future drift between the two registries forces the
contributor to either fix it on the spot or explicitly classify the
new entry in one of the allowlists with a comment. No more silent
fail-open tier-gating.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:05:17 +02:00
Till JS
c184991b3a feat(mana/web/nutriphi): inline text + photo quick-add in workbench ListView
The workbench card was read-only — users had to navigate to /nutriphi/add
to log anything. Now the card has a quick-add bar in the toolbar slot:

  - Text input → Enter or send button → mealMutations.create() with
    suggestMealType() (no AI round-trip; users get instant persistence
    and can edit nutrition later from the detail page)
  - 📷 button → file picker (capture=environment for mobile camera) →
    photoMutations.uploadAndAnalyze → mealMutations.createFromPhoto with
    the full Gemini result (foods + thumbnail + confidence)
  - Toast on success ("📷 Mahlzeit hinzugefügt · KI 87%") and on error

Item rendering also got a small upgrade:
  - Each row is now a link to /nutriphi/[id] (matches the rest of the
    nutriphi pages now that the detail route exists)
  - Thumbnail shown next to the row when present (uses photoThumbnailUrl
    for bandwidth)
  - 📷 indicator badge for photo-mode meals

Pre-existing bug fix in passing: the goals query was reading from the
non-existent table 'nutriphiGoals' instead of 'goals' (the actual table
name from module.config.ts), so the calorie target was never visible
in the workbench card. Switched to 'goals'.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:05:13 +02:00
Till JS
e579e292cc feat(mana/web/news): workbench ListView + dashboard widget
Surfaces News in two extra entry points beyond the dedicated /news
route. The workbench ListView is a compact ranked-feed view designed
for the AppPage carousel slot — it boots the same feed-cache poll, runs
the same scoreArticle pipeline, but renders smaller cards and skips the
onboarding wizard (un-onboarded users get a CTA pointing them at /news
instead). The NewsUnreadWidget shows the top three ranked unread
articles on the dashboard, sharing the exact same engine inputs so the
ordering matches the main feed. WidgetType + WIDGET_REGISTRY get the
new 'news-unread' entry, and dashboard.widgets.news_unread is added to
all five locale files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:54:57 +02:00
Till JS
8167d265a7 feat(mana/web/news): web routes + i18n locales
Adds the seven (app)/news/* routes: layout that boots the feed-cache
poll, main page with the 3-step onboarding wizard and the ranked feed
with reaction buttons, dual-source reader at /news/[id], saved reading
list with category filter strip + inline category editor + 3 tabs
(unread/favorites/archive), /news/add for ad-hoc URL paste,
/news/preferences for topics/languages/weight reset, /news/sources
for per-source block toggles. Five locale JSON files (de/en/es/fr/it,
~60 keys each) for the eventual $_('news.…') refactor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:54:25 +02:00
Till JS
de7e359580 feat(mana/web/news): client data layer + module library
Adds the local-first News module: 5 Dexie tables (newsArticles,
newsCategories, newsPreferences, newsReactions, newsCachedFeed) with
the cached pool intentionally outside the sync map, four mutation
stores (articles, categories, preferences, reactions, feed-cache),
typed DTOs + queries with decryption-aware liveQueries, the api.ts
client for /api/v1/news/{feed,extract}, and the pure feed-engine that
scores articles by recency × topicWeight × sourceWeight and applies
reaction-driven weight updates client-side.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:53:52 +02:00
Till JS
45790ffbb8 refactor(mana): rename inventar → inventory across the codebase
The workbench-registry app id 'inventar' did not match its
@mana/shared-branding MANA_APPS counterpart 'inventory', so the tier-
gating join in apps/web/src/lib/app-registry/registry.ts silently
failed for the inventory module — it fell into the "no MANA_APPS
entry, default visible" fallback and was effectively un-gated. The
codebase had also voted overwhelmingly for 'inventar' (53 files) vs
'inventory' (3 files in shared-branding), so the long-standing
mismatch was just bookkeeping debt waiting to bite.

Pre-release, no live data, so the cleanest fix is to align everything
on the English 'inventory':

- Workbench-registry id, module.config.ts appId, module folder, route
  folder and i18n locale folder all renamed via git mv
- Standalone apps/inventar/ workspace package renamed
- All imports, store identifiers (InventarEvents → InventoryEvents,
  INVENTAR_GUEST_SEED, inventarModuleConfig), i18n keys and href/goto
  paths follow the rename
- The German display label "Inventar" is preserved everywhere it is a
  user-visible string (page titles, i18n values, toast labels)
- Dexie table prefixes (invCollections, invItems, …) are unchanged
- Drive-by fix: ListView.svelte was querying non-existent
  inventarCollections/inventarItems tables — corrected to the actual
  invCollections/invItems names from module.config
- The "inventar ↔ inventory id mismatch" workaround comment in
  registry.ts is removed since the mismatch no longer exists

module-registry.ts also picks up the user's parallel newsModuleConfig
addition because both edits land in the same import block — keeping
them split would have left the build in an inconsistent state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:50:24 +02:00
Till JS
23f13d7139 test(mana/web/nutriphi): cover mealMutations.update
Five new cases against fake-indexeddb covering the new update mutation:

  - patches description and re-encrypts (verified via ENC_PREFIX wire
    format check + absence of original AND new plaintext in the blob)
  - patches numeric nutrition fields (stays plaintext for aggregation)
  - partial update only touches the supplied fields (mealType change
    does not zero out nutrition)
  - bumps updatedAt
  - throws on missing id

Total nutriphi suite is now 16/16 cases, ~50ms.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:45:35 +02:00
Till JS
4fd6a5cc77 feat(mana/web/nutriphi): meal detail page + foods breakdown + thumbnail-aware lists
New /nutriphi/[id] route — the missing endpoint of the photo workflow.
Loads the meal via inline useLiveQueryWithDefault(loadMealById, ...) so
the closure captures page.params.id directly (planta DetailView pattern).

Detail page features:
  - Full-resolution photo, click-to-expand lightbox modal
  - All six nutrient cards with the same color tokens as the dashboard
  - "Erkannte Bestandteile" — list of AI-identified foods (name +
    quantity + kcal) so users can see what Gemini actually parsed
  - Inline edit form (mealtype + description + 6 nutrient inputs),
    persists via mealMutations.update
  - "🔄 Erneut analysieren" for photo meals — calls analyze on the
    stored URL and overwrites description + nutrition without
    re-uploading the file
  - Two-stage delete confirm

Add page (add/+page.svelte):
  - Captures upload.thumbnailUrl + analysis.foods after KI analysis
  - Persists both via the extended createFromPhoto signature
  - Shows the foods breakdown card under the confidence badge so users
    see the parse before saving (closes the trust gap on low-confidence
    runs)

List pages (Heute + History):
  - Switch to photoThumbnailUrl ?? photoUrl for the row image — saves
    bandwidth on the most-rendered surface
  - Each meal row is now a link to /nutriphi/[id]
  - History row layout split into <a> + sibling delete button so the
    delete click doesn't bubble through navigation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:45:21 +02:00
Till JS
de4f766b06 feat(mana/web/nutriphi): extend meal schema (foods + thumbnail) + update mutation
Schema additions on LocalMeal:
  - photoThumbnailUrl: pre-generated mana-media thumbnail URL, used in
    list views to save bandwidth (full photoUrl stays for the detail
    view + lightbox)
  - foods: AnalyzedFood[] (name / quantity / calories) — Gemini Vision
    already returns this breakdown but the previous flow threw it away
  - new AnalyzedFood type exported from the barrel

Encryption registry:
  - meals encrypted allowlist now includes 'foods' (food names are
    user content; aes.ts JSON-stringifies arrays before wrap, so an
    array value works the same as a string)
  - registry comment updated to enumerate which photo fields stay
    plaintext and why

New mutation: mealMutations.update(id, dto) for inline meal edits.
Patches only the supplied fields, runs encryptRecord on the partial
update so encrypted columns stay encrypted, then re-decrypts the merged
row to return a plaintext snapshot.

queries.ts: new loadMealById(id) helper used by the detail page's
inline useLiveQueryWithDefault wrapper (matches the planta DetailView
pattern of capturing the route param directly in the closure).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:44:55 +02:00
Till JS
c7fd9369c9 test(mana/web/nutriphi): integration tests for meal mutations + encryption
11 cases against fake-indexeddb covering:

  - mealMutations.create: persistence, encryption allowlist (description
    encrypted, nutrition / mealType / structural fields plaintext),
    decryptRecord round-trip, plaintext snapshot return value,
    default-date and explicit-date paths
  - mealMutations.createFromPhoto: inputType=photo, photoMediaId /
    photoUrl plaintext, snapshot includes photo fields
  - mealMutations.delete: stamps deletedAt + updatedAt without
    physically removing the row (sync needs the tombstone)

Setup mirrors planta/mutations.test.ts: real Web Crypto via
generateMasterKey + MemoryKeyProvider, the same trigger / funnel-tracking
mocks the planta tests use, encryption verified through the ENC_PREFIX
wire-format check rather than mocking aes.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:14:24 +02:00
Till JS
189249ba01 feat(mana/web/nutriphi): photo capture + AI meal recognition flow
Wires the new backend endpoints into the unified Mana app and rebuilds
the meal-add page around two modes:

  - Text mode: free-text description + optional " KI-Vorschlag" button
    that runs Gemini on the description and prefills all six nutrient
    fields. The badge auto-clears if the user edits the description so
    stale estimates can't be silently saved.

  - Foto mode: file picker (accept=image/*, capture=environment for
    mobile camera) → preview → upload to mana-media → Gemini Vision on
    the stored URL. Result prefills the same form fields for review.
    Re-analyze without re-upload is supported.

Both modes show a confidence badge (green ≥50 %, yellow with a "prüfen"
warning below). Save is disabled in foto mode until the upload+analysis
has completed, so a meal can never be persisted with a dangling photo
reference.

New module files:
  - api.ts          server-only client (uploadMealPhoto, analyzeMealPhoto, analyzeMealText)
  - mutations.ts    mealMutations.create / .createFromPhoto / .delete + photoMutations
                    keeps the encryption pattern explicit (clone → encrypt → write,
                    return plaintext snapshot)

Touched:
  - queries.ts      propagate photoMediaId/photoUrl through toMealWithNutrition
  - index.ts        export the new mutations + types
  - registry.ts     extend the meals comment to document why nutrition,
                    photoMediaId, photoUrl and confidence stay plaintext
  - +page.svelte / history/+page.svelte    show 64×64 / 48×48 thumbnail
                    + 📷 indicator for photo-mode meals

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:14:07 +02:00
Till JS
e6564cfc96 fix(mana/web): add standard mobile-web-app-capable meta tag
Chrome deprecates the apple-prefixed meta and now logs a warning
asking for the standardized mobile-web-app-capable equivalent. Adds
the standard tag alongside the apple one so iOS Safari keeps working.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:08:12 +02:00
Till JS
f0faae0fb9 feat(mana/web): same-origin proxy for /api/v1/who/* → mana-api
Replaces the cross-origin call to https://mana-api.mana.how with a
SvelteKit catch-all server route that proxies internally to the
mana-api container over the docker network.

Why
---
The mana-api.mana.how cloudflared route was added as part of the
production deploy of apps/api, but reloading the cloudflared
LaunchDaemon to pick up the new ingress rule needs sudo. The deploy
automation runs unattended (no interactive password prompt), so
the cloudflared route ends up registered with Cloudflare DNS but
not yet served by the local tunnel — every browser request to
mana-api.mana.how gets a 404 from the catch-all rule until someone
manually restarts the daemon.

Same-origin proxy through mana-web sidesteps the whole problem:

  browser → cloudflared → mana-web (mana.how) → mana-api (docker net)

mana.how is already routed, mana-web is already up, mana-api is
already on the same docker network — no new cloudflared work
needed. The deploy is now fully sudo-free and self-contained.

What's in this commit
---------------------
  routes/api/v1/who/[...path]/+server.ts (NEW)
    Catch-all SvelteKit handler. Forwards GET/POST/PUT/DELETE to
    http://mana-api:3060/api/v1/who/<path> with the Authorization
    header from the incoming request. 30s timeout, body streamed
    through, status + content-type passed through 1:1, errors
    surface as 502 so DevTools clearly distinguishes "proxy
    failed" from "handler crashed".

  modules/who/stores/games.svelte.ts
    Drop the getManaApiUrl() import. API_BASE is now the constant
    string '/api/v1/who' — same-origin, no env injection needed.

  modules/who/ListView.svelte
    Same change for the deck-catalogue fetch on mount.

The MANA_API_INTERNAL_URL env var on the proxy lets the upstream
hostname be overridden for local-dev use (default
http://mana-api:3060 matches the docker compose service name).

Trade-off: one extra hop (mana-web in the middle) for every
request. Measured in single-digit ms over the bridge network so
the practical cost is invisible. The big win is the sudo-free
deploy.

Pattern can be reused for the other apps/api modules as their
compute features come online in production — same shape, just
swap [...path] segment to /api/v1/calendar/[...path],
/api/v1/picture/[...path], etc.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:06:04 +02:00
Till JS
ab0ca99239 feat(mana/web): app picker — autofocused search + alphabetical order
The "App hinzufügen" picker now sorts available apps alphabetically by
their (i18n-resolved) display name and shows an autofocused search input
above the list to filter quickly. Enter selects the first match.
PickerOverlay gains a `subheader` snippet slot so other pickers can
embed their own controls between header and list without having to
re-implement the shell.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:37:04 +02:00
Till JS
41c705a303 feat(mana/web): per-module icons + wire workbench title link
Adds an optional icon field to AppDescriptor and assigns a Phosphor icon
to all 33 registered apps (CheckSquare for todo, Calendar for calendar,
AddressBook for contacts, …). AppPage now passes both the icon and
titleHref={`/${appId}`} to PageShell, so workbench cards show the
module's icon next to the now-clickable title instead of the generic
color dot.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:28:01 +02:00
Till JS
a130f8e4c0 feat(mana/web): clickable page titles open route in new tab
PageShell gains an optional titleHref prop — when set, the header title
renders as an <a target="_blank"> with hover underline. Also wires this
into the homepage app gallery (shared-ui/AppsPage): the grid card title
is now an anchor to /{app.id}, while the rest of the card still opens
the existing detail modal. Card converted from <button> to role=button
so the nested anchor is valid HTML.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:27:51 +02:00
Till JS
2f00d9c5d3 feat(memoro): show title source label below the title input
Mirror the "Voxtral via mana-stt" label that already sits under the
transcript: a small italic line directly below the title input
showing which tier (and roughly which model) generated the title.
This way the user can see at a glance whether the title came from
the local rules engine, from Gemma 4 in their browser, from
gemma3:4b on the Mana server, or from Google Gemini — and can
decide whether to keep it or rewrite manually.

Storage:

  apps/mana/apps/web/src/lib/modules/memoro/llm-watcher.svelte.ts
    - When applying a completed title task, the watcher now also
      stamps memo.metadata.titleSource with the LlmTier string
      ('none' | 'browser' | 'mana-server' | 'cloud') from the queue
      row's `source` field. Stored in the existing plaintext metadata
      object — no encryption needed (the tier name isn't sensitive
      and the encryption registry for memos only covers
      title/intro/transcript). Existing metadata fields are
      spread-preserved so we don't accidentally wipe STT failure
      markers etc.

Manual override clears the marker:

  apps/mana/apps/web/src/lib/modules/memoro/stores/memos.svelte.ts
    - memosStore.update() now detects when `title` is in the diff
      and clears `metadata.titleSource` so the DetailView stops
      showing "via Mana-Server (gemma3:4b)" for a title the user
      typed themselves. Only fires when title is actually present
      in the update payload — non-title updates leave metadata alone
      so we don't blow away other markers.

Display:

  apps/mana/apps/web/src/lib/modules/memoro/views/DetailView.svelte
    - New TITLE_SOURCE_LABELS map gives each tier a human-readable
      label that surfaces the actual model name where known:
        none        → "Lokal (regelbasiert)"
        browser     → "Auf deinem Gerät (Gemma 4)"
        mana-server → "Mana-Server (gemma3:4b)"
        cloud       → "Google Gemini"
      We deliberately don't reuse @mana/shared-llm's tierLabel()
      because the model name is more informative than the abstract
      tier in this UX context.
    - $derived `titleSourceLabel` reads memo.metadata.titleSource
      and validates it via an isLlmTier type guard. Returns null
      (→ no label rendered) when:
        * the entity hasn't loaded yet
        * a title task is currently in flight (titleIsGenerating)
        * the title input is currently focused (user is editing)
        * the metadata field is missing or not a known tier value
    - New `<div class="source-label title-source-label">` slot
      between the title-row and the properties block, with a small
      CSS override (.title-source-label) for a tighter top gap and
      a slight left indent so it visually lines up under the input
      text rather than under the input border.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:19:59 +02:00
Till JS
7fa3afcdc7 fix(mana/web/sync): push fresh writes immediately via listener bridge
createUnifiedSync exported onPendingChange but nothing ever called it,
so the Dexie hook in database.ts recorded _pendingChanges rows that
the sync engine never heard about. Live writes only ever drained on
the next page reload (via drainLeftoverPending). Observed live as
fresh calendar/timeblocks writes piling up in _pendingChanges with
zero POST traffic to sync.mana.how.

Add a listener bridge: database.ts exposes setPendingChangeListener,
trackPendingChange invokes it after each successful _pendingChanges
insert, and sync.ts registers schedulePush (gated on a known channel)
inside startAll. stopAll clears the listener so a torn-down sync
engine can't get re-triggered by a stale callback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:13:25 +02:00
Till JS
b68fcc8fd1 fix(mana/web/planta): /planta routes — layout fix, i18n, nullability, button nesting
- Add /planta/+layout.svelte that provides every live-query context
  the legacy routes already reference via getContext (plants,
  plantPhotos, wateringSchedules, wateringLogs, plantTags, tags).
  Without this layout the legacy routes would crash at runtime with
  "Cannot read properties of undefined (reading 'value')" — they had
  always relied on a provider that did not exist anywhere in the repo.
- Replace every hardcoded German label across +page.svelte,
  [id]/+page.svelte, add/+page.svelte and tags/+page.svelte with
  $_('planta.*') calls so the locale switcher actually changes the
  copy. Health/light/humidity helper maps converted from German maps
  to switch + i18n lookups.
- Fix the 4 type errors in [id]/+page.svelte caused by SvelteKit's
  $page.params.id being string | undefined: coerce to '' so the
  helpers stay strictly typed and "missing id" still resolves to
  "not found".
- Fix the SSR hydration warning on /planta from a <button> nested
  inside another <button> in the plant grid. Replaced the outer
  card with <div role="link" tabindex="0"> + Enter/Space keydown
  handler so the inner "water now" button is structurally legal.
- formatDate calls drop the hardcoded de-DE locale and use the
  browser locale (undefined) instead.
- Toast notifications on every mutation in these routes so failures
  are user-visible (handleWater, handleDelete, savePlant).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:05:56 +02:00
Till JS
60fedbb611 feat(mana/web/planta): photo upload, AI identification, tags, watering history, i18n + tests
Brings the planta module to production-ready state:

- Photo upload UI in the workbench DetailView (file picker, primary
  selection, removal, hover overlay) wired to /api/v1/planta/photos/upload
- AI plant identification trigger that calls /analysis/identify on the
  primary photo and shows a result card with apply-to-plant CTA;
  applyIdentification only fills empty fields by default to avoid
  clobbering user edits
- Tag picker (chip UI + dropdown) backed by plantTagOps junction
- Watering history list (last 5 logs) in DetailView
- Full i18n: every locale (de/en/es/fr/it) now has plant/list/photo/
  identify/errors/success keys; ListView and DetailView consume them
  via $_('planta.*') instead of hardcoded German
- Toast notifications on every mutation success/failure path
- mutations.ts refactored: methods now throw on failure instead of
  swallowing errors and returning null, so callers can surface them
- New api.ts client for the two server-only operations (upload, identify)
- New photoMutations + plantMutations.applyIdentification helpers
- quick-input-adapter type fix: stop referencing the non-existent
  parsed.species field; create plants through plantMutations.create
  so encryption + timestamps run, and decrypt names before substring
  search
- 20 new tests:
  - queries.test.ts (13 pure-function tests for getDaysUntilWatering /
    isWateringOverdue / getScheduleForPlant / getLogsForPlant)
  - mutations.test.ts (7 fake-indexeddb integration tests for
    wateringMutations.logWatering — log appended, schedule re-anchored,
    soft-deleted schedules skipped, multi-call uniqueness)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:05:13 +02:00
Till JS
b83e8d6d92 fix(mana/web): seed pending-changes badge count on mount
Some checks are pending
CI / Build mana-api-gateway (push) Blocked by required conditions
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
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
The OfflineIndicator badge shows networkStore.pendingCount, which is
only refreshed inside unifiedSync.onStatusChange. That callback fires
on transitions only — so on a fresh tab where sync stays idle, the
badge sticks at the last persisted value (or 0). Observed live as
"13 pending" while _pendingChanges actually held 27 rows.

Extract refreshPendingCount as a local helper and call it once right
after startAll() to seed the badge. The transition-driven refresh
inside onStatusChange now reuses the same helper.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:25:06 +02:00
Till JS
be8c0482b7 fix(mana/web/sync): drain leftover pending changes on startup
startAll() registered channels but never kicked a push for changes
that survived across page reloads. schedulePush only fires from the
Dexie hook on fresh writes, so any pending row from a previous session
sat in _pendingChanges until the user happened to mutate the same
table again — observed live as 27 pending across mana/memoro/places
that never reached the server despite a healthy sync route.

Add drainLeftoverPending() called once at the end of startAll(): scan
_pendingChanges for distinct appIds and schedulePush each registered
channel. Fire-and-forget; errors swallowed because the push retry
path already handles failures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:24:51 +02:00
Till JS
0450c86527 fix(shared-llm): SSE shape diagnostics + simpler title prompt + fragment detection
User test on the mana-server tier showed Ollama gemma3:4b returning
LITERALLY empty content for the title task, which is much weirder
than the small browser model misbehaving. Three layered fixes plus
diagnostics that will tell us what's actually happening over the
wire next time.

1. remote.ts: SSE diagnostics + liberal field shape

   The mana-llm /v1/chat/completions endpoint claims OpenAI
   compatibility, but different upstream providers (Ollama, OpenAI,
   Gemini) wrap their token text in different field paths inside
   the SSE delta. Be liberal in what we accept:
     - choice.delta.content   (canonical OpenAI)
     - choice.delta.text      (some Ollama-compat shims)
     - choice.message.content (non-streaming response embedded in stream)
     - choice.text            (legacy completion API)

   Plus: count totalFrames + dataFrames + capture firstFrameRaw +
   firstFrameParsed during the stream. When `collected` is empty at
   the end of the stream, dump all of that to console.warn so the
   next test session shows us exactly what mana-llm is sending. This
   is the only reliable way to debug "empty completion" without a
   network sniffer in the user's browser.

2. generate-title.ts: drop few-shot, use simple system+user prompt

   The previous few-shot prompt with three `Aufnahme: "..."\nTitel: ...`
   examples was apparently too much for Ollama gemma3:4b on the
   mana-server tier — it returned literal "" for reasons we don't
   fully understand (chat-template confusion with the embedded
   quotes? multi-section format? some quirk of how mana-llm formats
   the messages for Ollama?). Either way, the failure mode is clear.

   Replace with a minimal two-message format:
     - system: "Du erzeugst einen kurzen Titel (3-5 Wörter)..."
     - user: <transcript>
   Same instruction, much simpler shape. Bumped maxTokens 24 → 32
   to give the model breathing room.

3. generate-title.ts: rules fallback detects sentence fragments

   Even when the LLM fails and we fall through to runRules, the
   previous heuristic for medium-length transcripts (10-20 words)
   would extract the first 7 words verbatim — which for a typical
   "Eine kleine Testaufnahme um zu sehen ob alles funktioniert" memo
   produces "Eine kleine Testaufnahme, um zu sehen, ob" as the
   "title". That's a sentence fragment ending mid-thought, not a
   title. Worse than "Memo vom 9. April 2026".

   Add a "looks like a sentence fragment" heuristic: if the last
   word of the extracted slice is a German stop-word or article
   (und/oder/wenn/ob/zu/um/der/die/das/ein/...) the result is
   clearly mid-clause. In that case fall through to dateLabel()
   instead of writing the fragment.

   Stop-word list is curated to 30 entries — common conjunctions,
   articles, prepositions, auxiliaries. Not exhaustive but catches
   the typical "first 7 words of a German sentence" failure mode.

After this commit lands, the next test will surface in the console
EITHER:
  - the actual delta shape mana-llm is using (so we know if our
    parser is wrong or if the model is genuinely silent)
  - a real LLM-generated title (if the simpler prompt worked)
  - "Memo vom <date>" via the rules fallback (if the LLM still
    fails but the rules fragment detection caught the bad slice)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:12:13 +02:00
Till JS
f24438f778 feat(mana/web): who module — frontend (game store + UI + routes)
Client side of the who module. Standard Mana module pattern: types,
collections (Dexie), queries (live), store (mutations), UI components,
routes. Plus three integration points (data layer registries).

Module files
------------
  types.ts
    Two Dexie record interfaces (LocalWhoGame, LocalWhoMessage) and
    matching view types. Server response shapes (WhoChatResponse,
    WhoRandomResponse, WhoGuessResponse) live here too so the store
    and UI both type-check against the same wire contract.

  collections.ts
    Dexie table accessors. No guest seed — the picker handles empty
    state directly.

  queries.ts
    Three liveQueries (allGames$, gameByIdLive, messagesForGameLive)
    that decrypt the encrypted-at-rest fields before returning view
    types. The messages query uses the [gameId+createdAt] composite
    index for ordering. toWhoGame / toWhoMessage converters bridge
    the BaseRecord-extended local types to the public view types.

  module.config.ts
    Standard ModuleConfig: appId='who', tables=[whoGames as 'games',
    whoMessages as 'messages']. The syncName remap means the unified
    Dexie table whoGames syncs to mana-sync's `games` collection
    under appId 'who' — keeps the wire format clean.

  stores/games.svelte.ts
    The mutation surface. Five public methods:
      - start(deckId)        → POST /who/random + insert LocalWhoGame
      - sendMessage(id, txt) → optimistic insert + POST /who/chat +
                               insert NPC reply + (on win) flip status
      - submitGuess(id, txt) → POST /who/guess + (on match) flip
      - surrender(id)        → status=surrendered + finishedAt
      - setNotes(id, notes)  → encrypted post-game notes
      - deleteGame(id)       → soft-delete game + cascade messages
    All writes go through encryptRecord for encrypted-at-rest fields.

UI components
-------------
  ListView.svelte
    Module landing page. Header + 4 deck cards (loaded from
    GET /api/v1/who/decks on mount) + past-games list. Picking a
    deck calls store.start() and navigates to the play view. Past
    games are clickable (read-only for finished games) and
    deletable.

  views/PlayView.svelte
    The chat-loop screen. Header with deck/difficulty + back button
    + Tippen/Aufgeben actions while playing. Scrollable message
    area with bubbles (user purple-tinted, NPC white-tinted).
    Textarea input with Enter-to-send + sending disabled state.
    On reveal: result banner with "Erraten in N Nachrichten!" and
    the resolved name. Post-game: input area swaps to a notes
    textarea with debounced auto-save. Explicit guess modal as
    fallback when the LLM forgets to emit the sentinel.

Routes
------
  /(app)/who                 → ListView wrapper
  /(app)/who/play/[gameId]   → PlayView wrapper, $page.params.gameId

Registry plumbing
-----------------
  database.ts
    Two new Dexie tables in version(1):
      whoGames: 'id, status, deckId, startedAt, finishedAt, [status+startedAt]'
      whoMessages: 'id, gameId, sender, createdAt, [gameId+createdAt]'

  module-registry.ts
    Imports whoModuleConfig and adds to MODULE_CONFIGS. The sync
    engine picks up the appId/table mapping automatically — no
    edits needed in sync.ts.

  crypto/registry.ts
    Two entries:
      whoGames:    { enabled: true, fields: ['revealedName', 'notes'] }
      whoMessages: { enabled: true, fields: ['content'] }
    All other fields stay plaintext for index/sort/filter.

Closes Phase A.2 / A.3 / A.4 / A.5 of docs/WHO_MODULE.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:10:34 +02:00
Till JS
d8591b320b fix(generate-title): few-shot prompt + rolling cleanup + date label for short transcripts
User test on browser tier (Gemma 4 E2B) showed two compounding bugs:

  1. The LLM produces empty content. The cleanup chain strips it to ""
     and falls through to runRules.
  2. runRules takes the first 7 words of the transcript. For short
     voice memos like "So erneut eine kleine Testaufnahme hier"
     (6 words) that means the entire transcript becomes the title —
     not actually a title, just the recording verbatim.

User log:
  [memoro] enqueued title task ...
  [generateTitle] LLM returned empty after cleanup, falling back to rules
  [memoro-llm-watcher] writing title to memo X: "So erneut eine kleine Testaufnahme hier"

Three changes to fix the actual quality, not just the empty-string
symptom from the previous commit:

1. Rewrite the LLM prompt as few-shot

   Replace the previous "Du erstellst kurze Titel — kein Markdown,
   keine Anführungszeichen, keine Vorrede, kein Punkt am Ende" prompt
   (a wall of negative constraints that small instruct models like
   Gemma 4 E2B handle poorly) with a few-shot user-only message:

     Erstelle einen kurzen Titel (3-5 Wörter) für die folgende Aufnahme.

     Beispiel 1:
     Aufnahme: "Erinnere mich daran, morgen Vormittag den Müll
                rauszubringen, bevor die Müllabfuhr kommt."
     Titel: Erinnerung Müll rausbringen

     Beispiel 2: ... (Idee Präsentation Demo-Start)
     Beispiel 3: ... (Steuererklärung 2025)

     Aufnahme: "<user transcript>"
     Titel:

   Small instruct models complete the pattern much more reliably
   than they obey negative constraints. The expected continuation is
   just the title text, no punctuation, no markdown, no preamble.

2. Rolling cleanup that won't go to empty

   The previous cleanup chain (`.trim().replace(quotes).replace(dots).trim()`)
   could end up with "" if the model emitted only `.` or `**.**` or
   similar. Replace with a four-stage chain that picks the FIRST
   non-empty stage from the bottom up:

     trimmed     = result.content.trim()
     stripFences = first line only (kills any model rambling)
     stripQuotes = strip surrounding quotes/markdown markers
     stripDots   = strip trailing dots
     cleaned     = stripDots || stripQuotes || stripFences || trimmed

   This way "Test." → "Test" but `"."` → `"."` (kept as-is rather
   than stripped to empty). The runRules fallback only fires when
   the model truly emits nothing usable in any stage.

3. runRules is smarter about short transcripts

   For voice memos with ≤8 words in the first sentence, the "title"
   would just be the whole transcript echoed back. That's not useful.
   The new threshold: short transcripts get a date label instead
   ("Memo vom 9. April 2026"), longer ones still get the first-N-words
   snippet. The threshold is empirical — short voice memos benefit
   from a date marker, longer ones can spare a few words for a snippet.

   Extracted dateLabel() to a module-scope function so both rulesImpl
   (for empty/short transcripts) and the watcher's last-resort
   backstop can format dates consistently.

Diagnostic: log the RAW LLM output before cleanup so the next test
session shows exactly what Gemma is producing. If the model is still
emitting only punctuation despite the few-shot prompt, the log will
show `"\n"` or `"."` and we'll know the bug is in the inference path
rather than the cleanup.

After this commit, the user-visible result for a 6-word transcript
on the browser tier should be:
  - LLM produces something real ("Test der Sprachaufnahme") → write it
  - LLM produces nothing → rules → "Memo vom 9. April 2026"
  - both fail somehow → watcher's date backstop → same
  - never the verbatim transcript

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:03:16 +02:00
Till JS
ea8ca13d37 fix(mana/web): wrap bare var(--color-X) refs in hsl() across 19 files (P5)
These were latent rendering bugs: --color-X holds raw HSL channels at
runtime (set by createThemeStore), so a bare var(--color-foreground) is
not a valid CSS color value — the browser falls back to inherited and
the affected elements render with the wrong color (often invisible
text on the same-colored background).

Mechanical wrap of every bare reference in the affected files:
  var(--color-X)              → hsl(var(--color-X))
  var(--color-X, #fallback)   → hsl(var(--color-X))   (fallback dropped)
  color-mix(... var(--color-X) N%, transparent)
                              → hsl(var(--color-X) / 0.NN)

Also re-mapped two long-removed token names:
  --color-surface     → --color-muted    (subtle surface intent)
  --color-destructive → --color-error    (semantic alias)

190 refs across 19 files (habits, photos, notes, places, todo, cycles
helpers + their parent route shells). Brand-literal hex/rgba colors
left untouched (cycles pink, sport/category palettes, indigo→violet
gradients, photo placeholder gradients).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:02:33 +02:00
Till JS
9760269e9f fix(memoro): generateTitle empty-result fallback + watcher diagnostics
User test surfaced the actual auto-title bug: the entire pipeline
(enqueue → process → watcher) works correctly, but the task result
itself is empty:

  [memoro] enqueued title task { taskId, memoId }
  [memoro-llm-watcher] saw 1 done title task(s)
  [memoro-llm-watcher] writing title to memo XXX: ""
  [memoro-llm-watcher] applied + cleared row YYY

The watcher faithfully wrote "" to memo.title, the input placeholder
showed "Titel..." again, and we looked stuck. Three layered fixes so
this can't bite us in any execution path going forward.

1. generate-title.ts: extract rules logic + use it as runLlm fallback

   Pulled the deterministic first-sentence heuristic into a private
   `rulesImpl()` function so both `runLlm` and `runRules` can call
   it. runLlm now invokes rulesImpl as a fallback when the cleaned
   LLM output is empty. This catches the case where the model emits
   only punctuation, only special tokens, or only whitespace — all
   of which collapse to "" after my cleanup chain (`.trim()` → strip
   surrounding quotes/markdown → strip trailing dots → `.trim()`).

   The most likely real-world trigger: Gemma 4 occasionally emits a
   single `.` for short prompts that hit its over-strict
   "answer with ONLY the title" instruction. The cleanup turns
   "." into "" and we lose the result.

2. llm-watcher.svelte.ts: date-based backstop for any empty result

   Belt-and-suspenders: even if a future task implementation forgets
   the rules fallback, the watcher itself now guarantees a non-empty
   title. When `row.result.trim()` is empty, synthesize a label like
   "Memo vom 9. April 2026" from the memo's createdAt (or the
   current date if createdAt is also broken). The user always sees a
   real title — never an empty placeholder.

   Same write path otherwise (encryptRecord + memoTable.update +
   delete queue row), just with the guaranteed-non-empty value.

3. llm-watcher.svelte.ts: enhanced diagnostic logging

   The "writing title" log now includes `row.source` (which tier
   actually executed) and `row.attempts`, so the next time we see
   weird behavior we can tell at a glance whether it was the
   browser tier, the rules tier, or the server. The empty-result
   path logs `console.warn` (not info) with the raw result via
   JSON.stringify so we see exactly what came back ("", ".", "  ",
   undefined-coerced-to-string, etc.).

After this commit lands:
  - Tier 0 user: runRules returns at minimum "Ohne Titel" (its
    own fallback). The watcher writes that.
  - Browser tier with empty Gemma output: runLlm now falls through
    to rulesImpl which also can't return empty. The watcher writes
    the rules-tier output.
  - Any other freak case where the result is still empty: the
    watcher's date-based backstop kicks in. "Memo vom <date>".

So the user-visible "stuck on empty title" symptom is impossible in
all three layers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:54:06 +02:00
Till JS
0987b08185 style(mana/web): migrate (app) page-level routes to theme tokens (P5)
calendar, contacts, finance, spiral, todo route shells: bare var()
references → wrapped hsl(), broken rgba/hex fallback chains dropped.

DnD overlay (`.mana-drop-target-hover` / `-success`) is duplicated
inline in calendar/contacts/todo since it's a `:global()` rule each
route declares for itself; all three now read --color-primary +
--color-success for the drop animation instead of literal indigo/green.

finance: income=success, expense=error, type-toggle uses
--color-error/--color-success with /0.15 + /0.3 alpha modifiers.

spiral: indigo→violet stat highlight + app-bar gradient stay literal
(spiral's brand mark is the indigo→violet ramp, not the app theme
primary). Danger button now uses --color-error.

Skipped: rsvp/[token] (public landing, deliberate rose palette outside
the auth-gated chrome) and observatory (cosmic-scenes brand palette,
already established as brand-legitimate).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:43:24 +02:00
Till JS
526d92f41c fix(memoro): diagnostic logs + loading states + transcription source label
User reported three issues after the Phase 5 + the encryption-decrypt
fix landed:

  1. Auto-title still doesn't appear (placeholder "Titel..." stays empty)
  2. No loading state visible while transcription / title are in flight
  3. Transcript should say which STT engine produced it

This commit ships diagnostics for issue 1 and concrete UX for 2 + 3.

Issue 1 — diagnostics (no fix yet, root cause unknown):

  Add console.info logs at every step of the auto-title pipeline so
  the next test session surfaces exactly where it breaks:

  - memos.svelte.ts after llmTaskQueue.enqueue() succeeds:
      "[memoro] enqueued title task { taskId, memoId }"
  - memos.svelte.ts on enqueue failure:
      "[memoro] failed to enqueue title task: <err>"
  - memoro/llm-watcher.svelte.ts on subscribe:
      "[memoro-llm-watcher] starting subscription"
  - watcher's next handler when rows arrive:
      "[memoro-llm-watcher] saw N done title task(s)"
  - applyRow logs each step: drop / skip / write / consume

  Refactor: extract per-row logic into applyRow() so the next handler
  loop can wrap each row in try/catch — a single bad row won't crash
  the watcher and prevent later rows from being processed.

  Belt-and-suspenders startup sweep: run a one-shot manual sweep of
  done rows immediately after subscribing. Dexie liveQuery sometimes
  misses the first emission when the subscription is set up in the
  same microtask as a recent table update; the sweep catches any
  done rows that already exist from a previous tab session OR that
  were written between layout mount and subscription start.

  Encryption check fix: the previous skip-if-manual-title check
  read `memo.title?.trim()` after Dexie.get(), but Dexie reads
  return the ENCRYPTED row (no decrypt hook) — so memo.title is
  either null/undefined (no manual title) OR an `enc:1:...` blob
  (manual title set). Either way, presence-check is enough; no
  need to decrypt to know whether the user filled it in. The old
  code happened to work because trim() on a non-empty string
  returns truthy regardless. Comment now spells this out.

Issue 2 — visible loading states:

  apps/mana/apps/web/src/lib/modules/memoro/views/DetailView.svelte

  Transcript area now branches on processingStatus:

    - processing → "Wird transkribiert…" with three pulsing dots
                   (CSS @keyframes loadingPulse)
    - failed     → red error message + manual retry hint
    - completed + transcript → the transcript itself + source label
    - completed + no transcript → italic "Kein Transkript vorhanden."

  Title input placeholder swaps to "Titel wird generiert…" while a
  generateTitleTask for this memo is in pending or running state.
  The check uses a Dexie liveQuery against llmQueueDb.tasks via the
  [refType+refId] compound index, returning the most recent task row.
  Reactive — the placeholder switches back to plain "Titel…" the
  moment the watcher writes the title and deletes the queue row.

Issue 3 — transcription source label:

  Below the transcript: a small italic "Voxtral via mana-stt" label.
  Hardcoded to Voxtral because that's services/mana-stt's default
  model (DEFAULT_MODEL = "mistralai/Voxtral-Mini-3B-2507" in
  voxtral_service.py). If we ever route to Whisper or another model
  per-request, the label will need to come from the response payload
  rather than be hardcoded — Phase 5.5 work.

After this commit lands, the test loop is: record a memo, watch the
browser console for the [memoro] / [memoro-llm-watcher] log lines.
Whichever step is missing identifies the broken link.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:41:28 +02:00
Till JS
a7fbd29a67 style(mana/web): migrate photos/times/contacts module helpers to theme tokens (P5)
photos/PhotoCard + PhotoDetailModal: bare var() refs → hsl(var()), broken
fallbacks dropped. The lightbox backdrop stays explicit near-black — photo
viewing chrome is intentionally theme-neutral.

times/FocusCard: phase color (focus=red, break=green, idle=muted) reads
theme tokens via wrapped hsl() strings so the SVG ring tracks variants.
The bogus --color-input fallback is gone.

times/EntryItem: was referencing the long-removed shadcn aliases without
the --color- prefix (--border, --card, --foreground, --muted-foreground,
--primary, --input). Re-prefixed; --input → --background since we have
no separate input token. The delete button's text-red-500 / hover bg are
now --color-error so they track theme variants.

contacts/ContactPage: avatar + self-badge color-mix fallback chains
collapse to plain hsl(var(--color-primary) / 0.12).

The cycles pink #ec4899 birthday accent on the contact row stays literal
— it's a deliberate brand color, not theme intent.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:37:29 +02:00
Till JS
d2e44c8b65 chore(packages): remove 2 zero-consumer config packages
Item #21 in the pre-launch audit suggested merging the four
config-y packages (shared-config, shared-tsconfig, shared-vite-config,
shared-drizzle-config) into a single @mana/build-config with
conditional exports. The first reality-check of the item counted
package.json declarations and reported 5 total consumer relationships.
A second reality-check while implementing — grep over actual .ts /
.svelte / .json imports — showed two of the four packages are dead:

  - packages/shared-config/ (598 LOC, 4 TS files)
    Declared in apps/mana/apps/web/package.json but never imported
    anywhere. Stale dep from before the consolidation.

  - packages/shared-tsconfig/ (5 JSON tsconfig presets)
    Zero references anywhere. Not extended by any tsconfig.json,
    not declared in any package.json. Pure Pre-Consolidation
    leftover.

The remaining two packages were left intact:
  - shared-vite-config (3 real consumers in vite.config.ts files)
  - shared-drizzle-config (1 real consumer in mana-media)

They cover different toolchains (Vite SSR config vs drizzle-kit
generator config) — merging them into a single build-config would
be cosmetic, not a real reduction in complexity. Audit's "merge to
1" goal was based on the inflated consumer count and is no longer
worth doing.

Verification:
  - pnpm install completes cleanly
  - apps/api type-check still 0 errors
  - packages/shared-hono type-check still 0 errors

Net: 4 → 2 config packages, ~700 LOC dead code removed.

Also closes item #26 (non-root pnpm-lock.yaml status) — already
done in commit 034a07d16, doc was just out of date. Audit is now
29/29 items fully processed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:35:40 +02:00
Till JS
5c41ebea33 style(mana/web/automations): migrate ListView to theme tokens (P5)
Wraps all `var(--color-X)` references with `hsl()` and routes the muted
backgrounds + borders through `--color-card` / `--color-border` instead
of the rgba-on-white fallbacks. The brand violet (#8b5cf6) automations
accent and the deliberate when/filter/then flow-step palette
(blue/amber/green) stay literal — they encode trigger/condition/action
semantics, not theme intent.

Last file from the original P5 ListView migration list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:34:32 +02:00
Till JS
5052926481 refactor(mana/web): migrate chrome/notification components to theme tokens (P5)
Six chrome-level UI components — modals, toasts and prompts that float
above the workbench — moved off hand-rolled #1e293b/#e5e7eb/#6366f1/etc.
literals onto theme tokens.

Files migrated:
  - RecoveryCodeUnlockModal — backdrop overlay (literal black/60),
    danger-state background → color-error
  - SessionWarning — warning toast bg → color-warning, dark text on the
    bright warning bg stays literal (intentional contrast pair)
  - SuggestionToast — primary CTA → color-primary, muted/error text →
    tokens. The toast itself keeps its dark literal bg by design (it's
    a floating notification, not a theme-aware surface)
  - SyncConflictToast — hover background → color-surface-hover
  - PwaUpdatePrompt — primary CTA was hardcoded indigo (#6366f1), now
    follows the active theme variant
  - auth/AuthRequiredModal — backdrop overlay literal, primary button
    text → color-primary-foreground

Backdrop overlays use literal `hsl(0 0% 0% / 0.6)` rather than a theme
token because semi-transparent black is the deliberate UI affordance
for "modal screen dimmer", not a theme-aware surface.

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