Commit graph

3432 commits

Author SHA1 Message Date
Till JS
72a5995fa5 feat(articles): M9 workbench homepage — 4-tab shell + QuickAdd + StatsView
Articles ist jetzt als Workbench-App in apps.ts registriert
(icon BookOpen, collection 'articles', paramKey 'articleId') und
landet damit im Scene-App-Picker. HomeView/ListView/HighlightsView/
StatsView teilen sich eine neue ArticlesTabShell, die sowohl als
SvelteKit-Route als auch als Workbench-Karte rendert.

Shell (ArticlesTabShell.svelte):
 - Top-Bar mit QuickAddInput (URL einfügen + Enter = Save + goto
   Reader; kein Preview-Schritt) und Settings-Gear.
 - Tab-Leiste darunter: Leseliste | Highlights | Favoriten | Stats.
   Leseliste ist Default (initialTab='list').
 - Tab-Wechsel läuft intern via $state + Svelte-Context — kritisch
   für die Workbench-Karte, wo goto() den User aus der Karte kicken
   würde. getArticlesTabContext() aus tab-context.ts gibt tief
   verschachtelten Sektionen eine switchTo(tab)-API.
 - Padding 1rem 1.25rem auf der Shell selbst — PageShell.page-body
   hat null padding, sonst klebt QuickAdd am Card-Rand. Im Route-
   Kontext addiert's sich zum (app)-Layout-Padding ohne zu viel.

Tabs:
 - Leseliste (list): bestehende ListView mit optionalem
   initialFilter-Prop. Continue-Reading-Strip (HomeSectionWeiterlesen
   horizontal carousel) erscheint über den Filter-Chips wenn
   status='reading'-Artikel existieren und filter ∈ {all, reading}.
   Filter-Chips sind einzeilig + horizontal scrollbar mit
   scroll-snap-Einrast; inaktive Chips haben jetzt sichtbare
   Background-Füllung + Border via color-mix(currentColor) — adaptiv
   fürs Theme.
 - Highlights (highlights): HighlightsView unverändert (nur der
   eigene Header + Zurück-Button raus, liegt jetzt in der Shell).
 - Favoriten (favorites): ListView mit initialFilter='favorites' —
   Shell-Shortcut auf den Filter.
 - Stats (stats): neue StatsView mit Stats-Strip (savedThisWeek,
   finishedThisWeek, avg reading time), Highlight-Counter, Top-
   Sources und Archiv-Link.

Routes (unter (tabs)-Gruppe):
 - /articles                → initialTab="list"   (Default)
 - /articles/list           → initialTab="list"   (alias)
 - /articles/highlights     → initialTab="highlights"
 - /articles/favorites      → initialTab="favorites"
 - /articles/stats          → initialTab="stats"
 Detail/Add/Settings bleiben bewusst ausserhalb — die haben ihren
 eigenen Reader/Form-Chrome und sollen die Tab-Leiste nicht zeigen.

Neue Files:
 - ArticlesTabShell.svelte   (Tab-Host)
 - tab-context.ts            (Cross-Tab-Switch-Context)
 - components/ArticleCard.svelte (shared Card aus ListView extrahiert,
                                  row + compact Varianten)
 - components/QuickAddInput.svelte (URL-Input aus HomeView extrahiert)
 - components/HomeSectionSources.svelte
 - components/HomeSectionStats.svelte
 - components/HomeSectionWeiterlesen.svelte
 - views/StatsView.svelte
 - routes/(app)/articles/(tabs)/{+page,list,highlights,favorites,stats}

Gelöscht:
 - HomeView.svelte (Overview-Tab wurde rausgenommen auf User-Feedback)
 - HomeSectionFrisch/Highlights/Favorites (durch eigene Tabs ersetzt)

docs/plans/articles-homepage.md dokumentiert den Architektur-Plan,
inklusive der Entscheidung für "eine Card pro Domain, interne Tabs"
statt zwei separater App-Registrierungen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:50:38 +02:00
Till JS
a36e543e41 feat(agents): Phase 2d.3 — SpaceType-aware default agent bootstrap
Before this commit, the bootstrap created one "Mana" agent per user.
After per-Space migration, every Space needs its own default agent so
Actor attribution shows the right identity and missions land in the
right Space. Three users with Personal + Family + Brand Spaces would
have ended up with three "Mana" agents in the picker — ugly and
confusing.

Now each Space type gets a name that reads naturally:
- personal  → "Mana"           (keeps legacy name + DEFAULT_AGENT_ID
                                so historic Actor.displayName on
                                pre-migration records still renders)
- family    → "Familien-Helfer"
- team      → "Team-Assistent"
- brand     → "Brand-Assistent"
- club      → "Verein-Helfer"
- practice  → "Praxis-Assistent"

Stable id scheme:
- Personal: DEFAULT_AGENT_ID (legacy coupling with LEGACY_AI_PRINCIPAL)
- Others:   `default:<spaceId>` (deterministic, collision-free)

Bootstrap bypasses the regular createAgent path (which enforces
global name-uniqueness) because the same name is legitimately repeated
across multiple Spaces of the same type. Deduplication happens via
getAgent(id) + Dexie's add-or-skip for cross-tab races instead.

ensureDefaultAgent() reads the active Space via getActiveSpace(); when
no Space is loaded yet (pre-bootstrap first boot) it falls back to the
Personal default. The per-Space re-run on onActiveSpaceChanged (Phase
2d.4) picks up the correct agent once loadActiveSpace resolves.

Type-check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:40:28 +02:00
Till JS
68c0eb2892 chore(test + audit): add test-coverage audit + wire audit:all
#6 test coverage (pivot to reporting): 34/653 tests currently fail
(in-flight spaces-foundation migrations). Hard coverage thresholds
aren't enforceable until the suite is green, so this session ships a
file-presence audit instead of line-coverage gates.

  - scripts/audit-test-coverage.mjs — counts .svelte + .ts source files
    vs .test.ts + .spec.ts per module. Reports total ratio, lists
    modules with 0 tests + ≥3 source files (prioritised by size).
  - pnpm run audit:test-coverage  wires it into audit:*.
  - docs/optimizable/test-health.md — state + prevention path + top
    untested modules ranked by impact.

Current baseline: 2.6% file-level coverage. 66/78 modules have zero
tests. Biggest untested: times (32 src), articles (29), events (27),
inventory + skilltree (20 each).

#8 audit:all: single entry point for the reporting audits. Runs
port-drift + i18n-coverage + test-coverage in --summary mode. Distinct
from validate:all (which is gates, not reports).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:38:12 +02:00
Till JS
8a82f3c543 feat(kontext): Phase 2d.2 — kontextDoc is per-Space, not user-singleton
Since Phase 2d.2 of the space-scoped rollout, each Space can have its
own kontextDoc. Before this commit, the module was a user-level
singleton keyed by id='singleton' — which meant Shared/Brand/Family
Spaces saw the user's Personal-Space bio as their AI planner context.

Changes:
- types.ts: relax LocalKontextDoc.id to plain string (was the literal
  'singleton'). KONTEXT_SINGLETON_ID stays as an exported const so
  legacy Personal-Space rows (stamped before the refactor) are
  documented; no longer used at write sites.
- stores/kontext.svelte.ts: ensureDoc() finds the active-Space row via
  scopedTable(), creates a fresh UUID row if absent. setContent /
  appendContent operate on the found-or-created row's id. Personal-
  Space's legacy 'singleton' row keeps rendering because the
  `_personal:<userId>` sentinel is inside getInScopeSpaceIds()'s
  returned set.
- queries.ts: useKontextDoc() mirrors the same scopedTable filter.
- ai/missions/default-resolvers.ts: kontextIndexer surfaces the active
  Space's kontextDoc (not hardcoded 'singleton'). Shared-Spaces without
  a doc yet return an empty candidate list, which is the correct
  empty-state for the mission-input picker.

Type-check clean. No schema change; relies on v28's existing spaceId
stamping + the creating-hook's ongoing stamp (kontextDoc is in the
kontext module.config).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:35:23 +02:00
Till JS
219ccd3f2c chore(web): harden pnpm check with --fail-on-warnings
svelte-check emits a11y + dead-CSS + Svelte-5 $state warnings that were
previously non-blocking — pre-push only caught hard type errors. The
a11y-30 cleanup commit (3e09ff66d) brought the warning count to 0, so
flipping `--fail-on-warnings` on now makes the checker hold the line:
any new warning fails the pre-push hook that runs `pnpm check`.

Covers: a11y_click_events_have_key_events, a11y_consider_explicit_label,
css_unused_selector, state_referenced_locally, and the other svelte-check
diagnostic categories.

No behaviour change with current codebase (0 warnings); prevents drift
going forward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:33:50 +02:00
Till JS
35d9e023a6 feat(tag-presets): Phase 2d.1 — CRUD store + encryption for user-level presets
First wire-up of the userTagPresets surface from Phase 2b's v34 schema.
This is the store layer only — Space-create UI integration + the
apply-preset-to-space flow land in a follow-up commit alongside the
SpaceCreateDialog changes.

- lib/data/tag-presets/types.ts: LocalUserTagPreset shape + inline
  TagPresetEntry + toUserTagPreset converter.
- lib/data/tag-presets/store.svelte.ts: createPreset / updatePreset /
  deletePreset / setDefault / appendEntry. Stamps userId explicitly
  because userTagPresets is kept out of SYNC_APP_MAP (the Dexie
  creating-hook only fires for sync tables). At-most-one-default-per-
  user invariant enforced by clearDefaultFlag() before writes that set
  isDefault=true.
- lib/data/tag-presets/queries.ts: useUserTagPresets + useDefaultTagPreset
  live queries. User-scoped, no active-space filter (presets show from
  any Space context).
- crypto: move userTagPresets from plaintext-allowlist to
  ENCRYPTION_REGISTRY with fields ['name', 'tags']. AES wrapping handles
  the tags array via JSON-stringify, same pattern as food.foods.

Crypto audit: 196 tables (95 encrypted, +1 userTagPresets). Type-check
clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:26:39 +02:00
Till JS
4d91e2daad chore(services): add port-drift audit
Each services/*/CLAUDE.md declares `## Port: NNNN` — the authoritative
per-service port spec (docs/PORT_SCHEMA.md is explicitly partially
aspirational). This audit verifies:

  1. Declared port appears as a literal in the service's own source
     (catches: moved port in code but forgot to update CLAUDE.md).
  2. No two services claim the same port (catches: accidental
     collision when scaffolding new services).

Current state: ✓ 15 services, all declared ports found in code, zero
collisions (mana-auth/geocoding/stt/tts/image-gen/voice-bot/mail/
credits/user/subscriptions/analytics/events/news-ingester/ai/research).

Report-only; not a CI gate. Run with `pnpm run audit:port-drift`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:22:37 +02:00
Till JS
52af8c0cec refactor(theming): migrate who semantic colours to theme tokens
PlayView used Tailwind palette classes for game-status feedback:

  bg-emerald-500/10 + text-emerald-300   (won)    → bg-success/10 + text-success
  bg-amber-500/10 + text-amber-300       (lost)   → bg-warning/10 + text-warning
  border-red-500/20 + bg-red-500/10 +
    text-red-300                         (error)  → border-error/20 + bg-error/10 + text-error
  placeholder-white/30 focus:border-purple-400/50 → placeholder:text-muted-foreground/60 focus:border-primary/50

Semantic status now tracks the theme (errors are red in dark, darker red
in light, etc.) instead of being fixed hex ramps.

The `bg-purple-500` / `bg-purple-500/30` / `hover:bg-purple-600` classes
on the user's chat bubble and submit buttons STAY — purple is the who
module's primary identity colour (historical-deck accent `#a855f7` is
semantically the same hue). Documented in brand-literals.md §who.

Also harden two validators against mid-rename states where git ls-files
returns paths that aren't on disk yet — both now skip unreadable files
instead of crashing the pre-commit hook (caught while migrating who).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:19:53 +02:00
Till JS
eec369bd04 chore(i18n): add coverage audit + migration inventory
Translation infrastructure (@mana/shared-i18n + svelte-i18n + 35
per-module locale files with ~3500 lines across de/en/it/fr/es) is fully
wired, but 65/78 modules still hardcode German in .svelte templates
rather than calling {$_('module.key')}.

Adds:
  - scripts/audit-i18n-coverage.mjs — scans lib/modules/**/*.svelte for
    hardcoded German keywords (Abbrechen, Speichern, Löschen, etc.) in
    files that don't import $_(). Reports per-module hit counts,
    bucket (FULL/PARTIAL/NONE), and whether the locale file exists.
    Supports --summary and --top N flags.
  - pnpm run audit:i18n-coverage  wires it into the audit:* family
    (reporting only, not a CI gate — existing debt would fail
    validate:all otherwise).
  - docs/optimizable/i18n-migration-inventory.md — priority list,
    per-module workflow, and prevention plan.

Top offenders: broadcast (26 hits), articles (24), events (23),
invoices (22), quiz (20), stretch (20), library (19), profile (17),
skilltree (15, PARTIAL), calendar (14, PARTIAL). Modules without a
locale file (broadcast/articles/events/invoices/…) need the locale
stubs scaffolded first.

Real string migration is per-site careful work (key naming, 5-language
parity, UI visual QA) and is left for per-module follow-up sessions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:16:55 +02:00
Till JS
07e35d79f0 feat(db): Phase 2b — Dexie v34 schema for space-scoped data model
Adds the two small schema changes needed for the space-scoped rollout.
The heavy lifting (userId drop, kontextDoc reshape, store-API pivots)
lives in 2c / 2d — this commit keeps scope surgical to isolate the
Dexie version bump.

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:15:48 +02:00
Till JS
430aa30cbf refactor(theming): re-apply theme validator suite after parallel rebase
The plan-doc commits 129971ffc + 9db044178 dropped the
audit-theme-tokens → validate-theme-variables rename, the
validate-theme-tokens → validate-theme-utilities rename, the new
validate-theme-parity script, brand-literals.md, and the corresponding
package.json + lint-staged.config.js + themes.css wiring. The files
still existed on disk (git mv changes survived) but were untracked.

Restore the validator suite so `pnpm run validate:all` works again:
  - validate:theme-variables (CSS var names: --muted → --color-muted)
  - validate:theme-utilities (Tailwind: no white/N, no neutral palette)
  - validate:theme-parity    (every --color-* in :root ⇔ .dark + each
                              [data-theme="..."])

All three wired into validate:all and lint-staged. `pnpm run validate:all`
is clean.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:00:59 +02:00
Till JS
9db044178c docs(plans): Phase 1 audit — space-scoped migration
Audit of every Dexie table in apps/mana/apps/web/src/lib/data/database.ts
+ crypto registry finds no blockers for Phase 2, with two scope
adjustments to fold in:

1. Add agentKontextDocs (v22) to the to-migrate list. Per-agent
   context docs reference the aiAgents table; migration order must
   be agents first, then backfill agentKontextDocs.spaceId via
   parent-agent lookup.

2. The 46 already-space-scoped tables from the Spaces-Foundation
   sprint all still carry userId alongside spaceId. To hit the
   "no table has both userId and spaceId" invariant, Phase 2
   extends from just the 7 newly-migrated tables to a ~53-table
   sweep dropping userId everywhere. Mechanically identical per
   table, so the extra scope is cheap.

Also confirmed:
- All 19 junction tables have space-scoped parents — no dangling
  refs. Safe to migrate parents.
- Actor columns (__lastActor / __fieldActors) stamped everywhere by
  the Dexie creating hook — userId can be dropped confidently.
- userContext (v23 profile hub) is distinct from kontextDoc (AI
  planner injection). userContext stays user-level; kontextDoc
  moves per-Space. No collision.
- 10 user-level singleton tables correctly identified to stay
  user-level (userSettings, newsPreferences, meditateSettings, …).
- 10 internal/infra tables (_pendingChanges, _events, _aiDebugLog,
  …) get per-table treatment; mostly no spaceId needed.

Phase 2 can proceed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:28:00 +02:00
Till JS
129971ffc3 docs(plans): revise space-scoped plan — remove legacy residues
Self-audit of the previous draft surfaced 7 legacy residues that would
have left the rebuild short of the "optimal architecture" bar. Rewrite
the plan with those addressed:

1. Drop userId from data records entirely. Attribution lives in the
   Actor system (__lastActor / __fieldActors). userId stays only on
   explicitly user-scoped tables.
2. Active scene localStorage key becomes per-Space:
   `mana:workbench:activeSceneId:${spaceId}` — switch Space A → scene
   X, to B → scene Y, back to A → X restored.
3. New user-level userTagPresets table replaces the "copy from
   Personal" checkbox hack. First-class templates for seeding new
   Spaces with a named tag set; CRUD in Settings.
4. Encryption decision made in-line: globalTags + tagGroups names
   encrypted during migration, not deferred (tag names like
   "Therapie" or "Finanzen-privat" can leak personal categorization).
5. kontextDoc moves from user-level singleton to per-Space. AI runner
   pulls the active Space's kontextDoc; Shared-Spaces start without
   one until the user writes one.
6. Default-agent bootstrap uses SpaceType-aware names (Mana for
   personal, Familien-Helfer for family, Team-Assistent for team,
   etc.) so users don't end up with "three Mana" in their agent list.
7. Phase 1 explicitly audits every junction table to verify parent
   records carry spaceId — no silent user-global references.

Also: an explicit "No legacy residues" section anchors these as
intentional anti-patterns to prevent drift. Success criteria now
includes "no table has both userId AND spaceId" as a testable
invariant.

Timeline grows from 3–4 to 4–5 days; the delta is encryption wiring
+ userTagPresets CRUD + the userId→Actor cleanup.

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:55:56 +02:00
Till JS
470f3b1b6c docs(plans): space-scoped data model (Modell β) — commit plan
Supersedes per-space-vs-user-global-tags.md (which recommended defer
under "ship fast" assumptions). Pre-live + unlimited resources changes
the calculus: build the clean architecture now.

Decision: tags, tag-groups, workbench scenes, AI agents, and AI
missions all become Space-scoped. Only identity (user, session,
profile, MK key) and per-device UI prefs stay user-level.

Plan covers 8 phases across ~3–4 days:
1. Audit + schema design
2. Dexie migration (with backfill to user's Personal-Space)
3. Store APIs (implicit via scopedForModule wrapper)
4. Space-switch side-effects (reset active scene, bootstrap defaults)
5. Space-creation seeding (one-shot copy tags from Personal)
6. Backend (mana-sync + Postgres + RLS)
7. Docs + memory updates
8. Delete the old deferred plan

Includes edge cases, success criteria, and reasoning for why β over γ
(two clear levels beat one recursive primitive for user clarity).

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:42:55 +02:00
Till JS
db2023a77f docs(plans): per-Space vs user-global tags — decision deferred
Strategic decision doc covering whether the central tag system
(@mana/shared-stores → globalTags) should move from user-global to
per-Space, prompted by integration debt between Spaces (hard tenancy)
and Scene-Scope (tag-based view filter).

Surveyed current state: no spaceId column on globalTags or any of the
19 junction tables, 68 consumer imports, plaintext sync, guest-mode
seed.

Evaluated three options:
- A — status quo (user-global, no migration)
- B — fully per-Space (clean, but loses follow-me-everywhere)
- C — hybrid (nullable spaceId, recommended target if migration)

Recommendation: defer. Stay on A until one of five trigger signals
fires (first shared-Space tagging, user-reported clutter, scope
mismatch bug, >50 tags, or encryption/compliance need). Phase-by-phase
work breakdown included for when we revisit.

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

=== Consent-wall detection ===

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

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

=== Browser-HTML extract path ===

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:23:55 +02:00
Till JS
dc22240483 docs(plans): revise workbench-cards plan for cards-for-workflows rule
User feedback after the first batch shipped: the scene-picker got
cluttered when every admin/settings subpage became its own card.
Revise the plan to codify the sharper rule instead:

- Cards are for daily workflows
- Power-user domains get ONE card with internal tabs (initialTab prop
  for route deep-links)
- Config/settings stay as routes opened from the parent module's ⚙

Document the tabbed-card pattern (lib/modules/admin/tabs/*Tab.svelte
+ ListView container with role guard + initialTab), rewrite the
backlog around this principle, and fold batches 2/3/4 into a single
consolidated history that makes the scope revision explicit.

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 15:04:12 +02:00
Till JS
3e65637fcb revert(apps): settings pages back to routes (not workbench cards)
User feedback: per-module settings/preferences as separate workbench
cards bloats the scene-picker with rarely-used configuration surfaces.
Cards are for daily workflows; one-time config belongs in routes that
open from the parent module's ⚙ button.

- Inline the ListView content back into each /settings route
- Delete lib/modules/{broadcast-settings,invoices-settings,uload-settings,news-preferences}/
- Remove the four registerApp entries

Kept: spaces card (operative member management, daily use).
Deferred: admin-* cards will fuse into a single admin card with tabs
in a follow-up commit, since merging 4 power-user surfaces into tabs
is a different shape than deleting settings cards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:53:15 +02:00
Till JS
8647bfd100 Create tipps-module.md 2026-04-22 14:21:57 +02:00
Till JS
97d1dd0ec3 fix(articles): snapshot scroller ref in HighlightLayer effect teardown
Back-navigating from an article detail view to the list and into the
same article again crashed with
  TypeError: Cannot read properties of null (reading 'removeEventListener')

Sequence that triggered it:
 1. ReaderView unmounts, its own $effect cleanup calls onscroller(null).
 2. DetailView sets readerScroller = null.
 3. HighlightLayer's prop `scroller` becomes null.
 4. The old $effect's teardown fires and reads `scroller` — which now
    points at null instead of the element it had attached listeners to.
 5. null.removeEventListener(...) throws, Svelte can't finish tearing
    down the tree, and the re-mount never happens.

Fix: snapshot the element reference at setup time so the teardown uses
the same element the setup used, regardless of what the reactive prop
is currently pointing at. Comment block in the file explains the trap
so a future cleanup doesn't re-introduce it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:21:32 +02:00
Till JS
5bf3ea8cbd refactor(admin): drop nav tabs + overview duplication from layout
Now that every /admin/* page is a thin wrapper over a workbench card,
the layout's nav tabs are redundant with the workbench's own scene
navigation. The heading + tab strip were also duplicating chrome that
each card now owns.

- Layout shrinks to an auth guard: redirect non-admins, gate-screen if
  the session is not yet initialized.
- /admin/+page.svelte now wraps the existing admin module ListView
  instead of duplicating its stats/security/quick-links grid.

Smoketested: all 11 /admin/* and settings routes respond 200 with
clean SSR output; type-check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:16:56 +02:00
Till JS
7611d109be feat(articles): M8 highlights view + stats + dashboard widget
useStats() live-query aggregates total / per-status / savedThisWeek /
finishedThisWeek / topSites / totalHighlights in one scoped Dexie pass.
useAllHighlights() joins cross-article highlights with article-header
info (title, siteName, originalUrl) for rendering.

/articles/highlights — HighlightsView groups chronologically-sorted
highlights per article with color-accented stripes, click-to-reader
jumps, and two export actions:
  - Copy as Markdown (clipboard)
  - Download .md (file)
Export logic lives in lib/markdown-export.ts as a pure function
(renderHighlightsMarkdown) so future snapshot tests don't need the
render tree.

Dashboard widget: ArticlesUnreadWidget mirrors NewsUnreadWidget's
pattern — self-contained live query, top-3 unread/reading, stats
strip ("N ungelesen · M diese Woche gespeichert"), empty state
CTA to /articles/add. Registered in:
  - lib/types/dashboard.ts (WidgetType union + WIDGET_REGISTRY)
  - lib/components/dashboard/widget-registry.ts (component map)
  - lib/i18n/locales/dashboard/{de,en}.json (translations)
  fr/it/es intentionally left untranslated — consistent with how
  invoices_open and broadcasts are handled.

ListView gains a pencil button next to the settings gear linking
to /articles/highlights.

Also: plan doc marks M7 + M8 done with commit refs; M1–M8 scope is
now complete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:12:18 +02:00
Till JS
8a991f7c39 feat(articles): M7 share-target + bookmarklet — save from anywhere
@mana/shared-pwa gains PWAShareTarget + PWAShareTargetParams types
plus ManifestConfig.share_target pass-through. createPWAConfig now
accepts an optional `shareTarget` and threads it into the generated
manifest. Other apps keep working unchanged — the field is omitted
unless set.

Web app wiring:
 - vite.config.ts passes shareTarget: { action: '/articles/add',
   method: 'GET', params: { title, text, url } } so the installed PWA
   shows up as a destination in the Android / Chromium share sheet.
 - AddUrlForm reads ?url / ?text / ?title in onMount; falls back to
   the first URL-shaped token in ?text because some senders (Chrome
   Android, WhatsApp) put the shared link there instead of ?url. When
   a URL is pre-filled the Readability preview auto-triggers, so the
   user just hits "In Leseliste speichern" to confirm.
 - New /articles/settings route hosts the bookmarklet (drag-to-
   bookmarks-bar button + copy-to-clipboard + expandable snippet
   viewer) and a short Share-Target explainer with an iOS-Safari
   caveat. Linked from the ListView via a new gear button next to
   "+ Neu speichern".

Bookmarklet form (origin-prefixed so it works across tenants):
  javascript:void(window.open('${origin}/articles/add?url='+…))

Not in scope (plan marked optional): _pendingUrls offline queue.
Share without internet shows the existing error + retry state today;
can slot in as M7b if users hit it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:03:33 +02:00
Till JS
831c30eaa7 docs(plans): workbench-cards migration plan
Document the user's preference (cards over subroutes), the migration
pattern (module ListView + registerApp + thin route wrapper), what's
already shipped in batches 1 + 2, and the remaining backlog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 18:57:22 +02:00
Till JS
177734a860 fix(tsconfig): unblock shared-types consumers
shared-types/src/index.ts re-exports with explicit .ts extensions
(Tailwind v4 module resolver needs them). TS 5.7 requires consumers
to opt in via allowImportingTsExtensions. The flag only type-checks
when noEmit:true; the NestJS builder also needs
rewriteRelativeImportExtensions so tsc still emits valid JS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 18:53:55 +02:00
Till JS
92fe23d461 feat(apps): admin panels + module settings as workbench cards
Convert 8 admin/settings subroutes into scene-droppable workbench cards
so users can arrange them alongside other modules instead of navigating
to dedicated subroutes.

Admin cards (admin-role-gated inline, fallback gate-screen for non-admins):
- admin-users: user search + paginated table
- admin-system: service-health grid + monitoring links + env info
- admin-user-data: API-backed user browser (detail route stays)
- admin-complexity: route now wraps the existing complexity card

Module-settings cards (wrap existing form components where available):
- broadcast-settings, invoices-settings: wrap SettingsForm / SenderProfileForm
- uload-settings: data-stats + JSON export + clear-local-data danger zone
- news-preferences: topics/languages/weights/onboarding reset

All 8 subroutes reduced to 10-line ListView wrappers; admin layout
keeps the role guard so the routes are still gated on direct access.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 18:53:46 +02:00
Till JS
88eca8a759 feat(spaces): Spaces as workbench card + canonical /spaces route
Extract member management from /spaces/members into a reusable
workbench-card ListView so users can drop the surface into any scene.

- lib/modules/spaces/ListView.svelte — hint + invite + members + pending
  invitations, all theme-token driven
- APP_ICONS.spaces icon (three-silhouette cluster, teal→indigo)
- MANA_APPS entry id=spaces (beta tier, shared-space management)
- registerApp({ id: 'spaces' }) so the card is scene-droppable
- /spaces/+page.svelte as the new canonical route wrapper
- /spaces/members/+page.svelte kept as legacy alias
- SpaceSwitcher menu now links to /spaces

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 18:53:03 +02:00
Till JS
5924f4fac3 feat(articles): M6 AI tools — list / save / archive / tag / highlight
Five new entries in AI_TOOL_CATALOG (shared-ai/src/tools/schemas.ts):

  list_articles            auto     Read-only listing with status +
                                    query filter. Default hides
                                    archived; 'all' includes them.
  save_article             propose  URL → Readability → encrypted save.
                                    Delegates to articlesStore.saveFromUrl
                                    which already handles scope-aware
                                    dedupe. Duplicates surface as
                                    success:true with duplicate:true.
  archive_article          propose  setStatus('archived') after
                                    scoped existence check.
  tag_article              propose  Case-insensitive dedupe over
                                    globalTags; tagMutations.createTag
                                    fills in when missing. Junction
                                    write via articleTagOps.addTag.
  add_article_highlight    propose  Snaps to the first verbatim
                                    occurrence of `text` in the
                                    decrypted article.content. Fails
                                    cleanly when the snippet isn't
                                    found — no orphan highlights.

Policy, client executor, and server planner derive automatically from
the catalog (see root CLAUDE.md §"AI Tool Catalog") so no manual
registration in policy.ts / services/mana-ai is needed.

Skipped from the M6 plan: <AiProposalInbox module="articles" />. The
component doesn't exist in the current codebase — after the
pendingProposals-table drop in Dexie v29 the inbox surface moved to
the mission-detail cross-module view, and articles proposals show up
there automatically. Documented in docs/plans/articles-module.md.

Also updated: plan doc now marks M1–M6 as DONE with commit refs and
the next-step pointer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 18:46:13 +02:00
Till JS
12be75e6a6 fix(broadcast): track route paths + shared-branding tsconfig
Two fixes surfaced by the end-to-end smoke test.

1. broadcast-track.ts: inner route paths double-prefixed.
   Routes were declared as '/track/open/:token' etc, then
   mounted at '/api/v1/track', yielding '/api/v1/track/track/open/:token'
   — every tracking endpoint returned 404. Dropped the redundant
   '/track/' prefix so the full path is now
   '/api/v1/track/{open,click,unsubscribe}/:token' as the
   orchestrator + client both expect.

   Verified with live curl:
   - /track/open/BAD → 200 image/gif 42 bytes (graceful no-signal)
   - /track/click/?url missing → 400 missing url
   - /track/click?url=javascript: → 400 bad url
   - /track/click?url=https://ok + bad token → 302 graceful
   - /track/unsubscribe/BAD GET → 400 HTML
   - /track/unsubscribe/BAD POST → 400 (RFC 8058)

2. shared-branding/tsconfig.json: allowImportingTsExtensions
   missing. shared-types/src/index.ts uses explicit .ts
   imports (intentional, for Tailwind's module resolver); any
   downstream tsconfig without allowImportingTsExtensions emits
   8 errors. shared-auth already had this fix — shared-branding
   gets the same treatment. noEmit:true is set, so no rewrite
   flag needed.

   Verified: shared-branding pnpm check → 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 18:30:47 +02:00
Till JS
04293ed5e7 feat(articles): M4 tags + status filter, M5 migrate news:type='saved'
M4 — Tags + Filter:
- queries.ts: useArticleTagIds(id) + batched useArticleTagMap(ids)
  live queries against articleTagOps (the junction into globalTags).
- DetailView: TagField from @mana/shared-ui with the global tag pool +
  this article's selected ids; onChange fans out through
  articleTagOps.setTags, which diffs add/remove internally.
- ListView: 6 filter chips (Alle | Ungelesen | In Arbeit | Gelesen |
  Favoriten | Archiv) with live counts. Archived articles are hidden
  from the "Alle" view and only surface under the Archiv filter. Tag
  chips render inline on each card using the batched tag map + the
  global tag pool for colour lookup.

M5 — Migration + news deprecation:
- modules/articles/migrations/from-news.ts: boot-gated migration (per-
  device localStorage sentinel). Reads newsArticles with type='saved',
  decrypts under the newsArticles allowlist, re-encrypts under the
  articles allowlist, and copies into the articles table. Status maps
  isArchived→archived, isRead→finished, else unread. Source rows get
  soft-deleted so the sync engine removes them from other devices.
  Ran after crypto init (from (app)/+layout.svelte boot block), not
  in the Dexie .upgrade() hook, because the decrypt→re-encrypt round-
  trip needs Web Crypto + the master key.
- news/stores/articles.svelte.ts: removed saveFromUrl — ad-hoc URL
  saves now live in the articles module.
- news/api.ts: removed extractFromUrl helper + ExtractedArticleDto.
  The /api/v1/news/extract/* routes stay in apps/api for now because
  news-research still hits them for RSS discovery.
- news/index.ts: dropped the extractFromUrl re-export.
- news/tools.ts: the save_news_article AI tool keeps its name (so
  historic Mission iterations in the DB still resolve) but its
  execute body now routes through the articles module's saveFromUrl.
- routes/(app)/news/add + /news/saved: replaced with single-shot
  redirects to /articles/add and /articles respectively.
- news-research ListView + page: "Speichern" buttons now route to
  the articles module and navigate to /articles/[id] on success.

Plan: docs/plans/articles-module.md. M6 (AI tools + proposal inbox),
M7 (share target + bookmarklet), M8 (highlights view + stats) open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 18:17:04 +02:00
Till JS
9d6a5a53a8 feat(apps): register agents + timeline as MANA_APPS + broadcast URL fix + members page chrome
Three modules move from "dedicated route only" to "first-class
apps in the launcher". After this they show up in the AppDrawer
pill, can be pinned to workbench scenes, and get a direct URL from
the app switcher.

MANA_APPS entries added:
- agents  (/agents)    — AI agent management. Icon: smiling robot head
                         with antenna dot. violet→fuchsia gradient, status
                         beta, requiredTier beta.
- timeline (/timeline) — Chronological view across modules. Icon: vertical
                         event dots with connecting axis. amber→orange,
                         status beta, requiredTier beta.

Plus: broadcast's MANA_APPS entry already existed but had no URL
override, so the auto-derived /broadcast didn't match the real route
at /broadcasts. Added an APP_URL_OVERRIDES entry mapping
id='broadcast' → '/broadcasts' so the app switcher lands the user on
the right page. Icon + module.config stay singular.

Route wiring:
- /agents previously only had /agents/templates/ as a subroute. Added
  /agents/+page.svelte that renders the existing ai-agents ListView
  (at $lib/modules/ai-agents/), so the top-level URL works from the
  AppDrawer.
- /timeline already had a root +page.svelte — no work there.
- /broadcasts already had a root +page.svelte — no work there.

/spaces/members page chrome:
- Swapped the hand-rolled header for @mana/shared-ui PageHeader with
  backHref="/", breadcrumb "Workbench › Mitglieder verwalten", and the
  space name + type as the description. Feels like a native Mana page
  now instead of an orphaned admin route.
- Dropped the ~60 lines of unused .type-chip CSS (moved the chip info
  into the PageHeader description string).
- Container bumped to 720px max-width to match other admin pages.

0 errors across 7236 files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 16:32:41 +02:00
Till JS
1861e89d45 chore(broadcast): wire mana-mail into env pipeline + push schema
The three final pre-dogfood items:

1. drizzle.config: schemaFilter now includes 'broadcast' alongside
   'mail'. Without this, `bun run db:push` skipped the broadcast
   tables — schema existed in code but not in Postgres. Tested via
   db:push + psql \dt (3 tables created: campaigns, events, sends).

2. .env.development: new MANA-MAIL SERVICE section with Stalwart
   knobs + broadcast config (tracking secret, rate limits, send
   throttle). DEV secret is explicitly labelled non-production —
   prod rotates via env.

3. generate-env.mjs: new block writes services/mana-mail/.env on
   `pnpm setup:env`. Mirrors the invoices / research / events
   pattern. All 16 broadcast/mail vars flow through from SSOT.

Verified end-to-end:
- pnpm setup:env → services/mana-mail/.env contains
  BROADCAST_TRACKING_SECRET + rate limits
- bun run src/index.ts → /health returns 200 with the new config
- psql → broadcast.campaigns / events / sends are materialised

Broadcast module is now fully ready to send real mail — nothing
else required before the first dogfood campaign.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 16:21:57 +02:00
Till JS
3357e88a1c feat(articles): new read-it-later module — save / read / highlight
Pocket-style module for saving arbitrary web URLs, extracting readable
content server-side via @mana/shared-rss (Readability + JSDOM), and
storing it AES-GCM encrypted in IndexedDB for offline reading.

M1 skeleton: Dexie v33 (articles, articleHighlights, articleTags),
crypto registry entries, module registration, app-registry entry with
orange icon, empty-state ListView. articleTags is a pure junction
into the existing globalTags system (appId 'tags') — same pattern as
noteTags, eventTags, placeTags.

M2 URL save + reader: POST /api/v1/articles/extract (one endpoint,
not two — client caches the preview payload to avoid a double
server fetch). AddUrlForm with scope-aware dedupe, DetailView with
ReaderView typography shell (serif/sans, light/sepia/dark, size
slider), auto-tracked reading progress with scroll restore.

M3 highlights: TreeWalker-based plain-text offset resolution
(lib/offsets.ts), highlights store, floating HighlightMenu with
create + edit modes, HighlightLayer orchestrator that wraps/unwraps
highlight spans whenever highlights or htmlVersion changes. Four
colours (yellow/green/blue/pink), optional notes, click-to-edit,
dark-mode-aware overlay colours.

Drive-by: removed stale 'pendingProposals' entry from the plaintext
allowlist — the table was dropped in Dexie v29 and the allowlist
audit was flagging it as a dead entry.

Plan: docs/plans/articles-module.md. M4 (tags + filter + progress),
M5 (news:type='saved' migration), M6 (AI tools), M7 (share target),
M8 (highlights view + stats) still open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 16:20:23 +02:00
Till JS
8f6a4efddd fix(spaces): /spaces/members dark-mode — theme tokens throughout
Members page had the same bug as SpaceCreateDialog before its rewrite:
var(--color-surface-1, white) and friends with hardcoded white
fallbacks meant the panels, inputs, and member rows rendered as
blazing-white boxes in dark mode, with the member name going invisible
(white-on-white) and the header chips losing contrast.

Rewrote the stylesheet to mirror @mana/shared-ui Pill conventions:
  - hsl(var(--color-card|background|foreground|border|muted)) throughout
  - per-type chip colors get a :global(.dark) variant with inverted
    lightness so Memoro's MARKE badge reads in both themes
  - panels use --color-card + border + subtle shadow (matches Pill)
  - inputs use --color-input with --color-background fallback + a
    primary-tinted focus ring (box-shadow color-mix)
  - buttons use --color-primary with --color-primary-foreground
  - member rows use --color-muted with a half-opacity border
  - remove-btn hover uses color-mix so the red accent matches the theme
  - error / success messages get dark-mode color pairs

Also tightened the page:
  - Container padding bumped to breathe against the PillNav chrome.
  - h2 "Einladen" / "Mitglieder" are small uppercase labels now — they
    were invisible before and the chunky headline size competed with
    the page title.
  - Member row min-width + ellipsis so long names don't push the role
    badge off the right edge.

0 errors across 7234 files.

Plan: docs/plans/spaces-foundation.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 16:11:03 +02:00
Till JS
8e70bc9107 fix(spaces): persist active-space choice across reloads via localStorage
Switching to a non-personal space then refreshing reverted to Personal:

  GET http://localhost:3001/api/auth/organization/get-active-member
      → 400 Bad Request (NO_ACTIVE_ORGANIZATION)

Dev-mode cross-origin + SameSite=Lax means the Set-Cookie from Better
Auth's /organization/set-active response is quietly dropped by the
browser. The server's session record never gets activeOrganizationId
updated, so on the next page load get-active-member throws BAD_REQUEST
with NO_ACTIVE_ORGANIZATION — and my fallback promoted Personal,
reverting the user's explicit choice.

Fix: client-side localStorage hint that mirrors the server preference.

loadActiveSpace():
1. Ask the server (get-active-member) — trust it if it knows the org.
2. Otherwise fetch the org list + read the hint from localStorage.
   If the hint matches an org the user is actually a member of, call
   set-active again to re-sync the server and use that org.
3. Only fall back to Personal when there's no hint (truly fresh
   session or first-login).

writeActiveSpaceHint() is called from:
- SpaceSwitcher.switchTo() — on explicit switch.
- SpaceCreateDialog — on successful create (the user just chose it).
- accept-invitation — on accept (user opted in to the new space).
- loadActiveSpace() itself — after any successful resolve, so the
  hint stays current.

Exported from $lib/data/scope so additional flows (admin tools,
future delete-space) can keep it in sync.

Production impact: once COOKIE_DOMAIN=.mana.how is set with
SameSite=None+Secure, the server cookie takes precedence and the
hint is redundant but harmless.

0 errors across 7234 files.

Plan: docs/plans/spaces-foundation.md
2026-04-21 16:04:30 +02:00
Till JS
be45dcff82 fix(ai): defer seed-registry side-effect imports to browser boot
SSR 500'd on every (app)/* route with:

    TypeError: Cannot read properties of undefined (reading 'table')
    at apps/mana/apps/web/src/lib/modules/meditate/collections.ts:13:39
    at async eval (.../meditate/stores/meditate.svelte.ts:...)

Root cause: ai/missions/setup.ts had static side-effect imports of
meditate/habits/goals seed modules. Each seed module transitively
imports its module's collections.ts, which does `db.table(...)` at
module-eval time. During SSR, Vite's module-runner evaluates imports
depth-first — the seed imports race database.ts's own eager
dependency eval, observe `db` as still-undefined (live-binding in
the middle of a circular chain), and crash.

Fix: replace the three `import '$lib/modules/<X>/seed'` side-effect
imports with a single async `ensureSeedsRegistered()` that dynamic-
imports them, guarded by the `browser` flag so SSR never touches
them. Called fire-and-forget from `startMissionTick` (which is
itself client-only via the onMount wrapping in +layout.svelte), so
template applicators still see the registry populated before they
need it.

Net effect:
- SSR chain for any (app)/* route no longer touches meditate/habits/
  goals collections.ts → no db-undefined race.
- Browser behavior unchanged: seeds register at the first mission
  tick, just like before, before any template applier runs.

Verified: after HMR/manual cache-bust, curl / returns 200 in place
of the previous 500. Type-check 0 errors across 7230 files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:54:00 +02:00
Till JS
260dd312a9 feat(broadcast): M8 DNS auth check (SPF / DKIM / DMARC)
Closes the last plan milestone. Users can verify their sending-domain
setup without leaving the broadcast settings page.

Server (mana-mail)
- services/dns-check.ts: parseSpf / parseDkim / parseDmarc are pure
  functions. SPF accepts include:<mailDomain>, flags weak (+all) and
  wrong (include missing) and multi-record (RFC 7208 §3.2). DKIM
  needs v=DKIM1 + a p= public-key segment. DMARC requires v=DMARC1,
  flags p=none as weak (monitoring only), ok on quarantine/reject.
  All three are case-insensitive.
- lookupTxt(): DNS-over-HTTPS against Cloudflare 1.1.1.1 — avoids
  the Bun/container udp-resolver flakiness and works everywhere.
  Multi-string TXT (`"a" "b"`) get concatenated before parsing.
- checkDomain(): one call, three parallel DoH lookups, returns a
  structured result with suggested copy-paste records scoped to the
  user's actual mail domain from config.
- Route: GET /v1/mail/dns-check?domain=&selector= (JWT auth). Zod
  validates the domain looks sensible before hitting DoH.
- 16 unit tests covering all three parsers + multi-record edge case.

Client
- api.ts: runDnsCheck(domain, selector?) helper with typed result.
- components/DnsCheckBanner.svelte: derives domain from the default
  from-email (after @), calls the check on-demand, renders per-record
  status chips (ok / weak / wrong / missing) with messages, exposes
  copy-pasteable SPF + DMARC records when anything's off. DKIM setup
  is provider-specific so we show a hint rather than a canned record.
  Last-check timestamp persists to settings.dnsCheck so the banner
  survives a reload without re-hitting the API.
- Wired into SettingsForm between Impressum and Standard-Footer —
  where the user is already thinking about "what's required to
  actually send".

All checks clean:
- webapp pnpm check: 0 broadcast errors (4 pre-existing articles errors
  from parallel Spaces work, unrelated)
- mana-mail tests: 36/36 across tracking-token + link-rewriter + dns-check
- mana-mail build: 2.51 MB (+8 KB for juice — dns-check itself is ~3 KB)

Plan: docs/plans/broadcast-module.md §M8. All 10 milestones now done.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:48:03 +02:00
Till JS
75832faef7 feat(broadcast): enhanced ListView + dashboard widget + AI tools
Closes the M7/M9/M10 plan items in one pass since they share patterns.

ListView (M7)
- 4 stats cards at the top: versendet YTD, Ø Öffnungsrate, Ø Klickrate,
  Entwürfe. Same layout pattern as invoices for consistency.
- Status filter chips with live counts per status.
- Search across name + subject.
- Row now shows open-rate per-campaign when available.
- Settings gear in the header matches the invoices polish.

Dashboard widget (M10)
- BroadcastsWidget.svelte: 2x stats (sent YTD + avg open rate), next
  scheduled link, last sent link with open-rate badge. Empty state
  nudges toward creating a first campaign.
- Registered as 'broadcasts' in WIDGET_REGISTRY and the component map.
- Medium default size, no requiredBackend (reads from Dexie only;
  stats are mirrored from the last DetailView poll so no server
  round-trip for the widget).

AI tools (M9)
- 3 tools added to @mana/shared-ai's AI_TOOL_CATALOG:
  - create_campaign_draft (propose) — generates HTML body from a
    topic, lands as a draft; user picks audience + sends via UI
  - list_campaigns (auto) — id/name/subject/status/recipients
  - get_campaign_stats (auto) — rates as 0..1 floats
- broadcast/tools.ts: execute handlers with an HTML→CampaignContent
  shim (stores both html and a minimal Tiptap JSON placeholder so
  ListView renders without the editor having to remount). stripHtml
  helper derives plaintext.
- Registered in data/tools/init.ts after library.

Suggest-style tools (suggest_subject_lines) deliberately omitted —
they're pure generative and don't need an executor. The LLM can
produce subject ideas without a tool call.

Verified:
- pnpm check: 0 broadcast errors (4 pre-existing errors in articles
  module from parallel work, not mine)
- shared-ai test suite: 44/44 green (function-schema roundtrips the
  expanded catalog cleanly)
- mana-ai drift guard: 41/41 green

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:27:59 +02:00
Till JS
c5a76d726c fix(spaces): SpaceCreateDialog now reads in dark mode + cards all visible
Two visible bugs on the Neuer-Space dialog in dark mode:

1. Type cards were invisible except for the active one. The CSS used
   `var(--color-border, hsl(0 0% 88%))` — a CSS variable without HSL
   wrapping. The theme system actually uses shadcn-style raw values
   (e.g. `--color-border: 0 0% 88%`) that need to be wrapped with
   `hsl(...)` at use-site. Without the wrap the border was undefined,
   and the card fell back to "no border and no background" = loose
   text on the dialog.

2. Input fields (Name, URL-Kürzel, Brand-Voice) rendered with a
   hard-coded white fallback because `var(--color-surface-1, white)`
   ignored the theme token in dark mode. Same wrapping issue as #1.

Rewrote the dialog stylesheet to use the `hsl(var(--color-X))` pattern
consistently — mirroring @mana/shared-ui Pill.svelte, which does work
in both modes. Adjacent polish:

- Every type card now has border + subtle background in all states;
  hover lifts it; active uses color-mix with --pill-primary-color so
  it picks up the current theme variant's accent.
- Input fields use --color-input (shadcn standard) with --color-background
  fallback, with a primary-tinted focus ring.
- Section labels (legend, field > span) styled as small uppercase
  labels matching other form conventions.
- Backdrop is a bit darker and gets an extra alpha bump in dark mode.
- Error panel uses color-mix so it blends into dark mode instead of
  showing a bright light-red panel.
- Buttons use --color-primary with --color-primary-foreground for the
  text, with a small brightness filter on hover.

Plan: docs/plans/spaces-foundation.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:20:48 +02:00
Till JS
a312d98f09 feat(broadcast): click-link tracking + send throttle
Closes the last two dogfood blockers before real-campaign use.

link-rewriter.ts
- rewriteClickLinks(): walks <a href="http…"> in the HTML body and
  replaces each URL with /api/v1/track/click/{token}?url={original}
  so clicks go through the tracking endpoint. Regex-based because
  Tiptap output is well-formed; returns a count for debugging.
- Leaves mailto: / tel: / anchor fragments alone — wrapping those
  breaks the recipient's native handler and accomplishes nothing.
- `skipUrls` param carries the unsubscribe + web-view URLs (already
  tracking endpoints themselves) so they don't get double-wrapped.
- 11 unit tests covering http/https rewriting, skip list, non-http
  schemes, attribute preservation, multi-link count, quoted-attr
  variants, idempotency.

Orchestrator wiring
- substituteUrls now calls rewriteClickLinks after the preview-
  placeholder swap and before the open-pixel injection. The
  unsubscribe + web-view URLs from this same function are passed
  in as skip entries so they survive the pass untouched.
- Constructor gains `sendThrottleMs` param (default 150ms).
- Main send loop awaits sleep(throttleMs) between iterations. 150ms
  = ~6/sec = ~360/min, safely below most SMTP provider limits.
  100-recipient campaign = ~15s extra wall-clock but that's fine
  for MVP (and most campaigns are way smaller).

Config
- New env BROADCAST_SEND_THROTTLE_MS (default 150). Wired from
  loadConfig to the orchestrator constructor.

The broadcast module is now functionally complete for dogfooding.
Remaining before a real campaign can actually go out: run
`cd services/mana-mail && bun run db:push` to materialise the
broadcast.* schema tables.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:07:58 +02:00
Till JS
d887fc125d feat(broadcast): settings + detail view + compliance polish
Closes the "could actually dogfood" gap: legal address can be set,
sent campaigns have a proper view with live stats, and the send path
respects DSGVO.

Webapp
- components/SettingsForm.svelte: sender defaults + Impressum (required,
  highlighted amber until filled) + footer. Matches the invoices
  SenderProfileForm pattern — immediate save, dedicated section per
  concern.
- /broadcasts/settings/+page.svelte: mounts the form. ComposeView step
  3's "Einstellungen öffnen" CTA now lands somewhere.
- views/DetailView.svelte: read-only view for sent/scheduled/cancelled
  campaigns. 5-card stats grid (sent, open, click, bounce, unsub) with
  rate percentages. Polls mana-mail every 30s for up to 30 min after
  mount, persists back to Dexie via applyServerStatus so the list view
  + widget catch up. Includes a preview of the actual rendered campaign
  so "what went out" is visible after the fact.
- /broadcasts/[id]/+page.svelte: DetailView for non-drafts; drafts
  bounce to /edit via $effect-triggered goto.
- ListView row-click now routes by status (draft → edit, else → detail).

mana-mail compliance
- Orchestrator loadUnsubscribedEmails(): queries broadcast.sends WHERE
  status='unsubscribed' scoped to the user, filters the recipient list
  BEFORE any send rows get written. Campaign's totalRecipients reflects
  the post-skip count so open rates aren't inflated by "virtual sends".
  Skipped count surfaces in result.errors for the UI to show.
- jmap-client.submitEmail: new extraHeaders param. Sets custom headers
  via JMAP's `header:<Name>:asText` property convention.
- Orchestrator sets RFC 8058 headers per recipient:
    List-Unsubscribe: <https://.../track/unsubscribe/{token}>
    List-Unsubscribe-Post: List-Unsubscribe=One-Click
  This is what makes Gmail / Apple Mail show their native "Abmelden"
  button in the message header (not just a body link).

All checks clean: 0 TS errors, 37/37 webapp tests, 9/9 tracking-token
tests, mana-mail bun build = 2.50 MB.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 14:43:36 +02:00
Till JS
c9141e3b35 fix(spaces): keep process.env out of the browser branch in authBaseUrl
Opening the Space-Switcher threw "process is not defined" because the
missing-injection fallback inside the browser branch returned early
only when the injected URL was truthy, then fell through to a
`process.env.PUBLIC_MANA_AUTH_URL` read. `process` doesn't exist in
the browser, so empty injection = ReferenceError.

Fix: the browser branch now always returns (injected or localhost:3001
fallback) without touching `process`. The env-var read stays inside
the SSR path and is further guarded by `typeof process !== 'undefined'`
so a future change can't regress.

0 errors across 7203 files.

Plan: docs/plans/spaces-foundation.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:55:32 +02:00