diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts
index 3e7e753..c8376cb 100644
--- a/apps/api/src/index.ts
+++ b/apps/api/src/index.ts
@@ -16,6 +16,7 @@ import { decksGenerateRouter } from './routes/decks-generate.ts';
import { authorsRouter as marketplaceAuthorsRouter } from './routes/marketplace/authors.ts';
import { marketplaceDecksRouter } from './routes/marketplace/decks.ts';
import { exploreRouter as marketplaceExploreRouter } from './routes/marketplace/explore.ts';
+import { publicFeedRouter } from './routes/public-feed.ts';
import { engagementRouter as marketplaceEngagementRouter } from './routes/marketplace/engagement.ts';
import { subscriptionsRouter as marketplaceSubscriptionsRouter } from './routes/marketplace/subscriptions.ts';
import { forkRouter as marketplaceForkRouter } from './routes/marketplace/fork.ts';
@@ -90,6 +91,10 @@ app.route('/api/v1/marketplace/me', marketplaceMeRouter());
app.route('/api/v1/marketplace/authors', marketplaceAuthorsRouter());
app.route('/api/v1/marketplace/decks', marketplaceDecksRouter());
+// Plattform-Public-Feed-Konvention (LOCAL_FIRST_LOGIN_OPTIONAL.md §3 D).
+// hub.mana.how/timeline aggregiert über /api/v1/public/feed aller Apps.
+app.route('/api/v1/public', publicFeedRouter());
+
app.get('/', (c) =>
c.json({
app: 'wordeck',
diff --git a/apps/api/src/routes/public-feed.ts b/apps/api/src/routes/public-feed.ts
new file mode 100644
index 0000000..a20101d
--- /dev/null
+++ b/apps/api/src/routes/public-feed.ts
@@ -0,0 +1,118 @@
+/**
+ * Public-Feed-Endpoint nach Plattform-Konvention
+ * (siehe `mana/docs/playbooks/LOCAL_FIRST_LOGIN_OPTIONAL.md` Konvention D).
+ *
+ * Shape:
+ * GET /api/v1/public/feed?cursor=&limit=20
+ * → { items: [{ id, type, slug, title, excerpt, author, publishedAt, ... }],
+ * nextCursor: | null }
+ *
+ * Wordecks Public-Items sind die published Marketplace-Decks. Wir wrappen
+ * den Marketplace-Browse mit dem Konventions-Output. Aggregator-Apps wie
+ * `hub.mana.how/timeline` konsumieren genau diese Shape.
+ *
+ * Keine Auth — pure public read.
+ */
+
+import { Hono } from 'hono';
+
+import { authors, publicDecks } from '../db/schema/index.ts';
+import { count, eq, and, desc, sql } from 'drizzle-orm';
+import { getDb, type CardsDb } from '../db/connection.ts';
+
+interface FeedItem {
+ id: string;
+ type: 'deck';
+ slug: string;
+ title: string;
+ excerpt: string | null;
+ author: {
+ slug: string;
+ display_name: string;
+ };
+ publishedAt: string;
+ stats: {
+ card_count: number;
+ star_count: number;
+ };
+}
+
+function encodeCursor(offset: number): string {
+ return Buffer.from(JSON.stringify({ offset })).toString('base64url');
+}
+
+function decodeCursor(cursor: string): number {
+ try {
+ const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf-8'));
+ return typeof parsed.offset === 'number' ? parsed.offset : 0;
+ } catch {
+ return 0;
+ }
+}
+
+async function loadPage(
+ db: CardsDb,
+ offset: number,
+ limit: number,
+): Promise<{ items: FeedItem[]; total: number }> {
+ const starCount = sql`(SELECT count(*)::int FROM marketplace.deck_stars s WHERE s.deck_id = ${publicDecks.id})`;
+ const cardCountExpr = sql`COALESCE((SELECT v.card_count FROM marketplace.deck_versions v WHERE v.id = ${publicDecks.latestVersionId}), 0)`;
+
+ const rows = await db
+ .select({
+ id: publicDecks.id,
+ slug: publicDecks.slug,
+ title: publicDecks.title,
+ description: publicDecks.description,
+ createdAt: publicDecks.createdAt,
+ cardCount: cardCountExpr,
+ starCount,
+ ownerSlug: authors.slug,
+ ownerDisplayName: authors.displayName,
+ })
+ .from(publicDecks)
+ .innerJoin(authors, eq(authors.userId, publicDecks.ownerUserId))
+ .where(and(eq(publicDecks.isTakedown, false)))
+ .orderBy(desc(publicDecks.createdAt))
+ .limit(limit)
+ .offset(offset);
+
+ const totalRow = await db
+ .select({ value: count() })
+ .from(publicDecks)
+ .where(and(eq(publicDecks.isTakedown, false)));
+
+ return {
+ items: rows.map(
+ (r): FeedItem => ({
+ id: r.id,
+ type: 'deck',
+ slug: r.slug,
+ title: r.title,
+ excerpt: r.description,
+ author: { slug: r.ownerSlug, display_name: r.ownerDisplayName },
+ publishedAt: r.createdAt.toISOString(),
+ stats: { card_count: Number(r.cardCount), star_count: Number(r.starCount) },
+ }),
+ ),
+ total: totalRow[0]?.value ?? 0,
+ };
+}
+
+export function publicFeedRouter() {
+ const r = new Hono();
+ r.get('/feed', async (c) => {
+ const url = new URL(c.req.url);
+ const limitRaw = url.searchParams.get('limit');
+ const cursor = url.searchParams.get('cursor');
+ const limit = Math.min(Math.max(1, parseInt(limitRaw ?? '20', 10) || 20), 100);
+ const offset = cursor ? decodeCursor(cursor) : 0;
+
+ const { items, total } = await loadPage(getDb(), offset, limit);
+ const nextOffset = offset + items.length;
+ const nextCursor = nextOffset < total ? encodeCursor(nextOffset) : null;
+
+ return c.json({ items, nextCursor });
+ });
+ return r;
+}
diff --git a/apps/web/src/routes/datenschutz/+page.svelte b/apps/web/src/routes/datenschutz/+page.svelte
index d766eba..b3b5f5d 100644
--- a/apps/web/src/routes/datenschutz/+page.svelte
+++ b/apps/web/src/routes/datenschutz/+page.svelte
@@ -214,8 +214,33 @@
Lokal speichert die App im Browser-localStorage technisch notwendige
- Daten (Auth-Token, Spracheinstellung, sql-wasm-Cache für Offline-
- Study). Wir nutzen keine Tracking-Cookies.
+ Daten (Auth-Token, Spracheinstellung). Wir nutzen keine
+ Tracking-Cookies.
+
+ 7a. Lokale Daten + anonyme Nutzung
+
+ Seit Mai 2026 läuft Wordeck local-first: Decks,
+ Karten und Lern-Reviews werden zuerst in Ihrer Browser-IndexedDB
+ gespeichert (Datenbank-Name manasync_wordeck), nicht
+ direkt auf unseren Servern. Sie können die App komplett ohne
+ Konto nutzen — eine anonyme Geräte-ID
+ (anon:<ulid>) wird lokal erzeugt und verlässt
+ Ihr Gerät nicht.
+
+
+ Sobald Sie sich anmelden, werden alle bisher anonym gesammelten
+ Daten dem Konto zugeordnet und an unseren Sync-Server
+ sync2.mana.how übertragen — verschlüsselt mit einem
+ User-eigenen Master-Key (AES-GCM-256), der über
+ auth.mana.how in Ihrem Vault liegt. Inhalte
+ (Karteninhalte, Notizen) sind serverseitig nicht im Klartext
+ lesbar.
+
+
+ Sie können jederzeit ohne Anmeldung weiter lokal arbeiten —
+ diese Daten bleiben dann auf Ihrem Gerät. Bei Browser-Reset oder
+ manueller Löschung der IndexedDB sind sie verloren, sofern Sie
+ vorher kein Konto angelegt haben.
8. Ihre Rechte