Phase A — Cards joins the unified theme system:
- Drop placeholder --color-cards-* palette; app.css imports
@mana/shared-tailwind/themes.css + sources.css.
- Remove hardcoded class="dark" from app.html; body uses
bg-background text-foreground.
- New $lib/stores/theme.ts: createThemeStore({ appId: 'cards' }).
ThemeToggle from @mana/shared-theme-ui in the header next to
the streak chip.
- Sweep all neutral / red / emerald / amber / indigo utilities in
apps/cards/apps/web/src to semantic tokens (560 substitutions
across 19 files): bg-neutral-900 → bg-card, text-neutral-400 →
text-muted-foreground, bg-red-500 → bg-error, etc. Domain
literals kept (FSRS grade colors red/orange/green/blue, GitHub-
violet PR-merged badge, marketplace-amber Buy button, admin-
inbox category palette).
- Cards added to validate-theme-utilities scope so future drift
fails CI.
Phase C — per-app accent token:
- New --color-app-accent in shared-tailwind/themes.css. Theme-
agnostic (registered in validate-theme-parity's THEME_AGNOSTIC
regex), so it stays the same across light/dark/lume/etc. Defaults
to Mana indigo at :root.
- Cards layout writes 258 90% 66% (= #8b5cf6 violet, from
MANA_APPS.cards.color) onto documentElement at boot via
applyCardsAccent(). All Cards CTAs (Lernen, Abonnieren, Senden,
links inside cloze cards) flow through bg-app-accent /
text-app-accent now.
Net effect: Cards gets light/dark + 4 palette variants + a11y
toggles for free, and any future app can drop in by setting its
own --color-app-accent without touching shared-tailwind.
Marketplace-Plan auf den realen Code-Stand gebracht:
- §11 Phasen-Liste: pro Phase ✅ shipped / 🟡 partial / ⏳ pending
Marker mit Kurzbegründung. β/γ/δ/ε sind ✅, ζ und η sind 🟡 mit
ζ.2 / η.2 als nächste Sub-Phasen.
- §13a „Bekannte Limitierungen": neue Einträge für ζ (Refunds,
Reconciler, CSV) und η (Community-Verify-Cron, Mod-Permissions,
Public-Changelog, Rate-Limit). PR-Merge-Stale-Blindness und
Card-Preview-Heuristik bleiben dokumentiert.
- §15 ersetzt: Status-Tabelle pro Phase + Empfehlung für die
nächsten 4 Schritte (ζ.2 Reconciler → η.2 Verify-Cron →
Update-Mail δ.4 → Phase θ Auto-Tags/Summary).
Server (cards-server):
- ModerationService: createReport, listOpen, resolveReport,
takedownDeck, banAuthor, setVerifiedMana, restoreDeck. Takedown
also auto-closes any sibling open reports against the deck and
closes any open PRs (so contributors see clean state). Ban
cascades to all of the author's decks.
- routes/moderation.ts: POST /v1/reports (any authed user),
GET /v1/admin/reports (admin only), POST /v1/admin/reports/:id/
resolve, POST /v1/admin/decks/:slug/{takedown,restore}, POST
/v1/admin/authors/:slug/verify. Admin gate is `role === 'admin'`
for now — verified-mana-only mods land later.
- Notify hooks: takedown emails the deck owner, mana-verify status
change emails the author.
Frontend (cards-web):
- <ReportButton> (icon or inline variant) with category picker
+ optional explanation. On /d/<slug> as a discreet 🚩 next to
the published-date stamp; in <CardDiscussions> per non-own
comment.
- /d/<slug> shows a red "wurde von der Moderation entfernt" banner
when isTakedown is true.
- /admin/reports inbox: lists open reports with category badges,
Abweisen / Deck entfernen / Author bannen actions. Renders a
forbidden state if the current user isn't admin.
Server (cards-server):
- lib/credits.ts: thin internal-API client for mana-credits
(reserve / commit / refund-reservation / grant). Service-to-
service via X-Service-Key. Throws InsufficientCreditsError
separately so the buy flow can branch on UX.
- services/purchases.ts: 4-step purchase pipeline: reserve →
insert deck_purchases row → commit reservation → grant
author share + insert author_payouts. Idempotent on
(buyer, deck) so a refresh-spam-click can't double-charge.
Verified-mana authors get the 90/10 split, others 80/20
(already in config). Refunds intentionally out of scope —
see MARKETPLACE_PLAN §13a.
- routes/purchases.ts: POST /v1/decks/:slug/purchase,
GET /v1/me/purchases, GET /v1/authors/me/payouts.
- decks.bySlug now returns hasPurchased (null when anonymous,
bool when authed) so the deck-detail page can pick the right
CTA.
- subscriptions.subscribe now blocks paid decks unless the
caller has a non-refunded purchase row (owner exempt for
testing).
- Notify: author gets a "Verkauf"-Email at grant time, with a
deterministic externalId for dedup.
Frontend (cards-web):
- /d/<slug> shows "Kaufen für N 💎" instead of "Abonnieren"
when paid + not yet bought; flips to subscribe path once
purchased.
- /me/purchases page listing buyer history + (when present)
author-payout history. Linked from the top nav.
- DiscussionService.countsForDeck: bulk count (visible) comments per
card-content-hash for one deck. Mounted at GET
/v1/decks/:slug/discussion-counts so the public deck page can
render comment badges without N+1 fetches.
- <DeckCardList> on /d/<slug>: lists the latest version's cards,
renders a one-line preview + "💬 N" badge, and expands the
inline <CardDiscussions> on click. Anonymous visitors see counts;
posting requires auth (CardDiscussions already gates that).
- mana-notify integration in cards-server: PR-create notifies the
deck owner, merge/reject notifies the PR author. Fire-and-forget
via lib/notify.ts so a notify-service outage never rolls back a
domain action. ExternalIDs are deterministic (cards.pr.{event}.{id})
so retries dedupe.
- <CardDiscussions> on the learn page: collapsed by default, opens
via "💬 Diskussion" alongside the "✏️ Verbessern" trigger. Resets
whenever the current card changes so the panel doesn't bleed
between flashcards.
- MARKETPLACE_PLAN.md §13a — known limitations: PR-merge is
stale-blind (no rebase yet), diff-preview flat, threading 1-level.
Server (cards-server):
- PullRequestService: create / list / get / merge / close / reject.
Merge applies the PR's {add, modify, remove} diff to the latest
version's cards in a single transaction, writes a new
deck_version + deck_cards, bumps latest_version_id, and stamps
the PR with mergedIntoVersionId.
- DiscussionService: post / listForCard / hide. Threads are keyed
by card_content_hash so they survive version bumps.
- Routes mounted under /v1: POST/GET /decks/:slug/pull-requests,
GET /pull-requests/:id, POST /pull-requests/:id/{merge,close,reject},
GET/POST /cards/:contentHash/discussions, POST /discussions/:id/hide.
Frontend (cards-web):
- cardsApi.pullRequests + cardsApi.discussions client surface.
- <PullRequestsSection> on /d/:slug — lists PRs with diff preview;
owner sees Merge/Reject/Close buttons.
- <SuggestEditModal> + "✏️ Verbessern" button on /learn/:deckId for
cards from a subscribed deck — submits a one-card modify (or
remove) PR using the card's serverContentHash as the previous
hash.
- Deck/Card DTOs gain subscribedFromSlug + serverContentHash so the
learn page can decide whether to show the suggest-edit affordance.
Closes the marketplace subscribe loop. When the author publishes a
new version of a subscribed deck, the user sees a compact banner
with a per-card breakdown and one-click "Update anwenden" — FSRS
learning state survives across the merge.
- lib/services/subscribe.ts:
- previewUpdate(slug): server diff /v1/decks/:slug/diff?from=…
with a small UpdatePreview shape ({added, changed, removed,
unchanged} as counts).
- applyUpdate(slug):
- removed → soft-delete by [deckId+serverContentHash], cascade
reviewStore.softDeleteForCard.
- changed → update local card in place; ensureReviewsForCard
re-runs to pick up cloze-cluster changes. FSRS reviews stay
attached because the card-id never changes.
- added → fresh local card + fresh FSRS reviews.
- unchanged → only re-stamp ord if the diff moved it.
Bumps subscribedAtVersion locally + re-stamps server-side.
- lib/data/database.ts: Dexie v3 adds compound index
[deckId+serverContentHash] for the smart-merge lookup.
- routes/decks/[id]/+page.svelte:
- subscribed decks get a green "📥 Abonniert"-banner with the
version stamp + a deep-link to the public marketplace page.
- if previewUpdate returns a non-null diff, the banner inlines
the counts and an "Update anwenden" button.
- "Veröffentlichen", "Neue Karte", "Aus Text generieren" and
the per-card delete buttons are hidden when the deck is
subscribed — read-only mirror of the author's content.
Validated: svelte-check 0/0, vite build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-to-end subscribe flow on cards.mana.how. From a public deck page
the user can now pull the deck into their own Cards instance with
one click; subscribed decks live alongside own decks but carry a
`subscribedFromSlug` marker so the editor knows to hide mutate
controls (UI gating in δ.3).
- cards-core types: LocalDeck gains subscribedFromSlug +
subscribedAtVersion. LocalCard gains serverContentHash. Both
optional — own decks/cards are unaffected.
- data/database.ts: Dexie v2 adds index on cardDecks.subscribedFromSlug
so the lookup-by-slug path is O(1).
- lib/api/cards-api.ts: subscriptions.{list,subscribe,unsubscribe,
version,diff} + the SubscriptionInfo / ServerCard / DeckVersionPayload
/ DiffPayload types.
- lib/services/subscribe.ts: subscribeAndPull() sequences server
POST /subscribe → GET /decks/:slug → GET /versions/:semver →
create LocalDeck + LocalCards + ensure FSRS reviews. Re-pull
refreshes in place (Phase δ.3 will swap to real diff-apply that
keeps FSRS state). unsubscribe() soft-deletes the local mirror.
isSubscribedLocally() backs the deck-page state check.
- routes/d/[slug]/+page.svelte: full subscribe UI — Abonnieren →
Abonniert + Lernen-Button (deep-links into the existing learn
session route).
Validated: svelte-check 0/0, vite build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Server-side plumbing for Phase δ. Frontend hookup follows in δ.2.
- services/subscriptions.ts: subscribe/unsubscribe (idempotent
upsert on (user, deck), stamps the latest_version_id at
subscribe-time so the client knows what it pulled). listForUser
returns each sub with `updateAvailable: currentVersion !== latest`
so the client can render an update indicator without a second
round-trip. Refuses paid decks with 403 — that path comes back
in Phase ζ once the credits Marketplace lands.
- versionWithCards: deterministic ord-ordered card payload for a
specific version. Read-public so anonymous browsers can preview
a deck's content.
- diffSince: smart-merge payload between any two versions. Splits
the latest cards into added/changed/unchanged + lists removed
by content_hash. The 'changed' bucket is heuristic (ord-position
pair where one was removed and one was added) — solid enough
until Phase ε's pull-request pipeline gives us real card
lineage.
- routes/subscriptions.ts mounts: GET /v1/me/subscriptions,
POST/DELETE /v1/decks/:slug/subscribe (auth required),
GET /v1/decks/:slug/versions/:semver (public),
GET /v1/decks/:slug/diff?from=<semver> (public).
cards-web layout fix:
- Marketplace surface (/explore, /u/, /d/) was previously gated
behind the AuthGate — anonymous browsers got pushed to /login
via client-side navigate. PUBLIC_PATHS extended so those routes
SSR + render unauthed.
Validated: tsc clean on cards-server, svelte-check 0/0 on cards-web.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- PLAN_OPTION_C.md: new row covers verdaccio + news-ingester + mana-ai
with the cross-arch + workspace-deps gotchas
- infrastructure/README.md: hostname table catches up to npm.mana.how
(Phase 2f-1) and mana-ai.mana.how (Phase 2f-3); config v26 → v28
- infrastructure/.env.gpu-box.example: MANA_SERVICE_KEY +
MANA_AI_PRIVATE_KEY_PEM block added with note that the values mirror
Mini's .env.macmini (the latter's matching public-half stays on
mana-auth, that's what makes Mission-Grant decryption work)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-to-end "publish my local deck to the marketplace" surface in
the Cards standalone app. Hooks into cards-server (Phase β) so a
user can take a deck they've been editing locally and put it under
cards.mana.how/d/<slug> with one modal.
Pipeline:
• lib/api/cards-api.ts — typed fetch wrapper around the cards-server
/v1 surface. Reads the JWT from authStore, never from storage
directly. CardsApiError carries `{status, message, details}`
so UI can branch on 401/409/etc.
• lib/stores/author.svelte.ts — lazy-loaded author state. Caches
`cardsApi.authors.me()` on first access; resets cleanly on logout.
• lib/util/slug.ts — best-effort slugify mirror of the server-side
validator (server still has final say).
• lib/components/PublishDeckModal.svelte — three-stage flow:
become-author (slug + displayName + pseudonym), deck-meta (title,
description, language, license picker, semver, changelog), then
publishing → done with moderation-flag surface if AI mod returned
'flag'. Keys off authorStore.isAuthor to skip stage 1 for
returning authors.
• routes/decks/[id]/+page.svelte gets a "🌍 Veröffentlichen" button
next to "Lernen". Disabled until the deck has cards.
Wiring:
• hooks.server.ts injects __PUBLIC_CARDS_API_URL__ on every SSR'd
page so the client knows where cards-server lives.
• compose adds PUBLIC_CARDS_API_URL_CLIENT=https://cards-api.mana.how
to the cards-web container.
Validated: svelte-check 0/0, vite build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two 404s reported from prod:
1) sql.js's production-bundled loader requested /sql-wasm-browser.wasm
but only sql-wasm.wasm was in static/. The browser bundle is its own
file; copy both so the loader hits whichever variant the runtime
picks. Without this the .apkg import dies before reading SQLite.
2) shared-pwa's manifest references pwa-192x192.png, pwa-512x512.png
and apple-touch-icon.png. None existed → Chrome's manifest-icon
validator failed and there was no usable icon for A2HS. Generated
minimal indigo-stacked-cards PNGs at the three required sizes.
Note: the sync 402 reports in the same console output are a separate,
intentional behaviour — mana-sync's billing middleware blocks pull/
push when the user has no active sync subscription. No code change
needed; handled at the mana-credits layer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the gap from the first Anki-import pass: media files are now
uploaded alongside the cards instead of stripped.
Pipeline:
• parse.ts: read the .apkg's `media` JSON manifest, build a
filename → ZIP-entry map (Anki names files numerically; the
manifest is the original-name lookup table). Returned alongside
decks/cards as parsed.mediaByFilename.
• import.ts: collectMediaRefs() walks every card field, gathers
distinct <img src=…> and [sound:…] references — orphan media
bundled in the .apkg are ignored. Referenced files upload to
mana-media in 4 parallel workers, returning a filename → URL map.
• parse.sanitizeAnkiHtml() now takes that map: <img src="X"> →
<img src="<url>" alt="" />, [sound:Y] → <audio controls
preload="metadata" src="<url>"/>. The remaining-tag stripper has
a negative lookahead for img/audio/video/source so the new tags
survive.
• CardFace already renders <img>/<audio> via @mana/cards-core's
DOMPurify config (the image/audio attachments commit added the
allowlist), so the freshly-imported cards just work in the
learn session.
UI:
• AnkiImport gains an "uploading-media" stage with X / N progress
bar between preview and card creation.
• Preview now shows the media count, copy promise updated from
"Bilder/Audio bleiben raus" to "Bilder + Audio werden mit
übernommen".
• Result block reports `N Medien übernommen · M fehlgeschlagen`.
Phase-2 ideas: per-user media scoping in mana-media; verify-then-
upload via /media/hash/:sha256 to skip duplicates from re-imports.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cards can now carry image, audio, and video attachments uploaded to
mana-media (the existing CAS service that already powers picture,
photos, wardrobe, etc.).
Pipeline:
• lib/media/upload.ts wraps POST /api/v1/media/upload (multipart,
app=cards). Returns { id, url, kind } with the right variant URL
per kind (medium for images, full file for audio/video). 25 MB
cap matches the website-upload pattern.
• mediaToFieldSnippet(): drops Markdown ![]() for images; raw
<audio>/<video controls> for the others — the user can later
tweak attributes by hand.
• Deck-detail card editor gains a "📎 Anhang" button next to every
text field (front/back/cloze). Pick → upload → snippet appended
to the field's content. Loading + error states surfaced inline.
Render:
• @mana/cards-core/render.ts whitelists `audio`, `source`, `video`
plus the `controls`/`preload`/`src`/`type` attrs in DOMPurify so
inline media survives sanitization. Markdown's <img> already
passed through the default policy.
Wiring:
• hooks.server.ts injects __PUBLIC_MANA_MEDIA_URL__.
• compose adds PUBLIC_MANA_MEDIA_URL_CLIENT=https://media.mana.how
to cards-web.
Phase 2 ideas: drag-drop directly into the textarea, paste-from-
clipboard for screenshots, mana-media auth scoping per user, Anki
import bringing media files along.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PDF input:
• lib/ai/pdf.ts wraps pdfjs-dist (Apache-2.0). Worker is bound via
Vite's `?worker` suffix so the heavy parsing runs off-main-thread.
• AiCardGen gains a "📄 PDF laden" button that pipes extracted text
into the same textarea — the user can review/trim before
generation. Reading state shows file name + page count + chars.
Heatmap:
• queries.useStudyHeatmap(weeks=12) fills gaps with count=0 so the
grid renders without holes.
• StudyHeatmap.svelte: 7 rows × N columns (Monday-anchored), 5
intensity buckets (neutral → emerald-300), tooltip per cell with
date + count, legend strip.
• Mounted on the dashboard between the deck list and the Anki import
so the user lands on a quick visual progress receipt every visit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PWA:
• SvelteKitPWA in vite.config via @mana/shared-pwa preset (name +
theme color). Layout injects pwaInfo.webManifest.linkTag so
Chrome's manifest pickup works → install icon + A2HS.
• Service worker registers automatically (workbox auto-update); the
cards-already-cached path now keeps working offline as long as the
user has visited a deck once.
AI generation:
• lib/ai/generate.ts — direct fetch to mana-llm /v1/chat/completions
(OpenAI-compatible, mirrors playground module). System prompt
constrains the model to a JSON array of {front, back}. Code-fence
stripping handles models that wrap JSON in ```json blocks despite
the prompt.
• AiCardGen.svelte — text in, list of generated cards out, per-card
checkbox preview, "X übernehmen" creates them via cardStore.
Phase-1 lands them as basic cards.
• Mounted on the deck-detail page next to "Neue Karte".
Wiring:
• hooks.server.ts injects __PUBLIC_MANA_LLM_URL__.
• compose adds PUBLIC_MANA_LLM_URL_CLIENT=https://llm.mana.how to the
cards-web service.
• app.d.ts gets vite-plugin-pwa virtual-module references so
svelte-check can resolve `virtual:pwa-info`.
Phase 2: PDF/image input, mana-credits gating, model selector,
streaming preview as cards arrive.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2e cleanup. status-page-gen + a dedicated nginx now run on the
GPU-Box (sparse repo clone provides the generator script + mana-apps.ts,
hourly git-pull via systemd timer). Container queries VictoriaMetrics
locally over docker-network ('http://victoriametrics:9090'), no public
vm.mana.how endpoint required — that hostname is also gone from the
GPU tunnel config (v25 → v26 effectively, removed in same PUT that
added status.mana.how).
DNS for status.mana.how now points at the mana-gpu-server tunnel.
Mini-tunnel ingress for it is removed; the previous 'mana-status-gen'
container on the Mini was stopped + rm'd.
Side benefit: closes the inode-stale-bind-mount bug that took status.
mana.how down for a few hours — single-file bind mounts on the Mini
break whenever the CD git-checkout rewrites the source file. The
GPU-Box mounts the same files but the systemd timer git-pulls in-
place, preserving the inode.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Anki users have decks they hate the UI for but can't migrate. This
gives them a one-drop path: drop a .apkg on the homepage, see a
preview, confirm, the cards land in our DB and start syncing.
Pipeline (lib/anki/):
• parse.ts — JSZip → sql.js (WASM SQLite) → walk Anki's three core
tables (col, notes, cards). Models (col.models JSON) classify each
note: type=0 → basic / basic-reverse, type=1 → cloze. Anki cards
table has one row per generated learnable unit (basic-reverse = 2,
cloze = N) — we dedupe at the note level since our model
regenerates those automatically via reviewStore.ensureReviewsForCard.
• import.ts — every Anki deck becomes one of ours (1:1, "::" → " / ");
fields go through sanitizeAnkiHtml (drops <img>, [sound:], maps
<b>/<i> to Markdown). Orphans land in a fallback "Anki-Import"
deck.
UI: AnkiImport.svelte on the decks list — drag-drop or click,
parse → preview ("X Decks, Y Karten"), confirm → import. No images,
no audio, no review history (cards are FSRS-new on import) — those
are Phase 2.
Deps: sql.js 1.14, jszip 3.10, @types/sql.js. WASM blob copied into
static/ so SvelteKit serves it at /sql-wasm.wasm.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two small UI surfaces over data the backend already computes:
• Header shows current streak (🔥 N) — useStreak() walks back through
cardStudyBlocks until it finds a gap.
• Decks list shows a "fällig"-pill per deck and a total in the header
subline — useDueCountByDeck() joins cardReviews→cards once and
groups by deckId.
Both queries live in lib/queries.ts and use Dexie liveQuery, so the
header refreshes automatically the moment a learn session ticks the
study block forward.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
createManaAuthStore from @mana/shared-auth-ui reads the auth backend
URL from window.__PUBLIC_MANA_AUTH_URL__ at runtime. Without the
injection it falls back to a relative URL, so signIn POSTs land at
cards.mana.how/api/v1/auth/login (SvelteKit 404, HTML body) instead
of auth.mana.how/api/v1/auth/login.
Adds a hooks.server.ts modeled after the mana-web one, but trimmed
to the two URLs the standalone app actually consumes today (auth +
sync). The values come from PUBLIC_MANA_AUTH_URL_CLIENT and
PUBLIC_MANA_SYNC_URL_CLIENT in docker-compose.macmini.yml.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The cards-spinoff commit (0a544ac41) added @mana/cards-core as a
workspace dependency for apps/mana/apps/web but didn't update the
two Dockerfiles that COPY-and-pnpm-install the workspace into the
image. CD's --no-cache build for mana-web therefore failed at
`pnpm install` with ERR_PNPM_WORKSPACE_PKG_NOT_FOUND, leaving the
container on a stale pre-cleanup image whose ListView28 chunk still
referenced the dropped contextSpaces Dexie table — every mana.how
route 500'd.
Adding the COPY line to both files (the shared sveltekit-base layer
and the per-app layer that does a second pnpm install) makes the
package available to the workspace resolver and lets the build go
through.
Plus the Phase 2c-d doc updates that piled up today (Glitchtip
on dedicated GPU-box stack, gitignore for *_CREDENTIALS.md files).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Phase-1 crypto wrapper is a no-op stub; it never actually imports
@mana/shared-crypto. The dep was forward-looking for the Phase-2 vault
wiring, but it broke `pnpm install` inside the cards-web Dockerfile
because the sveltekit-base image only ships a curated subset of
@mana/* packages and shared-crypto isn't one of them.
The wiring will come back when the vault roundtrip is on, together
with a base-image bump.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-push hook runs svelte-check with --fail-on-warnings; nine
long-standing warnings in unrelated files (forms / website-blocks)
were blocking otherwise-clean pushes.
Each <label> here is a visual label whose control follows on the next
line — accessible to a screen reader through proximity but not through
a `for=`/`id` association. The state_referenced_locally cases capture
a prop on first render by design (re-running the hook on prop change
would be a different feature). The <nav role=tablist> is the existing
tab-strip semantic.
All seven sites get scoped svelte-ignore comments rather than functional
rewrites — the goal is to unblock CI, not redesign these components.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Builds out the Cards spinoff end-to-end so the standalone app at
cards.mana.how shares its data layer with the in-mana cards module
through a single pure-utility package.
Why a spinoff and not just a deeper module: per the GUIDELINES, Cards
gets its own brand + URL while reusing mana-auth, mana-sync, and the
mana-credits/billing stack. The in-mana module under mana.how/cards
stays untouched as the integrated experience.
Phase 0 — mana-modul foundation
• New tables cardReviews + cardStudyBlocks (Dexie v61) + plaintext
classification in the crypto registry.
• LocalCard learns a {type, fields} shape; legacy front/back columns
kept as a back-compat mirror so older builds keep rendering.
• FSRS v6 scheduler + Cloze parser + Markdown render pipeline.
• UI in apps/mana/.../routes/(app)/cards/ gets a learn session
(learn/[deckId]), 4-type card editor, due-counter, markdown lists.
Phase 1 — standalone (apps/cards/apps/web)
• SvelteKit 2 + Svelte 5 + Tailwind 4, port 5180.
• Own Dexie 'cards' DB with a slim 5-table schema.
• Own sync engine: pending-changes hooks, 1 s push / 5 s pull against
POST /sync/cards, server-apply with suppression to avoid ping-pong.
• Auth-Gate via @mana/shared-auth-ui (LoginPage / RegisterPage).
• Encryption hooks at every write/read/apply path, currently no-op
stubs — flipping to real vault-backed AES-GCM is a single-file
change in src/lib/data/crypto.ts.
Shared package — @mana/cards-core
• Pulls types, cloze, card-reviews, FSRS wrapper, and Markdown
renderer out of the mana module so both frontends import from one
source. mana-modul keeps thin re-export shims so consumers don't
need to change imports.
• 19 vitest tests carried over from the mana module.
Server-side wiring
• cards.mana.how added to mana-auth PRODUCTION_TRUSTED_ORIGINS and
its CORS_ORIGINS env (sso-config.spec.ts stays green).
• New cards-web container in docker-compose.macmini.yml (mirrors
manavoxel-web pattern, 128m, depends on mana-auth healthy).
• cloudflared-config.yml repoints cards.mana.how from :5000 (the
unified mana-web container) to :5180. mana.how/cards is unchanged.
Cleanup
• Removed an unrelated 2026-03/04 NestJS+Supabase+Expo experiment
that was lingering under apps/cards/ (apps/landing, supabase/,
.github/workflows, MANA_CORE_*.md, etc.). It predated this plan
and would have confused future readers.
Validation
• svelte-check on mana-web: 0 errors over 7697 files
• svelte-check on cards-web: 0 errors over 3481 files
• vitest on cards-core: 19/19 pass
• pnpm check:crypto: 214 tables classified
• bun test sso-config.spec.ts: 8/8 pass
• vite build on cards-web: green
Not done in this commit (deliberate)
• Real encryption (vault roundtrip) — Phase 2.
• WebSocket-driven pull (5 s polling for now).
• Mobile/landing standalone surfaces — Phase 2/3.
• The actual production cutover on the Mac mini (build, deploy,
cloudflared sync) — config is staged, deploy is a user action.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Verbindliche Leitlinien für den Cards-Spinoff (Karteikarten-App mit
Spaced Repetition). Status: Planungsphase, noch kein Code. Doc dient
als nicht-verhandelbarer Kontext für PRs sobald gebaut wird.
Wichtigste Festlegungen:
- Game-Dev-Prinzip: Phase 1 baut NUR den Core-Gameloop (Lernsession).
KI-Generierung, Voice, Sharing, Stripe, Mobile, Dashboards = Phase 2+.
- Open-Source-only: jede Dep braucht OSI-konforme Lizenz.
- Zentrale Mana-Bausteine sind Pflicht, kein Eigen-Auth/Sync/Analytics.
- Daten-Contract mit dem bestehenden mana-Modul: gleiche Postgres-
Tabellen (cardDecks/cards + neu cardReviews/cardStudyBlocks),
appId='cards'. Schema-Änderungen rolled-out gemeinsam, nicht einseitig.
- FSRS v6 via ts-fsrs für Spaced-Repetition-Algorithmik.
- Phase 1 hat keinen eigenen Service — Lese-/Schreibpfad geht
ausschließlich über IndexedDB → mana-sync → Postgres.
Definition of Done in §7 ist die Acceptance-Liste fürs MVP.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Platform-Repo (Code/mana/) reserviert 3065 für mana-media; um Doppel-
Belegung zu vermeiden wandert mana-events (Public-RSVP / Event-Sharing)
auf 3115. Neuer Port-Block 311x ist unbenutzt und gehört strukturell
neben mana-mail (3042) bzw. die anderen 30xx Service-Ports.
Berührt jeden harden-coded 3065-Default — Server-Config, Webapp-Config,
SSR-Routes (rsvp/[token], status), Playwright-Webserver-Setup, e2e-Spec.
PUBLIC_MANA_EVENTS_URL in .env.development zieht beide Variablen mit.
PORT_SCHEMA.md trägt jetzt den Wechsel mit Datum + Begründung —
zukünftiges Ich soll nicht raten warum der Port aus der 30xx-Reihe
ausschert.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Schließt die platform/product-split-Lücke: HEAD's apps/api/src/index.ts
referenziert seit dem Forms-M10d-Commit personasInternalRoutes /
personasAdminRoutes — die Implementierung lag aber noch nicht im Repo.
Build war strukturell broken bis hierhin.
Was wandert von mana-auth nach apps/api:
apps/api/src/modules/personas/
├── schema.ts — pgSchema('personas') mit personas /
│ persona_actions / persona_feedback;
│ userId ist plain text (Cross-DB-FK auf
│ mana-auth's auth.users geht nach Split nicht).
├── internal-routes.ts — service-key gated GET /due, POST /:id/actions
│ und POST /:id/feedback. Append-only +
│ idempotent über deterministische row-ids
│ (tickId-i-tool / tickId-module).
└── admin-routes.ts — admin-JWT gated CRUD; ruft mana-auth via
/api/v1/admin/users + /api/v1/auth/register
+ /api/v1/internal/users/:id/persona-stamp
für den User-Lifecycle.
Persona-runner-Client zeigt jetzt auf apps/api:
- config.ts: neues apiUrl-Feld (default http://localhost:3060,
Env MANA_API_URL); authUrl bleibt für /api/v1/auth/login + spaces.
- clients/mana-auth-internal.ts: drei Calls treffen jetzt
/api/v1/personas/internal/* statt mana-auth's
/api/v1/internal/personas/* — Datei-Name bleibt um Call-Site-Diff
klein zu halten.
- index.ts: ManaAuthInternalClient bekommt config.apiUrl statt authUrl.
Seed/Cleanup-Skripte:
- --api= als bevorzugter Flag, --auth= als Legacy-Alias (cached
Shell-History würde sonst hart brechen).
- default http://localhost:3060, Env MANA_API_URL.
- Endpoint-Pfade umgeschrieben:
POST /api/v1/admin/personas → /api/v1/personas/admin
DELETE /api/v1/admin/personas/:id → /api/v1/personas/admin/:id
drizzle.config.ts: schema-Array + schemaFilter um 'personas' erweitert.
DB-push ist Pflicht-Schritt vor erstem Boot, sonst 42P01 auf /due.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Echter Server-Cron für recurring forms — wave-send läuft jetzt
unabhängig von Owner-Tab-State. Bisheriger M10c webapp-side scheduler
bleibt als Belt-and-suspenders aktiv (idempotent).
Architektur:
1. **Owner-private internal_meta auf unlisted snapshots**
- Drizzle: neue jsonb-column `internal_meta` (Drizzle migration
0001_internal_meta.sql).
- public-routes.ts strippt sie strukturell — die explicit select()-
projection enthält sie nicht (recipients + sender würden sonst
via share-link leaken).
- publish-route akzeptiert sie im Body, persistiert auf insert +
update.
- ALLOWED_COLLECTIONS um 'lasts' und 'forms' erweitert (war ein
latenter Bug — formsStore.setVisibility('unlisted') hätte ohne
diese Ergänzung 400 zurückbekommen; M4b lief vermutlich nie
end-to-end durch).
2. **shared-privacy publishUnlistedSnapshot**
- PublishUnlistedOptions erweitert um optionales `internalMeta`.
Forwarded an /api/v1/unlisted/:collection/:recordId body.
3. **Webapp formsStore**
- lib/wave-mail.ts: buildFormInternalMeta(form, broadcastSettings)
baut den Owner-Private-Blob: { kind, recurrence: {frequency,
recipientEmails, lastSentAt}, sender: {fromEmail, fromName,
replyTo, legalAddress}, formMeta: {title, description} }.
Returns null wenn Voraussetzungen fehlen (kein recurrence, keine
recipients, fehlende broadcast-settings).
- stores/forms.svelte.ts: setVisibility / regenerateUnlistedToken /
setUnlistedExpiry laden broadcastSettings via Dexie + decrypt,
bauen internalMeta, übergeben an publishUnlistedSnapshot. Form
wird vor dem buildFormInternalMeta-Call dekrypted.
4. **mana-mail internal bulk-send route**
- createInternalRoutes(accountService, broadcastOrchestrator,
maxRecipients) — Signature erweitert.
- Neue POST /api/v1/internal/mail/bulk-send: gleicher Payload-shape
wie user-facing /v1/mail/bulk-send aber userId aus Body statt
JWT. X-Service-Key-gate sitzt bei /api/v1/internal/* prefix.
Audit-trail trägt principalId aus Body. Cap = 5000 (gleicher
Wert wie user-facing).
5. **apps/api forms wave-worker**
- 5-min setInterval, advisory-lock-gated (key 0x464f5257 'FORW').
- Tick: select snapshots WHERE collection='forms' AND
internal_meta IS NOT NULL AND revoked_at IS NULL. Filter auf
kind='forms-recurrence' + isWaveDue (lastSentAt + period <= now,
never-sent fires sofort). Pro fälligem snapshot: build HTML/text
mailbody (mirror webapp wave-mail-render), POST an mana-mail
internal-bulk-send mit X-Service-Key + userId, dann jsonb_set
auf internal_meta.recurrence.lastSentAt. Per-snapshot errors
werden als console.warn geloggt, Tick läuft weiter.
- Disable via FORMS_WAVE_WORKER_DISABLED=true (tests / multi-
replica deployments).
- Wired in apps/api/src/index.ts neben startArticleImportWorker().
Trade-offs:
- internal_meta wird beim setVisibility/regenerate/setExpiry frisch
aus broadcast-settings gebaut — wenn der User später broadcast-
settings ändert (zB neuer fromEmail) muss er das Form re-publishen
damit die snapshot-internal_meta aktualisiert wird. Doc-it: zukünftiger
Patch könnte ein "settings drift"-Warning ins UI surfacen.
- Worker-Update von lastSentAt geht NICHT zurück in den webapp-form
(settings.recurrence.lastSentAt ist verschlüsselt, server kann
nicht schreiben). Owner-UI zeigt ältere lastSentAt von manuellen
Sends; auto-cron-sends sind in den Server-Logs sichtbar. Future
patch: GET /api/v1/forms/:id/recurrence-status (auth) gibt das
snapshot.internal_meta zurück, UI rendert Auto-Cron-State.
- Webapp-side wave-scheduler (M10c) läuft parallel weiter — wenn
Owner-Tab offen ist, kann beides feuern. Idempotent durch
lastSentAt-check (weekly/monthly buckets), aber theoretisch könnte
double-fire passieren wenn die Calls innerhalb 1ms versetzt sind.
Real-world ignorierbar; future patch: scheduler liest jetzt
internal_meta.lastSentAt vom server-side state.
apps/api buildet (1776 modules). mana-mail buildet (523 modules).
svelte-check 0 errors in forms/. Forms-Tests 70/70 unverändert.
DB-Migration 0001_internal_meta.sql muss manuell appliziert werden
(siehe feedback memory: hand-authored SQL migrations sind nicht in
pnpm setup:db).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Schließt M7 ab: Form-Antworten erzeugen jetzt zusätzlich zu Kontakten
(M7a) und Event-RSVPs (M7b) auch Library-Einträge und Space-
Einladungen. feedback bleibt bewusst aus dem UI raus —
Architektur-Mismatch.
- types.ts:
- AutoSyncConfig erweitert um optional `libraryKind`
('book'|'movie'|'series'|'comic') und `spaceMemberRole`
('member'|'admin', default 'member').
- Form domain-type bekommt `spaceId: string` (war intern auf
LocalForm vorhanden, wird jetzt durch toForm exposed). Brauchen
wir, weil space_member-Invite den organizationId der Form-Owner-
Space schicken muss.
- queries.ts toForm: spaceId aus LocalForm.spaceId mappen, Fallback ''.
- lib/auto-sync.ts:
- buildLibraryEntryFromAnswers (pure): mappt title / creators /
year / review. creators-strings werden auf , ; \n gesplittet
(multi-author-mapping). year bounds-checked 1900..2100.
- buildSpaceInviteFromAnswers (pure): findet das erste Form-Feld
mit mapping='email', validiert per Loose-Regex, gibt
{email}-payload zurück.
- dispatchTarget('library'): wirft wenn libraryKind fehlt; ruft
libraryEntriesStore.createEntry mit kind+title+creators+year+
review.
- dispatchTarget('space_member'): wirft wenn form.spaceId fehlt;
POSTet an /api/auth/organization/invite-member über authFetch
mit role aus cfg.spaceMemberRole. Returns invitation.id oder
Fallback `invite:<email>` (better-auth response-shape kann je
nach Version variieren).
- dispatchTarget('feedback') wirft jetzt mit klarem Kommentar:
architektur-Mismatch — feedback ist zentraler Public-Hub,
nicht per-Owner-Daten. UI filtert die Option raus.
- applyAutoSync reicht `form` durch zu dispatchTarget (statt nur
cfg/answers), damit Space-Invite die spaceId hat.
- lib/auto-sync.spec.ts: 9 weitere Tests (4 library: title/creators/
year-bounds/empty, 5 space: extract/malformed/non-mapped/no-mapping/
non-string). Total Forms-Tests jetzt 70/70.
- SettingsPanel:
- SUPPORTED_TARGETS auf [contacts, events, library, space_member]
erweitert. feedback erscheint NICHT — Type bleibt für Legacy-
Daten erhalten, aber UI bietet ihn nicht an.
- Library-Block: kind-picker (book/movie/series/comic) +
LIBRARY_KEYS-Mapping (title, creators, year, review).
- Space-Member-Block: role-picker (member/admin) +
SPACE_KEYS-Mapping (nur 'email'). Hint "mappe genau ein Feld".
- setMappingFor preserved jetzt alle target-spezifischen Felder
(targetId, libraryKind, spaceMemberRole) damit ein Mapping-Edit
nicht den Rest droppt.
- 25 neue i18n-Keys × 5 Locales (autoSync.targetLibrary/SpaceMember,
libraryKindPicker/libraryKind.*, libraryKey.*, libraryHint,
spaceMemberRolePicker/RoleMember/RoleAdmin/Hint/MappingHint).
Parity 6515 keys aligned.
Trade-offs:
- Library-Auto-Sync erzeugt einen Eintrag pro Antwort. Deduplizierung
(gleicher Titel kommt schon vor) bleibt manueller User-Workflow —
Autosync hat kein Wissen über die existierenden Bibliothek.
- Space-Invite-Flow läuft asynchron: Submitter kriegt Mana-Mail mit
Invite-Link, klickt → wird Member. Bei nicht-Mana-Identitäten muss
der Submitter erst registrieren. Owner sieht den Pending-State unter
/spaces.
- feedback: bewusst nicht implementiert. Form-Antworten als public-
feedback einzukippen wäre semantisch falsch (Owner sammelt für
sich, nicht zur Veröffentlichung).
Forms-Tests 70/70. svelte-check 0 errors. apps/api unverändert.
i18n-parity 6515 keys × 5 locales aligned.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Killer-Feature für den Conversation-Mode (M9): User kann auf
choice/yes_no/rating-Feldern in eigenen Worten antworten ("ich nehme
den zweiten Vorschlag" / "klar bin ich dabei" / "so 4 von 5"), ein
LLM mappt das auf die strikte Option-ID / boolean / Integer.
- apps/api/modules/forms/public-routes.ts: neuer
POST /api/v1/forms/public/:token/conversation/extract Endpoint.
Rate-limited (30/min/token + 60/min/IP — Owner-Side-Costs für haiku
trotz unauthenticated-Pfad). freeText hard-cap 1000 Zeichen.
Token-resolve via unlistedSnapshots, fieldId muss im publish-Schema
existieren. Dispatch:
- text/email/number/date: passthrough (free-text IST die Antwort)
- single_choice/multi_choice/yes_no/rating: mana-llm haiku-Call
mit field-spezifischem System-Prompt + JSON-only-Output, Parser
validiert Option-IDs gegen das Schema (Hallucination-Schutz).
Response { extracted, confidence: 'high' | 'low', alternatives? }.
confidence='low' wenn LLM unsicher → Client zeigt Warnung im
Preview-Block, User kann manuell auswählen.
- ConversationFormView: collapsible <details>"Lieber in eigenen
Worten antworten?"-Block unter den quick-reply-Buttons aller
choice/yes_no/rating-Felder. User tippt Free-Text → "Verstehen"
ruft endpoint → Preview-Karte mit der erkannten Antwort
(teal=high-confidence, amber=low-confidence) → "Übernehmen" oder
"Abbrechen". commitExtract löst setAnswerAndAdvance aus, läuft
über den selben Pfad wie quick-reply-Klick.
Schema-Validierung im Parser:
- single_choice: optionId muss in field.options sein, sonst null
- multi_choice: filtert nur valide IDs raus, Array kann leer sein
- yes_no: nur true/false/null erlaubt
- rating: round(value), bounds-check 1..ratingScale
LLM-Call:
- model claude-haiku-4-5 (cheapest)
- temperature 0 (deterministisch)
- maxTokens 200 (JSON-Output ist klein)
- Markdown-code-fence-Strip für robustes JSON-Parsing
Trade-offs:
- Public-Endpoint = ungated LLM-Spend für Form-Owner. Rate-Limits
+ freeText-Cap mitigaten Spam, aber 30 Calls/min × 200 tokens =
moderate Kosten pro Form. Owner sollte das im Hinterkopf haben.
- Confidence='low' eskaliert zur User-Sichtbarkeit, bricht aber
nicht den Flow — User kann übernehmen oder abbrechen.
Forms-Tests 61/61 unverändert (extract braucht Live-LLM für E2E,
absichtlich kein vitest-Mock). svelte-check 0 errors. apps/api
buildet (1772 modules).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Public-Form-Variante als linearer Chat-Flow (M9, KF4 aus dem Plan).
Owner wählt im Builder zwischen "klassisch" (alle Felder
gleichzeitig, M4b-View) und "conversation" (eine Frage nach der
anderen, mobile-friendly).
LLM-gestützte free-text → typed-Antwort-Extraktion (z.B. "Ich nehme
den zweiten Vorschlag" → option-id) bleibt M9b — die jetzige
Implementierung nutzt typed widgets pro Field-Type für einen
deterministischen ersten Wurf.
- types.ts: FormSettings.experience: 'classic' | 'conversation'
(default 'classic'). Reist im Settings-Blob mitverschlüsselt.
- data/unlisted/resolvers.ts: buildFormBlob whitelistet experience
ins public-snapshot — nur ein Enum, kein PII.
- SharedFormView (M4b) bleibt der classic-Renderer.
- ConversationFormView (neu, ~600 Zeilen):
- Linear: stepIndex zeigt durch das Visible-Subset von
resolveVisibleFields (gleicher branching-resolver wie classic).
- Pro Step: question-bubble + Field-Type-spezifischer Widget:
short_text/long_text/email/number → Free-Text-Input mit
Enter-Submit, date → datepicker, yes_no → 2 Quick-Reply-Buttons,
rating → Skala-Buttons, single_choice → vertikale
Quick-Reply-Liste, multi_choice → Toggle-Chips + "Weiter",
section → "Verstanden"-Step, consent → Yes(/Nein optional).
- Answer-Bubble nach Submit; "← Vorherige" droppt das letzte
Q/A-Pair und löscht die Antwort, damit der branching-resolver
den nächsten Step neu berechnet.
- Final-Step: Submitter-Name+Email (optional) + bestehender
POST /api/v1/forms/public/:token/submit.
- Progress-Bar oben, "via Mana Forms"-Footer.
- routes/share/[token]/+page.svelte: dispatched bei
collection='forms' auf experience-Wert — 'conversation' →
ConversationFormView, sonst SharedFormView.
- SettingsPanel: dropdown unter den Anonymous-Toggle, dt./eng./es./
fr./it. (15 neue i18n-Keys × 5 Locales = 6498 keys aligned).
Trade-offs:
- Branching reagiert pro-step: wenn der User auf einer späteren Frage
zurückgeht und die Quelle einer Hide-Regel ändert, fällt der
zwischenzeitlich gerenderte Pfad weg — eventuell taucht eine neue
Frage als "next" auf. Dokumentiert als linearer "tree-walk" statt
WYSIWYG-Snapshot, üblich für Typeform-Klone.
- Ohne LLM-Extraction (M9b) sind die Quick-Replies nicht fluide; das
ist intent: deterministic > magical for first ship.
Forms-Tests 61/61. svelte-check 0 errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Headless wave-send während die Mana-Tab offen ist (M10c). Echter
Server-Cron (mana-ai oder mana-notify) bleibt M10d.
- lib/wave-mail.ts: sendWaveViaBulkMail POSTet an
/api/v1/mail/bulk-send mit form-derived payload (subject =
"{title} — {cohort}", htmlBody mit Inline-CSS + share-link-Button +
Impressum + Abmelden-Footer, textBody plain). campaignId =
form-{formId}-{cohort} (idempotent über Retries). Wirft
WavePreconditionError wenn fromEmail/fromName/legalAddress fehlen
oder Empfänger leer sind — Caller fällt auf mailto-Bridge zurück.
- lib/wave-scheduler.ts: singleton setInterval (5 min,
Page-visibility-aware — pausiert bei hidden), Tick scant formTable,
dekrypt-aware, filtert published+token+recurrence+recipients+due,
ruft sendWaveViaBulkMail + markWaveSent. Wirft nicht — per-form
errors werden als console.warn geloggt, Schedule läuft weiter.
Initial-tick 30s nach start damit Montag-Morgen-Welle nicht
5 Minuten warten muss. start/stop idempotent.
- BuilderView.sendWave: versucht erst bulk-send (wenn
broadcasts-Settings configured = defaultFromEmail + legalAddress),
fällt auf mailto-Bridge zurück (M10b) wenn precondition fehlt.
waveError-state für non-precondition-Fehler. Confirm-Dialog hat
jetzt zwei Texte (confirmBulk vs confirmSend).
- (app)/+layout.svelte: startWaveScheduler() neben startMissionTick()
beim Auth-Ready, stopWaveScheduler() im onDestroy.
- 5 neue i18n-Keys × 5 Locales (forms.builder.recurrence.confirmBulk).
Parity 6495.
Trade-offs:
- Auto-Tick nur während Tab offen — headless Cron via mana-ai-Mission
oder mana-notify-Worker bleibt M10d.
- Bulk-send bypasst die Campaign-Pipeline der broadcasts (kein
Audience-Filter, kein Rich-Editor) — ist Absicht für Forms-Wellen
als kurze transactional notifications.
- DSGVO: Impressum + Abmelden-Footer ({{unsubscribe_url}} wird vom
Orchestrator pro Empfänger ersetzt) sind Pflicht via
WavePreconditionError; Mailto-Fallback hat das nicht — User-Risk.
Forms-Tests 61/61 unverändert. svelte-check 0 errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#16 — Help-content entry for articles
The articles module had no entry in `app-registry/help-content.ts`
so the (?) icon in ModuleShell rendered an empty body. Added
description + 9 features + 4 tips covering Reader-View, Highlights,
Bookmarklet, Share-Target, and the new bulk-import flow with the
"Erneut speichern" rescue path for cookie-walled hits.
#10 — Console statements marked intentional
The 5 console.log/warn/error calls in import-worker.ts (boot,
tick errors, GC summary, stale-recovery sweep) were ESLint
warnings. They're intentional operational logs — same pattern as
services/mana-ai/src/cron/tick.ts. Added file-level
`/* eslint-disable no-console */` with a comment explaining the
pattern + that structured signal lives in Prometheus counters.
#17 — Full 5-locale i18n for the bulk-import UI
New `articles.import` namespace with 50 keys covering the
BulkImportForm, JobsList, JobDetailView, and AddUrlForm bulk-link.
All five locales translated by hand:
- de.json (canonical, mirrors the original hardcoded German)
- en.json (English)
- fr.json (French — bookmarklet → "bookmarklet HTML du navigateur")
- it.json (Italian — bookmarklet → "bookmarklet HTML del browser")
- es.json (Spanish — bookmarklet → "bookmarklet HTML del navegador")
Plural-aware `consent_hint_body` uses ICU plural format
(`{n, plural, one {…} other {…}}`) so single-vs-multiple article
counts read naturally in each language.
The consent-hint sentence is split into 3 keys (body/link/after-link)
so the link text appears mid-sentence rather than tacked on after.
Components converted to `$_('articles.import.*')` everywhere — no
remaining hardcoded strings in the bulk-import UI.
i18n parity validator: 76 namespaces × 5 locales — 6477 canonical
keys, all aligned. validate:i18n-hardcoded baseline unchanged for
articles files (broadcasts/notes/timeline failures are user-WIP).
Plan: docs/plans/articles-bulk-import.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Heimstart-Karte für das Forms-Modul, parallele zu BroadcastsWidget /
InvoicesOpenWidget:
- modules/forms/widgets/FormsWidget.svelte: 3-Spalten-Stats
(veröffentlicht / Entwurf / Antworten total + "+N/7T" delta für
letzte 7 Tage), bis zu 2 zuletzt aktualisierte Forms mit
Status-Punkt (grün=published, grau=sonst) + Response-Count +
relative-Zeit, "+N weitere"-Link wenn mehr als 2 Forms existieren.
Empty-State mit "+ Erstes Formular bauen". Live aus Dexie via 2
parallele liveQuery-Subs (forms + formResponses).
- types/dashboard.ts: WidgetType-Union erweitert um 'forms';
WIDGET_REGISTRY-Eintrag mit defaultSize 'medium' + 📋-Icon.
- components/dashboard/widget-registry.ts: FormsWidget importiert +
in widgetComponents map registriert.
- 5 Locales × 2 dashboard-Keys (forms.title + forms.description).
App-Registry-Eintrag für /forms in app-registry/apps.ts existiert
bereits (Parallel-Session). FormsWidget ist die _aggregierte_
Heimstart-Variante; der app-registry-Eintrag mountet die ListView
direkt als Modul-Card.
i18n-parity 6417 keys aligned. svelte-check 0 errors in
modules/forms/widgets/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Polish-pass on top of the bulk-import rollout. Five contained items.
#8 + #9 — Dexie v60 schema cleanup
- Drop articleImportJobs.leasedBy + .leasedUntil. They were defined
on the original v57 schema as a soft-lease handshake, but the
worker uses pg_try_advisory_xact_lock and never wrote them.
Local-* type + projection row stripped.
- Drop the standalone `state` index on articleImportItems.
[jobId+state] covers the worker's hot query; the state-solo
index had no call site.
Both changes lossless — Dexie just removes the column declarations
from new rows; existing rows still carry the dead nulls (zombies)
until the next full row-rewrite. Not worth a hard migration for
two never-written columns.
#15 — MAX_URLS_PER_JOB hard cap (200)
articleImportsStore.createJob() throws if the URL list exceeds the
cap. BulkImportForm surfaces the limit in the live counter chip
and disables the submit when over. The worker can chew through any
N, but at high counts the UI gets unwieldy (no virtualisation) and
wall-clock duration climbs into multi-hour. 200 is a pragmatic
ceiling — Pocket-export dumps average 50–150.
#13 — Filter-Tabs in JobsList
Pill-style tabs above the list: Alle / Aktiv / Fertig / Mit Fehlern,
each with the row count. Disabled when the bucket is empty so the
user only sees actionable filters. The "Mit Fehlern" filter
(errorCount > 0) is the most valuable for triage.
#18 — apps/mana/CLAUDE.md
- Articles row added to the Tool Coverage table (5 propose +
1 auto, including the new auto-policy import_articles_from_urls).
- New "Articles bulk-import" section after the AI Workbench part:
pipeline diagram, table list, actor + metrics + cap pointers.
#20 — ARTICLES_IMPORT_WORKER_DISABLED env var documented
New row under "Mana API — Articles Bulk-Import Worker" in
docs/ENVIRONMENT_VARIABLES.md.
Plan: docs/plans/articles-bulk-import.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Neuer Block-Type `formEmbed` im Website-Builder
(docs/plans/forms-module.md M8):
- @mana/website-blocks/src/formEmbed/:
- schema.ts: FormEmbedSchema mit token (32-char base64url) +
titleOverride + optional resolved-Block (formTitle, fields,
branching, settings.{submitButtonLabel, successMessage}).
FormFieldEmbedSchema duplicated leichtgewichtig statt cross-
package import — website-blocks bleibt self-contained.
- FormEmbed.svelte: edit/preview rendert Placeholder-Card mit
Token-Snippet und resolved-Status; public rendert die kompletten
11 Field-Types inkl. Live-Branching-aware-Render. Submitter-
Block (Name+Email optional). Submit POSTet an
/api/v1/forms/public/:token/submit. Lazy-Fallback fetcht
/api/v1/unlisted/public/:token wenn die publish-resolver-blob
fehlt. Bot-Honeypot bleibt M8-Polish.
- FormEmbedInspector.svelte: Token-Input mit base64url-Validierung
bei blur, optional titleOverride, resolved-Card mit
Field-Count + Logik-Regel-Count.
- BLOCK_SPECS + BLOCK_SCHEMAS + BLOCK_DEFAULTS um formEmbed
erweitert. schemas.test.ts erwartet jetzt 12 Block-Types.
- apps/mana/apps/web/src/lib/modules/website/forms-embeds.ts:
resolveFormEmbed scant formTable nach unlistedToken (linear scan
ist günstig bei <100 forms pro user, kein Index nötig), dekrypted,
validiert published-status, gibt resolved-Block zurück.
- publish.ts.resolveEmbedsInTree erweitert um formEmbed-Branch — ruft
resolveFormEmbed parallel zu resolveEmbed (moduleEmbed) im selben
Walk.
Trade-offs:
- Token statt formId: bei Token-Rotation (M4b) muss der User den Block
neu konfigurieren. Der formEmbed-Block-Resolver erkennt das + setzt
resolved.error; public-Renderer fällt auf lazy-fetch zurück.
- Plaintext stored: das resolved-Blob landet als plaintext im
public-snapshot, gleiches Trust-Modell wie moduleEmbed (öffentliche
Website per Definition).
Tests: website-blocks 50/50 grün (12 schema-block-types + per-type
defaults validation). svelte-check 0 errors. forms 26/26 unverändert.
Use-Case: Vereins-Sommerfest. User legt /forms/anmeldung an,
publisht, setzt unlisted, kopiert Token. Im Website-Builder fügt er
einen formEmbed-Block auf der Event-Seite ein, paste Token → bei
Publish wird der Form-Schema inlined → Besucher submitten direkt
auf der Vereins-Website.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#6 — Worker test coverage on the deterministic helpers
Three new bun-test files in apps/api/src/modules/articles/:
- field-meta.test.ts (6 tests): pins down the legacy-vs-F3 fix
so it can never regress silently — including the regression
check from the live-test-found bug (string vs object compare
across both shapes evaluates correctly).
- consent-wall.test.ts (8 tests): the heuristic we extracted
in #4. German + English vocab, wordcount threshold + the
boundary case, case-insensitivity.
- import-worker.test.ts (5 tests): countByState rollup. Pins
down the consent-wall-counts-as-saved semantics so the
progress bar doesn't off-by-one and allTerminal stays correct.
Total 19 bun tests, all green.
countByState + StateCounts exported (test-only access).
#14 — Consent-wall recovery UI in JobDetailView
Bulk-import items that hit a cookie-wand land as state='consent-wall'
with the teaser saved. Before this commit there was no UX path to
"rescue" them other than navigating to the article and re-saving
manually. Now:
- Job-level hint banner appears when warningCount > 0,
explaining the cookie-wand semantics + linking to
/articles/settings (where the v2 bookmarklet lives).
- Per-item action group on consent-wall rows: "Teaser ansehen"
(open existing article) + "Erneut speichern" (deep-link to
/articles/add?source=bookmarklet&url=… so the bookmarklet's
postMessage handshake has the URL pre-populated).
Plan: docs/plans/articles-bulk-import.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bei einer neuen Form-Antwort entsteht automatisch ein Kontakt im
contacts-Modul (docs/plans/forms-module.md M7 — Teil 1):
- lib/auto-sync.ts:
- buildContactFromAnswers (pure): Mapping form-field-id →
contact-key (firstName/lastName/email/phone/...). Special target
`name` splittet auf erstes Whitespace in firstName + lastName.
- applyAutoSync (per Response): idempotent via syncedTargets-Check,
schreibt nach Erfolg `{target, recordId}` ans Response-Row.
- runAutoSyncSweep: scant alle Forms mit autoSync, dekrypt-aware
(vault-locked = no-op), filtert pre-decrypt auf nicht-bereits-
synced Responses für günstigen Skip. Per-Response-Errors werden
geloggt aber blockieren den Rest nicht.
- dispatchTarget für 'events' / 'feedback' / 'library' /
'space_member' wirft "M7b not yet" — Surface ist da, UI filtert.
- lib/auto-sync.spec.ts: 6/6 Vitest-Cases.
- SettingsPanel: target-Dropdown ('Nichts' / 'Kontakt') + bei contacts
Mapping-Grid über alle Antwortfelder mit dropdown der 15 contact-
keys (name als auto-split, sonst firstName/lastName/email/phone/
mobile/company/jobTitle/street/city/postalCode/country/birthday/
website/notes).
- BuilderView reicht items-Field-Liste an SettingsPanel weiter.
- ResponsesView triggert runAutoSyncSweep on-mount + bei Response-
Liste-Änderung. Bei synced > 0: Toast "{n} automatisch
synchronisiert" 4 Sek lang.
- 8 neue i18n-Keys × 5 Locales (forms.builder.autoSync.*).
Total Forms-Tests: 22/22 (5 csv + 11 branching + 6 auto-sync).
svelte-check 0 errors. i18n-parity 6407 keys.
Future M7b: events (RSVP), feedback, library, space_member.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#5 — SYSTEM_ARTICLES_IMPORT_WORKER hoisted into @mana/shared-ai
The worker built its actor inline, bypassing the SystemSource union
that's the blessed list for system-write principals. Now uses
makeSystemActor(SYSTEM_ARTICLES_IMPORT_WORKER) like every other
server-side system writer (mission-runner, projection, …).
#7 — sync-db helper hoisted out of mcp/ into lib/
Implementation moved to apps/api/src/lib/sync-db.ts; mcp/sync-db.ts
is a re-export shim so existing MCP imports keep working. Articles
bulk-import + future modules import from lib/ directly — no more
"articles depending on mcp" layering smell.
#11 — Prometheus metrics for the worker
New counters + histogram in lib/metrics.ts under
mana_api_articles_import_*:
- ticks_total{result=processed|skipped|error}
- items_total{result=extracted|error|consent_wall|cancelled}
- extract_duration_seconds (histogram, 0.25–30s buckets)
- jobs_completed_total{result=done}
- pickup_gc_rows_total
Worker tick + extractor instrumented at the right transition points.
Steady-state pickup_gc_rows_total > 0 over time signals a stuck
consumer somewhere — useful operator alert.
Plan: docs/plans/articles-bulk-import.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two cleanup follow-ups that the parallel sessions which extracted these
modules left behind, surfaced by the route-drift test added in 6d193a9fa:
who — `chore: extract who module into standalone repo` (a3eedfc87) +
follow-up cleanup (f076d9345) removed `lib/modules/who/` and the
workbench `registerApp({ id: 'who' })` block, but the broken `/who/+page`
and `/who/play/[gameId]/+page` routes still imported the deleted module
and the MANA_APPS entry, APP_ICONS icon, categories.ts mapping and
help-content block were still in place. Drop all five.
kontext — `feat(notes): isSpaceContext flag replaces kontext module
(Option B)` (8fbdc6db7) replaced the kontext module with a per-note
`isSpaceContext` flag in the notes module. The MANA_APPS entry I added
in 6d193a9fa and the matching APP_ICONS entry are now both stale —
there is no `kontext` route, no module, no registerApp. Drop them.
Verification: `registry.spec.ts` 4/4 green, `svelte-check src/lib`
0 errors / 5 warnings (pre-existing in other files).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four cross-cutting fixes that make the bulk-import worker safe to run
under real production load. All four were called out as live-rollout
risks in the post-ship review of docs/plans/articles-bulk-import.md.
#1 — Same fieldMetaTime bug fixed in mana-ai
The articles fix in 054b9e5be hoists the helper to its own file
`apps/api/src/modules/articles/field-meta.ts`. The same naive
`rowFM[k] >= localTime` LWW comparison existed in three more
projections under services/mana-ai (missions-projection,
snapshot-refresh, agents-projection). Once any F3 stamp lands
beside a legacy-string stamp, the comparison evaluates
`'[object Object]' >= 'ISO-…'` (false) and the older value wins.
New `services/mana-ai/src/db/field-meta.ts` — same helper,
deliberately duplicated (each service treats sync_changes as a
read-only event log; sharing infra across services is out of
scope here). All 61 mana-ai bun tests still pass.
#2 — Stale 'extracting' items recycle
If the worker dies mid-fetch (OOM, pod restart), items stay in
state='extracting' forever and the job never completes. New sweep
at the start of `processOneJob`: items whose lastAttemptAt is
older than 5 minutes get bounced back to 'pending' so the next
tick re-claims them. STALE_EXTRACTING_MS tuned for the 15s
shared-rss fetch + JSDOM-parse worst case.
#3 — Pickup-row GC
Every 30 ticks (~once per minute) the worker hard-deletes
articleExtractPickup rows older than 24h. Without this a stuck
pickup-consumer (all tabs closed, Web-Lock mismatch) would let
sync_changes accumulate without bound. Logs the row count when
non-zero so we can spot stuck consumers in the wild.
#4 — DRY consent-wall heuristic
Identical CONSENT_KEYWORDS + threshold lived in routes.ts AND
import-extractor.ts. Hoisted to
`apps/api/src/modules/articles/consent-wall.ts`; both call sites
now share one heuristic.
Plan: docs/plans/articles-bulk-import.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Server-side Public-Submit für unlisted-shared Forms (Plan
docs/plans/forms-module.md M3.b):
- POST /api/v1/forms/public/:token/submit (apps/api):
- Token-resolve via unlistedSnapshots-Tabelle (eq, limit 1).
- Hard-blocks: 404 unbekannt, 410 revoked/expired, 400 wrong
collection, 400 invalid JSON.
- Schema-validiert serverseitig: filtert eingehende answers auf
field-IDs aus dem Snapshot (anti-injection), prüft required
Antwort-Felder + required consent-Felder.
- Hashed IP (SHA-256, hex) als Anti-Spam-Fingerprint, plus
User-Agent + Referer truncated, in submitterMeta.
- Schreibt sync_changes(table='formResponses', op='insert', data,
field_meta, actor='system:forms-public-submit', origin='system')
in einer Transaktion mit set_config('app.current_user_id') für
RLS — mirror vom articles import-extractor.
- Token-scoped rate-limit (10/min) + IP-scoped (30/min), gleiche
Architektur wie unlisted/public-routes.
- Returns { ok: true, responseId, submittedAt }.
- SharedFormView (apps/mana/apps/web): handleSubmit POSTet jetzt an
${PUBLIC_MANA_API_URL || origin:3060}/api/v1/forms/public/:token/submit.
Submitting-State (Disabled-Button + "Sende ..."), Error-Block bei
Server-Fehlern, Submitter-Block (Name + Email, beide optional). Der
DEV-Hinweis ist weg.
Encryption: server speichert plaintext im sync_changes-Blob. Der
Client-side Decrypt-Path ist no-op für non-encrypted shapes
(record-helpers.ts:241), also kein Crash beim Pull. Encrypted-at-rest
für public submissions ist M6 ZK-Mode (eigener per-Form-Key der
Form-Owner client-seitig hält).
Mounted pre-auth in apps/api/src/index.ts neben unlisted/public.
apps/api buildet (1769 modules, no TS errors). svelte-check:
0 errors in forms/. Forms-Modul ist End-to-End nutzbar — User legt
Form an, publisht, setzt visibility=unlisted, kopiert Share-Link,
externe Person füllt aus + sendet, Antwort landet im
ResponsesView des Owners.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AI-Zugriff aufs Forms-Modul (docs/plans/forms-module.md M5):
Propose (User-Approval erforderlich):
- forms_create — neues Formular im Draft-Status, optional mit Feldern.
Field-Shape im params-Array: { type, label, required?, helpText?,
options?: [{label}] }. Type-Enum aus dem 11-Typ-Katalog. Planner
kann z.B. "Vereins-Anmeldung" mit Name+Email+Position+Trikotgröße
in einem Aufruf bauen.
- forms_add_field — Feld ans Ende anhängen, Reorder bleibt User
vorbehalten (Drag im Builder).
- forms_publish — draft → published. Wirft, wenn Form keine Antwort-
felder hat (nur section/consent würde Public-Submit sinnlos machen).
- forms_close — published → closed, Antworten + Share-Link bleiben.
Auto (silent execution während Planner-Reasoning):
- forms_list — Metadaten (id, title, status, fieldCount, responseCount,
visibility), Status-Filter optional, Default-Limit 50. VaultLocked-
aware → klare Fehlermeldung statt Crash.
- forms_get_responses — Aggregat-Stats: per Form ein
ResponseAggregate {totalCount, statusCounts, choiceHistograms,
textSamples, numericStats}. Choice-Felder mit Option-Label-Mapping
(nicht Option-IDs), Text-Felder als Sample-Array (cap 50, default).
- forms_summarize_responses — gleicher Aggregator mit window-filter
(sinceDays) und höherem Sample-Cap (200), als Daten-Vorlage für
LLM-Clustering im nächsten Planner-Schritt. Augur-style: keine
eigene LLM-Roundtrip, der Planner formuliert Themes selbst.
Verdrahtung:
- AI_TOOL_CATALOG in @mana/shared-ai mit 7 ToolSchema-Einträgen +
defaultPolicy.
- ModuleTool-Implementierungen in modules/forms/tools.ts mit
scopedForModule für Space-Awareness, decryptRecords für encrypted-
table-Reads, VaultLocked-Handling.
- Registriert in data/tools/init.ts.
Validierung:
- mana-ai planner-drift test: 4/4 grün — alle 4 propose-Tools
(forms_create/add_field/publish/close) im SERVER_TOOLS-Subset.
- svelte-check 0 errors in forms/.
- Forms unit tests: 16/16 (csv + branching) unverändert grün.
Tools-executor.test.ts ist pre-existing rot wegen
$lib/modules/context-Drift in module-registry.ts (Parallel-Session-
WIP, nicht durch mich).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Companion deletion sweep — acb737e25 removed all the *registry refs*
to the legacy `context` module, but its source files were still on
disk on main (because the original deletion in d3e2e73ca on the
articles-bulk-import branch was bundled with unrelated photon /
broadcast-rename work and never landed on main). Dropping them now
so the consolidation is self-contained:
- apps/mana/apps/web/src/lib/modules/context/ — entire module dir
- apps/mana/apps/web/src/routes/(app)/context/ — page routes
- apps/mana/apps/web/src/lib/components/dashboard/widgets/ContextDocsWidget.svelte
- apps/mana/apps/web/src/lib/i18n/locales/context/{de,en,es,fr,it}.json
- packages/shared-branding/src/logos/ContextLogo.svelte
Verified: svelte-check + tsc --noEmit both clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>