55 Session-Devlogs nach _legacy-sessions/ verschoben (git mv erhält
History) und Disclaimer-Header injected. Astro-Routen so angepasst
dass alle alten /devlog/<slug>-URLs erhalten bleiben — slug.replace
strippt das _legacy-sessions/-Prefix in [slug].astro/index.astro/
activity.astro. Build-Test verifiziert: 55 Posts in dist/devlog/, kein
nested _legacy-sessions/-Pfad.
Disclaimer-Box pro Datei:
> **Legacy-Format.** Dieser Eintrag stammt aus dem Session-basierten
> Devlog vor der Umstellung auf das Tages-Modell (Cutover 2026-05-09).
> Bestand bleibt erhalten und unverändert; neue Einträge folgen der
> Tages-Konvention mit spieler.md + macher.md pro 06–06-Bucket.
> Spec: mana/docs/DEVLOG.md.
Offene Cutover-Schritte (eigene Session, blocked auf Verdaccio-
Token-Setup für @mana/devlog-gen):
- Tages-Modell-Collection mit data.json + spieler.md + macher.md
(eigenes Schema, parallel zur Legacy-Ansicht)
- @mana/devlog-gen via npm.mana.how integrieren (aktuell nur auf
pkg.mana.how Lame-Duck publiziert)
- CI-Workflow .github/workflows/devlog.yml für pnpm devlog:today
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>