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>
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>
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>
skilltree/types.ts has had `var(--color-branch-{intellect,body,creativity,
social,practical,mindset})` references for as long as I can grep, but those
CSS variables were never defined anywhere. Every skill in the gamified
tree was rendering inherited color (effectively invisible accent), making
the 6 branches visually indistinguishable.
Add the 6 colors as a new "domain accent" section in shared-tailwind/themes.css,
defined once at :root and never overridden by .dark or variant blocks
(they're brand-internal accents, not theme-aware — the same way cycles
keeps its brand pink literal).
- intellect → blue (217 91% 60%) — knowledge, thinking
- body → red (0 84% 60%) — physical, energy
- creativity → violet (271 91% 65%) — art, expression
- social → amber (38 92% 50%) — warmth, relationships
- practical → teal (173 80% 40%) — craft, tools
- mindset → green (142 71% 45%) — calm, growth
Also update skilltree/types.ts to wrap the var() calls with hsl() per
the canonical convention (the values are now raw HSL channels).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two pre-existing bugs in the memoro module that became visible after
the Phase 5 LLM auto-title work landed. Both are independent of the
Phase 5 framework — neither was introduced by it — but the auto-title
was the first feature to systematically write to memo.title, which is
when the broken read path stopped hiding behind always-null titles.
Bug 1: DetailView shows ciphertext instead of plaintext
apps/mana/apps/web/src/lib/modules/memoro/views/DetailView.svelte
passed `useDetailEntity({ table: 'memos', ... })` WITHOUT setting
`decrypt: true`. The crypto registry has memos.{title, intro,
transcript} marked as encrypted, so the inputs were binding to
raw `enc:1:Ghj1eJV0zz4PgfRL...` ciphertext strings instead of
plaintext. Nobody noticed before because:
- title was always null (no UI path to set it until Phase 5)
- intro is rarely used
- transcript was the only visible encrypted field, and the
garbled `enc:1:...` string in the transcript area was apparently
attributed to "broken transcription" rather than "broken read"
Add `decrypt: true` to the useDetailEntity options. Same flag the
other Mana modules already use for their encrypted DetailViews.
Bug 2: createdAt and updatedAt never set on memo records
apps/mana/apps/web/src/lib/modules/memoro/stores/memos.svelte.ts
create() built a LocalMemo object without populating createdAt or
updatedAt. The LocalMemo type declares both as required strings,
but TypeScript didn't catch the omission because the store relied
on a TS Type assertion / partial-shape pattern.
The Dexie creating hook in apps/mana/apps/web/src/lib/data/database.ts
only auto-stamps userId + __fieldTimestamps — it does NOT auto-stamp
createdAt. Module stores are expected to set their own timestamps
(consistent with the todo, calendar, contacts, notes stores etc.).
So every memoro memo had `createdAt === undefined`, and the
DetailView's `new Date(memo.createdAt ?? '').toLocaleDateString('de')`
rendered as "Erstellt: Invalid Date" for every single memo.
Fix: set both timestamps explicitly in create() before the Dexie
add. Existing memos in the wild are still broken — they'd need a
one-shot migration to backfill createdAt from the
__fieldTimestamps map, but that's a bigger commit.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The /admin route in the unified Mana web app was rendering hardcoded
mock data (42 users, 156 successful logins, 3 failed) for every
admin who opened it. The previous code had a TODO comment to wire
up a real endpoint and the backend half had been waiting for the
frontend half ever since the consolidation landed.
Backend (mana-auth):
Add GET /api/v1/admin/stats — admin-only, returns the seven counts
the dashboard needs in a single response. Each count is its own
Drizzle query against auth.users / auth.sessions / auth.login_
attempts; they run in parallel via Promise.all so total latency is
dominated by the round-trip to Postgres, not the per-query work.
Stats:
- totalUsers → users where deleted_at IS NULL
- newUsers7d → users created in the last 7 days
- newUsers30d → users created in the last 30 days
- activeSessions → sessions where expires_at > now() AND not revoked
- uniqueUsers24h → distinct user_id from sessions with last_activity
in the last 24h (and not revoked)
- loginSuccess7d → login_attempts where successful=true, last 7d
- loginFailed7d → login_attempts where successful=false, last 7d
Plus a generatedAt ISO timestamp so the client can show staleness
if it ever caches the response.
Frontend (apps/mana/apps/web):
- Add adminService.getStats() in the existing admin API service
(sits next to getUsers / getUserData / deleteUserData; uses the
same authenticated base-client and ApiResult envelope).
- Replace the onMount mock-data block in admin/+page.svelte with
a single adminService.getStats() call. Drop the local Stats
interface in favor of the AdminStats type exported from the
service.
- Guard the Success Rate calculation against division by zero on
fresh deployments — when there have been no login attempts in
the last 7 days, render '—%' instead of NaN%.
Verification:
- mana-auth type-check unchanged (baseline errors only)
- mana-auth runtime tests still 19/19 passing
- svelte-check on the two changed web files: zero errors
Closes item #12 in docs/REFACTORING_AUDIT_2026_04.md.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
15 files across 11 modules — the consumer-specific style overrides on
top of DetailViewShell, plus a few module-internal sub-views and pages
(SymbolDetailView, SymbolsView, ContactPage, PageEditBar, TodoPage,
CycleCalendar, SymptomManager).
All :global(.dark) duplicates removed (theme system handles light/dark
via .dark class on <html>) and the hand-rolled #374151/#9ca3af palette
+ indigo/violet/red/green brand accents replaced with hsl(var(--color-X)).
Brand colors that should NOT track theme primary stay literal:
- cycles brand pink (#ec4899) — menstrual cycle tracker accent
- dreams indigo accents and skilltree violet star colors → color-primary
(these were arbitrary indigo brand accents, now they follow the variant)
- semantic income/success green → color-success
- semantic error/danger red → color-error
- favorite/star amber/gold → color-warning
Final P5 batch closing out the visual track consolidation. Combined with
earlier P5 commits (foundation shells, page-shell, picker, list views),
mana-web now has a single coherent theme-token convention across the
workbench surface.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Guests and under-tier users could see and use every module in the
workbench because tier-filtering only existed in @mana/shared-branding's
MANA_APPS list — never in the workbench app-registry that the picker
and the page-level routes actually consume. Three leaks closed:
──── 1. Workbench AppPagePicker ────
The picker was calling getAllApps() and only filtering by "already
open in this scene". Result: a guest opening "Add page" saw all 32
modules including founder-only ones like dreams, finance, memoro.
Fix: new getAccessibleApps(userTier) helper in app-registry/registry.ts
joins the workbench in-memory map with MANA_APPS by id, looks up
each app's requiredTier, and filters via hasAppAccess. Apps that
exist in the workbench registry but NOT in MANA_APPS (`automations`,
`playground`, the `inventar` ↔ `inventory` id mismatch) default to
visible — hiding them would silently break internal tools for
founders/devs.
AppPagePicker now takes a `userTier` prop and calls
getAccessibleApps(userTier) instead of getAllApps(). (app)/+page.svelte
threads authStore.user?.tier into it.
──── 2. openApps soft-filter ────
The default Home scene seeds [todo, calendar, notes] — `notes` is
founder-tier, so a brand-new guest device would still try to render
the notes view in their workbench tab strip even though they can't
actually use it. Same risk for any cross-device synced scene that
contains gated apps (e.g. founder logs in on a public-tier secondary
account).
Fix: (app)/+page.svelte derives `openApps` through a soft filter
(isAppAccessible) instead of using workbenchScenesStore.openApps
directly. The store keeps the full list — we don't destructively
delete on tier downgrades — so the tabs reappear when the user
upgrades or signs in. Internal-only apps (no MANA_APPS entry)
stay visible by the same default-visible rule.
──── 3. Per-route tier gate in (app)/+layout.svelte ────
The wrapping <AuthGate> in (app)/+layout.svelte:
- only runs onMount, so it doesn't react to client-side navigation
- skips the tier check entirely when !authStore.isAuthenticated
- has no per-route requiredTier — it's set once on the outer wrapper
So a guest typing /dreams or /cycles in the URL bar slipped past
silently and rendered the gated module. Same for a public-tier user
clicking through to /finance.
Fix: reactive `routeBlocked` derivation in the (app) layout:
- Extract first path segment from $page.url.pathname
- Look it up in MANA_APPS by id
- If found and user (or 'guest') doesn't satisfy requiredTier,
render an inline tier-denied panel instead of {@render children()}
The panel mirrors AuthGate's tier-denied design (same locked icon +
tier comparison + "Zur Übersicht" / "Anmelden" buttons) but works
reactively for any subsequent navigation. Routes that don't map to
a MANA_APPS id (settings, profile, admin, help, observatory, …)
fall through with routeAppId=null and are never blocked.
──── New helpers in app-registry ────
getAccessibleApps(userTier?) — filtered AppDescriptor[]
isAppAccessible(appId, userTier?) — boolean for single-app lookup
Both treat `userTier === undefined | null` as 'guest' (the lowest
tier in @mana/shared-branding). Both default-visible for apps not
in MANA_APPS so the workbench-internal tools keep working.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sweep across the workbench-foundation components that wrap or chrome
every app surface in mana-web. All :global(.dark) duplicates removed
(theme system handles light/dark via .dark class on <html>) and the
hand-rolled #374151/#9ca3af palette + indigo/violet brand accents
replaced with hsl(var(--color-X)).
Files migrated:
- DetailViewShell — the inline-edit detail card my P2 introduced.
Had compound :global(.dark .detail-view .X) selectors duplicating
every input/border for dark mode. Now single source.
- PickerOverlay — the workbench app picker / page picker.
- page-carousel/PageCarousel — drop-target hover (was hardcoded violet)
becomes color-primary.
- workbench/AppPage — drop-target hover (was hardcoded green) becomes
color-success.
- workbench/scenes/{ConfirmDialog,SceneRenameDialog,SceneTabs} —
modal overlays. The black-50% backdrop stays literal (hsl(0 0% 0% / 0.5))
since it's a deliberate semi-transparent overlay, not theme-aware.
The .danger button background switches from #dc2626 to color-error.
- voice/VoiceCaptureBar — primary indigo accent + red error state
both follow theme tokens now.
- links/LinkedItems — text + border tokens.
These are foundational — every app rendered in the workbench inherits
its chrome and detail view from these files, so any visual tweak now
propagates everywhere consistently.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three pnpm artifacts that were either Pre-Consolidation leftovers or
unintentional drift:
- apps/context/pnpm-lock.yaml + apps/context/pnpm-workspace.yaml
apps/context used to be its own nested workspace declaring
apps/* and packages/*. After consolidation only apps/context/
apps/mobile remains, and the root pnpm-workspace.yaml already
matches it via 'apps/*/apps/*'. The nested lockfile (242 KB)
was a separate dependency graph drifting independently from
the root.
- services/mana-media/packages/client/pnpm-lock.yaml
Anomalous lockfile in a workspace sub-package. The root
workspace already covers services/*/packages/* — no reason
for client/ to maintain its own resolution.
Verified after deletion:
- pnpm install completes cleanly (~16s) and now resolves
apps/context/apps/mobile from the root lockfile (pnpm list
confirms the workspace registration)
- apps/api type-check still 0 errors
- mana-auth tests still 19/19 passing
Tracked as item #26 in docs/REFACTORING_AUDIT_2026_04.md.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three independent dead-code cleanups bundled together because they
all touch dev scripts in the root package.json:
1. games/voxelava/ + games/worldream/ — orphaned game stubs
~5886 LOC of Svelte components, route handlers, and types with
no root package.json in either directory, no CI references, no
docker-compose entry, no mana-apps registry presence. The
matching root scripts dev:worldream:web + worldream:dev pointed
to a @worldream/web filter that doesn't exist as a workspace
member. games/arcade and games/whopixels remain untouched.
2. apps/memoro/* — clean stale @memoro/web references
apps/memoro/apps/web/ was removed during the consolidation; the
memoro frontend now lives in apps/mana/apps/web/src/lib/modules/
memoro/. But several scripts still pointed at the deleted
filter:
- root: dev:memoro:web (deleted), dev:memoro:app + :full
rewritten to drop the :web piece (server + audio-server
only)
- apps/memoro/package.json: dev:web removed, top-level dev
script removed (filtered @memoro/* which would have hit
the dead web filter)
3. apps/memoro/apps/server: declare @mana/notify-client dep
src/lib/notify.ts:6 has been importing @mana/notify-client
without declaring it in package.json — works by accident via
hoisted node_modules in the workspace. Add the dep so the
import is properly tracked. Found while verifying that
notify-client (which has 0 declared consumers) was actually
safe to keep.
Tracked as items #18, #19, #29 in
docs/REFACTORING_AUDIT_2026_04.md.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
First real-world consumer of the @mana/shared-llm tier framework.
After STT transcription completes for a voice memo, the memos store
fire-and-forgets a generateTitleTask into the persistent task queue
with refType:'memo' + refId:memoId. A module-side watcher subscribed
via Dexie liveQuery to completed task rows writes the result back
into memo.title and deletes the queue row to mark it consumed.
What this commit ships:
apps/mana/apps/web/src/lib/llm-tasks/generate-title.ts
- generateTitleTask: minTier='none', contentClass='personal'
- runLlm: sends a German system prompt asking for a 3-7 word
title, defensive cleanup of any quotes/markdown the model
might leak through despite the prompt
- runRules: takes the first sentence (split on .!?\n), caps
at maxWords/60-chars, returns a non-empty fallback string.
Predictable and free, works on every device including the
ones where the user has opted out of all LLM tiers.
apps/mana/apps/web/src/lib/llm-task-registry.ts
- Register generateTitleTask alongside extractDate + summarize
so the queue processor can resolve the name back to the
task object after a row is pulled from the persistent table.
apps/mana/apps/web/src/lib/modules/memoro/stores/memos.svelte.ts
- After transcribeMemo successfully writes the transcript +
processingStatus:'completed', enqueue a generateTitleTask
tagged with refType:'memo' + refId + priority:1. Skips the
enqueue if the memo already has a non-empty title (so
manually-titled memos aren't overwritten on re-transcription)
or if the transcript came back empty.
- Wrapped in try/catch — queue failures must NEVER break the
transcription happy path.
apps/mana/apps/web/src/lib/modules/memoro/llm-watcher.svelte.ts
- startMemoroLlmWatcher() / stopMemoroLlmWatcher()
- Subscribes via Dexie liveQuery to llmQueueDb.tasks rows
where state='done', taskName='common.generateTitle',
refType='memo'. For each row:
1. Skip + delete row if result isn't a string (defensive)
2. Skip + delete row if memo no longer exists (deleted
between enqueue and result)
3. Skip + delete row if memo already has a manual title
(user typed one during the LLM round-trip)
4. Otherwise: encryptRecord + memoTable.update with
{ title: result, updatedAt: now }, then delete the
queue row to mark it consumed.
- Module-scope subscription handle, idempotent start/stop.
apps/mana/apps/web/src/routes/(app)/+layout.svelte
- startMemoroLlmWatcher() in handleAuthReady's Phase A right
after startLlmQueue(). The watcher needs to run regardless
of whether the user is currently on /memoro — a memo
transcribing in the background should auto-title even
while the user is doing something else.
- stopMemoroLlmWatcher() in onDestroy alongside stopLlmQueue().
End-to-end flow with a Tier 0 user (no AI enabled):
1. User records a memo via voice capture
2. memos.svelte.ts createWithTranscription() inserts the memo
with processingStatus:'processing'
3. transcribeMemo POSTs the audio to mana-stt, awaits the
transcript
4. Successful transcript → memos.svelte.ts writes
{ transcript, processingStatus:'completed' } to memoTable
5. Same function enqueues generateTitleTask with the transcript
6. LlmTaskQueue processor picks it up (the queue is running in
the background since layout init), calls
orchestrator.run(generateTitleTask, { text: transcript })
7. Orchestrator: Tier 0 user → no LLM tier → falls through to
runRules() which returns the first-sentence heuristic
8. Queue marks the row done with the rules-tier title string
9. Memoro watcher's liveQuery fires with the new completed row
10. Watcher writes title + deletes the queue row
11. ListView's existing useLiveQuery on memoTable picks up the
title change automatically
End-to-end flow with a Browser-tier user:
Steps 1-6 identical, then:
7. Orchestrator: browser tier ready → calls
generateTitleTask.runLlm with the BrowserBackend
8. Web Worker (Phase 3) runs Gemma 4 E2B against a 32-token
budget, returns a 3-7 word German title
9-11. Same as Tier 0 — the title lands in memo.title without
the user clicking anything
This is the validation the entire 4-phase architecture was built
for: a module-side auto-feature that's completely tier-agnostic,
fire-and-forget, persistent across reloads, and that gracefully
degrades from Gemma 4 down to a regex when the user has opted out.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The workbench paper card containing every app — was hardcoded cream
(#fffef5) light + dark brown (#252220) dark via :global(.dark).
Now uses hsl(var(--color-card)) so it follows the active theme variant.
The drag-handle bar, move buttons, window buttons, resize handle and
title all switch from hand-rolled gray scale to color-foreground /
color-muted-foreground / color-surface-hover. The close button hover
becomes color-error. The resize purple glow becomes color-primary.
This is the foundational shell — every app rendered in the workbench
inherits its background from this file, so the migration here unblocks
visual consistency across the whole app surface.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
31 hand-rolled rules + their :global(.dark) duplicates → hsl(var(--color-X)).
The largest scoped-CSS file in the P5 sweep. Indigo accents (#6366f1) for
the inline-editor borders, status badges, view-tab active state, filter-tab
active state, search-input focus, and ins-symbol active state all become
hsl(var(--color-primary)) so they follow the active theme variant. Lucid
star ratings (was hardcoded amber) become hsl(var(--color-warning)).
Danger reds for transcription failure / delete become hsl(var(--color-error)).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
11 hand-rolled rules + their :global(.dark) duplicates → hsl(var(--color-X)).
The brand pink (#ec4899) stays literal — it's the menstrual cycle tracker
brand color and should not track theme variants. Danger reds switch to
hsl(var(--color-error)) so they follow the theme palette.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
21 hand-rolled rules + their :global(.dark) duplicates → hsl(var(--color-X)).
income/expense semantic colors switch from literal #22c55e/#ef4444 to
hsl(var(--color-success))/hsl(var(--color-error)) — they keep their meaning
(green for money in, red for money out) but now follow the theme palette.
.add-btn primary action and .cat-chip.selected state move from hardcoded
indigo to color-primary so they follow the active theme variant.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
22 hand-rolled rules + their :global(.dark) duplicates → hsl(var(--color-X)).
The inline-editor border/background (was hardcoded indigo rgba) now uses
hsl(var(--color-primary) / alpha). The .ed-btn.primary save button (was
hardcoded #6366f1) becomes hsl(var(--color-primary)) so it follows the
active theme variant.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
13 hand-rolled rules + their :global(.dark) duplicates → hsl(var(--color-X)).
The hardcoded #3b82f6 today-marker becomes hsl(var(--color-primary)) so it
follows the active theme variant. Drag-target hover outline (was hardcoded
blue-500) also becomes primary. Tag-pill background keeps its --tag-color
custom property logic.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The mechanical var()→hsl(var()) sweep in commit 6e20c298a left 14
lines across spiral/+page.svelte and KeyboardShortcutsModal.svelte
with three closing parens instead of two:
background: hsl(var(--color-card)));
^ extra
Each of those originals had a hex fallback like
`var(--color-card, #fff)` and the replacer kept the trailing close
paren after dropping the fallback. The styles never showed up wrong
in the browser because the parser silently dropped the entire
declaration, but Tailwind 4's CSS validator catches it now and the
production build fails with "Missing opening (".
Mechanical fix via sed `s/hsl\(var\(--color-([a-z-]+)\)\)\);/
hsl(var(--color-$1));/g` on the two files. No semantic change.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
First file in the visual track consolidation, retried after the theme
system was rewritten in 919fcca4b. The earlier attempt failed because
themes.css was inconsistent (--color-X resolved to wrapped string from
@theme inline, then to raw channels after runtime store ran, with raw
channels being invalid as a CSS color). With the new single-layer
themes.css all hsl(var(--color-X)) usages resolve correctly in light
and dark mode regardless of hydration state.
Substitution applied (the canonical pattern):
color: #374151 + :global(.dark) #e5e7eb → color: hsl(var(--color-foreground))
color: #9ca3af → color: hsl(var(--color-muted-foreground))
border: 1px rgba(0,0,0,0.08) + dark dup → border: 1px solid hsl(var(--color-border))
background: rgba(0,0,0,0.04) + dark dup → background: hsl(var(--color-surface-hover))
Net: 260 → 245 LOC, 7 hand-rolled palette rules eliminated, all 6
:global(.dark) selectors removed (theme system handles both modes via
.dark class on <html>).
Brand colors that should NOT track the theme primary stay literal:
- violet category badge (#8b5cf6) — kept hardcoded with color-mix for
the 12% alpha background, since this is a deliberate accent
indicating quote category, not a theme color
- favorite-active red — switched from literal to var(--color-error)
so it follows the theme's error color (consistent with other
delete/danger affordances in the app)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Until now, modules wanting to use the orchestrator had to await each
LLM call inline in their store code. That's fine for foreground tasks
("user clicked summarize") but a non-starter for background work
("auto-tag every new note", "generate a title for every voice memo
after STT finishes"). Background tasks need to:
- Queue up while no LLM tier is ready, then drain when one becomes
available (e.g. user just enabled the browser tier from settings)
- Survive page reloads, browser restarts, and the user navigating
away mid-execution
- Run one at a time without blocking the foreground UI
- Allow modules to subscribe to results reactively without polling
- Retry transient failures (network, model loading) but not
semantic ones (tier-too-low, content blocked)
Phase 4 ships exactly that.
Architecture:
packages/shared-llm/src/queue.ts — LlmTaskQueue class
+ QueuedTask interface (the persistent row shape)
+ EnqueueOptions (refType/refId/priority/maxAttempts)
+ TaskRegistry type (name → LlmTask map)
+ LlmTaskQueueOptions (table + orchestrator + registry +
retryBackoffMs + idleWakeupMs)
Public API:
- enqueue(task, input, opts) → string (returns the queued id)
- get(id), list(filter)
- retry(id), cancel(id), purge(olderThanMs)
- start(), stop() (idempotent processor lifecycle)
apps/mana/apps/web/src/lib/llm-queue.ts — web app singleton
- Dedicated `mana-llm-queue` Dexie database (separate from the
main `mana` IDB; see comment for the rationale: ephemeral
per-device state, no encryption needed, no sync needed, doesn't
belong in the long-frozen `mana` schema)
- Wires up the queue with llmOrchestrator + taskRegistry
- Exposes startLlmQueue() / stopLlmQueue() for the layout hook
apps/mana/apps/web/src/lib/llm-task-registry.ts
- Maps task names → task objects so the queue processor can
look up the implementation when pulling rows off the table.
Closures can't be persisted, so we round-trip via name.
- Currently registers extractDateTask + summarizeTextTask;
module-side tasks land here as we add them.
apps/mana/apps/web/src/routes/(app)/+layout.svelte
- startLlmQueue() in handleAuthReady's Phase A (auth-independent)
so guests + authenticated users both get the queue
- stopLlmQueue() in onDestroy as a fire-and-forget cleanup
Processor loop semantics (the heart of the implementation):
1. On start(), reclaim any 'running' rows from a crashed previous
session — reset them to 'pending'. The orphan recovery is the
reason a crash mid-task doesn't leave the queue stuck.
2. findNextRunnable() picks the highest-priority pending task whose
`notBefore` (retry-backoff timestamp) is in the past. Sort key:
priority desc, then enqueuedAt asc (FIFO within priority).
3. Mark the task running, increment attempts, look up the LlmTask
in the registry, hand it to orchestrator.run().
4. On success: mark done, store result + source + finishedAt.
5. On error:
- TierTooLowError or ProviderBlockedError → fail immediately,
no retry. These are not transient — the user's settings or
the content itself need to change.
- Anything else → if attempts < maxAttempts, reset to pending
with notBefore = now + retryBackoffMs (default 60s). Else
mark failed.
6. When no work is pending, sleep on a Promise that resolves when
either (a) someone calls enqueue() (which fires notifyWakeup),
or (b) idleWakeupMs elapses (default 30s, safety net for any
missed wakeup signal).
Module-side reactive reads use Dexie liveQuery directly on the queue
table — no special subscription API on the queue itself. This is
consistent with how every other Mana module reads its data, so the
mental model stays uniform:
const tags = useLiveQuery(
() => llmQueueDb.tasks
.where({ refType: 'note', refId, taskName: 'common.extractTags' })
.reverse().first(),
[refId]
);
Smoke test: a new "Queue" tab in /llm-test lets you enqueue the
existing extractDate / summarize tasks and watch the live state of
the queue table via liveQuery. The display includes per-row state
badge (pending/running/done/failed), tier source, attempt count,
input/output, and a "Done/failed löschen" button that exercises
purge().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
After the themes.css rewrite (commit 919fcca4b), --color-X holds raw HSL
channels instead of full hsl() strings. Files using `var(--color-X)` standalone
in scoped CSS were already broken — the value is not a valid CSS color, so
the browser fell back to either the literal hex fallback (`var(--color-X, #...)`)
or to inherited (white in light mode). The Phase 1 rewrite is neutral for
those files (same broken behavior as before), but the now-canonical convention
is to wrap with hsl().
Sweep 11 components/routes that are NOT in the Phase 6 visual rewrite scope:
- Breadcrumbs, KeyboardShortcutsModal
- dashboard/TilePanel, dashboard/TileResizeHandle
- page-carousel/PageCarousel
- workbench/scenes/{ConfirmDialog, SceneRenameDialog, SceneTabs}
- routes/(app)/{llm-test, observatory, spiral}
Mechanical replacement: `var(--color-X[, fallback])` → `hsl(var(--color-X))`.
The hex fallbacks are dropped because :root in themes.css now defines all
--color-X values statically.
TilePanel had two unknown-token references that don't exist in the new
themes.css schema and were silently rendering their hex fallbacks:
- `--color-text` → `--color-foreground` (semantic synonym)
- `--color-destructive` → `--color-error` (shadcn name for the same concept)
Skipped from this sweep:
- Files in Phase 6 modules (places, habits, contacts, todo, dreams, finance,
calendar, notes, photos, automations, cycles, events, zitare) — they will
be migrated together with their hand-rolled palettes in Phase 6.
- skilltree/types.ts uses --color-branch-{intellect,body,…} tokens that have
never been defined anywhere — long-standing bug, needs actual brand colors
added to themes.css to fix properly.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Phase 3 build failed at the worker bundling step with:
"Invalid value 'iife' for option 'worker.format' - UMD and IIFE
output formats are not supported for code-splitting builds."
Vite defaults workers to IIFE format which can't handle code-split
imports. @mana/local-llm's new worker.ts imports @huggingface/
transformers, which itself is internally code-split into many
chunks (the ONNX runtime, the model classes, the tokenizer
families, the lazy backend selectors). IIFE has no way to load
those at runtime.
Switch the web app's vite.config.ts to `worker: { format: 'es' }`.
Module workers are supported in every browser that supports
WebGPU (Chrome 80+, Edge 80+, Safari 15+), so no users lose
support.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pre-launch theme system audit found multiple parallel layers in themes.css
(--theme-X full hsl strings, --X partial shadcn aliases, --color-X populated
by runtime store with raw channels) plus dead-code companion files. The
inconsistency caused light-mode regressions when scoped-CSS consumers
wrote `var(--color-X)` standalone — the variable holds raw HSL channels
which is invalid as a color value, browser fell back to inherited (white).
Rewrite to one consistent layer:
- Source of truth: --color-X defined as raw HSL channels (e.g.
`0 0% 17%`) in :root, .dark, and all variant [data-theme="..."]
blocks. Matches the format the runtime store
(@mana/shared-theme/src/utils.ts) writes, eliminating the
static-fallback-vs-runtime mismatch and the corresponding flash
of unstyled content on hydration.
- @theme inline uses self-reference + Tailwind v4 <alpha-value>
placeholder so utility classes generate correctly AND opacity
modifiers work: `text-foreground/50` → `hsl(var(--color-foreground) / 0.5)`.
- @layer components (.btn-primary, .card, .badge, etc.) wraps
var(--color-X) refs with hsl() — they were broken in light mode
too for the same reason.
Convention going forward (also documented in the file header):
1. Markup: use Tailwind utility classes (text-foreground, bg-card, …)
2. Scoped CSS: hsl(var(--color-X)) — always wrap with hsl()
3. NEVER raw var(--color-X) in CSS — that's the bug pattern
Net file: 692 → 580 LOC. Single source layer, no indirection.
Also delete dead companion files (zero imports anywhere):
- tailwind-v4.css (had broken self-reference, never imported)
- theme-variables.css (legacy hex-based palette)
- components.css (legacy component utilities)
- index.js / preset.js / colors.js (Tailwind v3 preset format,
irrelevant under Tailwind v4)
package.json exports map shrinks accordingly to just `./themes.css`.
Consumers using `hsl(var(--color-X))` (~379 files across mana-web,
manavoxel-web, arcade-web) keep working unchanged — the public API
name `--color-X` is preserved. Only the broken pattern `var(--color-X)`
(~61 files) needs a follow-up sweep, handled in a separate commit.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>