Vorbestehender Bug: zeigte auf nicht-existente Org `mana/`. Korrigiert
auf den tatsächlichen GitHub-Pfad. Edit-Link auf docs-Seite + GitHub-
Icon im Footer funktionieren jetzt.
Phase-3-Rename des ehemaligen Multi-App-Monorepos zum eigenständigen
Produkt-Repo. Verein heißt mana e.V., Plattform-Domain bleibt mana.how,
apps/mana/ bleibt unverändert — nur der Repo-Container kriegt den
neuen Namen "managarten" (Garten der mana-Apps).
Geändert:
- package.json#name + #description
- README.md (Titel + erster Absatz)
- TROUBLESHOOTING.md
- alle Mac-Mini-Skripte (Pfade ~/projects/mana-monorepo → ~/projects/managarten)
- COMPOSE_PROJECT_NAME-default in scripts/mac-mini/status.sh
- .github/workflows/cd-macmini.yml + mirror-to-forgejo.yml
- apps/docs (astro.config.mjs + content)
- .claude/settings.local.json (Bash-Permission-Pfade)
- alle docs/*.md Pfad-Referenzen
- launchd plists, .env.macmini.example, infrastructure/
Forgejo-Repo + GitHub-Repo bereits via API umbenannt. Lokales
Verzeichnis-Rename + Mac-Mini-Cutover folgen separat.
Cards-App ist seit 2026-05-08 ein eigenständiges Repo
git.mana.how/till/cards (Strategie-B-Greenfield, beschlossen
2026-05-08), live auf cardecky.mana.how + cardecky-api.mana.how.
Alte cards-web-Container wurden gestoppt + entfernt.
Rollback: git checkout cards-decommission-base -- apps/cards/
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root package.json devDeps reference @mana/eslint-config (workspace:*).
Even with `--filter @mana/api...`, pnpm resolves the root workspace
manifest and chokes on the missing package. Copy eslint-config into the
builder stage so the root resolves cleanly. Same pattern as the platform
mana-auth Dockerfile already uses.
Discovered while running the 8-Doppel-Cutover smoke test on Mac Mini:
mana-api build failed with ERR_PNPM_WORKSPACE_PKG_NOT_FOUND.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Part of the 8-Doppel-Cutover (2026-05-08, plan
~/.claude/plans/floating-swinging-flurry.md):
- docker-compose.{macmini,dev,test}.yml: build context for
mana-{auth,credits,media,llm,notify} switched to ../mana/services/...
so the Mac Mini stack pulls platform services from the platform repo
(sibling clone), not from services/ in this monorepo.
- .npmrc + apps/api/{Dockerfile,package.json}: @mana/media-client now
resolved from Verdaccio (npm.mana.how, ^0.1.0) instead of as a
workspace COPY from services/mana-media/packages/client. Build-arg
NPM_TOKEN flows through .npmrc for pnpm install auth. Required
before services/mana-media/ can be deleted.
- .github/workflows/{ci,cd-macmini,daily-tests}.yml: removed the
detect-/build-/test-jobs that targeted services/mana-{auth,credits,
notify,media}/. Those services build out of the platform repo now —
CI for them belongs in mana/-repo (open). cd-macmini's
workflow_dispatch can still rebuild any of them on demand;
auto-detect on path-change is gone for these five.
- scripts/{mac-mini/push-schemas.sh,run-integration-tests.sh}:
rewritten to look in ../mana/ for the platform services.
- package.json dev:{auth,credits,notify,media}: paths point at
../mana/services/... so local dev still works post-cutover.
What this commit does NOT do: delete services/mana-{auth,credits,...}
from this repo. That waits for Phase 7 once the Mac Mini stack has
booted cleanly from the new build paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Catches the service-level package.json files that the previous
sweep (4cca25ed0) missed — they don't appear in any dev:*:full
orchestrator but get invoked when someone runs `pnpm --filter
@mana/<service> dev` directly.
Touched: mana-geocoding, mana-mail, mana-subscriptions, mana-mcp,
news-ingester, mana-persona-runner, mana-research, mana-user,
plus apps/memoro (server + audio-server).
mana-ai stays on --watch on purpose: its entry uses an explicit
`Bun.serve({...})` call instead of `export default { port,
fetch }`, plus a SIGTERM/SIGINT handler that calls
`server.stop()`. --hot would replace the module without releasing
the old server reference and produce exactly the EADDRINUSE we're
trying to avoid. If mana-ai gets refactored to the standard
default-export shape, flip its dev script too.
Fans out the cards-server fix from 08f422340 to every other Bun
service in the monorepo. --hot keeps the port bound across HMR
reloads via globalThis[hmrSymbol]; --watch restarts the process
and races old/new Bun.serve for the port.
Touched: dev:auth, dev:credits, dev:events, dev:analytics,
dev:memoro:server, dev:memoro:audio-server, dev:uload:server in
the root package.json plus the matching `dev` script in each
service's own package.json. All six services already export the
`{ port, fetch }` default that Bun's --hot expects.
Smoke-tested: pnpm dev:cardecky:full boots clean, then touching
auth/credits/cards-server entry files all hot-reload without
dropping their port.
(memoro/apps/audio-server doesn't have a `dev: bun --watch ...`
script in its own package.json, so only the root entry got the
swap there.)
- CardFace now renders one card surface that flips on Y-axis when
the user taps it. Both faces share a CSS-grid cell so the parent
height is the max of front/back, no jumpy reflow on flip.
- Tap-anywhere on the surface reveals (only while showBack is
false). The /learn page keeps the keyboard space/enter shortcut
via the existing handler; the standalone "Aufdecken" button is
now type-in-only (where the input field on the front breaks the
flip-mental-model).
- prefers-reduced-motion: reduce collapses the rotateY into an
instant cross-fade — same affordance, no vestibular trigger.
- Added a subtle "Tippe auf die Karte oder drücke Leertaste" hint
on the front face so the new affordance is discoverable.
- Fixed the last Phase-A leftover: focus:border-indigo-400 in the
type-in input → focus:border-app-accent.
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>