Compare commits

..

9 Commits

Author SHA1 Message Date
Marcel
4bcf568ed4 Merge branch 'main' of ssh://git.raddatz.cloud:222/marcel/familienarchiv
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m22s
CI / OCR Service Tests (push) Successful in 23s
CI / Backend Unit Tests (push) Successful in 3m41s
CI / fail2ban Regex (push) Successful in 46s
CI / Semgrep Security Scan (push) Successful in 22s
CI / Compose Bucket Idempotency (push) Successful in 1m7s
nightly / deploy-staging (push) Successful in 2m38s
2026-06-08 16:27:41 +02:00
Marcel
ddb1ec4df8 docs(timeline): add Zeitstrahl visual specs (global Concept A, event editor)
Visual design specs for Milestone #14:
- zeitstrahl-global-concepts.html — A/B/C exploration of the global timeline
- zeitstrahl-final-spec.html — canonical Concept A (global + per-person Lebensweg)
- zeitstrahl-event-editor-spec.html — curator event editor + document quick-action

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 16:27:15 +02:00
d650b6c066 refactor(search): remove NLP/smart-search feature entirely (#772)
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m23s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 3m46s
CI / fail2ban Regex (push) Successful in 46s
CI / Semgrep Security Scan (push) Successful in 25s
CI / Compose Bucket Idempotency (push) Successful in 1m8s
## Summary

- Removes the NLP/smart-search feature completely — the feature was too unreliable and slow; users get better results with the regular search filters
- Deletes the entire backend `search/` package (NlSearchController, NlQueryParserService, NlpClient, NlSearchRateLimiter — 14 classes + 6 test classes)
- Deletes the `nlp-service/` Python microservice (FastAPI, rapidfuzz, DB-backed person matching)
- Removes all frontend NL search components: SmartModeToggle, SmartSearchStatus, InterpretationChipRow, DisambiguationPicker, chip-types, theme-chip-removal
- Strips smart-mode logic from SearchFilterBar and documents/+page.svelte
- Removes `SMART_SEARCH_UNAVAILABLE` / `SMART_SEARCH_RATE_LIMITED` error codes from backend, frontend types, and all three i18n files (de/en/es)
- Removes `nlp-service` container and `APP_NLP_BASE_URL` from both docker-compose files
- Removes Ollama/NLP Prometheus scrape job and Grafana dashboard
- Deletes ADRs 028 (×2), 034, 035

## Test plan

- [ ] Backend compiles: `cd backend && ./mvnw compile -q` → BUILD SUCCESS
- [ ] Frontend server tests pass: `cd frontend && npm run test -- --project=server`
- [ ] No NLP/smart-search references remain in source: `grep -r "SmartSearch\|NlSearch\|nlp-service\|SMART_SEARCH" backend/src frontend/src`
- [ ] `docker compose config` validates both compose files
- [ ] Search page loads, filter bar works, no smart-mode toggle visible

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Marcel <marcel@familienarchiv>
Reviewed-on: #772
2026-06-08 10:57:00 +02:00
Marcel
e63eaadc33 docs(timeline): add Person date+precision migration as foundational issue
Replace Person birthYear/deathYear integers with birthDate/deathDate +
DatePrecision so known exact birthdays render precisely. Migration,
re-import preservation rule, and bounded blast radius captured; becomes
issue 1 the timeline's derived events depend on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 19:26:13 +02:00
Marcel
d4a25e34d8 docs(timeline): add family timeline (Zeitstrahl) design spec
Hand-curated, year-banded vertical timeline weaving derived person
life-events, curated personal/historical events, and date-placed
letters. Includes proposed sub-issue breakdown for a milestone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 19:18:55 +02:00
Marcel
8e63867ad8 docs(specs): UI specs for Lesereisen reader and Journey editor
All checks were successful
CI / Unit & Component Tests (push) Successful in 3m23s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 4m2s
CI / fail2ban Regex (push) Successful in 46s
CI / Semgrep Security Scan (push) Successful in 21s
CI / Compose Bucket Idempotency (push) Successful in 1m8s
nightly / deploy-staging (push) Successful in 2m44s
lesereisen-reader-spec.html — Issue #752
  LR-0 type selector on /geschichten/new
  LR-1 REISE badge on the list
  LR-2 Journey reader (ordered cards, interlude asides, no position numbers)

lesereisen-editor-spec.html — Issue #753
  LE-1 empty JourneyEditor layout
  LE-2 editor with mixed items (documents + interludes, drag handles)
  LE-3 inline note-editing state
  LE-4 mobile layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 19:07:34 +02:00
Marcel
6b0a06e8b1 feat(nlp-service): scaffold — models, requirements, CLAUDE.md
Task 1: Create standalone FastAPI service scaffold with models, test framework,
and documentation. Includes ParseRequest, ParseResponse Pydantic models matching
OllamaExtraction contract, plus three passing tests validating model validation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 10:11:34 +02:00
Marcel
7c1eef710c docs(nlp): add spaCy NLP service implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 09:52:07 +02:00
Marcel
03e22a2f26 docs(nlp): add spaCy NLP service prototype design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 09:40:00 +02:00
8 changed files with 5032 additions and 0 deletions

View File

@@ -0,0 +1,808 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Lesereisen — Journey-Editor · Familienarchiv</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<style>
:root{--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;--navy:#012851;--mint:#A1DCD8;--sand:#F0EFE9;--turquoise:#00C7B1;--blue-tint:#E6F1FB;--blue:#2D7DD2;--blue-dark:#185FA5;--green-tint:#E8F5EA;--green:#3D8C4A;--green-dark:#2E6E39;--orange-tint:#FEF0E6;--orange:#E8862A;--orange-dark:#B46820;--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;--shadow-card:0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);--shadow-raised:0 4px 12px rgba(28,28,24,.08),0 2px 4px rgba(28,28,24,.04);--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
body{font-family:var(--font-sans);background:#E8E7E2;color:var(--color-text);font-size:14px;line-height:1.6;}
.doc{max-width:1200px;margin:0 auto;padding:48px 40px 120px;}
.doc-header{display:flex;justify-content:space-between;align-items:flex-end;padding-bottom:28px;border-bottom:1px solid var(--color-border);margin-bottom:48px;background:var(--color-page);margin:-48px -40px 48px;padding:48px 40px 28px;border-radius:var(--radius-xl) var(--radius-xl) 0 0;}
.doc-header h1{font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
.doc-header p{font-size:13px;color:var(--color-text-muted);max-width:680px;}
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;}
.pill-o{background:var(--orange-tint);color:var(--orange-dark);}
.section{margin-bottom:64px;}
.section-title{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:24px;}
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.65;max-width:720px;margin-bottom:20px;}
.jh{padding:20px 24px;border-radius:var(--radius-xl);margin-bottom:40px;display:flex;align-items:center;gap:16px;}
.jh .jn{font-family:var(--font-display);font-size:48px;font-weight:300;line-height:1;opacity:.5;}
.jh h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
.jh p{font-size:13px;line-height:1.5;}.jh .fl{font-family:var(--font-mono);font-size:11px;margin-top:6px;opacity:.7;}
.jh-o{background:var(--orange-tint);border:1px solid #F0C99A;}
.jh-o .jn{color:var(--orange);}
.jh-o p,.jh-o .fl{color:var(--orange-dark);}
.scr{margin-bottom:56px;}
.scr-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;}
.scr-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
.scr-id{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);padding:2px 8px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-page);}
.scr-desc{font-size:12px;color:var(--color-text-muted);line-height:1.6;max-width:720px;margin-bottom:6px;}
.scr-var{font-size:11px;color:var(--color-text-muted);margin-bottom:20px;}.scr-var strong{color:var(--color-text);}
.previews{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;align-items:flex-start;margin-bottom:20px;}
.prev-col{display:flex;flex-direction:column;align-items:center;gap:10px;}
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
.desk{width:100%;max-width:1040px;background:var(--color-page);border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.06);display:flex;flex-direction:column;}
.phone{width:320px;flex-shrink:0;background:var(--color-page);border-radius:36px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.07);display:flex;flex-direction:column;border:6px solid #1C1C18;}
.pst{padding:10px 20px 0;display:flex;justify-content:space-between;align-items:center;font-size:12px;background:var(--color-page);}.pst b{font-weight:600;}.pst span{font-size:10px;}
.pb{flex:1;overflow-y:auto;display:flex;flex-direction:column;}
.fa-nav{height:32px;background:var(--navy);display:flex;align-items:center;padding:0 12px;gap:8px;flex-shrink:0;}
.fa-logo{font-size:7px;font-weight:900;color:#fff;letter-spacing:.8px;border-bottom:2px solid var(--mint);padding-bottom:1px;}
.fa-link{font-size:5.5px;color:rgba(255,255,255,.4);font-weight:700;text-transform:uppercase;letter-spacing:.05em;}
.fa-link.active{color:var(--mint);}
.fa-nav-r{margin-left:auto;display:flex;gap:5px;align-items:center;}
.fa-av{width:16px;height:16px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:rgba(255,255,255,.5);}
.m-nav{height:26px;background:var(--navy);display:flex;align-items:center;padding:0 10px;gap:6px;flex-shrink:0;}
.m-logo{font-size:6px;font-weight:900;color:#fff;letter-spacing:.7px;border-bottom:1.5px solid var(--mint);padding-bottom:1px;}
.m-nav-r{margin-left:auto;display:flex;gap:4px;align-items:center;}
.m-av{width:14px;height:14px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:rgba(255,255,255,.5);}
.m-ham{display:flex;flex-direction:column;gap:2px;width:12px;}
.m-ham span{height:1.5px;background:rgba(255,255,255,.6);border-radius:1px;}
/* ── impl-ref table ── */
.agent{background:var(--color-text);color:#E8E8E2;padding:24px;border-radius:var(--radius-lg);margin-top:20px;}
.agent h4{font-size:9px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:#5A5A55;margin-bottom:12px;}
.at{width:100%;border-collapse:collapse;font-family:var(--font-mono);font-size:10px;}
.at thead tr{border-bottom:1px solid #2A2A26;}
.at th{text-align:left;padding:6px 10px;font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#5A5A55;font-family:var(--font-sans);}
.at td{padding:5px 10px;border-bottom:1px solid #1E1E1A;vertical-align:top;line-height:1.5;}
.at tr:last-child td{border-bottom:none;}
.at td:first-child{color:#7A7A72;}
.at td:nth-child(2){color:#E8E8E2;font-weight:500;}
.at td:nth-child(3){color:#5A5A55;}
.at .grp td{padding-top:14px;font-family:var(--font-sans);font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#3A3A36;}
/* ── LLM guide ── */
.llm{background:var(--color-page);border:2px solid var(--navy);border-radius:var(--radius-xl);padding:32px 40px;margin-top:64px;}
.llm h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:8px;color:var(--navy);}
.llm h3{font-size:14px;font-weight:600;margin:20px 0 8px;}
.llm h4{font-size:12px;font-weight:600;margin:14px 0 6px;color:var(--color-text-muted);}
.llm p,.llm li{font-size:13px;color:var(--color-text-muted);line-height:1.65;}
.llm ul,.llm ol{padding-left:20px;margin-bottom:12px;}
.llm li{margin-bottom:4px;}
.llm code{font-family:var(--font-mono);font-size:11px;background:var(--color-surface);padding:1px 5px;border-radius:3px;}
.llm table{width:100%;border-collapse:collapse;margin:12px 0;font-size:12px;}
.llm th,.llm td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--color-border);}
.llm th{font-weight:500;color:var(--color-text);font-size:11px;text-transform:uppercase;letter-spacing:.05em;}
.llm td{color:var(--color-text-muted);}
/* ── Editor chrome (shared with writer spec) ── */
.ed-topbar{background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:0 14px;gap:8px;height:38px;flex-shrink:0;}
.ed-back{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px;color:var(--color-text-muted);flex-shrink:0;}
.ed-title-label{font-family:var(--font-sans);font-size:10px;font-weight:500;color:var(--color-text);flex:1;}
.ed-status-pill{display:inline-flex;align-items:center;padding:2px 7px;border-radius:20px;font-size:8px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;flex-shrink:0;}
.ed-status-draft{background:#F0EFE9;color:#6B6A63;border:1px solid #D8D7D0;}
.ed-status-pub{background:var(--green-tint);color:var(--green-dark);border:1px solid #A0D8A8;}
.ed-delete-link{font-size:8px;font-weight:600;color:#DC4C3E;margin-left:8px;}
.ed-split{display:flex;flex:1;overflow:hidden;}
.ed-sidebar{width:210px;flex-shrink:0;border-left:1px solid #e4e2d7;background:#fff;display:flex;flex-direction:column;overflow-y:auto;}
.ed-sb-section{padding:12px 12px 10px;}
.ed-sb-title{font-size:8px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:8px;}
.ed-sb-divider{height:1px;background:#e4e2d7;}
.ed-search-row{display:flex;align-items:center;gap:6px;background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-sm);padding:4px 8px;margin-bottom:6px;}
.ed-search-input{font-size:9px;color:var(--color-text-muted);}
.ed-chip{display:inline-flex;align-items:center;gap:4px;padding:3px 7px;background:var(--sand);border:1px solid var(--color-border);border-radius:12px;font-size:8px;font-weight:500;color:var(--color-text);margin:0 4px 4px 0;}
.ed-chip-x{color:var(--color-text-muted);font-size:9px;cursor:pointer;margin-left:2px;}
.ed-hint{font-size:8px;color:var(--color-text-muted);line-height:1.5;margin-top:4px;}
.ed-savebar{background:#fff;border-top:1px solid #e4e2d7;padding:9px 14px;display:flex;align-items:center;justify-content:space-between;flex-shrink:0;gap:10px;}
.ed-savebar-hint{font-size:8px;color:var(--color-text-muted);}
.ed-savebar-actions{display:flex;align-items:center;gap:7px;}
.ed-btn-ghost{font-size:9px;font-weight:600;padding:5px 12px;border-radius:var(--radius-sm);border:1px solid var(--color-border);color:var(--color-text);background:#fff;cursor:pointer;white-space:nowrap;}
.ed-btn-ghost.retract{color:#B46820;border-color:#E8D5B0;}
.ed-btn-primary{font-size:9px;font-weight:600;padding:5px 12px;border-radius:var(--radius-sm);background:var(--navy);color:#fff;border:none;cursor:pointer;white-space:nowrap;}
/* ── Journey Editor main area ── */
.je-main{flex:1;display:flex;flex-direction:column;padding:14px 16px;overflow-y:auto;gap:8px;background:var(--color-page);}
.je-title-input{font-family:var(--font-display);font-size:15px;font-weight:400;color:var(--color-text);border:none;border-bottom:1px solid var(--color-border);padding:4px 0 6px;width:100%;outline:none;background:transparent;letter-spacing:-.01em;}
.je-title-input.placeholder{color:var(--color-text-muted);font-style:italic;}
.je-sep{height:1px;background:var(--color-border);margin:2px 0;}
.je-intro-area{font-family:Georgia,serif;font-size:9px;line-height:1.7;color:var(--color-text-muted);font-style:italic;border:none;padding:5px 0;width:100%;outline:none;background:transparent;min-height:36px;resize:none;}
.je-intro-label{font-size:7.5px;font-weight:600;letter-spacing:.07em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:2px;}
.je-list-label{font-size:7.5px;font-weight:600;letter-spacing:.07em;text-transform:uppercase;color:var(--color-text-muted);margin-bottom:5px;margin-top:4px;}
/* ── Item rows ── */
.je-item{display:flex;align-items:stretch;gap:0;background:#fff;border:1px solid #E4E2D7;border-radius:4px;margin-bottom:5px;overflow:hidden;}
.je-drag{width:16px;background:#F5F4EE;border-right:1px solid #E4E2D7;display:flex;align-items:center;justify-content:center;cursor:grab;flex-shrink:0;}
.je-drag-dots{display:flex;flex-direction:column;gap:2px;}
.je-drag-dot{width:3px;height:3px;border-radius:50%;background:#C4C3BC;}
.je-num{width:20px;display:flex;align-items:flex-start;justify-content:center;padding-top:8px;font-size:8px;font-weight:700;color:#9B9A93;flex-shrink:0;}
.je-body{flex:1;padding:7px 8px 7px 4px;}
.je-doc-title{font-size:9px;font-weight:600;color:var(--navy);line-height:1.3;margin-bottom:2px;}
.je-doc-meta{font-size:7.5px;color:var(--color-text-muted);margin-bottom:5px;}
.je-note-area{width:100%;min-height:32px;font-family:Georgia,serif;font-size:8px;line-height:1.55;color:var(--color-text);font-style:italic;border:1px solid var(--color-border);border-radius:3px;background:var(--color-surface);padding:4px 6px;resize:none;outline:none;}
.je-note-add{font-size:7.5px;font-weight:600;color:var(--blue);cursor:pointer;display:inline-flex;align-items:center;gap:2px;}
.je-remove{width:24px;display:flex;align-items:flex-start;justify-content:center;padding-top:7px;flex-shrink:0;}
.je-remove-x{font-size:11px;color:#C4C3BC;cursor:pointer;line-height:1;font-weight:300;}
.je-interlude-bg{background:var(--orange-tint);border-color:#F0C99A;}
.je-interlude-icon{font-size:8px;color:var(--orange);margin-bottom:2px;}
.je-interlude-area{width:100%;min-height:36px;font-family:Georgia,serif;font-size:8px;line-height:1.6;color:var(--color-text);font-style:italic;border:1px solid #F0C99A;border-radius:3px;background:rgba(255,255,255,.6);padding:4px 6px;resize:none;outline:none;}
.je-empty{padding:16px;text-align:center;border:1px dashed var(--color-border);border-radius:4px;background:var(--color-surface);}
.je-empty-text{font-family:Georgia,serif;font-size:8px;color:var(--color-text-muted);font-style:italic;}
/* ── Add bar ── */
.je-add-bar{display:flex;gap:7px;padding:6px 0 4px;}
.je-add-btn{font-size:8px;font-weight:600;padding:5px 10px;border-radius:3px;border:1px dashed var(--color-border);color:var(--color-text-muted);background:transparent;cursor:pointer;display:flex;align-items:center;gap:3px;}
.je-add-btn:hover{border-color:var(--navy);color:var(--navy);}
/* ── Inline note editing state (highlight) ── */
.je-note-editing{border-color:var(--navy);background:#fff;}
/* ── Mobile journey editor ── */
.mob-topbar{background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:0 10px;gap:6px;height:34px;flex-shrink:0;}
.mob-back{font-size:8px;color:var(--color-text-muted);}
.mob-label{font-family:var(--font-sans);font-size:9px;font-weight:500;color:var(--color-text);flex:1;}
.mob-body{flex:1;overflow-y:auto;padding:10px 12px;display:flex;flex-direction:column;gap:7px;background:var(--color-page);}
.mob-title-input{font-family:var(--font-display);font-size:13px;color:var(--color-text-muted);font-style:italic;border:none;border-bottom:1px solid var(--color-border);padding:3px 0 5px;width:100%;background:transparent;outline:none;}
.mob-collapsible{background:#fff;border:1px solid #e4e2d7;border-radius:3px;overflow:hidden;}
.mob-coll-hdr{display:flex;align-items:center;justify-content:space-between;padding:7px 9px;font-size:8.5px;font-weight:600;color:var(--color-text);}
.mob-coll-chevron{font-size:9px;color:var(--color-text-muted);}
.mob-savebar{background:#fff;border-top:1px solid #e4e2d7;padding:8px 10px;display:flex;gap:6px;flex-shrink:0;}
.mob-btn{font-size:8.5px;font-weight:600;padding:7px 0;border-radius:3px;text-align:center;flex:1;}
.mob-btn-ghost{border:1px solid var(--color-border);color:var(--color-text);background:#fff;}
.mob-btn-primary{background:var(--navy);color:#fff;border:none;}
.mob-je-item{display:flex;align-items:stretch;gap:0;background:#fff;border:1px solid #E4E2D7;border-radius:3px;margin-bottom:4px;overflow:hidden;}
.mob-je-drag{width:14px;background:#F5F4EE;border-right:1px solid #E4E2D7;display:flex;align-items:center;justify-content:center;}
.mob-je-body{flex:1;padding:6px 7px;}
.mob-je-title{font-size:8.5px;font-weight:600;color:var(--navy);line-height:1.3;margin-bottom:1px;}
.mob-je-meta{font-size:7px;color:var(--color-text-muted);}
.mob-je-note{margin-top:4px;padding:3px 5px;background:var(--color-surface);border-left:2px solid var(--mint);font-size:7.5px;font-style:italic;color:var(--color-text-muted);}
.mob-je-interlude{background:var(--orange-tint);border-color:#F0C99A;}
.mob-je-interlude-text{font-size:7.5px;font-style:italic;color:var(--color-text);}
@media(max-width:900px){.doc{padding:24px 16px 80px;}}
</style>
</head>
<body>
<div class="doc">
<!-- ═══ DOC HEADER ═══ -->
<div class="doc-header">
<div>
<h1>Lesereisen — Journey-Editor</h1>
<p>Kuratierungs-Oberfläche für <code>JourneyEditor</code> auf <code>/geschichten/[id]/edit</code> (wenn <code>type === 'JOURNEY'</code>). Geordnete Briefliste mit Drag-to-Reorder, Dokumenten-Picker, Interlude-Notizen und Inline-Annotation-Editing. Ersetzt den TipTap-Editor für den Journey-Typ.</p>
</div>
<div class="doc-meta">
Familienarchiv<br/>
<span class="pill pill-o">Final Spec</span><br/>
2026-06-07 &middot; @leonievoss<br/>
<span style="font-size:10px;margin-top:4px;display:inline-block;">Issue #753</span>
</div>
</div>
<!-- ═══ JOURNEY HEADER ═══ -->
<div class="jh jh-o">
<div class="jn">E</div>
<div>
<h2>Journey-Editor</h2>
<p>BLOG_WRITERs kuratieren eine geordnete Briefsequenz — Briefe hinzufügen, Zwischentexte einfügen, Reihenfolge per Drag anpassen, Notizen inline bearbeiten.</p>
<div class="fl">/geschichten/[id]/edit (type === 'JOURNEY')</div>
</div>
</div>
<!-- ═══ KONZEPT ═══ -->
<div class="section">
<div class="section-title">Konzept</div>
<p class="prose">Der <code>JourneyEditor</code> ist eine parallele Implementierung zum bestehenden <code>GeschichteEditor</code> und wird auf derselben Edit-Route eingeblendet wenn <code>type === 'JOURNEY'</code>. Das Split-Layout (70/30) bleibt erhalten: links die Briefliste, rechts die Sidebar mit Personen und Status.</p>
<p class="prose">Die linke Fläche zeigt: oben einen optionalen Einleitungs-Textarea (<code>body</code>), darunter die geordnete Itemliste, ganz unten eine Aktionsleiste mit „+ Brief hinzufügen" und „+ Zwischentext hinzufügen". Jedes Item hat einen Drag-Handle, eine Positionsnummer, den Inhalt und einen Entfernen-Button.</p>
<p class="prose">Dokument-Items zeigen Titel und Kurz-Metadaten. Eine „Notiz hinzufügen/bearbeiten"-Aktion expandiert ein Textarea direkt unterhalb des Items — kein Modal, kein separates Formular. Interlude-Items (reiner Zwischentext) zeigen direkt ein editierbares Textarea mit orangenem Hintergrund zur klaren visuellen Unterscheidung.</p>
<p class="prose">Speicheraktionen: Speichern (bei veröffentlichter Journey) oder „Entwurf speichern" + „Veröffentlichen" (bei DRAFT). Die Savebarlogik ist identisch zum GeschichteEditor. Alle Mutationen lösen sofort einen API-Call aus und aktualisieren den lokalen Zustand optimistisch — kein separates Save für einzelne Items.</p>
</div>
<!-- ═══ SCREEN LE-1: EMPTY EDITOR ═══ -->
<div class="section">
<div class="section-title">Screens — Leerer Editor</div>
<div class="scr">
<div class="scr-head">
<h3>LE-1 — Journey-Editor leer</h3>
<span class="scr-id">Issue #753 · LE-1</span>
</div>
<p class="scr-desc">Ausgangszustand einer neuen oder leeren Lesereise. Titel-Input oben. Darunter ein optionaler Einleitungs-Textarea. Leere Itemliste mit Leerstate-Text. Aktionsleiste mit zwei Buttons. Sidebar: Personen-Verknüpfung und Status-Anzeige. Keine Items → „Veröffentlichen" noch nicht aktiv (Disabled-Hint erscheint).</p>
<p class="scr-var"><strong>Varianten:</strong> Neuer Entwurf ohne Titel (hier gezeigt) · Mit Titel, leere Liste</p>
<div class="previews">
<div class="prev-col" style="width:100%;max-width:1040px;">
<span class="bp-lbl">Desktop — 1040px · Neuer Entwurf</span>
<div class="desk" style="min-height:500px;">
<div class="fa-nav">
<span class="fa-logo">ARCHIV</span>
<span style="width:1px;height:14px;background:rgba(255,255,255,.1);margin:0 2px;"></span>
<span class="fa-link">Dokumente</span>
<span class="fa-link">Personen</span>
<span class="fa-link active">Geschichten</span>
<span class="fa-link">Chronik</span>
<div class="fa-nav-r">
<div class="fa-av" style="background:#012851;color:var(--mint);font-size:5px;font-weight:800;">MR</div>
</div>
</div>
<div class="ed-topbar">
<div class="ed-back">&#8592;</div>
<div class="ed-title-label" style="display:flex;align-items:center;gap:6px;">
Neue Lesereise
<span style="font-size:7px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--orange-dark);background:var(--orange-tint);border:1px solid #F0C99A;padding:1px 5px;border-radius:3px;">REISE</span>
</div>
<div class="ed-status-pill ed-status-draft">ENTWURF</div>
</div>
<div class="ed-split">
<!-- Left: Journey editor area -->
<div class="je-main">
<input class="je-title-input placeholder" type="text" value="" placeholder="Titel der Lesereise" readonly/>
<div class="je-sep"></div>
<div>
<div class="je-intro-label">Einleitung (optional)</div>
<textarea class="je-intro-area" placeholder="Optionaler Einleitungstext für diese Lesereise…" readonly></textarea>
</div>
<div class="je-sep"></div>
<div class="je-list-label">Briefe &amp; Zwischentexte</div>
<div class="je-empty">
<div class="je-empty-text">Noch keine Einträge. Füge den ersten Brief oder einen Zwischentext hinzu.</div>
</div>
<div class="je-add-bar">
<button class="je-add-btn">
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
Brief hinzufügen
</button>
<button class="je-add-btn">
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
Zwischentext hinzufügen
</button>
</div>
</div>
<!-- Right: Sidebar -->
<div class="ed-sidebar">
<div class="ed-sb-section">
<div class="ed-sb-title">Personen</div>
<div class="ed-search-row">
<span style="font-size:9px;color:var(--color-text-muted);">&#128269;</span>
<div class="ed-search-input">Person suchen…</div>
</div>
<div class="ed-hint">Welche historischen Personen kommen in dieser Lesereise vor?</div>
</div>
<div class="ed-sb-divider"></div>
<div class="ed-sb-section">
<div class="ed-sb-title">Status</div>
<div class="ed-status-pill ed-status-draft" style="font-size:9px;">ENTWURF</div>
<div class="ed-hint" style="margin-top:6px;">Noch nicht öffentlich sichtbar. Füge mindestens einen Brief hinzu, um zu veröffentlichen.</div>
</div>
</div>
</div>
<div class="ed-savebar">
<span class="ed-savebar-hint">Alle Änderungen werden als Entwurf gespeichert.</span>
<div class="ed-savebar-actions">
<button class="ed-btn-ghost">Entwurf speichern</button>
<button class="ed-btn-primary" style="opacity:.4;cursor:not-allowed;">Veröffentlichen</button>
</div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>impl-ref — LE-1 Leerer Editor</h4>
<table class="at">
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
<tbody>
<tr class="grp"><td colspan="3">Seitenstruktur</td></tr>
<tr><td>Bedingte Verzweigung</td><td>{#if geschichte.type === 'JOURNEY'}&lt;JourneyEditor /&gt;{:else}&lt;GeschichteEditor /&gt;{/if}</td><td>in edit/+page.svelte; Props: geschichte: Geschichte</td></tr>
<tr><td>Split-Layout</td><td>flex flex-1 overflow-hidden (gleich wie GeschichteEditor)</td><td>70/30; Sidebar identisch</td></tr>
<tr><td>Topbar-Badge</td><td>„REISE" Pill neben dem Titel-Label</td><td>orange; kein interaktives Element; zeigt Typ</td></tr>
<tr class="grp"><td colspan="3">Titel-Input</td></tr>
<tr><td>Titel-Input</td><td>font-serif text-2xl border-b border-line pb-2 w-full bg-transparent outline-none</td><td>bind:value={title}; gleiche Validierung wie GeschichteEditor (required)</td></tr>
<tr class="grp"><td colspan="3">Einleitungs-Textarea</td></tr>
<tr><td>Intro-Textarea</td><td>font-serif text-sm italic text-ink-3 leading-relaxed w-full resize-none bg-transparent outline-none border-none py-1</td><td>bind:value={body}; plaintext; auto-resize per rows-attr oder JS</td></tr>
<tr><td>Label</td><td>text-[10px] font-bold uppercase tracking-widest text-ink-3 mb-1</td><td>„EINLEITUNG (OPTIONAL)"</td></tr>
<tr class="grp"><td colspan="3">Leerstate</td></tr>
<tr><td>Leerstate-Container</td><td>py-8 text-center border border-dashed border-line rounded-sm bg-surface</td><td>verschwindet sobald erstes Item vorhanden</td></tr>
<tr><td>Leerstate-Text</td><td>font-serif text-xs text-ink-3 italic</td><td></td></tr>
<tr class="grp"><td colspan="3">Veröffentlichen-Button</td></tr>
<tr><td>Disabled-Zustand</td><td>disabled={items.length === 0 || !title.trim()}</td><td>opacity-40 + cursor-not-allowed; keine Tooltip nötig — Sidebar-Hint erklärt es</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ═══ SCREEN LE-2: EDITOR WITH ITEMS ═══ -->
<div class="section">
<div class="section-title">Screens — Editor mit Einträgen</div>
<div class="scr">
<div class="scr-head">
<h3>LE-2 — Journey-Editor mit Einträgen</h3>
<span class="scr-id">Issue #753 · LE-2</span>
</div>
<p class="scr-desc">Gefüllte Itemliste mit gemischten Typen: Dokument-Item ohne Notiz, Interlude-Item (reiner Zwischentext), Dokument-Item mit bestehender Notiz. Jedes Item zeigt Drag-Handle links, Positionsnummer, Inhalt und Entfernen-Button. Aktionsleiste bleibt unter der Liste sichtbar.</p>
<p class="scr-var"><strong>Varianten:</strong> Veröffentlichte Journey (hier gezeigt) · Entwurf · Mobile</p>
<div class="previews">
<div class="prev-col" style="width:100%;max-width:1040px;">
<span class="bp-lbl">Desktop — 1040px · VERÖFFENTLICHT</span>
<div class="desk" style="min-height:580px;">
<div class="fa-nav">
<span class="fa-logo">ARCHIV</span>
<span style="width:1px;height:14px;background:rgba(255,255,255,.1);margin:0 2px;"></span>
<span class="fa-link">Dokumente</span>
<span class="fa-link">Personen</span>
<span class="fa-link active">Geschichten</span>
<div class="fa-nav-r">
<div class="fa-av" style="background:#012851;color:var(--mint);font-size:5px;font-weight:800;">KR</div>
</div>
</div>
<div class="ed-topbar">
<div class="ed-back">&#8592;</div>
<div class="ed-title-label" style="display:flex;align-items:center;gap:6px;">
Lesereise bearbeiten
<span style="font-size:7px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--orange-dark);background:var(--orange-tint);border:1px solid #F0C99A;padding:1px 5px;border-radius:3px;">REISE</span>
</div>
<div class="ed-status-pill ed-status-pub">VERÖFFENTLICHT</div>
<span class="ed-delete-link">Löschen</span>
</div>
<div class="ed-split">
<!-- Left: Journey editor area with items -->
<div class="je-main">
<input class="je-title-input" type="text" value="Briefe aus Breslau 19381942" readonly/>
<div class="je-sep"></div>
<div>
<div class="je-intro-label">Einleitung (optional)</div>
<textarea class="je-intro-area" readonly style="color:var(--color-text);">Der Briefwechsel zwischen Franz Raddatz und seiner Schwester Emma umspannt vier Jahre — von den letzten Friedenssommern bis zum Ende des Krieges.</textarea>
</div>
<div class="je-sep"></div>
<div class="je-list-label">Briefe &amp; Zwischentexte</div>
<!-- Item 1: Document, no note -->
<div class="je-item">
<div class="je-drag">
<div class="je-drag-dots">
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
</div>
</div>
<div class="je-num">1</div>
<div class="je-body">
<div class="je-doc-title">Brief vom 12. Juli 1938</div>
<div class="je-doc-meta">12. Juli 1938 &middot; von Franz Raddatz an Emma Müller</div>
<div class="je-note-add">
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
Notiz hinzufügen
</div>
</div>
<div class="je-remove"><div class="je-remove-x">&#215;</div></div>
</div>
<!-- Item 2: Interlude -->
<div class="je-item je-interlude-bg">
<div class="je-drag" style="background:rgba(232,134,42,.08);border-right-color:#F0C99A;">
<div class="je-drag-dots">
<div class="je-drag-dot" style="background:#D4A574;"></div><div class="je-drag-dot" style="background:#D4A574;"></div>
<div class="je-drag-dot" style="background:#D4A574;"></div><div class="je-drag-dot" style="background:#D4A574;"></div>
</div>
</div>
<div class="je-num" style="color:var(--orange-dark);">
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" style="margin-top:7px;"><path d="M2 4h8M2 7h5" stroke="var(--orange)" stroke-width="1.5" stroke-linecap="round"/></svg>
</div>
<div class="je-body" style="padding-top:6px;">
<div style="font-size:7.5px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--orange-dark);margin-bottom:4px;">Zwischentext</div>
<textarea class="je-interlude-area" readonly>Im Sommer 1938 schrieb Franz voller Zuversicht — er hatte kaum eine Ahnung, wie bald sich die Welt um ihn herum verändern würde.</textarea>
</div>
<div class="je-remove"><div class="je-remove-x" style="color:#D4A574;">&#215;</div></div>
</div>
<!-- Item 3: Document with note -->
<div class="je-item">
<div class="je-drag">
<div class="je-drag-dots">
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
</div>
</div>
<div class="je-num">2</div>
<div class="je-body">
<div class="je-doc-title">Postkarte aus Breslau, August 1938</div>
<div class="je-doc-meta" style="margin-bottom:5px;">22. Aug. 1938 &middot; von Franz Raddatz an Emma Müller</div>
<textarea class="je-note-area" readonly>Diese Karte ist ungewöhnlich kurz für Franz — vier Zeilen, fast hastig. Ein Zeichen der aufkommenden Unruhe in den Nachrichten?</textarea>
<div class="je-note-add" style="margin-top:3px;color:var(--color-text-muted);">
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M2 2l6 6M8 2l-6 6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
Notiz entfernen
</div>
</div>
<div class="je-remove"><div class="je-remove-x">&#215;</div></div>
</div>
<!-- Item 4: Document, no note -->
<div class="je-item">
<div class="je-drag">
<div class="je-drag-dots">
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
</div>
</div>
<div class="je-num">3</div>
<div class="je-body">
<div class="je-doc-title">Brief vom 3. September 1939</div>
<div class="je-doc-meta">3. Sept. 1939 &middot; von Emma Müller an Franz Raddatz</div>
<div class="je-note-add">
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
Notiz hinzufügen
</div>
</div>
<div class="je-remove"><div class="je-remove-x">&#215;</div></div>
</div>
<!-- Add bar -->
<div class="je-add-bar">
<button class="je-add-btn">
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
Brief hinzufügen
</button>
<button class="je-add-btn">
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
Zwischentext hinzufügen
</button>
</div>
</div>
<!-- Right: Sidebar -->
<div class="ed-sidebar">
<div class="ed-sb-section">
<div class="ed-sb-title">Personen</div>
<div class="ed-search-row">
<span style="font-size:9px;color:var(--color-text-muted);">&#128269;</span>
<div class="ed-search-input">Person suchen…</div>
</div>
<div style="display:flex;flex-wrap:wrap;margin-top:4px;">
<span class="ed-chip">
<span style="width:10px;height:10px;border-radius:50%;background:#012851;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:var(--mint);">FR</span>
Franz Raddatz
<span class="ed-chip-x">&#215;</span>
</span>
<span class="ed-chip">
<span style="width:10px;height:10px;border-radius:50%;background:#534AB7;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:#fff;">EM</span>
Emma Müller
<span class="ed-chip-x">&#215;</span>
</span>
</div>
</div>
<div class="ed-sb-divider"></div>
<div class="ed-sb-section">
<div class="ed-sb-title">Status</div>
<div class="ed-status-pill ed-status-pub" style="font-size:9px;">VERÖFFENTLICHT</div>
<div class="ed-hint" style="margin-top:6px;">Änderungen gehen sofort live.</div>
</div>
</div>
</div>
<div class="ed-savebar">
<span class="ed-savebar-hint">Änderungen sofort live — Leser sehen die aktuelle Version.</span>
<div class="ed-savebar-actions">
<button class="ed-btn-ghost retract">Zurück zu Entwurf</button>
<button class="ed-btn-primary">Speichern</button>
</div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>impl-ref — LE-2 Items-Liste</h4>
<table class="at">
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
<tbody>
<tr class="grp"><td colspan="3">Item-Zeile allgemein</td></tr>
<tr><td>Item-Container</td><td>flex items-stretch bg-white border border-line rounded-sm mb-2 overflow-hidden</td><td>interlude: bg-orange-50 border-orange-200</td></tr>
<tr><td>Drag-Handle</td><td>w-4 bg-surface border-r border-line flex items-center justify-center cursor-grab shrink-0</td><td>aria-label="Reihenfolge ändern"; cursor-grabbing während Drag</td></tr>
<tr><td>Positions-Nr.</td><td>w-5 text-[10px] font-bold text-ink-3 flex items-start justify-center pt-2 shrink-0</td><td>aus Array-Index, nicht item.position</td></tr>
<tr><td>Entfernen-Button</td><td>w-6 flex items-start justify-center pt-2 shrink-0</td><td>× aria-label="Eintrag entfernen"; hover: text-red-500; Confirm nur wenn note vorhanden</td></tr>
<tr class="grp"><td colspan="3">Dokument-Item</td></tr>
<tr><td>Brieftitel</td><td>text-[11px] font-semibold text-ink leading-snug mb-0.5</td><td>document.title</td></tr>
<tr><td>Briefmeta</td><td>text-xs text-ink-3</td><td>formatDate(doc.documentDate) · "von X" oder "von X an Y"</td></tr>
<tr><td>Notiz-Textarea (sichtbar)</td><td>w-full min-h-[40px] font-serif text-xs italic bg-surface border border-line rounded-sm p-1.5 resize-none focus:border-primary focus:bg-white mt-2</td><td>auto-expand; bind:value={item.note}</td></tr>
<tr><td>„Notiz hinzufügen" Link</td><td>text-xs font-semibold text-blue-600 inline-flex items-center gap-1 mt-1</td><td>togglet Notiz-Textarea</td></tr>
<tr><td>„Notiz entfernen" Link</td><td>text-xs text-ink-3 inline-flex items-center gap-1 mt-1</td><td>zeigt sich wenn note.trim() nicht leer; setzt note = '' und blendet Textarea aus</td></tr>
<tr class="grp"><td colspan="3">Interlude-Item</td></tr>
<tr><td>Interlude-Container</td><td>bg-orange-50 border-orange-200 (überschreibt Item-Container)</td><td>kein Positions-Kreis; Positions-Spalte zeigt Icon statt Zahl</td></tr>
<tr><td>Label „Zwischentext"</td><td>text-[9px] font-bold uppercase tracking-widest text-orange-700 mb-1</td><td>immer sichtbar; nicht editierbar</td></tr>
<tr><td>Zwischentext-Textarea</td><td>w-full min-h-[44px] font-serif text-xs italic bg-white/60 border border-orange-200 rounded-sm p-1.5 resize-none focus:border-orange-400</td><td>bind:value={item.note}; auto-expand; min 44px für Touch-Target</td></tr>
<tr class="grp"><td colspan="3">Aktionsleiste</td></tr>
<tr><td>Add Bar</td><td>flex gap-2 pt-2 pb-1</td><td>immer unten sichtbar, auch wenn Liste gefüllt</td></tr>
<tr><td>„Brief hinzufügen" Button</td><td>border border-dashed border-line rounded-sm px-3 py-1.5 text-xs font-semibold text-ink-2 hover:border-primary hover:text-primary flex items-center gap-1</td><td>öffnet existierende DocumentPicker-Komponente als Dropdown/Modal</td></tr>
<tr><td>„Zwischentext hinzufügen" Button</td><td>gleich wie Brief-Button</td><td>fügt neues Interlude-Item am Ende ein; Fokus auf das neue Textarea</td></tr>
<tr class="grp"><td colspan="3">Drag-to-Reorder</td></tr>
<tr><td>Bibliothek</td><td>@dnd-kit/core oder svelte-dnd-action (bereits im Projekt prüfen)</td><td>kein neues Package ohne Absprache</td></tr>
<tr><td>Reorder-API-Call</td><td>PUT /api/geschichten/{id}/items/reorder — body: [{id, position}] für alle Items</td><td>nach jedem Drop ausgelöst; optimistisch: lokalen State sofort aktualisieren</td></tr>
<tr><td>Accessibility</td><td>Drag-Handle: role="button" tabIndex=0; Keyboard: Space startet Drag, Arrow hoch/runter verschiebt, Space/Enter bestätigt, Esc abbricht</td><td>WCAG 2.1 SC 2.1.1</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ═══ SCREEN LE-3: INLINE NOTE EDITING ═══ -->
<div class="section">
<div class="section-title">Screens — Inline-Notiz-Editing</div>
<div class="scr">
<div class="scr-head">
<h3>LE-3 — Notiz-Textarea wird geöffnet</h3>
<span class="scr-id">Issue #753 · LE-3</span>
</div>
<p class="scr-desc">Wenn der Nutzer auf „Notiz hinzufügen" klickt, expandiert das Item um ein Textarea direkt unterhalb der Briefmeta — kein Modal. Der Fokus springt automatisch in das Textarea. Das Textarea hat einen blauen Fokusring als Orientierungshilfe. Ein API-PATCH wird beim Verlassen des Textareas (blur) ausgelöst, nicht bei jedem Tastendruck.</p>
<p class="scr-var"><strong>Inset-Ansicht — kein vollständiger Seiten-Mockup nötig</strong></p>
<div class="previews">
<div class="prev-col" style="width:100%;max-width:560px;">
<span class="bp-lbl">Inset — Notiz-Textarea geöffnet (Fokus)</span>
<div style="background:#E8E7E2;padding:16px;border-radius:var(--radius-xl);">
<!-- Item before (no note) -->
<div class="je-item" style="margin-bottom:5px;">
<div class="je-drag">
<div class="je-drag-dots">
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
</div>
</div>
<div class="je-num">1</div>
<div class="je-body">
<div class="je-doc-title">Brief vom 12. Juli 1938</div>
<div class="je-doc-meta">12. Juli 1938 &middot; Franz → Emma</div>
<div class="je-note-add">
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
Notiz hinzufügen
</div>
</div>
<div class="je-remove"><div class="je-remove-x">&#215;</div></div>
</div>
<!-- Item with opened note textarea (focused) -->
<div class="je-item">
<div class="je-drag">
<div class="je-drag-dots">
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
</div>
</div>
<div class="je-num">2</div>
<div class="je-body">
<div class="je-doc-title">Postkarte aus Breslau, August 1938</div>
<div class="je-doc-meta" style="margin-bottom:5px;">22. Aug. 1938 &middot; Franz → Emma</div>
<!-- Focused textarea -->
<textarea class="je-note-area je-note-editing" style="outline:none;box-shadow:0 0 0 2px rgba(1,40,81,.2);" readonly placeholder="Kuratoren-Notiz für diesen Brief…">|</textarea>
<div style="font-size:7px;color:var(--color-text-muted);margin-top:3px;">Wird gespeichert, wenn du das Feld verlässt.</div>
<div class="je-note-add" style="color:var(--color-text-muted);margin-top:2px;">
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M2 2l6 6M8 2l-6 6" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
Notiz entfernen
</div>
</div>
<div class="je-remove"><div class="je-remove-x">&#215;</div></div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>impl-ref — LE-3 Inline-Notiz</h4>
<table class="at">
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
<tbody>
<tr class="grp"><td colspan="3">Toggleverhalten</td></tr>
<tr><td>Lokaler State</td><td>let noteOpen = item.note !== null and item.note !== ''</td><td>öffnet sich automatisch wenn Notiz bereits vorhanden</td></tr>
<tr><td>„Notiz hinzufügen" Klick</td><td>noteOpen = true; tick().then(() => noteTextarea.focus())</td><td>Fokus nach Svelte-Tick um DOM-Update abzuwarten</td></tr>
<tr><td>Textarea blur-Handler</td><td>on:blur={() => saveNote(item.id, note)}</td><td>PATCH /api/geschichten/{id}/items/{itemId} mit {note}</td></tr>
<tr><td>Leere Notiz on blur</td><td>wenn note.trim() === '' → noteOpen = false; note = null</td><td>verhindert leere Notizen im Backend</td></tr>
<tr class="grp"><td colspan="3">Fokus-Styling</td></tr>
<tr><td>Fokus-Ring</td><td>focus:border-primary focus:ring-2 focus:ring-primary/20 focus:bg-white</td><td>sichtbarer Ring für Keyboard-Navigation; ring-offset für Abstand</td></tr>
<tr><td>Spar-Hint</td><td>text-[9px] text-ink-3 mt-1</td><td>„Wird gespeichert, wenn du das Feld verlässt."; verschwindet wenn noteOpen = false</td></tr>
<tr class="grp"><td colspan="3">Barrierefreiheit</td></tr>
<tr><td>aria-label Textarea</td><td>aria-label="Kuratoren-Notiz für {document.title}"</td><td>spezifisch; Screen-Reader nennt Brief-Kontext</td></tr>
<tr><td>aria-expanded Toggle</td><td>aria-expanded={noteOpen} auf „Notiz hinzufügen"-Button</td><td>kommuniziert Expand-State</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ═══ SCREEN LE-4: MOBILE ═══ -->
<div class="section">
<div class="section-title">Screens — Mobile Editor</div>
<div class="scr">
<div class="scr-head">
<h3>LE-4 — Mobile Journey-Editor</h3>
<span class="scr-id">Issue #753 · LE-4</span>
</div>
<p class="scr-desc">Auf Mobile (320px) entfällt die Sidebar-Split. Die Personen- und Status-Sektion werden als ausklappbare Sektionen unter der Itemliste gezeigt. Drag-to-Reorder ist auf Mobile durch Long-Press aktiviert. Die Aktionsleiste scrollt mit dem Inhalt.</p>
<p class="scr-var"><strong>Primäre Zielgruppe für den Editor: Desktop/Tablet. Mobile ist sekundär — alle Funktionen erreichbar, aber Drag ist schwerer bedienbar.</strong></p>
<div class="previews">
<div class="prev-col">
<span class="bp-lbl">Mobile — 320px · mit Einträgen</span>
<div class="phone" style="min-height:580px;">
<div class="pst"><b>9:41</b><span>&#9679;&#9679;&#9679;</span></div>
<div class="pb">
<div class="m-nav">
<span class="m-logo">ARCHIV</span>
<div class="m-nav-r">
<div class="m-av">KR</div>
<div class="m-ham"><span></span><span></span><span></span></div>
</div>
</div>
<div class="mob-topbar">
<span class="mob-back">&#8592;</span>
<span class="mob-label">Lesereise bearbeiten</span>
<div class="ed-status-pill ed-status-pub" style="font-size:7px;padding:1px 5px;">VER&Ouml;FF.</div>
</div>
<div class="mob-body">
<input class="mob-title-input" type="text" value="Briefe aus Breslau 19381942" readonly/>
<!-- Item 1: Document -->
<div class="mob-je-item">
<div class="mob-je-drag">
<div class="je-drag-dots">
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
<div class="je-drag-dot"></div><div class="je-drag-dot"></div>
</div>
</div>
<div class="mob-je-body">
<div class="mob-je-title">Brief vom 12. Juli 1938</div>
<div class="mob-je-meta">12. Juli 1938 &middot; Franz → Emma</div>
</div>
<div style="padding:6px 6px 0 0;font-size:10px;color:#C4C3BC;">&#215;</div>
</div>
<!-- Item 2: Interlude -->
<div class="mob-je-item mob-je-interlude">
<div class="mob-je-drag" style="background:rgba(232,134,42,.08);border-right-color:#F0C99A;"></div>
<div class="mob-je-body">
<div style="font-size:6.5px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--orange-dark);margin-bottom:3px;">Zwischentext</div>
<div class="mob-je-interlude-text">Im Sommer 1938 schrieb Franz voller Zuversicht…</div>
</div>
<div style="padding:6px 6px 0 0;font-size:10px;color:#D4A574;">&#215;</div>
</div>
<!-- Item 3: Document with note -->
<div class="mob-je-item">
<div class="mob-je-drag"></div>
<div class="mob-je-body">
<div class="mob-je-title">Postkarte Aug. 1938</div>
<div class="mob-je-meta">22. Aug. 1938 &middot; Franz → Emma</div>
<div class="mob-je-note">Diese Karte ist ungewöhnlich kurz für Franz…</div>
</div>
<div style="padding:6px 6px 0 0;font-size:10px;color:#C4C3BC;">&#215;</div>
</div>
<!-- Add bar -->
<div style="display:flex;gap:5px;padding:4px 0;">
<button class="je-add-btn" style="flex:1;font-size:7.5px;padding:6px 8px;justify-content:center;">+ Brief</button>
<button class="je-add-btn" style="flex:1;font-size:7.5px;padding:6px 8px;justify-content:center;">+ Zwischentext</button>
</div>
<!-- Collapsible: Personen -->
<div class="mob-collapsible">
<div class="mob-coll-hdr">
Personen
<span class="mob-coll-chevron">&#8250;</span>
</div>
</div>
<!-- Collapsible: Status -->
<div class="mob-collapsible">
<div class="mob-coll-hdr">
Status &amp; Speichern
<span class="mob-coll-chevron">&#8250;</span>
</div>
</div>
</div>
<div class="mob-savebar">
<button class="mob-btn mob-btn-ghost" style="font-size:8px;flex:0 0 auto;padding:7px 10px;">Entwurf</button>
<button class="mob-btn mob-btn-primary">Speichern</button>
</div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>impl-ref — LE-4 Mobile</h4>
<table class="at">
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
<tbody>
<tr class="grp"><td colspan="3">Layout-Anpassungen</td></tr>
<tr><td>Split entfällt</td><td>@media (max-width: 768px): flex-col; Sidebar-Sektionen als Collapsibles am Ende</td><td>gleich wie GeschichteEditor auf Mobile</td></tr>
<tr><td>Collapsibles</td><td>details/summary oder eigene boolean-Toggle; Personen + Status separat</td><td>geschlossen beim ersten Laden; Fokus öffnet</td></tr>
<tr class="grp"><td colspan="3">Touch &amp; Drag</td></tr>
<tr><td>Drag auf Mobile</td><td>Long-Press (500ms) auf dem Drag-Handle aktiviert Drag</td><td>dnd-kit unterstützt Touch nativ; kein separates Config nötig</td></tr>
<tr><td>Touch Target Items</td><td>min-h-[44px] für jede Item-Zeile</td><td>WCAG 2.2 AA; durch Padding gesichert</td></tr>
<tr><td>Add-Buttons</td><td>flex-1; volle verfügbare Breite geteilt</td><td>min-h-[44px] als Touch-Target</td></tr>
<tr class="grp"><td colspan="3">Savebar</td></tr>
<tr><td>Savebar Mobile</td><td>flex gap-2; „Zurück zu Entwurf" komprimiert zu „Entwurf"</td><td>Volltext passt nicht auf 320px</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ═══ LLM IMPLEMENTATION GUIDE ═══ -->
<div class="llm">
<h2>Implementation Guide — Journey-Editor</h2>
<h3>Neue Komponente</h3>
<table>
<thead><tr><th>Datei</th><th>Typ</th><th>Beschreibung</th></tr></thead>
<tbody>
<tr><td><code>src/lib/geschichte/JourneyEditor.svelte</code></td><td>Svelte-Komponente</td><td>Hauptkomponente; Props: <code>geschichte: Geschichte</code></td></tr>
<tr><td><code>src/lib/geschichte/JourneyItemRow.svelte</code></td><td>Svelte-Komponente</td><td>Eine Zeile (Dokument oder Interlude); Props: <code>item: JourneyItem, position: number</code>, Events: <code>remove, noteChange</code></td></tr>
</tbody>
</table>
<h3>Edit-Page-Integration</h3>
<ul>
<li><code>GeschichteEditor.svelte</code> erhält ein neues Prop <code>type: GeschichteType</code>.</li>
<li>Wenn <code>type === 'JOURNEY'</code>: rendere <code>JourneyEditor</code> statt TipTap-Editor. Die Sidebar (Personen, Status, Savebar) bleibt identisch.</li>
<li>Die Savebar-Logik ist in der Edit-Page (<code>+page.svelte</code>) verankert — <code>JourneyEditor</code> gibt nur Änderungen nach oben (Svelte-Events oder bindable Props), die Seite hält den Save-State.</li>
</ul>
<h3>API-Calls</h3>
<table>
<thead><tr><th>Aktion</th><th>Endpoint</th><th>Body</th></tr></thead>
<tbody>
<tr><td>Brief hinzufügen</td><td><code>POST /api/geschichten/{id}/items</code></td><td><code>{documentId: UUID}</code></td></tr>
<tr><td>Zwischentext hinzufügen</td><td><code>POST /api/geschichten/{id}/items</code></td><td><code>{note: string}</code></td></tr>
<tr><td>Notiz speichern/bearbeiten</td><td><code>PATCH /api/geschichten/{id}/items/{itemId}</code></td><td><code>{note: string | null}</code></td></tr>
<tr><td>Item entfernen</td><td><code>DELETE /api/geschichten/{id}/items/{itemId}</code></td><td></td></tr>
<tr><td>Reihenfolge speichern</td><td><code>PUT /api/geschichten/{id}/items/reorder</code></td><td><code>[{id: UUID, position: number}]</code></td></tr>
</tbody>
</table>
<h3>Optimistische Updates</h3>
<ul>
<li>Alle Mutationen (add, remove, reorder, noteChange) aktualisieren den lokalen State <em>sofort</em>, der API-Call läuft parallel.</li>
<li>Bei Fehler: lokalen State zurückrollen und einen <code>aria-live="polite"</code>-Fehlerhinweis anzeigen.</li>
<li>Notiz-Saving ist ein Sonderfall: es gibt kein optimistisches Update da der Wert bereits live im Textarea ist — nur blur → PATCH.</li>
</ul>
<h3>DocumentPicker-Integration</h3>
<ul>
<li>Der „Brief hinzufügen"-Button öffnet die bestehende <code>DocumentPicker</code>-Komponente (prüfe <code>$lib/document/</code> auf vorhandene Typeahead-Komponenten).</li>
<li>Nach Auswahl eines Dokuments: <code>POST /items</code> mit <code>documentId</code>, neues Item wird an das Ende der Liste angehängt und eingeblendet.</li>
<li>Bereits in der Journey enthaltene Dokumente: in der Picker-Ergebnisliste mit einem „Bereits enthalten"-Hinweis markieren und deaktivieren.</li>
</ul>
<h3>Drag-to-Reorder</h3>
<ul>
<li>Bibliothek: prüfe zunächst ob <code>@dnd-kit/core</code> oder <code>svelte-dnd-action</code> bereits im <code>package.json</code> ist. Kein neues Package einführen ohne Absprache.</li>
<li>Nach dem Drop: neue Reihenfolge als Array <code>[{id, position}]</code> berechnen (position = index * 10 lässt Lücken für künftige Inserts) und <code>PUT /items/reorder</code> senden.</li>
<li>Keyboard-Drag: Space/Enter startet, Arrow Up/Down verschiebt, Space/Enter bestätigt, Escape abbricht. Screenreader-Announcement: „Eintrag X von Position Y nach Z verschoben".</li>
</ul>
<h3>Barrierefreiheit</h3>
<ul>
<li>Items-Liste: <code>&lt;ol&gt;</code>-Element — kommuniziert die Ordnung an Screenreader.</li>
<li>Drag-Handle: <code>role="button"</code>, <code>tabindex="0"</code>, <code>aria-label="Reihenfolge von '{title}' ändern"</code>.</li>
<li>Entfernen-Button: <code>aria-label="'{title}' entfernen"</code>; kein reines ×-Zeichen ohne Label.</li>
<li>Notiz-Textarea: <code>aria-label="Kuratoren-Notiz für '{title}'"</code>.</li>
<li>Touch-Targets: alle interaktiven Elemente min 44×44px (WCAG 2.2 AA).</li>
<li>Fokusring: <code>focus-visible:ring-2 focus-visible:ring-primary</code> auf allen Buttons und Textareas.</li>
</ul>
<h3>Abgrenzung zu GeschichteEditor</h3>
<ul>
<li>TipTap wird für JOURNEY <em>nicht</em> geladen — kein unnötiger Bundle-Load.</li>
<li>Die Sidebar (Personen, Status) ist für beide Typen identisch — kein Duplikat, die Sidebar-Komponente wird geteilt.</li>
<li>Savebar-Logik (DRAFT/PUBLISHED/Retract) ist identisch — JourneyEditor ändert sie nicht.</li>
<li><code>Geschichte.body</code> dient für JOURNEY als Einleitungstext (Plaintext, kein HTML). Kein Rich-Text-Rendering auf der Leseseite nötig.</li>
</ul>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,727 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Lesereisen — Reader-Integration · Familienarchiv</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<style>
:root{--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;--navy:#012851;--mint:#A1DCD8;--sand:#F0EFE9;--turquoise:#00C7B1;--blue-tint:#E6F1FB;--blue:#2D7DD2;--blue-dark:#185FA5;--green-tint:#E8F5EA;--green:#3D8C4A;--green-dark:#2E6E39;--orange-tint:#FEF0E6;--orange:#E8862A;--orange-dark:#B46820;--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;--shadow-card:0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);--shadow-raised:0 4px 12px rgba(28,28,24,.08),0 2px 4px rgba(28,28,24,.04);--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
body{font-family:var(--font-sans);background:#E8E7E2;color:var(--color-text);font-size:14px;line-height:1.6;}
.doc{max-width:1200px;margin:0 auto;padding:48px 40px 120px;}
.doc-header{display:flex;justify-content:space-between;align-items:flex-end;padding-bottom:28px;border-bottom:1px solid var(--color-border);margin-bottom:48px;background:var(--color-page);margin:-48px -40px 48px;padding:48px 40px 28px;border-radius:var(--radius-xl) var(--radius-xl) 0 0;}
.doc-header h1{font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
.doc-header p{font-size:13px;color:var(--color-text-muted);max-width:680px;}
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;}
.pill-o{background:var(--orange-tint);color:var(--orange-dark);}
.section{margin-bottom:64px;}
.section-title{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:24px;}
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.65;max-width:720px;margin-bottom:20px;}
.jh{padding:20px 24px;border-radius:var(--radius-xl);margin-bottom:40px;display:flex;align-items:center;gap:16px;}
.jh .jn{font-family:var(--font-display);font-size:48px;font-weight:300;line-height:1;opacity:.5;}
.jh h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
.jh p{font-size:13px;line-height:1.5;}.jh .fl{font-family:var(--font-mono);font-size:11px;margin-top:6px;opacity:.7;}
.jh-o{background:var(--orange-tint);border:1px solid #F0C99A;}
.jh-o .jn{color:var(--orange);}
.jh-o p,.jh-o .fl{color:var(--orange-dark);}
.scr{margin-bottom:56px;}
.scr-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;}
.scr-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
.scr-id{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);padding:2px 8px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-page);}
.scr-desc{font-size:12px;color:var(--color-text-muted);line-height:1.6;max-width:720px;margin-bottom:6px;}
.scr-var{font-size:11px;color:var(--color-text-muted);margin-bottom:20px;}.scr-var strong{color:var(--color-text);}
.previews{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;align-items:flex-start;margin-bottom:20px;}
.prev-col{display:flex;flex-direction:column;align-items:center;gap:10px;}
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
.desk{width:100%;max-width:1040px;background:var(--color-page);border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.06);display:flex;flex-direction:column;}
.phone{width:320px;flex-shrink:0;background:var(--color-page);border-radius:36px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.07);display:flex;flex-direction:column;border:6px solid #1C1C18;}
.pst{padding:10px 20px 0;display:flex;justify-content:space-between;align-items:center;font-size:12px;background:var(--color-page);}.pst b{font-weight:600;}.pst span{font-size:10px;}
.pb{flex:1;overflow-y:auto;display:flex;flex-direction:column;}
.fa-nav{height:32px;background:var(--navy);display:flex;align-items:center;padding:0 12px;gap:8px;flex-shrink:0;}
.fa-logo{font-size:7px;font-weight:900;color:#fff;letter-spacing:.8px;border-bottom:2px solid var(--mint);padding-bottom:1px;}
.fa-link{font-size:5.5px;color:rgba(255,255,255,.4);font-weight:700;text-transform:uppercase;letter-spacing:.05em;}
.fa-link.active{color:var(--mint);}
.fa-nav-r{margin-left:auto;display:flex;gap:5px;align-items:center;}
.fa-av{width:16px;height:16px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:rgba(255,255,255,.5);}
.m-nav{height:26px;background:var(--navy);display:flex;align-items:center;padding:0 10px;gap:6px;flex-shrink:0;}
.m-logo{font-size:6px;font-weight:900;color:#fff;letter-spacing:.7px;border-bottom:1.5px solid var(--mint);padding-bottom:1px;}
.m-nav-r{margin-left:auto;display:flex;gap:4px;align-items:center;}
.m-av{width:14px;height:14px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:rgba(255,255,255,.5);}
.m-ham{display:flex;flex-direction:column;gap:2px;width:12px;}
.m-ham span{height:1.5px;background:rgba(255,255,255,.6);border-radius:1px;}
/* ── impl-ref table ── */
.agent{background:var(--color-text);color:#E8E8E2;padding:24px;border-radius:var(--radius-lg);margin-top:20px;}
.agent h4{font-size:9px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:#5A5A55;margin-bottom:12px;}
.at{width:100%;border-collapse:collapse;font-family:var(--font-mono);font-size:10px;}
.at thead tr{border-bottom:1px solid #2A2A26;}
.at th{text-align:left;padding:6px 10px;font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#5A5A55;font-family:var(--font-sans);}
.at td{padding:5px 10px;border-bottom:1px solid #1E1E1A;vertical-align:top;line-height:1.5;}
.at tr:last-child td{border-bottom:none;}
.at td:first-child{color:#7A7A72;}
.at td:nth-child(2){color:#E8E8E2;font-weight:500;}
.at td:nth-child(3){color:#5A5A55;}
.at .grp td{padding-top:14px;font-family:var(--font-sans);font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#3A3A36;}
/* ── LLM guide ── */
.llm{background:var(--color-page);border:2px solid var(--navy);border-radius:var(--radius-xl);padding:32px 40px;margin-top:64px;}
.llm h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:8px;color:var(--navy);}
.llm h3{font-size:14px;font-weight:600;margin:20px 0 8px;}
.llm h4{font-size:12px;font-weight:600;margin:14px 0 6px;color:var(--color-text-muted);}
.llm p,.llm li{font-size:13px;color:var(--color-text-muted);line-height:1.65;}
.llm ul,.llm ol{padding-left:20px;margin-bottom:12px;}
.llm li{margin-bottom:4px;}
.llm code{font-family:var(--font-mono);font-size:11px;background:var(--color-surface);padding:1px 5px;border-radius:3px;}
.llm table{width:100%;border-collapse:collapse;margin:12px 0;font-size:12px;}
.llm th,.llm td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--color-border);}
.llm th{font-weight:500;color:var(--color-text);font-size:11px;text-transform:uppercase;letter-spacing:.05em;}
.llm td{color:var(--color-text-muted);}
/* ── List row (re-used from reader-journey spec) ── */
.g-list-card{background:#fff;border:1px solid #E4E2D7;border-radius:4px;box-shadow:var(--shadow-card);overflow:hidden;}
.g-row{display:flex;gap:0;border-bottom:1px solid #F0EFE9;}
.g-row:last-child{border-bottom:none;}
.g-meta{width:88px;flex-shrink:0;padding:10px 10px 10px 12px;display:flex;flex-direction:column;gap:3px;border-right:1px solid #F0EFE9;}
.g-content{padding:10px 14px 10px 12px;flex:1;min-width:0;}
.g-av{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:7px;font-weight:800;color:#fff;flex-shrink:0;margin-bottom:3px;}
.av-navy{background:#012851;} .av-purple{background:#534AB7;} .av-teal{background:#0E9488;}
.g-author{font-size:7px;font-weight:700;color:#1C1C18;line-height:1.3;}
.g-date{font-size:6.5px;color:#6B6A63;}
.g-chip{display:inline-flex;align-items:center;gap:2px;padding:1px 5px;background:#F5F4EE;border:1px solid #D8D7D0;border-radius:10px;font-size:6px;font-weight:500;color:#1C1C18;margin-top:2px;max-width:76px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.g-title{font-family:Georgia,serif;font-size:11px;color:#012851;line-height:1.4;margin-bottom:2px;}
.g-excerpt{font-size:7.5px;color:#6B6A63;line-height:1.55;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;}
.g-filters{display:flex;gap:5px;align-items:center;padding:8px 12px;background:var(--color-page);border-bottom:1px solid #EDECEA;flex-wrap:wrap;}
.g-pill{display:inline-flex;align-items:center;padding:2px 8px;border-radius:10px;font-size:6.5px;font-weight:700;border:1px solid #D8D7D0;color:#6B6A63;background:transparent;}
.g-pill.active{background:#012851;color:#fff;border-color:#012851;}
.g-page-hdr{display:flex;justify-content:space-between;align-items:center;padding:10px 14px 6px;}
.g-page-title{font-family:Georgia,serif;font-size:16px;font-weight:400;color:#012851;}
.g-new-btn{font-size:7px;font-weight:700;padding:4px 10px;border-radius:3px;background:#012851;color:#fff;border:none;display:flex;align-items:center;gap:3px;}
/* ── Journey badge in list ── */
.j-badge{display:inline-flex;align-items:center;padding:1px 5px;border-radius:3px;font-size:5.5px;font-weight:700;letter-spacing:.07em;text-transform:uppercase;background:var(--orange-tint);color:var(--orange-dark);border:1px solid #F0C99A;margin-top:2px;}
/* ── Type selector cards ── */
.type-selector{display:flex;gap:12px;justify-content:center;padding:20px 24px;flex:1;align-items:center;background:#E8E7E2;}
.type-selector-inner{max-width:520px;width:100%;}
.type-selector-q{font-family:Georgia,serif;font-size:12px;font-weight:400;color:#6B6A63;text-align:center;margin-bottom:14px;}
.type-cards{display:flex;gap:10px;}
.type-card{flex:1;border:1px solid #D8D7D0;border-radius:6px;padding:12px 14px;cursor:pointer;background:#fff;display:flex;flex-direction:column;gap:5px;}
.type-card.selected{border-color:var(--orange);background:var(--orange-tint);box-shadow:0 0 0 2px rgba(232,134,42,.15);}
.type-card-icon{font-size:16px;margin-bottom:2px;}
.type-card-title{font-family:Georgia,serif;font-size:11px;font-weight:400;color:var(--navy);}
.type-card-desc{font-size:7.5px;color:#6B6A63;line-height:1.55;}
.type-card-check{width:14px;height:14px;border-radius:50%;background:var(--orange);display:flex;align-items:center;justify-content:center;margin-top:4px;align-self:flex-end;}
.type-card-check svg{width:8px;height:8px;}
.type-next-bar{display:flex;justify-content:flex-end;padding:8px 24px;background:#fff;border-top:1px solid #E4E2D7;}
.type-next-btn{font-size:8px;font-weight:700;padding:5px 14px;border-radius:3px;background:var(--navy);color:#fff;border:none;display:flex;align-items:center;gap:3px;}
/* ── Journey reader ── */
.jr-article{background:var(--color-page);border-radius:6px;padding:16px 20px;max-width:640px;margin:0 auto;}
.jr-back{font-size:7px;color:#6B6A63;margin-bottom:10px;display:flex;align-items:center;gap:2px;}
.jr-badge{display:inline-flex;align-items:center;padding:1px 6px;border-radius:3px;font-size:6px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;background:var(--orange-tint);color:var(--orange-dark);border:1px solid #F0C99A;margin-bottom:5px;}
.jr-title{font-family:Georgia,serif;font-size:18px;font-weight:400;color:#012851;line-height:1.3;margin-bottom:8px;}
.jr-metabar{display:flex;align-items:center;gap:6px;padding-bottom:8px;border-bottom:1px solid #EDECEA;margin-bottom:10px;}
.jr-metabar-r{margin-left:auto;display:flex;align-items:center;gap:6px;}
.jr-edit-btn{font-size:6.5px;font-weight:600;padding:2px 7px;border:1px solid #D8D7D0;border-radius:3px;color:#1C1C18;background:transparent;}
.jr-intro{font-family:Georgia,serif;font-size:8.5px;line-height:1.75;color:#6B6A63;font-style:italic;margin-bottom:12px;padding-bottom:10px;border-bottom:1px dashed #EDECEA;}
/* Journey items in reader */
.jr-item{display:flex;gap:7px;margin-bottom:9px;align-items:flex-start;}
.jr-num{width:18px;height:18px;border-radius:50%;background:#012851;color:#fff;display:flex;align-items:center;justify-content:center;font-size:7px;font-weight:700;flex-shrink:0;margin-top:1px;}
.jr-card{flex:1;background:#fff;border:1px solid #E4E2D7;border-radius:4px;padding:7px 9px;}
.jr-card-title{font-family:Georgia,serif;font-size:9px;color:#012851;line-height:1.3;margin-bottom:2px;font-weight:400;}
.jr-card-meta{font-size:6.5px;color:#6B6A63;margin-bottom:5px;}
.jr-card-link{font-size:7px;font-weight:600;color:#012851;display:flex;align-items:center;gap:2px;}
.jr-annotation{margin-top:6px;padding:5px 7px;border-left:2px solid var(--mint);background:#F5F4EE;border-radius:0 3px 3px 0;}
.jr-annotation-text{font-size:7.5px;font-style:italic;color:#6B6A63;line-height:1.55;}
.jr-interlude{margin:10px 0 10px 25px;padding:7px 9px;border-left:2px solid var(--orange);background:var(--orange-tint);border-radius:0 4px 4px 0;}
.jr-interlude-text{font-size:8px;font-style:italic;color:#1C1C18;line-height:1.65;}
/* Mobile list row */
.m-row{padding:9px 10px;border-bottom:1px solid #F0EFE9;background:#fff;}
.m-row-top{display:flex;align-items:center;gap:5px;margin-bottom:3px;}
.m-author-name{font-size:7px;font-weight:700;color:#1C1C18;}
.m-date{font-size:6.5px;color:#6B6A63;margin-left:auto;}
.m-title{font-family:Georgia,serif;font-size:10px;color:#012851;line-height:1.4;margin-bottom:2px;}
.m-excerpt{font-size:7px;color:#6B6A63;line-height:1.5;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;}
.m-filters{display:flex;gap:4px;padding:6px 10px;background:var(--color-page);border-bottom:1px solid #EDECEA;overflow-x:auto;flex-wrap:nowrap;}
.m-filters::-webkit-scrollbar{display:none;}
/* Mobile journey reader */
.mjr-article{background:#fff;border-radius:6px;padding:12px 12px 16px;}
.mjr-back{font-size:7px;color:#6B6A63;margin-bottom:7px;display:flex;align-items:center;gap:2px;}
.mjr-badge{display:inline-flex;padding:1px 5px;border-radius:3px;font-size:5.5px;font-weight:700;letter-spacing:.07em;text-transform:uppercase;background:var(--orange-tint);color:var(--orange-dark);border:1px solid #F0C99A;margin-bottom:4px;}
.mjr-title{font-family:Georgia,serif;font-size:14px;font-weight:400;color:#012851;line-height:1.3;margin-bottom:6px;}
.mjr-metabar{display:flex;align-items:center;gap:5px;padding-bottom:6px;border-bottom:1px solid #EDECEA;margin-bottom:8px;}
.mjr-intro{font-family:Georgia,serif;font-size:8px;line-height:1.7;color:#6B6A63;font-style:italic;margin-bottom:9px;padding-bottom:7px;border-bottom:1px dashed #EDECEA;}
.mjr-item{display:flex;gap:5px;margin-bottom:7px;align-items:flex-start;}
.mjr-num{width:14px;height:14px;border-radius:50%;background:#012851;color:#fff;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:700;flex-shrink:0;margin-top:1px;}
.mjr-card{flex:1;background:#F5F4EE;border:1px solid #E4E2D7;border-radius:4px;padding:5px 7px;}
.mjr-card-title{font-family:Georgia,serif;font-size:8.5px;color:#012851;line-height:1.3;margin-bottom:1px;}
.mjr-card-meta{font-size:6px;color:#6B6A63;margin-bottom:4px;}
.mjr-card-link{font-size:6.5px;font-weight:600;color:#012851;}
.mjr-interlude{margin:7px 0 7px 19px;padding:5px 7px;border-left:2px solid var(--orange);background:var(--orange-tint);border-radius:0 3px 3px 0;}
.mjr-interlude-text{font-size:7.5px;font-style:italic;color:#1C1C18;line-height:1.6;}
/* ── Editor topbar (type selector screen) ── */
.ed-topbar{background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:0 14px;gap:8px;height:38px;flex-shrink:0;}
.ed-back{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px;color:var(--color-text-muted);flex-shrink:0;}
.ed-title-label{font-family:var(--font-sans);font-size:10px;font-weight:500;color:var(--color-text);flex:1;}
.ed-status-pill{display:inline-flex;align-items:center;padding:2px 7px;border-radius:20px;font-size:8px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;flex-shrink:0;}
.ed-status-draft{background:#F0EFE9;color:#6B6A63;border:1px solid #D8D7D0;}
@media(max-width:900px){.doc{padding:24px 16px 80px;}}
</style>
</head>
<body>
<div class="doc">
<!-- ═══ DOC HEADER ═══ -->
<div class="doc-header">
<div>
<h1>Lesereisen — Reader-Integration</h1>
<p>Typauswahl bei <code>/geschichten/new</code>, Journey-Badge auf der Übersichtsliste und die neue geordnete Leseansicht auf <code>/geschichten/[id]</code> wenn <code>type === 'JOURNEY'</code>. Bestehende Story-Ansichten bleiben unverändert.</p>
</div>
<div class="doc-meta">
Familienarchiv<br/>
<span class="pill pill-o">Final Spec</span><br/>
2026-06-07 &middot; @leonievoss<br/>
<span style="font-size:10px;margin-top:4px;display:inline-block;">Issue #752</span>
</div>
</div>
<!-- ═══ JOURNEY HEADER ═══ -->
<div class="jh jh-o">
<div class="jn">R</div>
<div>
<h2>Lesereisen — Reader</h2>
<p>Alle angemeldeten Familienmitglieder können Lesereisen entdecken und in Briefsequenzen mit Kuratoren-Notizen eintauchen. BLOG_WRITERs sehen zusätzlich Bearbeiten/Löschen-Aktionen.</p>
<div class="fl">/geschichten &middot; /geschichten/new &middot; /geschichten/[id]</div>
</div>
</div>
<!-- ═══ KONZEPT ═══ -->
<div class="section">
<div class="section-title">Konzept</div>
<p class="prose">Eine <em>Lesereise</em> ist eine <code>Geschichte</code> mit <code>type === 'JOURNEY'</code>. Ihr Kerninhalt ist eine geordnete Sequenz von Briefen (<code>JourneyItem</code>s mit <code>document_id</code>) und Zwischentexten (<code>JourneyItem</code>s ohne <code>document_id</code>). Das optionale Feld <code>body</code> dient als Einleitung/Preface.</p>
<p class="prose">Diese Spec deckt drei Änderungen ab: (1) die Typauswahl auf <code>/geschichten/new</code> als vorgelagerter Schritt, (2) das „REISE"-Badge in der Übersichtsliste, und (3) die neue Journey-Leseansicht auf der Detailseite, die den bestehenden Prosa-Body durch eine nummerierte Briefliste ersetzt.</p>
<p class="prose">Dokument-Items zeigen Titel, Datum, Sender→Empfänger und einen Link zum Brief. Optionale Kuratoren-Notizen erscheinen als Annotation mit Mint-Linker-Rand unter dem Briefeintrag. Interlude-Items (kein Dokument) erscheinen als eingerückte Absätze mit orangenem linken Rand — klar vom Dokumenttyp unterscheidbar, aber harmonisch im Lesefluss.</p>
</div>
<!-- ═══ SCREEN LR-0: TYPE SELECTOR ═══ -->
<div class="section">
<div class="section-title">Screens — Typauswahl</div>
<div class="scr">
<div class="scr-head">
<h3>LR-0 — Typauswahl /geschichten/new</h3>
<span class="scr-id">Issue #752 · LR-0</span>
</div>
<p class="scr-desc">Neuer vorgelagerter Schritt beim Erstellen einer Geschichte. Zwei Karten zur Auswahl: „Geschichte" (Prosa) und „Lesereise" (Briefsequenz). Die ausgewählte Karte wird hervorgehoben. Erst nach Auswahl wird der „Weiter"-Button aktiv. Auswahl bleibt im URL-Param erhalten (<code>?type=JOURNEY</code>).</p>
<p class="scr-var"><strong>Varianten:</strong> Keine Auswahl (Weiter-Button inaktiv) · Lesereise gewählt (hier gezeigt) · Geschichte gewählt</p>
<div class="previews">
<div class="prev-col" style="width:100%;max-width:1040px;">
<span class="bp-lbl">Desktop — 1040px · Lesereise gewählt</span>
<div class="desk" style="min-height:320px;">
<div class="fa-nav">
<span class="fa-logo">ARCHIV</span>
<span style="width:1px;height:14px;background:rgba(255,255,255,.1);margin:0 2px;"></span>
<span class="fa-link">Dokumente</span>
<span class="fa-link">Personen</span>
<span class="fa-link active">Geschichten</span>
<span class="fa-link">Chronik</span>
<div class="fa-nav-r">
<div class="fa-av" style="background:#012851;color:var(--mint);font-size:5px;font-weight:800;">MR</div>
</div>
</div>
<div class="ed-topbar">
<div class="ed-back">&#8592;</div>
<div class="ed-title-label">Neue Geschichte</div>
<div class="ed-status-pill ed-status-draft">ENTWURF</div>
</div>
<div class="type-selector">
<div class="type-selector-inner">
<div class="type-selector-q">Was möchtest du erstellen?</div>
<div class="type-cards">
<!-- Story card -->
<div class="type-card">
<div class="type-card-icon">✍️</div>
<div class="type-card-title">Geschichte</div>
<div class="type-card-desc">Freier Prosatext über Familienerlebnisse, Erinnerungen oder historische Einordnungen — mit verlinkten Personen und Dokumenten.</div>
</div>
<!-- Journey card (selected) -->
<div class="type-card selected">
<div class="type-card-icon">📜</div>
<div class="type-card-title">Lesereise</div>
<div class="type-card-desc">Geordnete Briefsequenz mit optionalen Kuratoren-Notizen zwischen den Briefen — für chronologische Korrespondenz-Sammlungen.</div>
<div class="type-card-check">
<svg viewBox="0 0 10 10" fill="none"><path d="M2 5l2.5 2.5L8 3" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
</div>
</div>
</div>
</div>
<div class="type-next-bar">
<button class="type-next-btn">
Weiter
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M4 2l4 3-4 3" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>impl-ref — LR-0 Typauswahl</h4>
<table class="at">
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
<tbody>
<tr class="grp"><td colspan="3">Layout</td></tr>
<tr><td>Selector area</td><td>flex flex-1 items-center justify-center bg-canvas px-6 py-10</td><td>zentriert, füllt restliche Höhe</td></tr>
<tr><td>Frage</td><td>font-serif text-sm text-ink-2 text-center mb-4</td><td></td></tr>
<tr><td>Karten-Grid</td><td>flex gap-4</td><td>2 gleich breite Karten; auf Mobile flex-col</td></tr>
<tr class="grp"><td colspan="3">Type-Karte</td></tr>
<tr><td>Karte (inaktiv)</td><td>border border-line rounded-md p-4 bg-white cursor-pointer hover:border-primary hover:bg-surface</td><td>focus-visible:ring-2 focus-visible:ring-primary</td></tr>
<tr><td>Karte (ausgewählt)</td><td>border-2 border-orange-500 bg-orange-50 shadow-sm</td><td>aria-pressed="true"; kein Tailwind-Kürzel — nutze CSS-var(--orange)</td></tr>
<tr><td>Check-Kreis</td><td>w-5 h-5 rounded-full bg-orange-500 flex items-center justify-center self-end mt-2</td><td>nur sichtbar wenn ausgewählt</td></tr>
<tr><td>Kartentitel</td><td>font-serif text-sm text-ink</td><td></td></tr>
<tr><td>Kartenbeschreibung</td><td>text-xs text-ink-3 leading-relaxed mt-1</td><td></td></tr>
<tr class="grp"><td colspan="3">Navigation</td></tr>
<tr><td>Weiter-Button</td><td>rounded border border-primary bg-primary text-white px-4 py-2 text-sm font-medium disabled:opacity-40</td><td>disabled wenn keine Karte ausgewählt</td></tr>
<tr><td>URL-Param</td><td>?type=STORY | ?type=JOURNEY</td><td>per goto() nach Klick auf Weiter; lesefreundlich bookmarkbar</td></tr>
<tr><td>Mobile</td><td>flex-col Karten; volle Breite</td><td>kein Scrollbedarf auf 320px</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ═══ SCREEN LR-1: LIST WITH BADGE ═══ -->
<div class="section">
<div class="section-title">Screens — Übersichtsliste</div>
<div class="scr">
<div class="scr-head">
<h3>LR-1 — Reise-Badge in /geschichten</h3>
<span class="scr-id">Issue #752 · LR-1</span>
</div>
<p class="scr-desc">Die Übersichtsliste erhält ein kleines „REISE"-Badge in der Metaspalte einer Journey-Zeile — unterhalb von Datum und Personenchip. Zeilen mit <code>type === 'STORY'</code> bleiben unverändert. Das Badge ist nicht klickbar, dient als reine visuelle Unterscheidung.</p>
<p class="scr-var"><strong>Varianten:</strong> Mischte Liste (hier gezeigt) · Nur-Journey-Filter · Nur-Story-Ansicht (unverändert)</p>
<div class="previews">
<!-- Desktop -->
<div class="prev-col" style="width:100%;max-width:1040px;">
<span class="bp-lbl">Desktop — 1040px · gemischte Liste</span>
<div class="desk">
<div class="fa-nav">
<span class="fa-logo">ARCHIV</span>
<span style="width:1px;height:14px;background:rgba(255,255,255,.1);margin:0 2px;"></span>
<span class="fa-link">Dokumente</span>
<span class="fa-link">Personen</span>
<span class="fa-link active">Geschichten</span>
<span class="fa-link">Chronik</span>
<div class="fa-nav-r">
<div class="fa-av" style="background:#012851;color:var(--mint);font-size:5px;font-weight:800;">MR</div>
</div>
</div>
<div style="background:#E8E7E2;flex:1;padding:14px 16px;">
<div class="g-page-hdr" style="padding:0 0 8px;">
<span class="g-page-title">Geschichten</span>
<button class="g-new-btn">
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M5 1v8M1 5h8" stroke="#fff" stroke-width="2" stroke-linecap="round"/></svg>
Neue Geschichte
</button>
</div>
<div class="g-list-card">
<div class="g-filters">
<span class="g-pill active">Alle</span>
<span class="g-pill">Franz Raddatz</span>
<span class="g-pill">Emma Müller</span>
<span class="g-pill" style="border-style:dashed;color:#6B6A63;">+ Person wählen</span>
</div>
<!-- Row 1: Story (no badge) -->
<div class="g-row">
<div class="g-meta">
<div class="g-av av-navy">MR</div>
<div class="g-author">Maria Raddatz</div>
<div class="g-date">14. März 2025</div>
<span class="g-chip">
<span style="width:8px;height:8px;border-radius:50%;background:#012851;display:inline-flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:var(--mint);flex-shrink:0;">FR</span>
Franz Raddatz
</span>
</div>
<div class="g-content">
<div class="g-title">Der Sommer in Breslau</div>
<div class="g-excerpt">Oma erzählte oft vom letzten Sommer vor dem Krieg, als die Familie noch vollständig zusammen war und niemand ahnte, was kommen würde…</div>
</div>
</div>
<!-- Row 2: Journey (badge!) -->
<div class="g-row">
<div class="g-meta">
<div class="g-av av-purple">KR</div>
<div class="g-author">Klaus Raddatz</div>
<div class="g-date">15. Mai 2025</div>
<span class="g-chip">
<span style="width:8px;height:8px;border-radius:50%;background:#012851;display:inline-flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:var(--mint);flex-shrink:0;">FR</span>
Franz Raddatz
</span>
<span class="j-badge">REISE</span>
</div>
<div class="g-content">
<div class="g-title">Briefe aus Breslau 19381942</div>
<div class="g-excerpt">Eine Lesereise durch den Briefwechsel zwischen Franz und Emma — von den letzten Friedenssommern bis zum Ende des Krieges.</div>
</div>
</div>
<!-- Row 3: Story -->
<div class="g-row">
<div class="g-meta">
<div class="g-av av-teal">GK</div>
<div class="g-author">Gertrud Koch</div>
<div class="g-date">18. Okt. 2024</div>
<span class="g-chip">
<span style="width:8px;height:8px;border-radius:50%;background:#534AB7;display:inline-flex;align-items:center;justify-content:center;font-size:4.5px;font-weight:800;color:#fff;flex-shrink:0;">EM</span>
Emma Müller
</span>
</div>
<div class="g-content">
<div class="g-title">Die Hochzeit im Krieg</div>
<div class="g-excerpt">1943, mitten im Chaos — Emma bestand darauf, dass das Fest stattfand. Ihr Bruder kam auf Fronturlaub, drei Tage nur, aber es reichte…</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Mobile -->
<div class="prev-col">
<span class="bp-lbl">Mobile — 320px</span>
<div class="phone">
<div class="pst"><b>9:41</b><span>&#9679;&#9679;&#9679;</span></div>
<div class="pb">
<div class="m-nav">
<span class="m-logo">ARCHIV</span>
<div class="m-nav-r">
<div class="m-av">MR</div>
<div class="m-ham"><span></span><span></span><span></span></div>
</div>
</div>
<div style="background:#E8E7E2;flex:1;display:flex;flex-direction:column;">
<div style="padding:8px 10px 4px;">
<span style="font-family:Georgia,serif;font-size:13px;color:#012851;">Geschichten</span>
</div>
<div class="m-filters">
<span class="g-pill active" style="font-size:6px;padding:2px 7px;">Alle</span>
<span class="g-pill" style="font-size:6px;padding:2px 7px;">Franz Raddatz</span>
<span class="g-pill" style="font-size:6px;padding:2px 7px;border-style:dashed;">+ Person…</span>
</div>
<div style="background:#fff;flex:1;">
<!-- Story row -->
<div class="m-row">
<div class="m-row-top">
<div class="g-av av-navy" style="width:16px;height:16px;font-size:5.5px;">MR</div>
<span class="m-author-name">Maria Raddatz</span>
<span class="m-date">14. Mrz. 2025</span>
</div>
<div class="m-title">Der Sommer in Breslau</div>
<div class="m-excerpt">Oma erzählte oft vom letzten Sommer vor dem Krieg…</div>
</div>
<!-- Journey row (badge) -->
<div class="m-row">
<div class="m-row-top">
<div class="g-av av-purple" style="width:16px;height:16px;font-size:5.5px;">KR</div>
<span class="m-author-name">Klaus Raddatz</span>
<span class="m-date">15. Mai 2025</span>
</div>
<div style="display:flex;align-items:center;gap:5px;margin-bottom:3px;">
<div class="m-title" style="margin-bottom:0;">Briefe aus Breslau 19381942</div>
<span class="j-badge" style="flex-shrink:0;">REISE</span>
</div>
<div class="m-excerpt">Eine Lesereise durch den Briefwechsel zwischen Franz und Emma…</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>impl-ref — LR-1 Journey-Badge in der Liste</h4>
<table class="at">
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
<tbody>
<tr class="grp"><td colspan="3">Badge</td></tr>
<tr><td>Journey badge</td><td>inline-flex items-center px-1.5 py-px rounded-sm text-[10px] font-bold uppercase tracking-wide bg-orange-50 text-orange-700 border border-orange-200</td><td>nur wenn type === 'JOURNEY'</td></tr>
<tr><td>Position Desktop</td><td>unterhalb Datum-Text und Personenchip in der Metaspalte (g-meta)</td><td>kein extra Abstand nötig — gap-1 der Flex-Spalte reicht</td></tr>
<tr><td>Position Mobile</td><td>inline flex items-center gap-1.5 neben Titel</td><td>Titel + Badge in einem flex-Wrapper; badge shrink-0</td></tr>
<tr><td>aria-label</td><td>aria-label="Lesereise"</td><td>Badge ist span, kein interaktives Element</td></tr>
<tr class="grp"><td colspan="3">Bedingte Logik</td></tr>
<tr><td>Svelte guard</td><td>{#if geschichte.type === 'JOURNEY'}&lt;span …&gt;REISE&lt;/span&gt;{/if}</td><td>kein Badge für STORY</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ═══ SCREEN LR-2: JOURNEY READER ═══ -->
<div class="section">
<div class="section-title">Screens — Journey-Leseansicht</div>
<div class="scr">
<div class="scr-head">
<h3>LR-2 — Journey-Detail /geschichten/[id]</h3>
<span class="scr-id">Issue #752 · LR-2</span>
</div>
<p class="scr-desc">Wenn <code>type === 'JOURNEY'</code> ersetzt die geordnete Briefliste den Prosa-Body. Optional zeigt ein Einleitungsabsatz (<code>body</code>) vor den Items. Jedes Item ist entweder ein Briefeintrag (Kartentitel, Datum, Link) oder ein Interlude-Absatz (orangener linker Rand, kursiv). Die Reihenfolge ergibt sich von oben nach unten — keine Nummern. Briefeinträge können eine optionale Kuratoren-Annotation unter dem Link zeigen.</p>
<p class="scr-var"><strong>Varianten:</strong> Leserin ohne Schreibrecht · BLOG_WRITER (Bearbeiten/Löschen sichtbar — hier gezeigt) · Mobile</p>
<div class="previews">
<!-- Desktop -->
<div class="prev-col" style="width:100%;max-width:1040px;">
<span class="bp-lbl">Desktop — 1040px · BLOG_WRITER-Ansicht</span>
<div class="desk" style="min-height:600px;">
<div class="fa-nav">
<span class="fa-logo">ARCHIV</span>
<span style="width:1px;height:14px;background:rgba(255,255,255,.1);margin:0 2px;"></span>
<span class="fa-link">Dokumente</span>
<span class="fa-link">Personen</span>
<span class="fa-link active">Geschichten</span>
<span class="fa-link">Chronik</span>
<div class="fa-nav-r">
<div class="fa-av" style="background:#012851;color:var(--mint);font-size:5px;font-weight:800;">MR</div>
</div>
</div>
<div style="background:#E8E7E2;flex:1;padding:16px 20px;">
<div class="jr-article">
<div class="jr-back">
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M6 2L2 5l4 3" stroke="#6B6A63" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
Zurück zu Geschichten
</div>
<div class="jr-badge">LESEREISE</div>
<div class="jr-title">Briefe aus Breslau 19381942</div>
<div class="jr-metabar">
<div class="g-av av-purple" style="width:20px;height:20px;font-size:6.5px;">KR</div>
<div>
<div style="font-size:7.5px;font-weight:700;color:#1C1C18;line-height:1.2;">Klaus Raddatz</div>
<div style="font-size:6.5px;color:#6B6A63;">zusammengestellt am 15. Mai 2025</div>
</div>
<div class="jr-metabar-r">
<button class="jr-edit-btn">Bearbeiten</button>
<span style="font-size:6.5px;font-weight:600;color:#DC4C3E;">Löschen</span>
</div>
</div>
<!-- Intro -->
<div class="jr-intro">Der Briefwechsel zwischen Franz Raddatz und seiner Schwester Emma umspannt vier Jahre — von den letzten unbeschwerten Sommerwochen 1938 bis zum Kriegsende. Diese Lesereise folgt den Briefen in chronologischer Reihenfolge.</div>
<!-- Item 1: Document, no annotation -->
<div class="jr-item">
<div class="jr-card">
<div class="jr-card-title">Brief vom 12. Juli 1938</div>
<div class="jr-card-meta">12. Juli 1938 &middot; von Franz Raddatz an Emma Müller</div>
<div class="jr-card-link">
<svg width="8" height="8" viewBox="0 0 10 12" fill="none"><rect x="1" y="1" width="8" height="10" rx="1" stroke="#012851" stroke-width="1"/><path d="M3 4h4M3 6.5h4M3 9h2" stroke="#012851" stroke-width=".7" stroke-linecap="round"/></svg>
Brief öffnen
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M4 2l4 3-4 3" stroke="#012851" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
</div>
</div>
<!-- Interlude -->
<div class="jr-interlude">
<div class="jr-interlude-text">Im Sommer 1938 schrieb Franz voller Zuversicht — er hatte kaum eine Ahnung, wie bald sich die Welt um ihn herum verändern würde. Seine Briefe aus dieser Zeit tragen eine Leichtigkeit, die in den späteren Kriegsjahren vollständig verschwindet.</div>
</div>
<!-- Item 2: Document with annotation -->
<div class="jr-item">
<div class="jr-card">
<div class="jr-card-title">Postkarte aus Breslau, August 1938</div>
<div class="jr-card-meta">22. Aug. 1938 &middot; von Franz Raddatz an Emma Müller</div>
<div class="jr-card-link">
<svg width="8" height="8" viewBox="0 0 10 12" fill="none"><rect x="1" y="1" width="8" height="10" rx="1" stroke="#012851" stroke-width="1"/><path d="M3 4h4M3 6.5h4M3 9h2" stroke="#012851" stroke-width=".7" stroke-linecap="round"/></svg>
Brief öffnen
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M4 2l4 3-4 3" stroke="#012851" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
<div class="jr-annotation">
<div class="jr-annotation-text">Diese Karte ist ungewöhnlich kurz für Franz — vier Zeilen, fast hastig. Ein Zeichen der aufkommenden Unruhe in den Nachrichten, oder schlicht die Hitze des Augusts?</div>
</div>
</div>
</div>
<!-- Item 3: Document -->
<div class="jr-item">
<div class="jr-card">
<div class="jr-card-title">Brief vom 3. September 1939</div>
<div class="jr-card-meta">3. Sept. 1939 &middot; von Emma Müller an Franz Raddatz</div>
<div class="jr-card-link">
<svg width="8" height="8" viewBox="0 0 10 12" fill="none"><rect x="1" y="1" width="8" height="10" rx="1" stroke="#012851" stroke-width="1"/><path d="M3 4h4M3 6.5h4M3 9h2" stroke="#012851" stroke-width=".7" stroke-linecap="round"/></svg>
Brief öffnen
<svg width="7" height="7" viewBox="0 0 10 10" fill="none"><path d="M4 2l4 3-4 3" stroke="#012851" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Mobile -->
<div class="prev-col">
<span class="bp-lbl">Mobile — 320px · Leserin</span>
<div class="phone" style="min-height:520px;">
<div class="pst"><b>9:41</b><span>&#9679;&#9679;&#9679;</span></div>
<div class="pb">
<div class="m-nav">
<span class="m-logo">ARCHIV</span>
<div class="m-nav-r">
<div class="m-av">MR</div>
<div class="m-ham"><span></span><span></span><span></span></div>
</div>
</div>
<div style="background:#E8E7E2;flex:1;padding:10px;">
<div class="mjr-article">
<div class="mjr-back">
<svg width="6" height="6" viewBox="0 0 10 10" fill="none"><path d="M6 2L2 5l4 3" stroke="#6B6A63" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
Zurück
</div>
<div class="mjr-badge">LESEREISE</div>
<div class="mjr-title">Briefe aus Breslau 19381942</div>
<div class="mjr-metabar">
<div class="g-av av-purple" style="width:16px;height:16px;font-size:5.5px;flex-shrink:0;">KR</div>
<div>
<div style="font-size:7px;font-weight:700;color:#1C1C18;">Klaus Raddatz</div>
<div style="font-size:6px;color:#6B6A63;">15. Mai 2025</div>
</div>
<div style="margin-left:auto;font-size:12px;color:#6B6A63;">···</div>
</div>
<div style="height:1px;background:#EDECEA;margin-bottom:8px;"></div>
<div class="mjr-intro">Der Briefwechsel zwischen Franz und Emma — von 1938 bis Kriegsende.</div>
<!-- Item 1 -->
<div class="mjr-item">
<div class="mjr-card">
<div class="mjr-card-title">Brief vom 12. Juli 1938</div>
<div class="mjr-card-meta">12. Juli 1938 · Franz → Emma</div>
<div class="mjr-card-link">Brief öffnen →</div>
</div>
</div>
<!-- Interlude -->
<div class="mjr-interlude">
<div class="mjr-interlude-text">Im Sommer 1938 schrieb Franz voller Zuversicht — er hatte kaum eine Ahnung, wie bald sich die Welt um ihn herum verändern würde.</div>
</div>
<!-- Item 2 -->
<div class="mjr-item">
<div class="mjr-card">
<div class="mjr-card-title">Postkarte Aug. 1938</div>
<div class="mjr-card-meta">22. Aug. 1938 · Franz → Emma</div>
<div class="mjr-card-link">Brief öffnen →</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>impl-ref — LR-2 Journey-Leseansicht</h4>
<table class="at">
<thead><tr><th>Element</th><th>Wert</th><th>Hinweise</th></tr></thead>
<tbody>
<tr class="grp"><td colspan="3">Seitenstruktur</td></tr>
<tr><td>Bedingte Logik</td><td>{#if geschichte.type === 'JOURNEY'} JourneyReader {:else} StoryReader {/if}</td><td>in +page.svelte von /geschichten/[id]</td></tr>
<tr><td>Artikel-Container</td><td>max-w-3xl mx-auto px-4 py-8</td><td>gleich wie StoryReader</td></tr>
<tr><td>Journey-Badge</td><td>inline-flex px-2 py-px rounded-sm text-[10px] font-bold uppercase tracking-widest bg-orange-50 text-orange-700 border border-orange-200 mb-2</td><td>über dem Titel; nicht für STORY</td></tr>
<tr><td>Titel</td><td>font-serif text-3xl text-ink leading-tight mb-4</td><td>gleich wie Story</td></tr>
<tr><td>Metabar</td><td>flex items-center gap-3 pb-4 border-b border-subtle mb-4</td><td>gleich wie Story</td></tr>
<tr><td>Bearbeiten/Löschen</td><td>nur BLOG_WRITE; auf Mobile im ··· BottomSheet</td><td>gleich wie Story</td></tr>
<tr class="grp"><td colspan="3">Intro-Absatz</td></tr>
<tr><td>Intro (body)</td><td>font-serif text-sm text-ink-2 italic leading-relaxed mb-6 pb-4 border-b border-dashed border-subtle</td><td>nur rendern wenn body nicht leer; kein HTML-Rendering — plaintext</td></tr>
<tr class="grp"><td colspan="3">Dokument-Item</td></tr>
<tr><td>Item-Zeile</td><td>mb-3</td><td>kein flex nötig — Karte ist full-width</td></tr>
<tr><td>Dokumentkarte</td><td>bg-white border border-line rounded-sm p-3</td><td></td></tr>
<tr><td>Brieftitel</td><td>font-serif text-sm text-ink leading-snug mb-0.5</td><td>document.title</td></tr>
<tr><td>Briefmeta</td><td>text-xs text-ink-3 mb-2</td><td>formatDate(document.documentDate) · "von X an Y"</td></tr>
<tr><td>Brief öffnen Link</td><td>inline-flex items-center gap-1 text-xs font-semibold text-ink hover:text-primary</td><td>href="/documents/{item.document.id}"</td></tr>
<tr class="grp"><td colspan="3">Kuratoren-Annotation</td></tr>
<tr><td>Annotation</td><td>mt-3 pl-3 border-l-2 border-mint bg-surface rounded-r-sm py-1.5 pr-2</td><td>nur rendern wenn item.note vorhanden</td></tr>
<tr><td>Annotations-Text</td><td>text-xs italic text-ink-2 leading-relaxed</td><td></td></tr>
<tr class="grp"><td colspan="3">Interlude-Item</td></tr>
<tr><td>Interlude-Block</td><td>pl-3 border-l-2 border-orange-400 bg-orange-50 rounded-r-sm py-2 pr-3 my-4</td><td>item.document === null</td></tr>
<tr><td>Interlude-Text</td><td>text-xs italic text-ink leading-relaxed</td><td>item.note; plaintext</td></tr>
<tr class="grp"><td colspan="3">Mobile</td></tr>
<tr><td>··· Menü</td><td>ml-auto text-ink-3; öffnet BottomSheet mit Bearbeiten + Löschen</td><td>BLOG_WRITE; gleich wie Story</td></tr>
<tr><td>Touch Target (Brief öffnen)</td><td>min-h-[44px] durch padding auf der Karte</td><td>WCAG 2.2 AA</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ═══ LLM IMPLEMENTATION GUIDE ═══ -->
<div class="llm">
<h2>Implementation Guide — Lesereisen Reader</h2>
<h3>Geänderte Views und Routen</h3>
<table>
<thead><tr><th>View</th><th>Route</th><th>Änderung</th></tr></thead>
<tbody>
<tr><td>Neue Geschichte</td><td>/geschichten/new</td><td>Neuer Typauswahl-Schritt als first render; setzt ?type=STORY|JOURNEY</td></tr>
<tr><td>Geschichten-Liste</td><td>/geschichten</td><td>Journey-Badge in GeschichtenCard wenn type === 'JOURNEY'</td></tr>
<tr><td>Geschichte-Detail</td><td>/geschichten/[id]</td><td>Bedingte Verzweigung: JourneyReader | StoryReader</td></tr>
</tbody>
</table>
<h3>Neue Komponenten</h3>
<ul>
<li><code>JourneyReader.svelte</code> — rendert Intro + Items-Liste; Props: <code>geschichte: GeschichteDetail</code></li>
<li><code>JourneyItemCard.svelte</code> — ein Dokument-Item mit optionaler Annotation; Props: <code>item: JourneyItem, position: number</code></li>
<li><code>JourneyInterlude.svelte</code> — ein reiner Text-Interlude; Props: <code>note: string</code></li>
</ul>
<h3>Datenmodell (nach #750)</h3>
<ul>
<li><code>GeschichteType: 'STORY' | 'JOURNEY'</code></li>
<li><code>JourneyItem: { id: UUID, position: number, document: DocumentSummary | null, note: string | null }</code></li>
<li><code>Geschichte.items</code> — geordnete Liste (nach <code>position</code> ASC); für STORY leer</li>
<li><code>Geschichte.body</code> — für JOURNEY der optionale Einleitungstext (plaintext, kein HTML); für STORY der Rich-Text-Body</li>
</ul>
<h3>Typauswahl — Implementierungshinweise</h3>
<ul>
<li>Die Typauswahl ist ein Schritt INNERHALB der <code>/geschichten/new</code>-Route — kein eigener URL, kein <code>goto()</code>. Zustand <code>let selectedType: GeschichteType | null = null</code> in der Komponente.</li>
<li>Erst wenn <code>selectedType !== null</code> ist der „Weiter"-Button aktiviert (<code>disabled={!selectedType}</code>).</li>
<li>Nach Klick auf „Weiter": wenn <code>selectedType === 'JOURNEY'</code><code>goto('/geschichten/new?type=JOURNEY')</code> und zeige den Journey-Editor (aus Issue #753); wenn <code>STORY</code> → bestehender GeschichteEditor (unverändert).</li>
<li>Die Karten verwenden <code>role="radio"</code> und <code>aria-checked</code> für Accessibility. Keyboard: Arrow-Keys wechseln zwischen den Karten, Space/Enter wählt aus.</li>
</ul>
<h3>Journey-Badge — Implementierungshinweise</h3>
<ul>
<li>Badge nur in <code>GeschichtenCard.svelte</code> hinzufügen — keine Änderung an der Listenlogik oder dem API-Aufruf.</li>
<li>Text: „REISE" (Kurzform für die Metaspalte); <code>aria-label="Lesereise"</code> für den Badge-Span.</li>
</ul>
<h3>Journey-Reader — Implementierungshinweise</h3>
<ul>
<li>Items werden bereits geordnet vom Backend geliefert (<code>ORDER BY position ASC</code>). Keine client-seitige Sortierung nötig.</li>
<li>Ein Item ist Interlude wenn <code>item.document === null</code>. In diesem Fall: <code>JourneyInterlude</code>-Komponente rendern.</li>
<li>Der Intro-Absatz (<code>body</code>) wird als Plaintext gerendert — <em>nicht</em> als innerHTML. Im Editor wird es als einfaches Textarea gespeichert, kein HTML.</li>
</ul>
<h3>Berechtigungen</h3>
<ul>
<li>„Bearbeiten" und „Löschen" nur für <code>currentUser.permissions.includes('BLOG_WRITE')</code> — gleich wie Story.</li>
<li>Auf Mobile: Bearbeiten/Löschen im BottomSheet hinter ··· — gleich wie Story.</li>
</ul>
<h3>Barrierefreiheit</h3>
<ul>
<li>Items-Liste: <code>&lt;ol&gt;</code> semantisch für die geordnete Briefliste. Interludes sind <code>&lt;li&gt;</code>-Elemente mit <code>aria-label="Kuratorennotiz"</code>.</li>
<li>„Brief öffnen"-Link: beschreibender Text mit Briefdatum im <code>aria-label</code>, z.B. <code>aria-label="Brief vom 12. Juli 1938 öffnen"</code>.</li>
<li>Touch-Targets: jede Dokumentkarte hat mindestens 44px Höhe durch den Padding der Karte.</li>
<li>Fokusring: <code>focus-visible:ring-2 focus-visible:ring-primary</code> auf allen Links.</li>
</ul>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,418 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Zeitstrahl — Ereignis-Editor &amp; Brief-Gruppierung · Quick-Action im Dokument · Familienarchiv</title>
<link href="https://fonts.googleapis.com/css2?family=Tinos:ital,wght@0,400;0,700;1,400&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Montserrat',system-ui,sans-serif;background:#ECEAE4;color:#1A1A1A;line-height:1.5;font-size:13px}
.page{max-width:1320px;margin:0 auto;padding:48px 32px 120px}
.mh{padding-bottom:24px;border-bottom:3px solid #012851;margin-bottom:48px}
.mh h1{font-size:23px;font-weight:900;color:#012851;letter-spacing:-.4px}
.mh p{font-size:13px;color:#555;max-width:790px;line-height:1.75;margin-top:8px}
.mh .byline{font-size:9px;color:#999;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;margin-top:10px}
.tag-row{display:flex;gap:6px;margin-top:12px;flex-wrap:wrap}
.tg{background:#012851;color:#a1dcd8;padding:2px 8px;border-radius:2px;font-size:8px;font-weight:700;letter-spacing:.8px;text-transform:uppercase}
.tg.mint{background:#a1dcd8;color:#012851}
.tg.slate{background:#607080;color:#e8edf2}
.sh{margin:60px 0 22px;padding-bottom:12px;border-bottom:2px solid #E0DDD6}
.sh h2{font-size:17px;font-weight:900;color:#012851}
.sh p{font-size:12.5px;color:#666;margin-top:5px;max-width:790px;line-height:1.65}
.callout{padding:13px 17px;border-radius:4px;font-size:12px;line-height:1.65;margin-bottom:18px}
.callout.navy{background:rgba(1,40,81,.06);border-left:3px solid #012851;color:#333}
.callout.mint{background:rgba(161,220,216,.18);border-left:3px solid #00c7b1;color:#1f3a3a}
.callout code{font-family:'Courier New',monospace;font-size:11px;background:#fff;padding:1px 4px;border-radius:2px}
.callout strong{font-weight:800;color:#012851}
/* desktop chrome */
.dchrome{background:#F0EFE9;border:1.5px solid #C4C0BA;border-radius:9px;overflow:hidden;box-shadow:0 8px 30px rgba(0,0,0,.12)}
.dbar{height:22px;background:#E2DFD8;border-bottom:1px solid #C4C0BA;display:flex;align-items:center;gap:4px;padding:0 9px}
.ddot{width:7px;height:7px;border-radius:50%;background:#C4BFB8}
.durl{flex:1;height:10px;background:#D2CEC8;border-radius:5px;margin:0 8px;max-width:360px}
.dnav{height:32px;background:#012851;display:flex;align-items:center;gap:13px;padding:0 16px}
.dnav .nlogo{font-family:'Tinos',serif;font-size:10px;color:#fff;font-weight:700}
.dnav .nlink{font-size:7.5px;color:rgba(255,255,255,.5);font-weight:700;text-transform:uppercase;letter-spacing:.4px}
.dnav .nlink.on{color:#fff;border-bottom:2px solid #a1dcd8;padding-bottom:9px}
.dnav .av{margin-left:auto;width:18px;height:18px;border-radius:50%;background:rgba(255,255,255,.12);display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;color:rgba(255,255,255,.55)}
.dcanvas{background:#f0efe9;padding:20px 26px 26px}
/* form atoms (mirror real Tailwind look) */
.lbl{font-size:8.5px;font-weight:800;letter-spacing:1.2px;text-transform:uppercase;color:#6b7280;margin-bottom:6px}
.inp{border:1px solid #e4e2d7;border-radius:4px;background:#fff;padding:8px 11px;font-size:12px;color:#012851}
.inp.title{font-family:'Tinos',serif;font-size:22px;font-weight:700;padding:11px 13px}
.card{border:1px solid #e4e2d7;border-radius:6px;background:#fff;padding:15px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
.card h3{font-size:8.5px;font-weight:800;letter-spacing:1.2px;text-transform:uppercase;color:#6b7280;margin-bottom:3px}
.card .hint{font-size:9.5px;color:#9a9a96;margin-bottom:9px}
.seg{display:inline-flex;border:1px solid #012851;border-radius:5px;overflow:hidden}
.seg span{font-size:10px;font-weight:700;padding:5px 13px;color:#012851}
.seg span.on{background:#012851;color:#fff}
.seg span+span{border-left:1px solid #012851}
.chips{border:1px solid #e4e2d7;border-radius:5px;background:#fff;padding:7px;display:flex;flex-wrap:wrap;gap:6px;align-items:center}
.chip-sel{display:inline-flex;align-items:center;gap:4px;background:#f5f4ef;border-radius:4px;padding:3px 8px;font-size:10px;color:#012851}
.chip-sel .x{color:#012851;opacity:.45;font-size:11px}
.chip-in{flex:1;min-width:90px;font-size:10.5px;color:#9a9a96;padding:3px 4px}
.btn{display:inline-flex;align-items:center;gap:6px;height:38px;padding:0 16px;border-radius:5px;font-size:12px;font-weight:600}
.btn.primary{background:#012851;color:#fff}
.btn.ghost{background:#fff;border:1px solid #e4e2d7;color:#012851}
.btn.danger{background:#fff;border:1px solid #e7c9c4;color:#c0392b}
.tagchip{display:inline-flex;align-items:center;gap:3px;font-size:8px;border-radius:9px;padding:2px 7px;color:#a0522d;background:#f6ece6}
.tagchip i{width:6px;height:6px;border-radius:2px;background:#a0522d;display:inline-block}
/* annotation callouts on mockups */
.anno{display:flex;gap:9px;align-items:flex-start;font-size:11.5px;color:#4a4a46;line-height:1.55;margin-bottom:7px}
.anno .n{flex-shrink:0;width:18px;height:18px;border-radius:50%;background:#012851;color:#a1dcd8;font-size:9px;font-weight:800;display:flex;align-items:center;justify-content:center;margin-top:1px}
.anno b{color:#012851}
.anno code{font-size:10px;background:#F0EFE9;padding:1px 4px;border-radius:2px}
.annogrid{display:grid;grid-template-columns:1fr 1fr;gap:6px 26px;margin-top:14px}
/* dropdown */
.dd{border:1px solid #e4e2d7;border-radius:6px;background:#fff;box-shadow:0 6px 18px rgba(0,0,0,.12);overflow:hidden;margin-top:3px}
.dd .opt{padding:7px 11px;font-size:11px;color:#012851;border-bottom:1px solid #f3f1ea;cursor:pointer}
.dd .opt:hover,.dd .opt.hl{background:#f5f4ef}
.dd .opt:last-child{border-bottom:none}
.dd .opt .d{font-size:8.5px;color:#9a9a96}
/* states grid */
.states{display:grid;grid-template-columns:repeat(2,1fr);gap:18px}
.state{border:1px solid #E0DDD6;border-radius:8px;background:#fff;overflow:hidden}
.state .sh2{background:#F4F2EC;border-bottom:1px solid #E0DDD6;padding:7px 12px;font-size:8.5px;font-weight:800;letter-spacing:.8px;text-transform:uppercase;color:#012851;display:flex;justify-content:space-between}
.state .sb{padding:13px}
.cap{font-size:11px;color:#888;font-style:italic;line-height:1.55;margin-top:8px}
/* impl-ref */
.impl-ref{background:#fff;border:1px solid #E0DDD6;border-radius:7px;overflow:hidden;margin-top:8px}
.impl-ref table{width:100%;border-collapse:collapse}
.impl-ref th{background:#012851;color:#fff;padding:8px 13px;text-align:left;font-size:8px;font-weight:800;letter-spacing:.6px;text-transform:uppercase}
.impl-ref td{padding:8px 13px;border-bottom:1px solid #F0EEE8;vertical-align:top;font-size:11px;color:#444;line-height:1.55}
.impl-ref tr:nth-child(even) td{background:#FAFAF7}
.impl-ref td:first-child{font-weight:700;color:#012851;white-space:nowrap;width:185px}
.impl-ref td code{font-size:9.5px;background:#F0EFE9;padding:1px 4px;border-radius:2px;font-family:'Courier New',monospace;color:#333}
hr{border:none;border-top:2px dashed #C8C4BE;margin:52px 0}
.note{font-size:11px;color:#888;font-style:italic;margin-top:10px;line-height:1.6}
</style>
</head>
<body>
<div class="page">
<!-- ══ MASTHEAD ══ -->
<div class="mh">
<h1>Ereignis-Editor &amp; Brief-Gruppierung · Quick-Action im Dokument</h1>
<p>Wie kuratierte Zeitstrahl-Ereignisse entstehen und wie Briefe gruppiert werden — von zwei Seiten in ein Datenmodell (<code style="font-family:monospace;font-size:12px">TimelineEvent.documents</code>): der <strong>Ereignis-Editor</strong> unter <code style="font-family:monospace;font-size:12px">/zeitstrahl/events/[id]/edit</code> (Kurator baut, verlinkt viele Briefe) und die <strong>Quick-Action im Dokument-Detail</strong> (beim Lesen schnell zuordnen). Beide bauen auf bereits ausgelieferten Komponenten auf.</p>
<div class="tag-row">
<span class="tg">Milestone #14 · Zeitstrahl</span>
<span class="tg mint">Reuse: GeschichteEditor · DocumentMultiSelect · PersonMultiSelect</span>
<span class="tg slate">WRITE_ALL</span>
</div>
<div class="byline">Familienarchiv · 2026-06-08 · Leonie Voss, UX Lead · gegründet auf Code: GeschichteEditor.svelte · DocumentMetadataDrawer.svelte · DocumentMultiSelect.svelte</div>
</div>
<!-- ══ 1 · TWO ENTRY POINTS ══ -->
<div class="sh">
<h2>1 · Zwei Einstiegspunkte, ein Datenmodell</h2>
<p>Manuelle Gruppierung = ein <code>TimelineEvent</code> mit verknüpften Dokumenten. Kuratoren arbeiten in beide Richtungen — wir bauen beide, statt eine zu erzwingen.</p>
</div>
<div class="states" style="grid-template-columns:1fr 1fr">
<div class="callout navy" style="margin:0">
<strong>A · Ereignis-zuerst</strong> — der Kurator baut den Zeitstrahl. <code>/zeitstrahl/events/new · [id]/edit</code> mit Dokument-Mehrfach-Picker = <b>Bulk-Linking</b> vieler Briefe auf einmal. Spiegelt 1:1 den <code>GeschichteEditor</code> (gleiche zwei-Spalten-Form, Sidebar-Picker, Sticky-Save-Bar).
</div>
<div class="callout mint" style="margin:0">
<strong>B · Dokument-zuerst</strong> — beim Lesen eines Briefs. Quick-Action im Dokument-Detail: bestehendes Ereignis wählen <i>oder</i> neu anlegen, verlinkt diesen einen Brief. Spiegelt die bestehende <b>Geschichten-Spalte</b> im Details-Drawer (<code>DocumentMetadataDrawer.svelte</code>).
</div>
</div>
<!-- ══ 2 · EVENT EDITOR ══ -->
<div class="sh">
<h2>2 · Ereignis-Editor — <code style="font-size:14px">/zeitstrahl/events/[id]/edit</code></h2>
<p>Form-Actions-Muster, gegated mit <code>WRITE_ALL</code>. Layout &amp; Verhalten 1:1 vom <code>GeschichteEditor</code> übernommen: Hauptspalte + Sidebar (<code>lg:grid-cols-[2fr_1fr]</code>), Sticky-Save-Bar, <code>beforeNavigate</code>-Warnung bei ungespeicherten Änderungen.</p>
</div>
<div class="dchrome" style="margin-bottom:16px">
<div class="dbar"><div class="ddot"></div><div class="ddot"></div><div class="ddot"></div><div class="durl"></div></div>
<div class="dnav"><span class="nlogo">Familienarchiv</span><span class="nlink">Dokumente</span><span class="nlink">Personen</span><span class="nlink on">Zeitstrahl</span><span class="nlink">Stammbaum</span><span class="av">KR</span></div>
<div class="dcanvas">
<div style="font-size:8px;font-weight:800;letter-spacing:1px;text-transform:uppercase;color:#8a8a86;margin-bottom:10px"> Zurück zum Zeitstrahl</div>
<div style="font-family:'Tinos',serif;font-size:18px;font-weight:700;color:#012851;margin-bottom:16px">Ereignis bearbeiten</div>
<div style="display:grid;grid-template-columns:2fr 1fr;gap:22px">
<!-- MAIN COLUMN -->
<div style="display:flex;flex-direction:column;gap:16px">
<!-- ① title -->
<div>
<div class="inp title" style="width:100%">Briefe von der Front</div>
</div>
<!-- ② type + ③ date/precision -->
<div style="display:flex;gap:22px;flex-wrap:wrap">
<div>
<div class="lbl">② Typ</div>
<div class="seg"><span class="on">Persönlich</span><span>Historisch</span></div>
</div>
<div>
<div class="lbl">③ Datum · Präzision</div>
<div style="display:flex;gap:6px;align-items:center">
<div class="inp" style="width:120px">1915</div>
<div class="inp" style="display:flex;align-items:center;gap:18px;color:#6b7280">Jahr <span style="font-size:8px"></span></div>
</div>
<div style="font-size:9px;color:#9a9a96;margin-top:5px;font-style:italic">Bei „Zeitspanne" erscheint ein zweites End-Datum-Feld. Bei „ca." / „Saison" passt sich nur das Label an.</div>
</div>
</div>
<!-- ④ description -->
<div>
<div class="lbl">④ Beschreibung <span style="color:#bbb;font-weight:600">· optional</span></div>
<div class="inp" style="width:100%;min-height:96px;color:#4a4a46;font-family:'Tinos',serif;line-height:1.6">Karls Feldpost von der Westfront, 1915 — wöchentliche Briefe an Elfriede und den neugeborenen Hans. Eine zusammenhängende Korrespondenz, die hier als Cluster gebündelt wird …</div>
<div style="font-size:9px;color:#9a9a96;margin-top:5px;font-style:italic">Schlichtes Textfeld (kein Rich-Text wie Geschichten) — Ereignisse sind kurze Notizen, keine Langform.</div>
</div>
</div>
<!-- SIDEBAR -->
<aside style="display:flex;flex-direction:column;gap:16px">
<!-- ⑤ linked letters = grouping -->
<div class="card" style="border-color:#a1dcd8;box-shadow:0 2px 10px rgba(161,220,216,.3)">
<h3 style="color:#012851">⑤ Verknüpfte Briefe · 24</h3>
<div class="hint">Diese Briefe bilden den Cluster. <code style="font-size:9px">DocumentMultiSelect</code></div>
<div class="chips">
<span class="chip-sel">✉ Westfront-Brief · Mär 1915 <span class="x">×</span></span>
<span class="chip-sel">✉ Feldpost Verdun · Jul 1915 <span class="x">×</span></span>
<span class="chip-sel">✉ Brief an Elfriede · Sep 1915 <span class="x">×</span></span>
<span class="chip-in">Brief suchen …</span>
</div>
</div>
<!-- ⑥ persons -->
<div class="card">
<h3>⑥ Beteiligte Personen</h3>
<div class="hint">Treibt die Lebensweg-Ansicht &amp; Filter. <code style="font-size:9px">PersonMultiSelect</code></div>
<div class="chips">
<span class="chip-sel">Karl Raddatz <span class="x">×</span></span>
<span class="chip-sel">Elfriede Raddatz <span class="x">×</span></span>
<span class="chip-sel">Hans Raddatz <span class="x">×</span></span>
<span class="chip-in">Person suchen …</span>
</div>
</div>
</aside>
</div>
<!-- ⑦ sticky save bar -->
<div style="margin:18px -26px -26px;border-top:1px solid #e4e2d7;background:#fff;box-shadow:0 -2px 8px rgba(0,0,0,.05);padding:13px 26px;display:flex;align-items:center;justify-content:space-between">
<span style="font-size:10px;color:#9a9a96">Änderungen werden erst beim Speichern übernommen.</span>
<div style="display:flex;gap:8px">
<span class="btn danger">Löschen</span>
<span class="btn ghost">Abbrechen</span>
<span class="btn primary">Speichern</span>
</div>
</div>
</div>
</div>
<div class="annogrid">
<div class="anno"><span class="n"></span><span><b>Titel</b> — großes Serifen-Feld, wie der Geschichten-Titel. Pflichtfeld (Validierung bei Blur).</span></div>
<div class="anno"><span class="n"></span><span><b>Typ</b><code>PERSONAL</code> / <code>HISTORICAL</code> Segmented-Control. Steuert Rendering (Mint-Pille vs. Welt-Band).</span></div>
<div class="anno"><span class="n"></span><span><b>Datum + Präzision</b> — geteilte <code>DatePrecisionInput</code> (gleiche Logik wie Dokument-Datum, <code>metaDatePrecision</code>). „Zeitspanne" blendet End-Datum ein.</span></div>
<div class="anno"><span class="n"></span><span><b>Beschreibung</b> — optionales Textfeld (<code>TEXT</code>), bewusst schlicht.</span></div>
<div class="anno"><span class="n"></span><span><b>Verknüpfte Briefe</b><b>hier wird gruppiert.</b> Wiederverwendung von <code>DocumentMultiSelect</code> (Typeahead, Chips, Hidden-Inputs).</span></div>
<div class="anno"><span class="n"></span><span><b>Beteiligte Personen</b><code>PersonMultiSelect</code>. Bestimmt, in welchem „Lebensweg" das Ereignis auftaucht.</span></div>
<div class="anno"><span class="n"></span><span><b>Sticky-Save-Bar</b> — Speichern primär, Abbrechen sekundär, Löschen nur im Edit-Modus (mit Bestätigung).</span></div>
<div class="anno"><span class="n"></span><span><b>/new</b> — leeres Formular. Mit <code>?documentId=…</code> ist Feld ⑤ vorbefüllt (aus der Quick-Action, §4-D).</span></div>
</div>
<!-- ══ 3 · GROUPING / DOCUMENT PICKER ══ -->
<div class="sh">
<h2>3 · Brief-Gruppierung im Editor — der Dokument-Picker</h2>
<p>Feld ⑤ ist der unveränderte <code>DocumentMultiSelect</code>: Tippen sucht über <code>/api/documents/search?q=…</code> (debounced 300&nbsp;ms), Treffer mit ehrlichem Datums-Label, bereits gewählte werden gefiltert. Jeder Klick fügt einen Brief zum Cluster.</p>
</div>
<div class="states">
<div class="state">
<div class="sh2"><span>Suche aktiv — Dropdown</span><span style="color:#9a9a96">DocumentMultiSelect</span></div>
<div class="sb">
<div class="chips" style="margin-bottom:0">
<span class="chip-sel">✉ Westfront-Brief · Mär 1915 <span class="x">×</span></span>
<span class="chip-sel">✉ Feldpost Verdun · Jul 1915 <span class="x">×</span></span>
<span class="chip-in" style="color:#012851">Verdun▏</span>
</div>
<div class="dd">
<div class="opt hl">Feldpost aus Verdun <span class="d">· Brief · Juli 1915</span></div>
<div class="opt">Brief aus dem Verdun-Lazarett <span class="d">· Brief · August 1916</span></div>
<div class="opt">Rückkehr aus Verdun <span class="d">· Brief · ca. 1917</span></div>
</div>
<div class="cap">Label = <code style="font-size:10px">title · formatDocumentDate(precision)</code>. Bereits verknüpfte Briefe erscheinen nicht in den Treffern (Dedup).</div>
</div>
</div>
<div class="state">
<div class="sh2"><span>Inline „+ Ereignis" am Jahres-Band</span><span style="color:#9a9a96">Zeitstrahl</span></div>
<div class="sb">
<div style="position:relative;padding-left:20px">
<div style="position:absolute;left:6px;top:2px;bottom:2px;width:2px;background:linear-gradient(#a1dcd8,#012851)"></div>
<div style="font-family:'Tinos',serif;font-size:13px;font-weight:700;color:#012851;margin-bottom:8px">1915</div>
<div style="background:#FAF9F5;border:1px solid #eeede8;border-radius:4px;padding:7px 9px;margin-bottom:8px"><div style="font-size:9.5px;font-weight:700;color:#012851">✉ 24 Briefe</div><div style="font-size:8px;color:#9a9a96">Monats-Dichte ▾</div></div>
<button style="display:inline-flex;align-items:center;gap:5px;border:1px dashed #a1dcd8;background:#fff;border-radius:6px;padding:5px 11px;font-size:10px;font-weight:600;color:#012851"> Ereignis aus diesem Jahr anlegen</button>
</div>
<div class="cap">Kuratoren können auch direkt im Zeitstrahl ein Ereignis anlegen — öffnet denselben Editor, Jahr &amp; Briefe des Bandes vorbefüllt.</div>
</div>
</div>
</div>
<hr>
<!-- ══ 4 · QUICK ACTION IN DOCUMENT DETAIL ══ -->
<div class="sh">
<h2>4 · Quick-Action im Dokument-Detail — wo sie lebt</h2>
<p>Die Dokument-Detailseite ist ein <b>vollflächiger Viewer ohne Sidebar</b> (<code>fixed inset</code>). Aktions-Flächen gibt es nur zwei: die <code>DocumentTopBar</code> und den aufklappbaren <b>Details-Drawer</b>. Die Quick-Action lebt an beiden — primär als <b>„Zeitstrahl"-Spalte im Drawer</b> (spiegelt die Geschichten-Spalte), plus ein <b>Top-Bar-Button</b> für den Ein-Klick-Weg.</p>
</div>
<div class="callout navy"><strong>Warum der Details-Drawer der richtige Ort ist:</strong> Er zeigt heute schon, <i>wozu ein Brief gehört</i> — Personen, Schlagwörter und <b>Geschichten</b> (mit „Zuordnen"-Aktion, gegated über <code>canBlogWrite</code>, <code>DocumentMetadataDrawer.svelte:210</code>). Zeitstrahl-Ereignisse sind strukturell identisch („dieser Brief gehört zu diesen Ereignissen") und bekommen daher eine gleichwertige vierte/fünfte Spalte. Konsistent &amp; auffindbar dort, wo Nutzer ohnehin „Zugehörigkeit" suchen.</div>
<div class="dchrome" style="margin-bottom:16px">
<div class="dbar"><div class="ddot"></div><div class="ddot"></div><div class="ddot"></div><div class="durl"></div></div>
<!-- document topbar -->
<div style="background:#fff;border-bottom:1px solid #e4e2d7;box-shadow:0 1px 3px rgba(0,0,0,.05);display:flex;align-items:center;height:54px">
<div style="width:3px;height:100%;background:#012851"></div>
<div style="width:34px;display:flex;justify-content:center;color:#6b7280;font-size:14px"></div>
<div style="width:1px;height:22px;background:#e4e2d7;margin:0 6px"></div>
<div style="flex:0 1 auto;min-width:0;padding-right:10px">
<div style="font-family:'Tinos',serif;font-size:13px;font-weight:700;color:#012851;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">Brief über die Lage an der Westfront</div>
<div style="font-size:8.5px;color:#6b7280">März 1915</div>
</div>
<div style="display:flex;gap:3px;margin-left:6px"><span style="width:20px;height:20px;border-radius:50%;background:#012851;color:#fff;font-size:7px;display:flex;align-items:center;justify-content:center">KR</span><span style="font-size:9px;color:#9a9a96;align-self:center"></span><span style="width:20px;height:20px;border-radius:50%;background:#5a8a6a;color:#fff;font-size:7px;display:flex;align-items:center;justify-content:center">ER</span></div>
<div style="margin-left:auto;display:flex;align-items:center;gap:7px;padding-right:14px">
<!-- Details toggle (active) -->
<span style="display:inline-flex;align-items:center;gap:5px;background:#012851;color:#fff;border-radius:5px;font-size:10px;font-weight:600;padding:6px 11px">Details ▴</span>
<div style="width:1px;height:22px;background:#e4e2d7"></div>
<!-- action buttons -->
<span style="display:inline-flex;align-items:center;gap:4px;border:1px solid #e4e2d7;border-radius:5px;font-size:10px;font-weight:600;color:#012851;padding:6px 11px">✎ Transkribieren</span>
<!-- NEW: Zeitstrahl quick button -->
<span style="display:inline-flex;align-items:center;gap:4px;background:#a1dcd8;color:#012851;border-radius:5px;font-size:10px;font-weight:700;padding:6px 11px">⊕ Zeitstrahl</span>
<span style="display:inline-flex;align-items:center;gap:4px;border:1px solid #e4e2d7;border-radius:5px;font-size:10px;color:#012851;padding:6px 9px"></span>
</div>
</div>
<!-- metadata drawer (opened) -->
<div style="background:#fff;border-bottom:1px solid #e4e2d7;padding:20px 24px">
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:24px">
<!-- Details -->
<div>
<div class="lbl">Details</div>
<div style="font-size:8px;font-weight:600;color:#9a9a96;margin-bottom:1px">Datum</div><div style="font-family:'Tinos',serif;font-size:12px;color:#012851;margin-bottom:8px">März 1915</div>
<div style="font-size:8px;font-weight:600;color:#9a9a96;margin-bottom:1px">Ort</div><div style="font-family:'Tinos',serif;font-size:12px;color:#012851;margin-bottom:8px">Westfront</div>
<div style="font-size:8px;font-weight:600;color:#9a9a96;margin-bottom:1px">Status</div><div style="font-family:'Tinos',serif;font-size:12px;color:#012851">Transkribiert</div>
</div>
<!-- Personen -->
<div>
<div class="lbl">Personen</div>
<div style="display:flex;align-items:center;gap:7px;margin-bottom:6px"><span style="width:26px;height:26px;border-radius:50%;background:#012851;color:#fff;font-size:8px;display:flex;align-items:center;justify-content:center">KR</span><span style="font-family:'Tinos',serif;font-size:12px;color:#012851">Karl Raddatz</span></div>
<div style="display:flex;align-items:center;gap:7px"><span style="width:26px;height:26px;border-radius:50%;background:#5a8a6a;color:#fff;font-size:8px;display:flex;align-items:center;justify-content:center">ER</span><span style="font-family:'Tinos',serif;font-size:12px;color:#012851">Elfriede Raddatz</span></div>
</div>
<!-- Schlagwörter -->
<div>
<div class="lbl">Schlagwörter</div>
<div style="display:flex;flex-wrap:wrap;gap:5px"><span style="background:#f5f4ef;border-radius:3px;font-size:8px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;color:#012851;padding:3px 7px">Krieg</span><span style="background:#f5f4ef;border-radius:3px;font-size:8px;font-weight:700;letter-spacing:.4px;text-transform:uppercase;color:#012851;padding:3px 7px">Briefe von der Front</span></div>
</div>
<!-- NEW: Zeitstrahl column -->
<div style="border-left:2px solid #eef6f5;padding-left:18px;margin-left:-6px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:9px">
<span class="lbl" style="margin:0;color:#012851">Zeitstrahl</span>
<span style="font-size:9px;font-weight:600;color:#6b7280">+ Zuordnen</span>
</div>
<!-- linked event -->
<div style="border:1px solid #e4e2d7;border-radius:5px;padding:7px 9px;margin-bottom:8px">
<div style="display:flex;align-items:center;justify-content:space-between"><span style="font-family:'Tinos',serif;font-size:11px;font-weight:700;color:#012851">Briefe von der Front</span><span style="color:#9a9a96;font-size:11px">×</span></div>
<div style="font-size:8px;color:#9a9a96;margin-top:1px">1915 · 24 Briefe · persönlich</div>
<span class="tagchip" style="margin-top:5px"><i></i>Krieg</span>
</div>
<!-- quick add row -->
<div style="display:flex;gap:6px">
<span style="flex:1;border:1px solid #e4e2d7;border-radius:4px;font-size:9px;color:#9a9a96;padding:5px 8px">Ereignis suchen …</span>
<span style="background:#012851;color:#fff;border-radius:4px;font-size:9px;font-weight:600;padding:5px 8px;white-space:nowrap"> Neu</span>
</div>
</div>
</div>
</div>
<div style="height:120px;background:repeating-linear-gradient(45deg,#ececec,#ececec 8px,#e4e4e4 8px,#e4e4e4 16px);display:flex;align-items:center;justify-content:center;color:#aaa;font-size:10px">↓ PDF-Viewer (Brief-Scan) …</div>
</div>
<div class="annogrid">
<div class="anno"><span class="n">A</span><span><b>Top-Bar-Button „⊕ Zeitstrahl"</b> — Mint-Akzent im Aktions-Cluster (<code>DocumentTopBarActions</code>). Öffnet ein kleines Popover zum Ein-Klick-Zuordnen, ohne den Drawer zu öffnen. Im Mobile-Menü als Eintrag.</span></div>
<div class="anno"><span class="n">B</span><span><b>„Zeitstrahl"-Spalte im Details-Drawer</b> — neue Spalte neben Geschichten. Zeigt verknüpfte Ereignisse (Titel · Datum · Tag-Chip), Unlink über <code>×</code>, plus Quick-Add-Zeile. Nur sichtbar/aktiv bei <code>canWrite</code>.</span></div>
<div class="anno"><span class="n">C</span><span><b>Quick-Add-Zeile</b> — Typeahead „Ereignis suchen …" (sofortiges Verlinken, keine Navigation) + <b>„+ Neu"</b>.</span></div>
<div class="anno"><span class="n">D</span><span><b>„+ Neu"</b><code>/zeitstrahl/events/new?documentId={id}</code> — öffnet den Editor (§2) mit diesem Brief in Feld ⑤ vorbefüllt. Spiegelt <code>/geschichten/new?documentId=</code>.</span></div>
</div>
<!-- ══ 5 · QUICK-ADD STATES ══ -->
<div class="sh"><h2>5 · Quick-Action — Zustände</h2><p>Der Typeahead in der Zeitstrahl-Spalte (oder im Top-Bar-Popover). Gleiches Muster wie <code>DocumentMultiSelect</code>, nur sucht es Ereignisse statt Dokumente.</p></div>
<div class="states" style="grid-template-columns:repeat(2,1fr)">
<div class="state">
<div class="sh2"><span>A · Nicht zugeordnet</span></div>
<div class="sb">
<div style="font-size:10px;color:#9a9a96;font-style:italic;margin-bottom:9px">Noch keinem Ereignis zugeordnet.</div>
<div style="display:flex;gap:6px"><span style="flex:1;border:1px solid #e4e2d7;border-radius:4px;font-size:10px;color:#9a9a96;padding:6px 9px">Ereignis suchen …</span><span class="btn primary" style="height:30px;font-size:10px;padding:0 11px"> Neu</span></div>
</div>
</div>
<div class="state">
<div class="sh2"><span>B · Suche — Treffer</span></div>
<div class="sb">
<div style="border:1px solid #012851;border-radius:4px;font-size:10px;color:#012851;padding:6px 9px;margin-bottom:0">Front▏</div>
<div class="dd">
<div class="opt hl">Briefe von der Front <span class="d">· 1915 · 24 Briefe</span></div>
<div class="opt">Kriegsausbruch <span class="d">· 1914 · 6 Briefe</span></div>
<div class="opt" style="color:#012851;font-weight:600"> „Front" als neues Ereignis anlegen</div>
</div>
</div>
</div>
<div class="state">
<div class="sh2"><span>C · Zugeordnet</span></div>
<div class="sb">
<div style="border:1px solid #e4e2d7;border-radius:5px;padding:7px 9px"><div style="display:flex;align-items:center;justify-content:space-between"><span style="font-family:'Tinos',serif;font-size:11px;font-weight:700;color:#012851">Briefe von der Front</span><span style="font-size:9px;color:#2e7d57">✓ verknüpft <span style="color:#9a9a96">×</span></span></div><span class="tagchip" style="margin-top:5px"><i></i>Krieg</span></div>
<div class="cap">Sofortiges Verlinken (POST). Toast „Zum Ereignis hinzugefügt", <code style="font-size:10px">aria-live</code>. Unlink über <code>×</code> (DELETE).</div>
</div>
</div>
<div class="state">
<div class="sh2"><span>D · Mehrfach zugeordnet</span></div>
<div class="sb">
<div style="display:flex;flex-direction:column;gap:5px">
<div style="border:1px solid #e4e2d7;border-radius:5px;padding:5px 9px;display:flex;justify-content:space-between"><span style="font-family:'Tinos',serif;font-size:10.5px;color:#012851">Briefe von der Front</span><span style="font-size:9px;color:#9a9a96">×</span></div>
<div style="border:1px solid #e4e2d7;border-radius:5px;padding:5px 9px;display:flex;justify-content:space-between"><span style="font-family:'Tinos',serif;font-size:10.5px;color:#012851">Weihnachten 1915</span><span style="font-size:9px;color:#9a9a96">×</span></div>
</div>
<div class="cap">Ein Brief darf zu mehreren Ereignissen gehören (ManyToMany) — alle werden gelistet.</div>
</div>
</div>
</div>
<!-- ══ 6 · TOKENS ══ -->
<div class="sh"><h2>6 · Wiederverwendete Bausteine &amp; Tokens</h2></div>
<div class="callout mint"><strong>Maximal wiederverwenden:</strong> <code>DocumentMultiSelect</code> (Brief-Gruppierung, unverändert) · <code>PersonMultiSelect</code> (Beteiligte) · <code>GeschichteEditor</code>-Layout (zwei Spalten, Sticky-Save, <code>beforeNavigate</code>) · <code>DocumentMetadataDrawer</code>-Spaltenmuster (Quick-Action) · <code>useUnsavedWarning</code> · <code>formatDocumentDate</code> / <code>DatePrecision</code>. Brand-Tokens wie im Zeitstrahl-Spec: Navy <code>#012851</code>, Mint <code>#a1dcd8</code>, Linie <code>#e4e2d7</code>, ink-3 <code>#6b7280</code>, danger <code>#c0392b</code>; Serifen-Titel (Tinos), Sans-Chrome (Montserrat).</div>
<!-- ══ 7 · IMPL-REF ══ -->
<div class="sh"><h2>7 · Implementierungs-Referenz &amp; Barrierefreiheit</h2></div>
<div class="impl-ref">
<table>
<tr><th>Baustein</th><th>Datei / Endpoint</th><th>Verantwortung</th></tr>
<tr><td>Editor-Route (neu)</td><td><code>/zeitstrahl/events/new · [id]/edit</code></td><td><code>+page.server.ts</code> (Form-Actions, <code>WRITE_ALL</code>) + <code>+page.svelte</code>; <code>?documentId=</code> vorbefüllt Feld ⑤</td></tr>
<tr><td>Editor-Komponente (neu)</td><td><code>TimelineEventEditor.svelte</code></td><td>Spiegelt <code>GeschichteEditor</code>: Titel, Typ, Datum+Präzision, Beschreibung; Sidebar-Picker; Sticky-Save; <code>beforeNavigate</code></td></tr>
<tr><td>Brief-Gruppierung (reuse)</td><td><code>DocumentMultiSelect.svelte</code></td><td>Unverändert — Typeahead <code>/api/documents/search</code>, Chips, Hidden-Inputs <code>documentIds</code></td></tr>
<tr><td>Personen (reuse)</td><td><code>PersonMultiSelect.svelte</code></td><td>Unverändert — Beteiligte Personen</td></tr>
<tr><td>Datum + Präzision</td><td><code>DatePrecisionInput</code> (geteilt)</td><td>Wie Dokument-Datum (<code>metaDatePrecision</code>); „Zeitspanne" → End-Datum; <code>formatDocumentDate</code> fürs Label</td></tr>
<tr><td>Quick-Action-Spalte (neu)</td><td><code>DocumentTimelineColumn.svelte</code></td><td>Im <code>DocumentMetadataDrawer</code> neben Geschichten; verknüpfte Ereignisse + Quick-Add; nur bei <code>canWrite</code></td></tr>
<tr><td>Quick-Add-Picker (neu)</td><td><code>DocumentTimelineEventPicker.svelte</code></td><td>Ereignis-Typeahead; sofort verlinken oder <code>?documentId=</code> zum Editor; auch im Top-Bar-Popover</td></tr>
<tr><td>Top-Bar-Button (neu)</td><td><code>DocumentTopBarActions</code> · <code>DocumentMobileMenu</code></td><td>„⊕ Zeitstrahl"-Button (canWrite); öffnet Quick-Add-Popover</td></tr>
<tr><td>Backend — CRUD</td><td><code>POST · PUT · DELETE /api/timeline/events</code></td><td><code>TimelineEventController</code>, <code>WRITE_ALL</code>; <code>TimelineEventRequest</code> mit <code>documentIds</code> / <code>personIds</code></td></tr>
<tr><td>Backend — Link/Unlink</td><td><code>PUT /api/timeline/events/{id}</code></td><td>Verlinken/Lösen läuft über das Event-Update (<code>documents</code>-Set); kein neuer ErrorCode nötig</td></tr>
<tr><td>Barrierefreiheit</td><td></td><td>Picker-Dropdowns Tastatur-navigierbar (↑↓↵), <code>aria-live</code> für „verknüpft/gelöst"; 44px-Ziele; sichtbarer Fokus-Ring; Löschen/Unlink mit Bestätigung</td></tr>
</table>
</div>
<div class="note">Offene Designentscheidung: Soll der Top-Bar-Button (A) MVP sein oder reicht zunächst die Drawer-Spalte (B)? Empfehlung: <b>B als MVP</b> (spiegelt Geschichten exakt, geringster Aufwand), A als schneller Nachzug.</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,391 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Globaler Zeitstrahl — Finale Spezifikation (Konzept A) · Milestone #14 · Familienarchiv</title>
<link href="https://fonts.googleapis.com/css2?family=Tinos:ital,wght@0,400;0,700;1,400&family=Montserrat:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Montserrat',system-ui,sans-serif;background:#ECEAE4;color:#1A1A1A;line-height:1.5;font-size:13px}
.page{max-width:1320px;margin:0 auto;padding:48px 32px 120px}
/* Masthead */
.mh{padding-bottom:24px;border-bottom:3px solid #012851;margin-bottom:48px}
.mh h1{font-size:23px;font-weight:900;color:#012851;letter-spacing:-.4px}
.mh p{font-size:13px;color:#555;max-width:780px;line-height:1.75;margin-top:8px}
.mh .byline{font-size:9px;color:#999;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;margin-top:10px}
.tag-row{display:flex;gap:6px;margin-top:12px;flex-wrap:wrap}
.tg{background:#012851;color:#a1dcd8;padding:2px 8px;border-radius:2px;font-size:8px;font-weight:700;letter-spacing:.8px;text-transform:uppercase}
.tg.mint{background:#a1dcd8;color:#012851}
.tg.slate{background:#607080;color:#e8edf2}
.sh{margin:60px 0 24px;padding-bottom:12px;border-bottom:2px solid #E0DDD6}
.sh h2{font-size:17px;font-weight:900;color:#012851}
.sh p{font-size:12.5px;color:#666;margin-top:5px;max-width:780px;line-height:1.65}
.callout{padding:13px 17px;border-radius:4px;font-size:12px;line-height:1.65;margin-bottom:18px}
.callout.navy{background:rgba(1,40,81,.06);border-left:3px solid #012851;color:#333}
.callout.mint{background:rgba(161,220,216,.18);border-left:3px solid #00c7b1;color:#1f3a3a}
.callout strong{font-weight:800;color:#012851}
/* legend */
.legend{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
.lg{background:#fff;border:1px solid #E0DDD6;border-radius:7px;padding:12px 14px;display:flex;gap:11px}
.lg .ico{flex-shrink:0;width:30px;height:30px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:14px}
.lg .ttl{font-size:10.5px;font-weight:800;color:#012851;margin-bottom:2px}
.lg .body{font-size:9.5px;color:#5a5a56;line-height:1.5}
.lg .body code{font-size:8.5px;background:#F0EFE9;padding:1px 3px;border-radius:2px;color:#444}
/* precision table */
.rules{background:#fff;border:1px solid #E0DDD6;border-radius:7px;overflow:hidden;margin-top:8px}
.rules table{width:100%;border-collapse:collapse}
.rules th{background:#F4F2EC;font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888;padding:8px 12px;text-align:left;border-bottom:1px solid #E0DDD6}
.rules td{font-size:11px;color:#444;padding:7px 12px;border-bottom:1px solid #F0EEE8;vertical-align:top;line-height:1.5}
.rules tr:last-child td{border-bottom:none}
.rules td:first-child{font-weight:700;color:#012851;width:110px}
.rules td code{font-size:10px;background:#F0EFE9;padding:1px 5px;border-radius:2px;color:#444}
.rules .ex{font-family:'Tinos',serif;font-size:13px;color:#012851}
/* ── Timeline atoms (Konzept A) ── */
.tl-canvas{background:#f0efe9;border:1.5px solid #E0DDD6;border-radius:10px;padding:24px 26px 30px}
.dh{font-family:'Tinos',serif;font-size:20px;font-weight:700;color:#012851}
.dh-sub{font-size:9.5px;color:#7a7a76;margin-bottom:20px}
/* desktop centered axis */
.axis{position:relative;max-width:780px;margin:0 auto;padding:4px 0}
.axis::before{content:"";position:absolute;left:50%;top:0;bottom:0;width:2.5px;background:linear-gradient(#a1dcd8,#012851,#607080);transform:translateX(-50%)}
.ybadge{text-align:center;position:relative;margin:4px 0 14px}
.ybadge span{background:#012851;color:#fff;font-family:'Tinos',serif;font-size:13px;font-weight:700;padding:2px 15px;border-radius:12px;position:relative;z-index:2}
.pill{text-align:center;position:relative;margin-bottom:15px}
.pill .inner{display:inline-flex;align-items:center;gap:9px;background:#fff;border:1.5px solid #012851;border-radius:22px;padding:5px 16px 5px 5px;position:relative;z-index:2;box-shadow:0 2px 7px rgba(1,40,81,.12)}
.pill.curated .inner{border-color:#a1dcd8;border-width:2px}
.pill .gly{width:28px;height:28px;border-radius:50%;background:#012851;color:#a1dcd8;font-size:14px;display:flex;align-items:center;justify-content:center;flex-shrink:0}
.pill.curated .gly{background:#a1dcd8;color:#012851}
.pill .tx{text-align:left}
.pill .tx .t{font-family:'Tinos',serif;font-size:12px;font-weight:700;color:#012851;display:block;line-height:1.15}
.pill .tx .s{font-size:8.5px;color:#8a8a86}
.wband{position:relative;margin:0 -26px 15px;padding:7px 26px;background:#EEEBE2;border-top:1px solid #ddd8cc;border-bottom:1px solid #ddd8cc;text-align:center}
.wband .t{font-family:'Tinos',serif;font-style:italic;font-size:12px;color:#5a6776}
.wband .s{font-size:8.5px;color:#8a8a86;margin-left:8px}
.lrow{display:flex;align-items:flex-start;margin-bottom:12px}
.lrow .half{flex:1}
.lrow .dot{width:13px;height:13px;border-radius:50%;background:#fff;border:2.5px solid #a1dcd8;margin-top:9px;flex-shrink:0;z-index:2;position:relative}
.lrow .a{padding-right:26px;text-align:right}
.lrow .b{padding-left:26px;text-align:left}
.lcard{display:inline-block;text-align:left;background:#fff;border:1px solid #e4e2d7;border-radius:5px;padding:8px 11px;box-shadow:0 1px 3px rgba(0,0,0,.05);max-width:300px}
.lcard.ev{border-left:3px solid #a1dcd8}
.lcard .t{font-size:11px;font-weight:700;color:#1A1A1A}
.lcard.ev .t{font-family:'Tinos',serif}
.lcard .m{font-size:8.5px;color:#9a9a96;margin-top:1px}
.chip{display:inline-flex;align-items:center;gap:3px;font-size:7.5px;border-radius:9px;padding:2px 7px;margin-top:5px}
.chip i{width:6px;height:6px;border-radius:2px;display:inline-block;flex-shrink:0}
.chip.krieg{color:#a0522d;background:#f6ece6}.chip.krieg i{background:#a0522d}
.chip.weih{color:#c17a00;background:#fbf3e3}.chip.weih i{background:#c17a00}
.chip.fam{color:#5a8a6a;background:#eaf1ec}.chip.fam i{background:#5a8a6a}
/* dense aggregate strip */
.strip{max-width:440px;margin:0 auto 15px;position:relative;z-index:2;background:#fff;border:1px solid #e4e2d7;border-radius:6px;box-shadow:0 1px 3px rgba(0,0,0,.05);padding:9px 13px}
.strip .hd{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}
.strip .ct{font-size:10.5px;font-weight:700;color:#012851}
.strip .ex{font-size:8px;color:#8a8a86}
.spark{display:flex;align-items:flex-end;gap:1.5px;height:30px}
.spark div{flex:1;background:#a1dcd8;border-radius:1px;min-height:1px}
.strip .axl{display:flex;justify-content:space-between;margin-top:3px}
.strip .axl span{font-size:6px;color:#bbb}
/* compressed gap */
.gap{max-width:440px;margin:0 auto 15px;position:relative;z-index:2;display:flex;align-items:center;gap:9px;padding:5px 14px;border:1px dashed #cfccc4;border-radius:18px;background:#f0efe9;color:#9a958c;font-size:9px;font-style:italic}
.gap .ln{flex:1;height:1px;background:#ddd8cc}
.gap b{color:#7a756c;font-style:normal;font-family:'Tinos',serif;font-size:10px}
/* undated bucket */
.undated{max-width:540px;margin:20px auto 0;background:#fff;border:1px dashed #c4c0ba;border-radius:6px;padding:12px 15px}
.undated .h{font-family:'Tinos',serif;font-size:12px;font-weight:700;color:#7a756c;margin-bottom:6px}
/* case tag floating */
.casetag{display:inline-block;background:#012851;color:#a1dcd8;font-size:7px;font-weight:800;letter-spacing:.6px;text-transform:uppercase;padding:2px 7px;border-radius:3px;margin-bottom:6px}
/* narrow (phone / rail) column */
.col3{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}
.modehd{font-size:9px;font-weight:800;letter-spacing:1px;text-transform:uppercase;color:#012851;margin-bottom:4px;display:flex;align-items:center;gap:6px}
.modehd .seg{background:#012851;color:#fff;font-size:7px;padding:2px 7px;border-radius:4px}
.modesub{font-size:9.5px;color:#888;font-style:italic;margin-bottom:9px;line-height:1.45;min-height:42px}
.nf{background:#fff;border:1px solid #e4e2d7;border-radius:6px;padding:14px}
.nsp{position:relative;padding-left:21px}
.nsp::before{content:"";position:absolute;left:7px;top:4px;bottom:4px;width:2px;background:linear-gradient(#a1dcd8,#012851,#607080)}
.nyr{font-family:'Tinos',serif;font-size:12px;font-weight:700;color:#012851;margin:2px 0 7px;position:relative}
.nyr::before{content:"";position:absolute;left:-14px;top:4px;width:8px;height:8px;border-radius:50%;background:#012851;border:2px solid #fff;box-shadow:0 0 0 1.5px #012851}
.nnode{position:relative;margin-bottom:9px}
.nnode .g{position:absolute;left:-18px;top:0;width:14px;height:14px;border-radius:50%;background:#012851;border:2px solid #fff;box-shadow:0 0 0 1.5px #012851;color:#a1dcd8;font-size:8px;display:flex;align-items:center;justify-content:center}
.nnode .lt{font-family:'Tinos',serif;font-size:10.5px;font-weight:700;color:#012851;line-height:1.2}
.nnode .lm{font-size:7.5px;color:#8a8a86}
.nwb{position:relative;margin:0 0 9px -9px;padding:5px 8px;background:#EEEBE2;border-left:2px solid #607080;border-radius:0 3px 3px 0}
.nwb .t{font-family:'Tinos',serif;font-style:italic;font-size:9.5px;color:#5a6776}
.nwb .s{font-size:7px;color:#8a8a86}
.nletter{position:relative;margin-bottom:7px}
.nletter .d{position:absolute;left:-15px;top:3px;width:6px;height:6px;border-radius:50%;background:#fff;border:1.5px solid #a1dcd8}
.ncard{background:#FAF9F5;border:1px solid #eeede8;border-radius:4px;padding:5px 8px}
.ncard .t{font-size:9px;font-weight:700;color:#1A1A1A}
.ncard .m{font-size:7px;color:#9a9a96}
.nstrip{background:#FAF9F5;border:1px solid #eeede8;border-radius:4px;padding:6px 8px;margin-bottom:7px}
.nbucket{border:1px solid #e4e2d7;border-radius:4px;padding:5px 8px;margin-bottom:5px;display:flex;align-items:center;justify-content:space-between}
/* impl-ref */
.impl-ref{background:#fff;border:1px solid #E0DDD6;border-radius:7px;overflow:hidden;margin-top:8px}
.impl-ref table{width:100%;border-collapse:collapse}
.impl-ref th{background:#012851;color:#fff;padding:8px 13px;text-align:left;font-size:8px;font-weight:800;letter-spacing:.6px;text-transform:uppercase}
.impl-ref td{padding:8px 13px;border-bottom:1px solid #F0EEE8;vertical-align:top;font-size:11px;color:#444;line-height:1.55}
.impl-ref tr:nth-child(even) td{background:#FAFAF7}
.impl-ref td:first-child{font-weight:700;color:#012851;white-space:nowrap;width:165px}
.impl-ref td code{font-size:9.5px;background:#F0EFE9;padding:1px 4px;border-radius:2px;font-family:'Courier New',monospace;color:#333}
.sw{display:inline-block;width:11px;height:11px;border-radius:2px;vertical-align:-1px;margin-right:5px;border:1px solid rgba(0,0,0,.12)}
hr{border:none;border-top:2px dashed #C8C4BE;margin:50px 0}
.note{font-size:11px;color:#888;font-style:italic;margin-top:10px;line-height:1.6}
</style>
</head>
<body>
<div class="page">
<!-- ══ MASTHEAD ══ -->
<div class="mh">
<h1>Globaler Zeitstrahl — Finale Spezifikation</h1>
<p>Kanonische Spezifikation für <code style="font-family:monospace;font-size:12px">/zeitstrahl</code> auf Basis von <strong>Konzept A „Der Lebensfaden"</strong>: eine durchgehende vertikale Achse, die Personen-Lebensereignisse, kuratierte Ereignisse und Briefe zu einer Erzählung in der Zeit verwebt. Dieselbe Komponente betreibt den globalen Zeitstrahl und den per-Person „Lebensweg". Enthält die vollständige Fall-Abdeckung (leere Jahre, wenige Briefe, hunderte Briefe, undatiert) und die drei Gruppierungs-Modi.</p>
<div class="tag-row">
<span class="tg">Milestone #14 · Zeitstrahl</span>
<span class="tg mint">Konzept A — final</span>
<span class="tg slate">Phone-first · honest DatePrecision</span>
</div>
<div class="byline">Familienarchiv · 2026-06-08 · Leonie Voss, UX Lead · ersetzt die A/B/C-Exploration zeitstrahl-global-concepts.html</div>
</div>
<!-- ══ 1 · ANATOMIE ══ -->
<div class="sh">
<h2>1 · Anatomie von Konzept A</h2>
<p>Eine Achse, sieben Bausteine. Die Zeit ist die Achse — Lebensereignisse &amp; Jahre als zentrierte Pillen <i>unterbrechen</i> den Faden (Text wird nie von der Linie gekreuzt), Welt-Ereignisse legen sich als Bänder quer, Briefe verdichten sich adaptiv.</p>
</div>
<div class="legend" style="margin-bottom:16px">
<div class="lg"><div class="ico" style="background:#012851;color:#a1dcd8"></div><div><div class="ttl">Lebensereignis-Pille</div><div class="body">Geburt <b>*</b> · Tod <b></b> · Heirat <b></b>. Abgeleitet aus <code>Person</code>-Daten. Zentriert, gefüllt — unterbricht die Achse. Glyphen aus <code>personLifeDates.ts</code>.</div></div></div>
<div class="lg"><div class="ico" style="background:#fff;border:2px solid #a1dcd8;color:#012851"></div><div><div class="ttl">Kuratierte Ereignis-Pille</div><div class="body"><code>PERSONAL</code> — Umzug, Auswanderung. Mint-Rand. Editierbar im Kurator-Editor.</div></div></div>
<div class="lg"><div class="ico" style="background:#EEEBE2;color:#607080;border:1px solid #607080"></div><div><div class="ttl">Welt-Band</div><div class="body"><code>HISTORICAL</code> — Krieg, Inflation. Gedämpftes Band quer über die Achse als Kontext.</div></div></div>
<div class="lg"><div class="ico" style="background:#fff;border:2px solid #a1dcd8"></div><div><div class="ttl">Einzel-Brief</div><div class="body">Kleiner Punkt + Karte, alternierend links/rechts. Wurzel-Tag-Farbchip. Link zu <code>/documents/[id]</code>.</div></div></div>
<div class="lg"><div class="ico" style="background:#a1dcd8;color:#012851;font-size:11px"></div><div><div class="ttl">Jahres-Strip</div><div class="body">Verdichtung dichter Jahre: Anzahl + 12-Monats-Sparkline. <code>MonthBucket</code> / <code>aggregateToYears</code>.</div></div></div>
<div class="lg"><div class="ico" style="background:#f0efe9;border:1px dashed #c4c0ba;color:#9a958c;font-size:11px"></div><div><div class="ttl">Lücke &amp; Ohne-Datum</div><div class="body">Ruhige/leere Jahre als dünne Span-Zeile gefaltet; <code>UNKNOWN</code>-Briefe im „Ohne Datum"-Eimer am Ende.</div></div></div>
</div>
<div class="callout navy"><strong>Gruppierungs-Umschalter</strong> (oben rechts im Zeitstrahl): <span style="display:inline-flex;border:1.5px solid #012851;border-radius:5px;overflow:hidden;vertical-align:middle;margin:0 4px"><span style="background:#012851;color:#fff;font-size:9px;font-weight:700;padding:3px 11px">Datum</span><span style="color:#012851;font-size:9px;font-weight:700;padding:3px 11px;border-left:1px solid #012851">Ereignis</span><span style="color:#012851;font-size:9px;font-weight:700;padding:3px 11px;border-left:1px solid #012851">Thema</span></span> steuert <b>nur, wie lose Briefe gebündelt werden</b>. Lebensereignisse, kuratierte Ereignisse und Welt-Bänder bleiben in allen Modi gleich auf der Achse. Standard = <b>Datum</b>.</div>
<!-- ══ 2 · GROUPING MODES ══ -->
<div class="sh">
<h2>2 · Die drei Gruppierungs-Modi</h2>
<p>Gleicher Ausschnitt (19141915), dreimal gerendert. Nur die <b>losen Briefe</b> ordnen sich um — die Achse bleibt stabil. Schmale Spaltenbreite = Phone-/Lebensweg-Form derselben Komponente.</p>
</div>
<div class="col3">
<!-- MODE: DATUM -->
<div>
<div class="modehd"><span class="seg">Datum</span> Chronologisch</div>
<div class="modesub">Standard. Briefe nach Datum; dichte Jahre verdichten zum Strip. Reine Zeit-Reihung.</div>
<div class="nf">
<div class="nsp">
<div class="nyr">1914</div>
<div class="nnode"><span class="g"></span><div class="lt">Heirat: Karl &amp; Elfriede</div><div class="lm">1914 · abgeleitet</div></div>
<div class="nwb"><div class="t">◍ Erster Weltkrieg</div><div class="s">19141918</div></div>
<div class="nletter"><span class="d"></span><div class="ncard"><div class="t">✉ Kriegsausbruch — Brief an die Familie</div><div class="m">Karl → Elfriede · 4. Aug 1914</div></div></div>
<div class="nyr">1915</div>
<div class="nnode"><span class="g">*</span><div class="lt">Geburt: Hans Raddatz</div><div class="lm">Sommer 1915 · abgeleitet</div></div>
<div class="nstrip">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:5px"><span style="font-size:9.5px;font-weight:700;color:#012851">✉ 24 Briefe</span><span style="font-size:7px;color:#8a8a86">Monate ▾</span></div>
<div style="display:flex;align-items:flex-end;gap:1.5px;height:20px"><div style="flex:1;height:20%;background:#a1dcd8"></div><div style="flex:1;height:35%;background:#a1dcd8"></div><div style="flex:1;height:55%;background:#a1dcd8"></div><div style="flex:1;height:70%;background:#a1dcd8"></div><div style="flex:1;height:60%;background:#a1dcd8"></div><div style="flex:1;height:85%;background:#a1dcd8"></div><div style="flex:1;height:100%;background:#a1dcd8"></div><div style="flex:1;height:88%;background:#a1dcd8"></div><div style="flex:1;height:72%;background:#a1dcd8"></div><div style="flex:1;height:48%;background:#a1dcd8"></div><div style="flex:1;height:55%;background:#a1dcd8"></div><div style="flex:1;height:40%;background:#a1dcd8"></div></div>
</div>
</div>
</div>
</div>
<!-- MODE: EREIGNIS -->
<div>
<div class="modehd"><span class="seg">Ereignis</span> Kuratiert</div>
<div class="modesub">Briefe bündeln unter kuratierte Ereignisse (<code>TimelineEvent.documents</code>). Erzählende Cluster statt Listen.</div>
<div class="nf">
<div class="nsp">
<div class="nyr">1914</div>
<div class="nnode"><span class="g"></span><div class="lt">Heirat: Karl &amp; Elfriede</div><div class="lm">1914 · abgeleitet</div></div>
<div class="nwb"><div class="t">◍ Erster Weltkrieg</div><div class="s">19141918</div></div>
<div class="nyr">1915</div>
<div class="nnode"><span class="g">*</span><div class="lt">Geburt: Hans Raddatz</div><div class="lm">Sommer 1915 · abgeleitet</div></div>
<div class="nletter"><span class="d"></span><div class="ncard" style="border-left:2px solid #a1dcd8"><div class="t">✉ Briefe von der Front · 24</div><div class="m">Karl ⇄ Elfriede &amp; Hans · 1915 ▾</div><span class="chip krieg" style="margin-top:4px"><i></i>Krieg</span></div></div>
<div class="nletter"><span class="d"></span><div class="ncard" style="border-left:2px solid #a1dcd8"><div class="t">✉ Weihnachten 1915 · 3</div><div class="m">kuratiertes Ereignis ▾</div><span class="chip weih" style="margin-top:4px"><i></i>Weihnachten</span></div></div>
</div>
</div>
</div>
<!-- MODE: THEMA -->
<div>
<div class="modehd"><span class="seg">Thema</span> Nach Wurzel-Tag</div>
<div class="modesub">Optional (Post-MVP). Lose Briefe je Jahr in Wurzel-Tag-Eimer; Mehrfach-Tags dedupliziert auf den primären.</div>
<div class="nf">
<div class="nsp">
<div class="nyr">1914</div>
<div class="nnode"><span class="g"></span><div class="lt">Heirat: Karl &amp; Elfriede</div><div class="lm">1914 · abgeleitet</div></div>
<div class="nwb"><div class="t">◍ Erster Weltkrieg</div><div class="s">19141918</div></div>
<div class="nbucket"><span class="chip krieg" style="margin:0"><i></i>Krieg</span><span style="font-size:8px;color:#8a8a86">6 ▾</span></div>
<div class="nyr">1915</div>
<div class="nnode"><span class="g">*</span><div class="lt">Geburt: Hans Raddatz</div><div class="lm">Sommer 1915 · abgeleitet</div></div>
<div class="nbucket"><span class="chip krieg" style="margin:0"><i></i>Krieg Briefe von der Front</span><span style="font-size:8px;color:#8a8a86">24 ▾</span></div>
<div class="nbucket"><span class="chip weih" style="margin:0"><i></i>Weihnachten</span><span style="font-size:8px;color:#8a8a86">3 ▾</span></div>
<div class="nbucket"><span class="chip fam" style="margin:0"><i></i>Familie</span><span style="font-size:8px;color:#8a8a86">2 ▾</span></div>
</div>
</div>
<div class="note" style="margin-top:8px">Hinweis im UI: „Brief mit mehreren Tags erscheint unter seinem primären Tag."</div>
</div>
</div>
<!-- ══ 3 · ALL CASES PREVIEW ══ -->
<div class="sh">
<h2>3 · Vollständige Vorschau — alle Dichte-Fälle</h2>
<p>Ein durchgehender Zeitstrahl (Desktop, zentrale Achse) von 1899 bis „Ohne Datum". Jeder Dichte-Fall kommt genau einmal vor — von leeren Jahren bis zu hunderten Briefen.</p>
</div>
<div class="callout navy" style="margin-bottom:18px">
<strong>Abgedeckte Fälle:</strong>
① leere Jahre (gefaltete Lücke) ·
② wenige Briefe ≤ 3 (einzelne Karten) ·
③ hunderte Briefe (Jahres-Strip + Sparkline) ·
④ kuratiertes Ereignis &amp; Welt-Band ·
⑤ ungenaue Präzision (<code>Sommer</code>, <code>ca.</code>) ·
⑥ undatierte Briefe (Ohne-Datum-Eimer).
</div>
<div class="tl-canvas">
<div class="dh">Zeitstrahl</div>
<div class="dh-sub">Die Familie Raddatz · 18991950 · 412 Briefe · 38 Ereignisse &nbsp;·&nbsp; <span style="color:#012851">Gruppierung: Datum</span></div>
<div style="text-align:center;margin-bottom:8px"><span class="casetag">① Leere Jahre → gefaltet</span></div>
<div class="axis">
<!-- ① empty years gap -->
<div class="gap"><span class="ln"></span><b>1899 1908</b> · keine Einträge<span class="ln"></span></div>
<!-- ② 3 letters -->
<div class="ybadge"><span>1909</span></div>
<div style="text-align:center;margin-bottom:4px"><span class="casetag">② Wenige Briefe → einzeln</span></div>
<div class="lrow"><div class="half a"><div class="lcard"><div class="t">✉ Brief aus Stettin</div><div class="m">Elfriede → Karl · Mai 1909</div><span class="chip fam"><i></i>Familie</span></div></div><div class="dot"></div><div class="half b"></div></div>
<div class="lrow"><div class="half a"></div><div class="dot"></div><div class="half b"><div class="lcard"><div class="t">✉ Geburtstagsgruß</div><div class="m">Karl → Hans · Sep 1909</div></div></div></div>
<div class="lrow"><div class="half a"><div class="lcard"><div class="t">✉ Brief zum Jahresende</div><div class="m">Karl → Elfriede · Dez 1909</div><span class="chip weih"><i></i>Weihnachten</span></div></div><div class="dot"></div><div class="half b"></div></div>
<!-- ④ life event + world band -->
<div class="ybadge"><span>1914</span></div>
<div class="pill"><span class="inner"><span class="gly"></span><span class="tx"><span class="t">Heirat: Karl &amp; Elfriede Raddatz</span><span class="s">1914 · abgeleitet aus Beziehung</span></span></span></div>
<div style="text-align:center;margin-bottom:6px"><span class="casetag">④ Welt-Band (RANGE 19141918)</span></div>
<div class="wband"><span class="t">◍ Erster Weltkrieg</span><span class="s">19141918 · historisch · 187 Briefe in dieser Zeit</span></div>
<!-- ③ hundreds of letters strip -->
<div class="ybadge"><span>1915</span></div>
<div style="text-align:center;margin-bottom:6px"><span class="casetag">③ Hunderte Briefe → Jahres-Strip</span> &nbsp; <span class="casetag" style="background:#607080">⑤ „Sommer 1915"</span></div>
<div class="pill"><span class="inner"><span class="gly">*</span><span class="tx"><span class="t">Geburt: Hans Raddatz</span><span class="s">Sommer 1915 · abgeleitet · SEASON</span></span></span></div>
<div class="strip">
<div class="hd"><span class="ct">✉ 187 Briefe</span><span class="ex">Monats-Dichte · antippen → Monate → Briefe ▾</span></div>
<div class="spark"><div style="height:22%"></div><div style="height:30%"></div><div style="height:48%"></div><div style="height:66%"></div><div style="height:58%"></div><div style="height:80%"></div><div style="height:100%"></div><div style="height:92%"></div><div style="height:74%"></div><div style="height:50%"></div><div style="height:44%"></div><div style="height:34%"></div></div>
<div class="axl"><span>Jan 1915</span><span>Dez 1915</span></div>
</div>
<!-- empty-ish span with some letters collapsed -->
<div class="gap"><span class="ln"></span><b>1916 1922</b> · Nachkriegsjahre · 96 Briefe<span class="ln"></span></div>
<!-- ⑤ approx precision world band -->
<div style="text-align:center;margin-bottom:6px"><span class="casetag" style="background:#607080">⑤ „ca. 1923" → APPROX</span></div>
<div class="wband"><span class="t">◍ Hyperinflation</span><span class="s">ca. 1923 · historisch</span></div>
<!-- curated personal event -->
<div class="ybadge"><span>1924</span></div>
<div class="pill curated"><span class="inner"><span class="gly"></span><span class="tx"><span class="t">Auswanderung nach Argentinien</span><span class="s">Frühjahr 1924 · persönlich · kuratiert</span></span></span></div>
<!-- tail empty -->
<div class="gap"><span class="ln"></span><b>1925 1950</b> · keine Einträge<span class="ln"></span></div>
</div>
<!-- ⑥ undated bucket -->
<div style="text-align:center;margin:6px 0"><span class="casetag" style="background:#7a756c">⑥ Undatiert → eigener Eimer am Ende</span></div>
<div class="undated">
<div class="h">Ohne Datum · 11 Briefe</div>
<div style="display:flex;align-items:center;gap:8px;padding:4px 0;border-top:1px solid #f0eee8"><span style="width:5px;height:5px;border-radius:50%;background:#c4c0ba;flex-shrink:0"></span><span style="font-size:9.5px;color:#3a3a36;flex:1">Brief ohne Jahresangabe</span><span style="font-size:8px;color:#aaa">Präzision UNKNOWN</span></div>
<div style="display:flex;align-items:center;gap:8px;padding:4px 0"><span style="width:5px;height:5px;border-radius:50%;background:#c4c0ba;flex-shrink:0"></span><span style="font-size:9.5px;color:#3a3a36;flex:1">Fragment, Absender unklar</span><span style="font-size:8px;color:#aaa">+ 9 weitere ▾</span></div>
</div>
</div>
<div class="note">Kein erfundenes Datum: undatierte Briefe wandern nie spekulativ in ein Jahr, sondern bleiben sichtbar im Eimer. <code>RANGE</code>-Einträge (Krieg) erscheinen einmal im Start-Jahr mit Spannen-Marker, nicht in jedem überspannten Jahr.</div>
<!-- ══ 4 · PRECISION ══ -->
<div class="sh"><h2>4 · Datums-Präzision (geteilt von Ereignissen &amp; Briefen)</h2><p>Eine Render-Logik für alle datierten Einträge — <code>dateLabel.ts</code>, gespeist von <code>DatePrecision</code>.</p></div>
<div class="rules">
<table>
<tr><th>DatePrecision</th><th>Darstellung</th><th>Beispiel</th><th>Wirkung auf der Achse</th></tr>
<tr><td>DAY</td><td>vollständiges Datum</td><td class="ex">28. Juli 1914</td><td>exakte Sortierung im Jahres-Band</td></tr>
<tr><td>MONTH</td><td>Monat + Jahr</td><td class="ex">Juli 1914</td><td>Monats-Sortierung</td></tr>
<tr><td>SEASON</td><td>Jahreszeit + Jahr</td><td class="ex">Sommer 1914</td><td>grobe Reihung</td></tr>
<tr><td>YEAR</td><td>nur Jahr</td><td class="ex">1914</td><td>ans Band-Ende</td></tr>
<tr><td>APPROX</td><td>„ca." + Jahr</td><td class="ex">ca. 1914</td><td>mit „ca."-Marker</td></tr>
<tr><td>RANGE</td><td>StartEnde</td><td class="ex">19141918</td><td>Start-Jahr, Spannen-Marker, nicht dupliziert</td></tr>
<tr><td>UNKNOWN</td><td>undatiert</td><td class="ex">Ohne Datum</td><td>eigener Eimer am Ende</td></tr>
</table>
</div>
<!-- ══ 5 · RESPONSIVE ══ -->
<div class="sh"><h2>5 · Responsiv — eine Komponente, drei Breiten</h2><p>Identisches Markup &amp; identische Daten. Nur die Achs-Position wechselt per Container-Query.</p></div>
<div class="legend" style="grid-template-columns:repeat(3,1fr)">
<div class="lg"><div class="ico" style="background:#012851;color:#a1dcd8;font-size:10px"></div><div><div class="ttl">≥ 1024px · Desktop</div><div class="body"><b>Zentrale Achse</b>, Briefe alternierend links/rechts, Welt-Bänder über volle Breite. Pillen unterbrechen die Linie.</div></div></div>
<div class="lg"><div class="ico" style="background:#012851;color:#a1dcd8;font-size:10px"></div><div><div class="ttl">&lt; 1024px · Phone</div><div class="body"><b>Linke Achse</b>, alles einseitig rechts. DOM-Reihenfolge bleibt streng chronologisch (<code>&lt;ol&gt;</code>) — Screenreader liest linear.</div></div></div>
<div class="lg"><div class="ico" style="background:#012851;color:#a1dcd8;font-size:10px"></div><div><div class="ttl">Lebensweg-Rail · 35%</div><div class="body">Gleiche linke Achse in der Personenseite (<code>&lt;TimelineView personId&gt;</code>), gefiltert auf eine Person. Rail-Tauglichkeit = Stärke von A.</div></div></div>
</div>
<!-- ══ 6 · TOKENS ══ -->
<div class="sh"><h2>6 · Design-Tokens (echte, ausgelieferte Werte)</h2><p>Aus <code>frontend/src/routes/layout.css</code>. Keine Hardcodes in der Komponente.</p></div>
<div class="impl-ref">
<table>
<tr><th>Rolle</th><th>Token</th><th>Wert</th><th>Einsatz</th></tr>
<tr><td>Achse / Knoten / Header</td><td><code>brand-navy</code></td><td><span class="sw" style="background:#012851"></span>#012851</td><td>Spine, Lebensereignis-Pillen, Jahres-Badges, Titel</td></tr>
<tr><td>Akzent / Brief-Punkt</td><td><code>brand-mint</code></td><td><span class="sw" style="background:#a1dcd8"></span>#a1dcd8</td><td>Brief-Punkte, kuratierte Pillen-Ränder, Sparkline, Dark-Mode-Auswahl</td></tr>
<tr><td>Historisch / Welt</td><td><code>tag-slate</code></td><td><span class="sw" style="background:#607080"></span>#607080</td><td>Welt-Bänder &amp; Glyphe ◍ — gedämpft</td></tr>
<tr><td>Tag-Chip-Farben</td><td><code>--c-tag-*</code> (Wurzel)</td><td><span class="sw" style="background:#5a8a6a"></span><span class="sw" style="background:#a0522d"></span><span class="sw" style="background:#c17a00"></span><span class="sw" style="background:#7a4f9a"></span></td><td>sage · sienna · amber · violet — Farbe vom Wurzel-Tag, Punkt + Label</td></tr>
<tr><td>Seite / Karte / Linie</td><td><code>canvas · surface · line</code></td><td><span class="sw" style="background:#f0efe9"></span><span class="sw" style="background:#fff"></span><span class="sw" style="background:#e4e2d7"></span></td><td>#f0efe9 · #ffffff · #e4e2d7</td></tr>
<tr><td>Text sekundär</td><td><code>text-ink-3</code></td><td><span class="sw" style="background:#6b7280"></span>#6b7280</td><td>Meta-Zeilen (4,8:1 auf weiß — AA ✓)</td></tr>
<tr><td>Schrift</td><td><code>font-serif · font-sans</code></td><td>Tinos · Montserrat</td><td>Namen/Titel serif · Labels/Chrome sans</td></tr>
<tr><td>Lebensdaten-Glyphen</td><td><code>personLifeDates.ts</code></td><td>* † ⚭</td><td>Geburt · Tod · Heirat — konsistent mit Personenkarten</td></tr>
</table>
</div>
<!-- ══ 7 · IMPL-REF ══ -->
<div class="sh"><h2>7 · Implementierungs-Referenz &amp; Barrierefreiheit</h2><p>Domain-Ordner <code>frontend/src/lib/timeline/</code>; Route <code>/zeitstrahl</code>; Backend <code>GET /api/timeline</code>.</p></div>
<div class="impl-ref">
<table>
<tr><th>Baustein</th><th>Komponente / Datei</th><th>Verantwortung</th></tr>
<tr><td>Orchestrator</td><td><code>TimelineView.svelte</code></td><td>Lädt <code>/api/timeline</code>; optionaler <code>personId</code> für globalen vs. Lebensweg-Modus; hält den Gruppierungs-Modus</td></tr>
<tr><td>Jahres-Band</td><td><code>YearBand.svelte</code></td><td>Jahres-Badge + Einträge; Lücken-Faltung ruhiger Spannen</td></tr>
<tr><td>Ereignis-Pille</td><td><code>EventCard.svelte</code></td><td>PERSONAL / HISTORICAL / abgeleitet; zentrierte Pille bzw. Welt-Band; präzisions-bewusstes Label</td></tr>
<tr><td>Brief-Karte</td><td><code>LetterCard.svelte</code></td><td>Einzel-Brief, alternierende Seite, Wurzel-Tag-Chip, Link <code>/documents/[id]</code></td></tr>
<tr><td>Jahres-Strip</td><td><code>YearLetterStrip.svelte</code></td><td>Adaptive Verdichtung ab Schwellwert; 12-Monats-Sparkline aus <code>MonthBucket</code> / <code>aggregateToYears</code> (<code>lib/document/timeline.ts</code>)</td></tr>
<tr><td>Datums-Helfer</td><td><code>dateLabel.ts</code></td><td><code>DatePrecision</code> → deutsches Label; geteilt von Ereignissen &amp; Briefen</td></tr>
<tr><td>Kurator-Editor</td><td><code>/zeitstrahl/events/new · [id]/edit</code></td><td>Ereignis anlegen/bearbeiten; Personen- + Dokument-Mehrfach-Picker (Bulk-Linking); <code>WRITE_ALL</code></td></tr>
<tr><td>Quick-Add</td><td><code>DocumentTimelineEventPicker.svelte</code></td><td>Auf <code>/documents/[id]</code>: Ereignis wählen/neu anlegen; verlinkt einen Brief</td></tr>
<tr><td>Daten-API</td><td><code>GET /api/timeline</code></td><td>Verschmilzt kuratierte + abgeleitete Ereignisse + Briefe in <code>TimelineDTO</code> (Jahres-Eimer + Ohne-Datum); Filter <code>personId · type · fromYear · toYear</code></td></tr>
<tr><td>Barrierefreiheit</td><td></td><td>Achse = <code>&lt;ol&gt;</code>, chronologische DOM-Reihenfolge; ◍ ✉ * nie nur Farbe — Glyphe + Label; 44px-Tap-Ziele; <code>prefers-reduced-motion</code>; axe in Light &amp; Dark</td></tr>
</table>
</div>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,182 @@
# Family Timeline (Zeitstrahl) — Design Spec
**Date:** 2026-06-07
**Status:** Approved — pending implementation plan
## Problem
The archive can capture, transcribe, organize, and browse letters, but the transcribed material does not yet add up to a *story in time*. Readers (younger, phone-first) have no way to feel the family's history unfold; transcribers don't see their work become something larger. A previous attempt to derive meaning automatically (LLM search) was slow and low-quality, so the family is wary of auto-extraction from handwriting.
## Goal
A **hand-curated, year-banded vertical timeline** — the "Zeitstrahl" — that weaves three layers into one chronological view:
1. **Person life-events** derived from already-curated structured data (`Person` birth/death dates, marriage years from `PersonRelationship.fromYear`). Trusted, free, no extra entry. (Requires the Person birth/death fields to move from year-integers to date + precision — see foundational issue 1.)
2. **Hand-curated events** the family writes — both **personal** (a move, an illness, emigration) and **historical** (a war, hyperinflation). Editorially controlled, always correct.
3. **Letters**, auto-placed by their existing `documentDate`, optionally hand-linked to an event to cluster them.
Two surfaces, one component:
- **Global timeline** at `/zeitstrahl`.
- **Per-person "Lebensweg"** — the same view filtered to one person, embedded on the Person detail page.
Built for phones (vertical scroll), honest about date precision, with no fabricated dates.
### Non-goals (YAGNI)
- ❌ Auto-extracting events from transcription text — explicitly avoided; this is what makes the feature trustworthy.
- ❌ Importing an external historical-events dataset — historical events are hand-entered too.
- ❌ A map / geographic view — that is a separate future feature (B2).
- ❌ Per-derived-event hide/override toggle — deferred refinement; MVP shows all derived events.
- ❌ Day-resolution timeline axis — the axis is the **year**; finer dates only affect within-band ordering and label text.
## Core principle: the year is the axis
Most dates in the archive are year-only (birth/death/marriage years are years by nature; many letters carry `YEAR`/`APPROX` precision). Therefore:
- The timeline spine is a **sequence of year bands**. Everything for a given year lives in that band.
- **Finer ordering only when we have it.** A `DAY`-precision letter (`1923-04-12`) sorts above a `YEAR`-precision one (`1923`) *within* the 1923 band; we never invent a day we don't have.
- **An "Ohne Datum" bucket** at the end holds items with `UNKNOWN` precision.
- **Honest precision rendering** reuses the existing `DatePrecision` enum for every dated item (events and letters share one rendering path).
### Date rendering (shared by events and letters)
| `DatePrecision` | German render | Example |
|---|---|---|
| `DAY` | full date | `28. Juli 1914` |
| `MONTH` | month + year | `Juli 1914` |
| `SEASON` | season + year | `Sommer 1914` |
| `YEAR` | year only | `1914` |
| `APPROX` | "ca." + year | `ca. 1914` |
| `RANGE` | startend year | `19141918` |
| `UNKNOWN` | undated bucket | `Ohne Datum` |
A `RANGE` item is shown in its **start year's band** with a span marker; it is not duplicated across every year it covers.
## Data model
A new `timeline/` domain package on the backend (kept deliberately separate from the in-flight Lesereisen/`Geschichte` work in #750753).
### `TimelineEvent` entity
Mirrors the `Document` date model for consistency, so events and letters use one date-handling code path.
| Field | Type | Notes |
|---|---|---|
| `id` | `UUID` | `@GeneratedValue(UUID)` |
| `title` | `String` | required |
| `type` | `EventType` enum | `PERSONAL`, `HISTORICAL` |
| `eventDate` | `LocalDate` | required — most precise date known (WW1 → `1914-07-28`; vague year → `1920-01-01`) |
| `precision` | `DatePrecision` | reuse existing enum; default `YEAR` — governs rendering & whether the day matters |
| `eventDateEnd` | `LocalDate` (nullable) | only set when `precision == RANGE` |
| `description` | `TEXT` (nullable) | free-text narrative for the event |
| `persons` | ManyToMany `Person` | who the event involves; drives the per-person view & filtering |
| `documents` | ManyToMany `Document` | optional hand-linked supporting letters (the "cluster letters to an event" feature) |
| `createdBy` / `createdAt` / `updatedBy` / `updatedAt` / `version` | audit | standard entity conventions |
- `@Schema(requiredMode = REQUIRED)` on every always-populated field (`id`, `title`, `type`, `eventDate`, `precision`).
- Collections use `@Builder.Default new HashSet<>()`.
- New Flyway migration adds `timeline_events`, `timeline_event_persons`, `timeline_event_documents` join tables.
### `EventType` enum
`PERSONAL` | `HISTORICAL`. Personal events render with a person/family accent; historical events with a "world" accent and muted styling so the two layers are visually separable.
### Prerequisite: migrate `Person` birth/death to date + precision
Today `Person` stores `birthYear`/`deathYear` as `Integer`, so a known exact birthday (e.g. `1901-03-14`) has nowhere to live and derived events are stuck at year precision. This is fixed by a **foundational Person-domain migration** that the timeline depends on (and which delivers value on its own — precise dates then render on person cards, hover cards, and the Stammbaum).
**Change:** replace `birthYear`/`deathYear` (`Integer`) with:
| Field | Type | Notes |
|---|---|---|
| `birthDate` | `LocalDate` (nullable) | most precise date known |
| `birthDatePrecision` | `DatePrecision` (nullable) | `YEAR` for year-only, `DAY` for exact birthdays, etc. |
| `deathDate` | `LocalDate` (nullable) | |
| `deathDatePrecision` | `DatePrecision` (nullable) | |
**Flyway data migration:** existing `birth_year``birth_date = '{year}-01-01'`, `birth_date_precision = 'YEAR'` (same for death); then drop the year columns.
**Re-import preservation (ADR-025):** the canonical importer (`PersonRegisterImporter` / `tools/import-normalizer/persons_tree.py`) only carries the *year*. On re-import it must **not** clobber a hand-entered finer-than-`YEAR` date — if the existing precision is `DAY`/`MONTH`/`SEASON`, preserve it; only refresh from the spreadsheet year when the field is empty or still `YEAR`-from-import.
**Bounding the blast radius:** `PersonNodeDTO` keeps exposing an `Integer birthYear`/`deathYear` *derived* from the new date (`birthDate.getYear()`), so the Stammbaum layout (`familyForest.ts` et al.) is untouched. Display surfaces (person card, hover card) move to a shared precision-aware formatter — extend the existing `frontend/src/lib/person/personLifeDates.ts`. The person edit/new forms gain date inputs with a precision selector.
**Scope note:** `PersonRelationship.fromYear` (marriage year) stays `Integer`/`YEAR` for MVP — precise marriage dates are a later, parallel extension if wanted.
### Derived person-events (not persisted)
Assembled on read from the migrated `Person` data; never stored:
| Source | Derived event | `eventDate` | precision |
|---|---|---|---|
| `Person.birthDate` | *Geburt: {name}* | `Person.birthDate` | `Person.birthDatePrecision` |
| `Person.deathDate` | *Tod: {name}* | `Person.deathDate` | `Person.deathDatePrecision` |
| `PersonRelationship` `SPOUSE_OF.fromYear` | *Heirat: {A} & {B}* | `{fromYear}-01-01` | `YEAR` |
Emitted in the same DTO shape as a curated event, flagged `derived: true`, `type = PERSONAL`. They cannot be edited from the timeline (they are edited at their source: Person record / relationship). A marriage is derived once per `SPOUSE_OF` edge (symmetric edges are stored once — see existing relationship rules).
### Letters
Placed by `Document.documentDate`:
- Band = `documentDate.getYear()`; `UNKNOWN` precision → "Ohne Datum" bucket.
- Sub-ordered within a band by full date when precision allows.
- A letter may also appear under an event it's linked to (via `TimelineEvent.documents`) as a cluster, in addition to its own band placement.
## Assembly & API
A `TimelineService` merges the three layers into a year-bucketed DTO for the requested scope and filters. Layering rules apply: the service owns `TimelineEventRepository` and reaches Person/Document/Relationship data through their **services**, never their repositories.
### DTOs
- `TimelineEntryDTO` — one renderable item: `kind` (`EVENT` | `LETTER`), `eventDate`, `precision`, `eventDateEnd`, `title`, `type` (for events), `derived` flag, plus the source id (eventId / documentId) and minimal display fields (sender/receiver names for letters, linked person ids for events).
- `TimelineYearDTO``{ year: int, entries: TimelineEntryDTO[] }`.
- `TimelineDTO``{ years: TimelineYearDTO[], undated: TimelineEntryDTO[] }`.
### Endpoints
- `GET /api/timeline` — global timeline. Query params (all optional): `personId`, `generation`, `type` (`PERSONAL`/`HISTORICAL`), `fromYear`, `toYear`. The per-person "Lebensweg" is just `GET /api/timeline?personId=…` — no separate endpoint. Requires `READ_ALL`.
- `POST /api/timeline/events` — create a curated event. `@RequirePermission(Permission.WRITE_ALL)`.
- `PUT /api/timeline/events/{id}` — update. `@RequirePermission(Permission.WRITE_ALL)`.
- `DELETE /api/timeline/events/{id}` — delete. `@RequirePermission(Permission.WRITE_ALL)`.
- `GET /api/timeline/events/{id}` — fetch a single event for the edit form. Requires `READ_ALL`.
Input DTO `TimelineEventRequest` lives flat in the `timeline/` package. Errors use `DomainException.notFound/...`; **no new `ErrorCode`** is required. Run `npm run generate:api` after backend model/endpoint changes.
## Frontend
- New domain dir `frontend/src/lib/timeline/`:
- `TimelineView.svelte` — orchestrator; accepts an optional `personId` prop so the same component powers both global and per-person views.
- `YearBand.svelte` — one year section header + its entries.
- `EventCard.svelte` — renders a `PERSONAL`/`HISTORICAL`/derived event with precision-aware date label.
- `LetterCard.svelte` — compact letter row (sender → receiver, snippet/title, date), links to `/documents/[id]`.
- `TimelineFilters.svelte` — person, generation, layer toggles, year range.
- `dateLabel.ts` — the shared precision→label helper (reuse/extend `lib/document/timeline.ts` helpers like `formatTickLabel` where they fit).
- Routes:
- `/zeitstrahl` — global timeline (`+page.server.ts` loads `/api/timeline`).
- `/zeitstrahl/events/new` and `/zeitstrahl/events/[id]/edit` — curator forms, gated to `WRITE_ALL`, using the form-actions pattern.
- Person detail page gains a **Lebensweg** card section embedding `<TimelineView personId={person.id} />`.
- Styling per project conventions (card pattern, brand tokens, `font-serif` for names/titles, `BackButton`, mobile-first at 375px, dark-mode tokens).
- i18n keys added to `messages/{de,en,es}.json` (German primary).
## Testing
- Backend: `TimelineService` assembly/merge/sort/precision-bucketing (unit + `@DataJpaTest` against Postgres via Testcontainers); controller permission gating; derived-event assembly (birth/death/marriage, symmetric marriage dedup).
- Frontend: `dateLabel.ts` precision rendering; `TimelineView` global vs `personId` modes (`*.svelte.spec.ts`); filter behavior.
- Follow project test discipline: targeted single-file runs locally only; full sweep left to CI.
## Proposed issue breakdown (milestone "Zeitstrahl / Family Timeline")
Ordered so each issue is independently shippable and reviewable; later issues depend on earlier ones. Issue 1 is a standalone Person-domain improvement and a hard prerequisite for the timeline's derived events.
1. **Person birth/death → date + precision (foundational)** — replace `birthYear`/`deathYear` with `birthDate`/`deathDate` + precision on `Person`; Flyway data migration (year → `YYYY-01-01`, `YEAR`); update importer with re-import preservation rule; derive year in `PersonNodeDTO` (Stammbaum untouched); move person card / hover card to a precision-aware `personLifeDates.ts`; add date+precision inputs to person new/edit forms. Ships value on its own.
2. **Backend: `TimelineEvent` entity + migration** — entity, `EventType`, Flyway migration + join tables, repository.
3. **Backend: TimelineEvent CRUD API**`TimelineEventController` + `TimelineService` write methods, `TimelineEventRequest` DTO, permission gating, `GET /events/{id}`.
4. **Backend: derived person-events** — assemble Geburt/Tod/Heirat from migrated Person + relationship data via their services; unit-tested dedup.
5. **Backend: timeline assembly endpoint**`GET /api/timeline` merging events + derived events + letters into `TimelineDTO`; year-bucketing, precision sort, undated bucket, filters.
6. **Frontend: shared date-label helper + types**`dateLabel.ts`, regen API types.
7. **Frontend: global `/zeitstrahl` view**`TimelineView`, `YearBand`, `EventCard`, `LetterCard`, server load.
8. **Frontend: filters**`TimelineFilters` (person / generation / layer / year range).
9. **Frontend: curator event forms**`/zeitstrahl/events/new` + `/[id]/edit`, gated, with document & person pickers.
10. **Frontend: per-person Lebensweg** — embed `<TimelineView personId>` on Person detail.
11. **Polish & a11y** — mobile layout at 375px, dark mode, axe checks, i18n completeness (de/en/es).
> An ADR may be warranted for the new `timeline/` domain + entity (per `docs/CLAUDE.md`, significant data-model change). Add as the next sequential ADR number when implementation starts.

View File

@@ -0,0 +1,188 @@
# spaCy NLP Service — Design Spec
**Date:** 2026-06-07
**Status:** Prototype
## Problem
The current NL search uses Ollama (`qwen2.5:7b-instruct-q4_K_M`) to parse free-text queries into structured extractions (person names, dates, role, keywords). Inference takes 515 seconds per query, making the feature too slow to be useful compared to filling in the filter UI manually.
## Goal
Build a standalone `nlp-service/` prototype that replaces Ollama with spaCy for query parsing. The prototype is scoped to **extraction quality evaluation** — run it locally, curl it with real archive queries, and measure whether spaCy extracts names/dates/keywords well enough to justify a full migration. No Java-side changes in this iteration.
## Extraction Contract
The service must produce an output compatible with the existing `OllamaExtraction` Java record:
| Field | Type | Description |
|---|---|---|
| `personNames` | `string[]` | Names of persons mentioned, left-to-right order |
| `personRole` | `"sender"` \| `"receiver"` \| `"any"` | Role of the person(s) in the document |
| `dateFrom` | `string \| null` | ISO 8601 date `YYYY-MM-DD` or null |
| `dateTo` | `string \| null` | ISO 8601 date `YYYY-MM-DD` or null |
| `keywords` | `string[]` | Content words — fuzzy-matched against tags by Java |
| `rawQuery` | `string` | Echo of the input query |
**Two-person ordering:** `personNames` must be in left-to-right span order. Java maps `[0]` → sender, `[1]` → receiver.
**`rawQuery` note:** In the current Java code `rawQuery` is set by the caller, not parsed from Ollama. The service echoes the input for convenience; the eventual `RestClientSpacyClient` will set it from the input directly, same as today.
## Architecture
```
nlp-service/
├── main.py # FastAPI app — /parse and /health endpoints
├── extractor.py # NLP pipeline: NER → role → dates → keywords
├── models.py # Pydantic request/response types
├── requirements.txt
├── Dockerfile
└── CLAUDE.md
```
Sits alongside `ocr-service/` in the repo. For the prototype it runs standalone (no docker-compose wiring).
## Extraction Pipeline (`extractor.py`)
Five steps run in sequence on each query.
### Step 1 — NER pass
Run spaCy on the query using the model for the requested language. Collect:
- All `PER` spans → candidates for `personNames`
- All `DATE` spans → raw text strings for step 3
### Step 2 — Role detection
Only relevant when exactly **one** PER entity is found. Walk the dependency tree of the PER span's root token; check if a governing `case` or `prep` token matches the sender or receiver preposition set for the language:
| Language | Sender prepositions | Receiver prepositions |
|---|---|---|
| `de` | von, vom | an, nach, für |
| `en` | from, by | to, for |
| `es` | de, por | para, a |
- One person + sender preposition → `personRole = "sender"`
- One person + receiver preposition → `personRole = "receiver"`
- One person + no match / two or more persons → `personRole = "any"`
Two-person queries always return `"any"` — Java derives direction from position.
### Step 3 — Date parsing
For each DATE span, inspect the token immediately before the span to detect range direction:
| Direction token | Effect |
|---|---|
| vor / before / antes de | Span → `dateTo` |
| nach / after / después de | Span → `dateFrom` |
| zwischen…und / between…and / entre…y | Earlier span → `dateFrom`, later → `dateTo` |
| No direction token (bare year/date) | Span → both `dateFrom` and `dateTo` set to that year (year-range, Jan 1Dec 31) |
`dateparser.parse()` with `PREFER_DAY_OF_MONTH=first` converts the span text to a Python `date`. For `dateTo` results that resolve to a year boundary, set to Dec 31 of that year (mirrors `RestClientOllamaClient.parseDate()` behaviour).
Output as ISO strings (`YYYY-MM-DD`) or `null`.
### Step 4 — Keyword extraction
Collect tokens that satisfy all of:
- POS tag is `NOUN` or `PROPN`
- Not a stopword
- Not inside any NER span (PER or DATE)
- Lemma length ≥ 3
Output as lowercased lemmas. These are fuzzy-matched against the tags table by `NlQueryParserService.resolveTags()` on the Java side — no tag lookup in the Python service.
Examples:
- "Briefe aus dem Krieg" → `keywords: ["brief", "krieg"]`
- "Texte über Weihnachten" → `keywords: ["text", "weihnachten"]`
### Step 5 — Assembly
```json
{
"personNames": ["Opa Hermann", "Marie"],
"personRole": "any",
"dateFrom": null,
"dateTo": "1920-12-31",
"keywords": ["brief"],
"rawQuery": "Briefe von Opa Hermann an Marie vor 1920"
}
```
## API
### `POST /parse`
**Request:**
```json
{ "query": "Briefe von Opa Hermann an Marie vor 1920", "lang": "de" }
```
`lang` is a required enum: `"de"` | `"en"` | `"es"`. Unknown values → HTTP 422 (FastAPI validation).
**Response:** extraction object as above, HTTP 200.
**Error:** pipeline crash → HTTP 500 `{"detail": "..."}`.
### `GET /health`
Returns HTTP 200 `{"status": "ok"}` when all three models are loaded.
## Language Models
| `lang` | spaCy model |
|---|---|
| `de` | `de_core_news_sm` |
| `en` | `en_core_web_sm` |
| `es` | `es_core_news_sm` |
All three models are loaded at startup and held in memory. Routing is by the `lang` field on the request.
## Dockerfile
Mirrors `ocr-service/``python:3.11-slim`, non-root user, models baked into the image:
```dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN python -m spacy download de_core_news_sm \
&& python -m spacy download en_core_web_sm \
&& python -m spacy download es_core_news_sm
COPY . .
RUN useradd --no-create-home --shell /usr/sbin/nologin --uid 1001 nlp \
&& chown -R nlp:nlp /app
USER nlp
EXPOSE 8001
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001"]
```
Image size: ~350 MB. No volume needed — models live in the image layer.
## Local Dev
```bash
cd nlp-service
pip install -r requirements.txt
python -m spacy download de_core_news_sm en_core_web_sm es_core_news_sm
uvicorn main:app --reload --port 8001
curl -X POST http://localhost:8001/parse \
-H "Content-Type: application/json" \
-d '{"query": "Briefe von Opa Hermann an Marie vor 1920", "lang": "de"}'
```
## Known Limitations
- **Historical names:** spaCy models are trained on modern news corpora. Unusual 18991950 German names may not score as `PER`. Mitigation: the Java `resolveNames()` already does fuzzy matching against the persons table, so partial name extraction is recoverable.
- **Role detection:** the preposition sets are a fixed enumeration (~12 tokens across 3 languages). Sentences that express direction without one of these prepositions will fall through to `personRole = "any"`. This is acceptable — `"any"` is the safe default and searches both sender and receiver positions.
- **"über Oma" ambiguity:** if spaCy recognises "Oma" as a PER entity it lands in `personNames` (person search); if not, it lands in `keywords` (tag search via Java). Both paths return relevant results. The prototype evaluation will reveal which path dominates for real archive queries.
## Out of Scope (prototype)
- docker-compose integration (Ollama replacement)
- Java-side changes (`RestClientSpacyClient`, rename `OllamaClient``NlParserClient`)
- Tag lookup inside the Python service
- Automated test suite (pytest fixtures) — evaluation is done by curling the running service