diff --git a/docs/superpowers/plans/2026-04-15-spa-sveltekit.md b/docs/superpowers/plans/2026-04-15-spa-sveltekit.md
new file mode 100644
index 0000000..277250e
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-15-spa-sveltekit.md
@@ -0,0 +1,2812 @@
+# SvelteKit-SPA Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Eine produktive SvelteKit-SPA bauen, die Jörgs Nostr-Posts (`kind:30023`) live von Public-Relays rendert, auf `svelte.joerg-lohrer.de` deployed wird und später `joerg-lohrer.de` ablösen soll.
+
+**Architecture:** SvelteKit mit `adapter-static` (SSR aus, Fallback `index.html`), `applesauce-relay` + `applesauce-loaders` + `applesauce-signers` für Nostr, `marked` + `DOMPurify` + `highlight.js` für Markdown. Konfiguration zur Laufzeit aus `kind:10002` (Relays) und `kind:10063` (Blossom). Legacy-URLs werden via `history.replaceState` auf kanonische kurze Form normalisiert.
+
+**Tech Stack:** SvelteKit 2.x · Svelte 5 · TypeScript · Vite · `applesauce-relay` · `applesauce-loaders` · `applesauce-signers` · `nostr-tools` (nip19 Encoding) · `marked` · `DOMPurify` · `highlight.js` · Vitest · Playwright · `adapter-static`
+
+**Scope:** Diese Implementation deckt die vollständige SPA-Spec ab — Home mit Liste + Profil, Einzelpost, Tag-Filter, Reactions und NIP-07-Kommentare. Die Publish-Pipeline (Markdown → Events) ist nicht Teil dieses Plans — sie hat eine eigene Spec und einen separaten Plan.
+
+**Ausführungsort:** Branch `spa`, Unterordner `app/` des Repos.
+
+---
+
+## Referenzen
+
+- **Spec SPA:** [`docs/superpowers/specs/2026-04-15-nostr-page-design.md`](../specs/2026-04-15-nostr-page-design.md)
+- **Spec Publish-Pipeline:** [`docs/superpowers/specs/2026-04-15-publish-pipeline-design.md`](../specs/2026-04-15-publish-pipeline-design.md)
+- **Mini-Spike als funktionierendes Referenz-Verhalten:** [`preview/spa-mini/index.html`](../../../preview/spa-mini/index.html) — live unter `https://spa.joerg-lohrer.de/`
+- **Autoren-Pubkey (npub):** `npub1f7jar3qnu269uyx5p0e4v24hqxjnxysxudvujza2ur5ehltvdeqsly2fx9`
+- **Autoren-Pubkey (hex):** `4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41`
+- **Bootstrap-Relay:** `wss://relay.damus.io`
+- **Beispiel-Event zum Testen:** `kind:30023`, `d:dezentrale-oep-oer` (existiert bereits auf allen 5 Relays)
+
+---
+
+## File-Structure
+
+```
+app/
+├── package.json
+├── svelte.config.js
+├── vite.config.ts
+├── tsconfig.json
+├── .gitignore
+├── README.md
+├── static/
+│ ├── favicon.ico
+│ └── robots.txt
+├── src/
+│ ├── app.html # HTML-Shell,
-Defaults
+│ ├── app.d.ts # TypeScript-Ambient-Deklarationen
+│ ├── hooks.client.ts # Globaler Client-Hook (Fehler-Reporting)
+│ ├── lib/
+│ │ ├── nostr/
+│ │ │ ├── config.ts # BOOTSTRAP_RELAY, AUTHOR_PUBKEY_HEX, FALLBACK_READ_RELAYS
+│ │ │ ├── pool.ts # RelayPool Singleton
+│ │ │ ├── relays.ts # loadOutboxRelays (kind:10002)
+│ │ │ ├── blossom.ts # loadBlossomServers (kind:10063) — read-only, für später
+│ │ │ ├── loaders.ts # loadProfile, loadPostList, loadPost, loadReplies, loadReactions
+│ │ │ ├── signer.ts # NIP-07-Wrapper
+│ │ │ └── naddr.ts # nip19.naddrEncode Helper
+│ │ ├── render/
+│ │ │ └── markdown.ts # renderMarkdown(md: string): string
+│ │ ├── url/
+│ │ │ └── legacy.ts # parseLegacyUrl, canonicalPostPath
+│ │ ├── stores/
+│ │ │ └── readRelays.ts # derived Store: aktuelle Read-Relay-Liste
+│ │ └── components/
+│ │ ├── ProfileCard.svelte
+│ │ ├── PostCard.svelte
+│ │ ├── PostView.svelte
+│ │ ├── TagChip.svelte
+│ │ ├── ReplyList.svelte
+│ │ ├── ReplyItem.svelte
+│ │ ├── ReplyComposer.svelte
+│ │ ├── Reactions.svelte
+│ │ └── LoadingOrError.svelte
+│ └── routes/
+│ ├── +layout.svelte # Shell (Header nur auf /, Breadcrumb ansonsten)
+│ ├── +layout.ts # export const prerender = false; ssr = false
+│ ├── +page.svelte # Home: Profil + Beitragsliste
+│ ├── +error.svelte # Fehlerseite
+│ ├── [...slug]/+page.svelte # Catch-all: Legacy-Check, dann Einzelpost
+│ └── tag/
+│ └── [name]/+page.svelte # Tag-Filter
+├── tests/
+│ ├── unit/
+│ │ ├── markdown.test.ts
+│ │ ├── legacy-url.test.ts
+│ │ ├── naddr.test.ts
+│ │ └── loaders.test.ts # gegen Mock-Relay
+│ └── e2e/
+│ ├── home.test.ts
+│ ├── post.test.ts
+│ ├── legacy-redirect.test.ts
+│ └── tag.test.ts
+└── playwright.config.ts
+```
+
+---
+
+## Phase 1 — Setup (Tasks 1–6)
+
+### Task 1: SvelteKit-Projekt initialisieren
+
+**Files:**
+- Create: `app/package.json`
+- Create: `app/svelte.config.js`
+- Create: `app/vite.config.ts`
+- Create: `app/tsconfig.json`
+- Create: `app/.gitignore`
+- Create: `app/src/app.html`
+- Create: `app/src/app.d.ts`
+- Create: `app/src/routes/+page.svelte`
+- Create: `app/static/favicon.ico`
+- Create: `app/static/robots.txt`
+
+- [ ] **Step 1.1: Arbeitsverzeichnis vorbereiten und SvelteKit-Skeleton erzeugen**
+
+```sh
+cd /Users/joerglohrer/repositories/joerglohrerde
+mkdir -p app
+cd app
+# Nicht-interaktiv: minimal TypeScript-Skeleton anlegen
+npx --yes sv create . --template minimal --types ts --no-add-ons --install npm
+```
+
+Expected: Verzeichnis `app/` hat `package.json`, `svelte.config.js`, `vite.config.ts`, `src/`, `static/`, `node_modules/` installiert.
+
+- [ ] **Step 1.2: `svelte.config.js` auf adapter-static umstellen**
+
+Ersetze den Inhalt von `app/svelte.config.js` durch:
+
+```js
+import adapter from '@sveltejs/adapter-static';
+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
+
+/** @type {import('@sveltejs/kit').Config} */
+const config = {
+ preprocess: vitePreprocess(),
+ kit: {
+ adapter: adapter({
+ pages: 'build',
+ assets: 'build',
+ fallback: 'index.html',
+ precompress: false,
+ strict: false,
+ }),
+ alias: {
+ $lib: 'src/lib',
+ },
+ },
+};
+
+export default config;
+```
+
+- [ ] **Step 1.3: adapter-static als Dependency hinzufügen**
+
+```sh
+cd app
+npm install --save-dev @sveltejs/adapter-static
+```
+
+Expected: `package.json` hat `"@sveltejs/adapter-static"` in `devDependencies`.
+
+- [ ] **Step 1.4: Globales SSR deaktivieren via `+layout.ts`**
+
+Create `app/src/routes/+layout.ts`:
+
+```ts
+export const prerender = false;
+export const ssr = false;
+export const trailingSlash = 'always';
+```
+
+- [ ] **Step 1.5: Minimale `+page.svelte` für erste Build-Verifikation**
+
+Ersetze Inhalt von `app/src/routes/+page.svelte` durch:
+
+```svelte
+SvelteKit-SPA bootet
+Wird Stück für Stück mit Nostr-Funktionalität gefüllt.
+```
+
+- [ ] **Step 1.6: Build testen**
+
+```sh
+cd app
+npm run build
+```
+
+Expected: `build/index.html` existiert, keine Fehler.
+
+- [ ] **Step 1.7: `app/.gitignore` setzen**
+
+Create oder überschreibe `app/.gitignore`:
+
+```
+node_modules/
+build/
+.svelte-kit/
+package-lock.json
+.env
+.env.local
+*.log
+```
+
+Hinweis: `package-lock.json` wird bewusst nicht committed, weil die Repo-weite Policy das so handhabt. Wenn die Policy später ändert, diese Zeile entfernen.
+
+- [ ] **Step 1.8: Commit**
+
+```sh
+cd /Users/joerglohrer/repositories/joerglohrerde
+git add app/
+git commit -m "spa: sveltekit-skeleton mit adapter-static initialisiert"
+```
+
+---
+
+### Task 2: Dependencies installieren und Aliases konfigurieren
+
+**Files:**
+- Modify: `app/package.json`
+- Modify: `app/tsconfig.json`
+
+- [ ] **Step 2.1: Runtime-Dependencies installieren**
+
+```sh
+cd app
+npm install \
+ applesauce-core \
+ applesauce-relay \
+ applesauce-loaders \
+ applesauce-signers \
+ nostr-tools \
+ marked \
+ dompurify \
+ highlight.js \
+ rxjs
+```
+
+- [ ] **Step 2.2: Dev-Dependencies installieren (Tests)**
+
+```sh
+cd app
+npm install --save-dev vitest @playwright/test @testing-library/svelte jsdom
+```
+
+- [ ] **Step 2.3: Type-Definitionen für DOMPurify**
+
+```sh
+cd app
+npm install --save-dev @types/dompurify
+```
+
+- [ ] **Step 2.4: Vitest-Konfiguration erweitern in `vite.config.ts`**
+
+Ersetze Inhalt von `app/vite.config.ts` durch:
+
+```ts
+import { sveltekit } from '@sveltejs/kit/vite';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ plugins: [sveltekit()],
+ test: {
+ include: ['tests/unit/**/*.{test,spec}.{js,ts}'],
+ environment: 'jsdom',
+ globals: true,
+ },
+});
+```
+
+- [ ] **Step 2.5: npm-Scripts ergänzen in `package.json`**
+
+Öffne `app/package.json`, ergänze im `"scripts"`-Objekt:
+
+```json
+"test:unit": "vitest run",
+"test:e2e": "playwright test",
+"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
+"deploy:svelte": "../scripts/deploy-svelte.sh"
+```
+
+(Die `build`, `preview`, `dev` Scripts erzeugt SvelteKit initial — nur ergänzen.)
+
+- [ ] **Step 2.6: Commit**
+
+```sh
+cd /Users/joerglohrer/repositories/joerglohrerde
+git add app/
+git commit -m "spa: runtime- und dev-dependencies installiert"
+```
+
+---
+
+### Task 3: Konfigurations-Modul `config.ts`
+
+**Files:**
+- Create: `app/src/lib/nostr/config.ts`
+
+- [ ] **Step 3.1: Config-Konstanten anlegen**
+
+Create `app/src/lib/nostr/config.ts`:
+
+```ts
+/**
+ * Nostr-Konfiguration der SPA.
+ *
+ * Wichtig: Der AUTHOR_PUBKEY_HEX muss synchron zum tatsächlichen
+ * Autorenkonto sein (siehe docs/superpowers/specs/2026-04-15-nostr-page-design.md).
+ */
+
+/** npub1f7jar3qnu269uyx5p0e4v24hqxjnxysxudvujza2ur5ehltvdeqsly2fx9 in hex */
+export const AUTHOR_PUBKEY_HEX =
+ '4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41';
+
+/** Bootstrap-Relay für das initiale Lesen von kind:10002 */
+export const BOOTSTRAP_RELAY = 'wss://relay.damus.io';
+
+/** Fallback, falls kind:10002 nicht geladen werden kann */
+export const FALLBACK_READ_RELAYS = [
+ 'wss://relay.damus.io',
+ 'wss://nos.lol',
+ 'wss://relay.primal.net',
+ 'wss://relay.tchncs.de',
+ 'wss://relay.edufeed.org',
+];
+
+/** Habla.news-Deep-Link-Basis (für Nutzer ohne JS oder wenn Events fehlen) */
+export const HABLA_BASE = 'https://habla.news/a/';
+
+/** Timeout-Werte in ms */
+export const RELAY_TIMEOUT_MS = 8000;
+export const RELAY_HARD_TIMEOUT_MS = 15000;
+```
+
+- [ ] **Step 3.2: Commit**
+
+```sh
+cd /Users/joerglohrer/repositories/joerglohrerde
+git add app/src/lib/nostr/config.ts
+git commit -m "spa: nostr-konfigurations-modul mit pubkey, bootstrap-relay, fallbacks"
+```
+
+---
+
+### Task 4: URL-Parsing-Modul mit TDD
+
+**Files:**
+- Create: `app/src/lib/url/legacy.ts`
+- Create: `app/tests/unit/legacy-url.test.ts`
+
+- [ ] **Step 4.1: Failing test für `parseLegacyUrl`**
+
+Create `app/tests/unit/legacy-url.test.ts`:
+
+```ts
+import { describe, expect, it } from 'vitest';
+import { parseLegacyUrl, canonicalPostPath } from '$lib/url/legacy';
+
+describe('parseLegacyUrl', () => {
+ it('extrahiert dtag aus der Hugo-URL-Form mit Trailing-Slash', () => {
+ expect(parseLegacyUrl('/2025/03/04/dezentrale-oep-oer.html/')).toBe(
+ 'dezentrale-oep-oer',
+ );
+ });
+
+ it('extrahiert dtag aus der Hugo-URL-Form ohne Trailing-Slash', () => {
+ expect(parseLegacyUrl('/2024/01/26/offenheit-das-wesentliche.html')).toBe(
+ 'offenheit-das-wesentliche',
+ );
+ });
+
+ it('returned null für die kanonische kurze Form', () => {
+ expect(parseLegacyUrl('/dezentrale-oep-oer/')).toBeNull();
+ });
+
+ it('returned null für leeren Pfad', () => {
+ expect(parseLegacyUrl('/')).toBeNull();
+ });
+
+ it('returned null für andere Strukturen', () => {
+ expect(parseLegacyUrl('/tag/OER/')).toBeNull();
+ expect(parseLegacyUrl('/some/random/path/')).toBeNull();
+ });
+
+ it('decodiert percent-encoded dtags', () => {
+ expect(parseLegacyUrl('/2024/05/12/mit%20leerzeichen.html/')).toBe(
+ 'mit leerzeichen',
+ );
+ });
+});
+
+describe('canonicalPostPath', () => {
+ it('erzeugt // mit encodeURIComponent', () => {
+ expect(canonicalPostPath('dezentrale-oep-oer')).toBe('/dezentrale-oep-oer/');
+ });
+
+ it('kodiert Sonderzeichen', () => {
+ expect(canonicalPostPath('mit leerzeichen')).toBe('/mit%20leerzeichen/');
+ });
+});
+```
+
+- [ ] **Step 4.2: Test ausführen, erwarte FAIL**
+
+```sh
+cd app
+npm run test:unit -- legacy-url
+```
+
+Expected: Import-Fehler, weil `$lib/url/legacy` nicht existiert.
+
+- [ ] **Step 4.3: Implementation**
+
+Create `app/src/lib/url/legacy.ts`:
+
+```ts
+/**
+ * Erkennt Legacy-Hugo-URLs der Form /YYYY/MM/DD/.html oder .../.html/
+ * und gibt den dtag-Teil zurück. Für alle anderen Pfade: null.
+ */
+export function parseLegacyUrl(path: string): string | null {
+ const match = path.match(/^\/\d{4}\/\d{2}\/\d{2}\/([^/]+?)\.html\/?$/);
+ if (!match) return null;
+ return decodeURIComponent(match[1]);
+}
+
+/**
+ * Erzeugt die kanonische kurze Post-URL //.
+ */
+export function canonicalPostPath(dtag: string): string {
+ return `/${encodeURIComponent(dtag)}/`;
+}
+```
+
+- [ ] **Step 4.4: Test ausführen, erwarte PASS**
+
+```sh
+cd app
+npm run test:unit -- legacy-url
+```
+
+Expected: alle 8 Tests grün.
+
+- [ ] **Step 4.5: Commit**
+
+```sh
+cd /Users/joerglohrer/repositories/joerglohrerde
+git add app/src/lib/url/legacy.ts app/tests/unit/legacy-url.test.ts
+git commit -m "spa: url-parser für legacy-hugo-urls (tdd)"
+```
+
+---
+
+### Task 5: naddr-Encoder-Modul mit TDD
+
+**Files:**
+- Create: `app/src/lib/nostr/naddr.ts`
+- Create: `app/tests/unit/naddr.test.ts`
+
+- [ ] **Step 5.1: Failing test**
+
+Create `app/tests/unit/naddr.test.ts`:
+
+```ts
+import { describe, expect, it } from 'vitest';
+import { buildHablaLink } from '$lib/nostr/naddr';
+
+describe('buildHablaLink', () => {
+ it('erzeugt einen habla.news/a/-Link mit naddr1-Bech32', () => {
+ const link = buildHablaLink({
+ pubkey: '4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41',
+ kind: 30023,
+ identifier: 'dezentrale-oep-oer',
+ relays: ['wss://relay.damus.io'],
+ });
+ expect(link).toMatch(/^https:\/\/habla\.news\/a\/naddr1[a-z0-9]+$/);
+ });
+
+ it('ist deterministisch für gleiche Inputs', () => {
+ const args = {
+ pubkey: '4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41',
+ kind: 30023,
+ identifier: 'foo',
+ relays: ['wss://relay.damus.io'],
+ };
+ expect(buildHablaLink(args)).toBe(buildHablaLink(args));
+ });
+});
+```
+
+- [ ] **Step 5.2: Test ausführen (FAIL)**
+
+```sh
+cd app
+npm run test:unit -- naddr
+```
+
+- [ ] **Step 5.3: Implementation**
+
+Create `app/src/lib/nostr/naddr.ts`:
+
+```ts
+import { nip19 } from 'nostr-tools';
+import { HABLA_BASE } from './config';
+
+export interface NaddrArgs {
+ pubkey: string;
+ kind: number;
+ identifier: string;
+ relays?: string[];
+}
+
+export function buildNaddr(args: NaddrArgs): string {
+ return nip19.naddrEncode({
+ pubkey: args.pubkey,
+ kind: args.kind,
+ identifier: args.identifier,
+ relays: args.relays ?? [],
+ });
+}
+
+export function buildHablaLink(args: NaddrArgs): string {
+ return `${HABLA_BASE}${buildNaddr(args)}`;
+}
+```
+
+- [ ] **Step 5.4: Test ausführen (PASS)**
+
+```sh
+cd app
+npm run test:unit -- naddr
+```
+
+- [ ] **Step 5.5: Commit**
+
+```sh
+cd /Users/joerglohrer/repositories/joerglohrerde
+git add app/src/lib/nostr/naddr.ts app/tests/unit/naddr.test.ts
+git commit -m "spa: naddr/habla-link-helper (tdd)"
+```
+
+---
+
+### Task 6: Deploy-Script für svelte.joerg-lohrer.de
+
+**Files:**
+- Create: `scripts/deploy-svelte.sh`
+- Create: `scripts/README.md`
+
+- [ ] **Step 6.1: Deploy-Script anlegen**
+
+Create `scripts/deploy-svelte.sh`:
+
+```bash
+#!/usr/bin/env bash
+# Deploy: SvelteKit-Build nach svelte.joerg-lohrer.de per FTPS.
+# Credentials kommen aus ./.env.local (gitignored), Variablen-Prefix SVELTE_FTP_.
+
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT"
+
+if [ ! -f .env.local ]; then
+ echo "FEHLER: .env.local fehlt — Credentials ergänzen (siehe .env.example)." >&2
+ exit 1
+fi
+
+# nur SVELTE_FTP_* exportieren
+set -a
+# shellcheck disable=SC1090
+. <(grep -E '^SVELTE_FTP_' .env.local)
+set +a
+
+for v in SVELTE_FTP_HOST SVELTE_FTP_USER SVELTE_FTP_PASS SVELTE_FTP_REMOTE_PATH; do
+ if [ -z "${!v:-}" ]; then
+ echo "FEHLER: $v fehlt in .env.local." >&2
+ exit 1
+ fi
+done
+
+BUILD_DIR="$ROOT/app/build"
+if [ ! -d "$BUILD_DIR" ]; then
+ echo "FEHLER: app/build nicht vorhanden. Bitte vorher 'npm run build' in app/ ausführen." >&2
+ exit 1
+fi
+
+echo "Lade Build von $BUILD_DIR nach ftp://$SVELTE_FTP_HOST$SVELTE_FTP_REMOTE_PATH"
+
+# pro Datei ein curl-Upload (zuverlässig auf macOS ohne lftp)
+find "$BUILD_DIR" -type f -print0 | while IFS= read -r -d '' local_file; do
+ rel="${local_file#$BUILD_DIR/}"
+ remote="ftp://$SVELTE_FTP_HOST${SVELTE_FTP_REMOTE_PATH%/}/$rel"
+ echo " → $rel"
+ curl -sSf --ssl-reqd --ftp-create-dirs \
+ --user "$SVELTE_FTP_USER:$SVELTE_FTP_PASS" \
+ -T "$local_file" "$remote"
+done
+
+echo "Upload fertig. Live-Check:"
+curl -sIL "https://svelte.joerg-lohrer.de/" | head -5
+```
+
+- [ ] **Step 6.2: Script ausführbar machen**
+
+```sh
+chmod +x scripts/deploy-svelte.sh
+```
+
+- [ ] **Step 6.3: Scripts-README anlegen**
+
+Create `scripts/README.md`:
+
+```markdown
+# Scripts
+
+- **`deploy-svelte.sh`** — deployed den SvelteKit-Build aus `app/build/` nach
+ `svelte.joerg-lohrer.de` via FTPS. Benötigt `.env.local` im Repo-Root mit
+ den Variablen `SVELTE_FTP_HOST`, `SVELTE_FTP_USER`, `SVELTE_FTP_PASS`,
+ `SVELTE_FTP_REMOTE_PATH`. Aufruf:
+
+ ```sh
+ cd app && npm run build && cd .. && ./scripts/deploy-svelte.sh
+ ```
+```
+
+- [ ] **Step 6.4: Test-Deploy mit dem Minimal-Build aus Task 1**
+
+Voraussetzung: `SVELTE_FTP_*` in `.env.local` ist ausgefüllt und `svelte.joerg-lohrer.de` hat SSL aktiv.
+
+```sh
+cd app && npm run build && cd ..
+./scripts/deploy-svelte.sh
+```
+
+Expected: Upload läuft durch, `curl -sI https://svelte.joerg-lohrer.de/` liefert HTTP 200 und Inhalt zeigt „SvelteKit-SPA bootet".
+
+- [ ] **Step 6.5: `.htaccess` für SPA-Fallback anlegen (analog zu Mini-Spike)**
+
+Create `app/static/.htaccess`:
+
+```apache
+RewriteEngine On
+
+# HTTPS forcieren
+RewriteCond %{HTTPS} !=on
+RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
+
+# Existierende Datei oder Verzeichnis? Direkt ausliefern.
+RewriteCond %{REQUEST_FILENAME} -f [OR]
+RewriteCond %{REQUEST_FILENAME} -d
+RewriteRule ^ - [L]
+
+# Alles andere → SPA-Fallback (SvelteKit mit adapter-static)
+RewriteRule ^ /index.html [L]
+```
+
+Rebuild und redeploy:
+
+```sh
+cd app && npm run build && cd ..
+./scripts/deploy-svelte.sh
+```
+
+- [ ] **Step 6.6: Commit**
+
+```sh
+cd /Users/joerglohrer/repositories/joerglohrerde
+git add scripts/ app/static/.htaccess
+git commit -m "spa: deploy-script und htaccess für svelte.joerg-lohrer.de"
+```
+
+---
+
+## Phase 2 — Datenebene (Tasks 7–14)
+
+### Task 7: Markdown-Renderer mit TDD
+
+**Files:**
+- Create: `app/src/lib/render/markdown.ts`
+- Create: `app/tests/unit/markdown.test.ts`
+
+- [ ] **Step 7.1: Failing test**
+
+Create `app/tests/unit/markdown.test.ts`:
+
+```ts
+import { describe, expect, it } from 'vitest';
+import { renderMarkdown } from '$lib/render/markdown';
+
+describe('renderMarkdown', () => {
+ it('rendert einfachen Markdown-Text zu HTML', () => {
+ const html = renderMarkdown('**bold** and *italic*');
+ expect(html).toContain('bold ');
+ expect(html).toContain('italic ');
+ });
+
+ it('entfernt world');
+ expect(html).not.toContain('
+
+{#if loading && !error}
+ Lade von Nostr-Relays …
+{:else if error}
+
+ {error}
+ {#if hablaLink}
+
+
+ In Habla.news öffnen
+
+ {/if}
+
+{/if}
+
+
+```
+
+- [ ] **Step 15.2: Commit**
+
+```sh
+cd /Users/joerglohrer/repositories/joerglohrerde
+git add app/src/lib/components/LoadingOrError.svelte
+git commit -m "spa: loading-or-error-komponente"
+```
+
+---
+
+### Task 16: Globales Styling im `app.html`
+
+**Files:**
+- Modify: `app/src/app.html`
+
+- [ ] **Step 16.1: app.html mit CSS-Variablen und Base-Layout**
+
+Ersetze Inhalt von `app/src/app.html` durch:
+
+```html
+
+
+
+
+
+
+
+ Jörg Lohrer
+
+ %sveltekit.head%
+
+
+ %sveltekit.body%
+
+
+```
+
+- [ ] **Step 16.2: Commit**
+
+```sh
+cd /Users/joerglohrer/repositories/joerglohrerde
+git add app/src/app.html
+git commit -m "spa: globales styling mit css-variablen im app.html"
+```
+
+---
+
+### Task 17: `+layout.svelte` mit Container und Bootstrap
+
+**Files:**
+- Create: `app/src/routes/+layout.svelte`
+
+- [ ] **Step 17.1: Layout-Komponente mit Relay-Bootstrap**
+
+Create `app/src/routes/+layout.svelte`:
+
+```svelte
+
+
+
+ {@render children()}
+
+
+
+```
+
+- [ ] **Step 17.2: Commit**
+
+```sh
+cd /Users/joerglohrer/repositories/joerglohrerde
+git add app/src/routes/+layout.svelte
+git commit -m "spa: layout mit container und relay-bootstrap"
+```
+
+---
+
+### Task 18: `ProfileCard.svelte`
+
+**Files:**
+- Create: `app/src/lib/components/ProfileCard.svelte`
+
+- [ ] **Step 18.1: Komponente anlegen**
+
+Create `app/src/lib/components/ProfileCard.svelte`:
+
+```svelte
+
+
+{#if profile}
+
+ {#if profile.picture}
+
+ {:else}
+
+ {/if}
+
+
{profile.display_name ?? profile.name ?? ''}
+ {#if profile.about}
+
{profile.about}
+ {/if}
+ {#if profile.nip05 || profile.website}
+
+ {/if}
+
+
+{/if}
+
+
+```
+
+- [ ] **Step 18.2: Commit**
+
+```sh
+cd /Users/joerglohrer/repositories/joerglohrerde
+git add app/src/lib/components/ProfileCard.svelte
+git commit -m "spa: profile-card komponente"
+```
+
+---
+
+### Task 19: `PostCard.svelte` (Listenelement)
+
+**Files:**
+- Create: `app/src/lib/components/PostCard.svelte`
+
+- [ ] **Step 19.1: Komponente anlegen**
+
+Create `app/src/lib/components/PostCard.svelte`:
+
+```svelte
+
+
+
+
+
+
{date}
+
{title}
+ {#if summary}
{summary}
{/if}
+
+
+
+
+```
+
+- [ ] **Step 19.2: Commit**
+
+```sh
+cd /Users/joerglohrer/repositories/joerglohrerde
+git add app/src/lib/components/PostCard.svelte
+git commit -m "spa: post-card listenelement"
+```
+
+---
+
+### Task 20: Home-Page `+page.svelte`
+
+**Files:**
+- Modify: `app/src/routes/+page.svelte`
+
+- [ ] **Step 20.1: Home-Page mit Profil + Liste**
+
+Ersetze Inhalt von `app/src/routes/+page.svelte` durch:
+
+```svelte
+
+
+
+
+Beiträge
+
+
+
+{#each posts as post (post.id)}
+
+{/each}
+
+
+```
+
+- [ ] **Step 20.2: Lokal testen**
+
+```sh
+cd app
+npm run dev
+```
+
+Öffne `http://localhost:5173/`. Erwartung: Profilkachel oben, „Beiträge"-Überschrift, Liste der publizierten Posts mit Thumbnails.
+
+- [ ] **Step 20.3: Commit**
+
+```sh
+cd /Users/joerglohrer/repositories/joerglohrerde
+git add app/src/routes/+page.svelte
+git commit -m "spa: home-page mit profil und beitragsliste"
+```
+
+---
+
+### Task 21: `PostView.svelte` Komponente
+
+**Files:**
+- Create: `app/src/lib/components/PostView.svelte`
+
+- [ ] **Step 21.1: Komponente anlegen**
+
+Create `app/src/lib/components/PostView.svelte`:
+
+```svelte
+
+
+{title}
+
+
+{#if image}
+
+{/if}
+
+{#if summary}
+ {summary}
+{/if}
+
+{@html bodyHtml}
+
+
+```
+
+- [ ] **Step 21.2: Commit**
+
+```sh
+cd /Users/joerglohrer/repositories/joerglohrerde
+git add app/src/lib/components/PostView.svelte
+git commit -m "spa: post-view komponente mit markdown-rendering"
+```
+
+---
+
+### Task 22: Catch-all-Route `[...slug]/+page.svelte` mit Legacy-Normalisierung
+
+**Files:**
+- Create: `app/src/routes/[...slug]/+page.svelte`
+- Create: `app/src/routes/[...slug]/+page.ts`
+
+- [ ] **Step 22.1: Route-Load-Funktion**
+
+Create `app/src/routes/[...slug]/+page.ts`:
+
+```ts
+import { error, redirect } from '@sveltejs/kit';
+import { parseLegacyUrl, canonicalPostPath } from '$lib/url/legacy';
+import type { PageLoad } from './$types';
+
+export const load: PageLoad = async ({ url }) => {
+ const pathname = url.pathname;
+
+ // Legacy-Form /YYYY/MM/DD/.html/ → Redirect auf //
+ const legacyDtag = parseLegacyUrl(pathname);
+ if (legacyDtag) {
+ throw redirect(301, canonicalPostPath(legacyDtag));
+ }
+
+ // Kanonisch: // — erster Segment des Pfades.
+ const segments = pathname.replace(/^\/+|\/+$/g, '').split('/');
+ if (segments.length !== 1 || !segments[0]) {
+ throw error(404, 'Seite nicht gefunden');
+ }
+
+ return { dtag: decodeURIComponent(segments[0]) };
+};
+```
+
+- [ ] **Step 22.2: PostView-Seite**
+
+Create `app/src/routes/[...slug]/+page.svelte`:
+
+```svelte
+
+
+← Zurück zur Übersicht
+
+
+
+{#if post}
+
+{/if}
+
+
+```
+
+- [ ] **Step 22.3: Lokal testen**
+
+```sh
+cd app
+npm run dev
+```
+
+Öffne `http://localhost:5173/dezentrale-oep-oer/`. Erwartung: Post rendert. Breadcrumb „← Zurück zur Übersicht" funktioniert. Teste auch `http://localhost:5173/2025/03/04/dezentrale-oep-oer.html/` — Browser sollte auf `/dezentrale-oep-oer/` umleiten und Post zeigen.
+
+- [ ] **Step 22.4: Commit**
+
+```sh
+cd /Users/joerglohrer/repositories/joerglohrerde
+git add app/src/routes/
+git commit -m "spa: catch-all-route mit legacy-redirect und postview"
+```
+
+---
+
+## Phase 4 — Tag-Navigation (Tasks 23–25)
+
+### Task 23: Tag-Filter-Loader
+
+**Files:**
+- Modify: `app/src/lib/nostr/loaders.ts` (ergänzen)
+
+- [ ] **Step 23.1: `loadPostsByTag` hinzufügen**
+
+Am Ende von `app/src/lib/nostr/loaders.ts` anhängen:
+
+```ts
+/**
+ * Filtert Post-Liste clientseitig nach Tag-Name.
+ * (Relay-seitige #t-Filter werden nicht von allen Relays unterstützt — safer
+ * ist es, die ganze Liste zu laden und lokal zu filtern.)
+ */
+export async function loadPostsByTag(tagName: string): Promise {
+ const all = await loadPostList();
+ const norm = tagName.toLowerCase();
+ return all.filter((ev) =>
+ ev.tags.some((t) => t[0] === 't' && t[1]?.toLowerCase() === norm),
+ );
+}
+```
+
+- [ ] **Step 23.2: Commit**
+
+```sh
+cd /Users/joerglohrer/repositories/joerglohrerde
+git add app/src/lib/nostr/loaders.ts
+git commit -m "spa: tag-filter-loader (case-insensitive, client-side)"
+```
+
+---
+
+### Task 24: Tag-Seite
+
+**Files:**
+- Create: `app/src/routes/tag/[name]/+page.ts`
+- Create: `app/src/routes/tag/[name]/+page.svelte`
+
+- [ ] **Step 24.1: Load-Funktion**
+
+Create `app/src/routes/tag/[name]/+page.ts`:
+
+```ts
+import type { PageLoad } from './$types';
+
+export const load: PageLoad = async ({ params }) => {
+ return { tagName: decodeURIComponent(params.name) };
+};
+```
+
+- [ ] **Step 24.2: Seite**
+
+Create `app/src/routes/tag/[name]/+page.svelte`:
+
+```svelte
+
+
+← Zurück zur Übersicht
+
+#{tagName}
+
+
+
+{#each posts as post (post.id)}
+
+{/each}
+
+
+```
+
+- [ ] **Step 24.3: Lokal testen**
+
+```sh
+cd app
+npm run dev
+```
+
+Öffne `http://localhost:5173/tag/OER/`. Erwartung: Liste aller Posts mit `t`-Tag „OER".
+
+- [ ] **Step 24.4: Commit**
+
+```sh
+cd /Users/joerglohrer/repositories/joerglohrerde
+git add app/src/routes/tag/
+git commit -m "spa: tag-filter-seite"
+```
+
+---
+
+### Task 25: Tag-Link-Sicherheit fixen
+
+**Files:**
+- Modify: `app/src/lib/components/PostView.svelte:63-65`
+
+Hintergrund: in Task 21 wurde `href="/tag/{encodeURIComponent(t)}/"` schon gesetzt — sollte passen. Verifizieren.
+
+- [ ] **Step 25.1: Verifizieren**
+
+Öffne `http://localhost:5173/dezentrale-oep-oer/`. Klick auf Tag „OER". Erwartung: Navigation zu `/tag/OER/`, Liste wird gefiltert.
+
+- [ ] **Step 25.2: Keine Änderung nötig, Verifikations-Step. Commit wird übersprungen, wenn nichts geändert wurde.**
+
+---
+
+## Phase 5 — Reactions & NIP-07-Kommentare (Tasks 26–32)
+
+### Task 26: `Reactions.svelte`
+
+**Files:**
+- Create: `app/src/lib/components/Reactions.svelte`
+
+- [ ] **Step 26.1: Komponente anlegen**
+
+Create `app/src/lib/components/Reactions.svelte`:
+
+```svelte
+
+
+{#if reactions.length > 0}
+
+ {#each reactions as r}
+
+ {displayChar(r.content)}
+ {r.count}
+
+ {/each}
+
+{/if}
+
+
+```
+
+- [ ] **Step 26.2: In PostView einbinden**
+
+Modify `app/src/lib/components/PostView.svelte` — am Anfang des `
+
+
+
+ {authorNpub}
+ ·
+ {date}
+
+ {event.content}
+
+
+
+```
+
+- [ ] **Step 27.2: Commit**
+
+```sh
+cd /Users/joerglohrer/repositories/joerglohrerde
+git add app/src/lib/components/ReplyItem.svelte
+git commit -m "spa: reply-item komponente"
+```
+
+---
+
+### Task 28: `ReplyList.svelte`
+
+**Files:**
+- Create: `app/src/lib/components/ReplyList.svelte`
+
+- [ ] **Step 28.1: Komponente anlegen**
+
+Create `app/src/lib/components/ReplyList.svelte`:
+
+```svelte
+
+
+
+ Kommentare ({replies.length})
+ {#if loading}
+ Lade Kommentare …
+ {:else if replies.length === 0}
+ Noch keine Kommentare.
+ {:else}
+
+ {#each replies as reply (reply.id)}
+
+ {/each}
+
+ {/if}
+
+
+
+```
+
+- [ ] **Step 28.2: Commit**
+
+```sh
+cd /Users/joerglohrer/repositories/joerglohrerde
+git add app/src/lib/components/ReplyList.svelte
+git commit -m "spa: reply-list komponente"
+```
+
+---
+
+### Task 29: `ReplyComposer.svelte`
+
+**Files:**
+- Create: `app/src/lib/components/ReplyComposer.svelte`
+
+- [ ] **Step 29.1: Komponente anlegen**
+
+Create `app/src/lib/components/ReplyComposer.svelte`:
+
+```svelte
+
+
+
+ {#if !nip07}
+
+ Um zu kommentieren, benötigst du eine Nostr-Extension
+ (Alby ,
+ nos2x ),
+ oder kommentiere direkt in einem Nostr-Client.
+
+ {:else}
+
+
+
+ {publishing ? 'Sende …' : 'Kommentar senden'}
+
+
+ {#if error}
{error}
{/if}
+ {#if info}
{info}
{/if}
+ {/if}
+
+
+
+```
+
+- [ ] **Step 29.2: Commit**
+
+```sh
+cd /Users/joerglohrer/repositories/joerglohrerde
+git add app/src/lib/components/ReplyComposer.svelte
+git commit -m "spa: reply-composer mit nip-07 signing"
+```
+
+---
+
+### Task 30: Replies und Composer in PostView integrieren
+
+**Files:**
+- Modify: `app/src/lib/components/PostView.svelte`
+- Modify: `app/src/routes/[...slug]/+page.svelte`
+
+- [ ] **Step 30.1: PostView um Replies + Composer erweitern**
+
+Modify `app/src/lib/components/PostView.svelte` — im `