Compare commits

...

24 Commits

Author SHA1 Message Date
Marcel
b0aa3a6ffd docs(spec): reader dashboard design exploration and final spec (#447)
Some checks failed
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 4m29s
CI / OCR Service Tests (pull_request) Successful in 35s
CI / Backend Unit Tests (pull_request) Failing after 3m13s
Seven HTML mockups covering the full design process for the
permission-gated reader dashboard (issue #447): 3 initial concept
variants (A/B/C), 3 iterations of concept B (B.1 hell, B.2 warm,
B.3 navy), and the final merged spec combining B.1 layout with B.3
person cards. Final spec includes all 4 view variants: desktop light
READ_ALL, desktop light BLOG_WRITE, desktop dark, and mobile (3 phone
frames: light reader, light BLOG_WRITE, dark reader).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 11:36:04 +02:00
Marcel
d01b9a7508 docs(claude-md): replace hex values with CSS var refs, expand route trees
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m31s
CI / OCR Service Tests (pull_request) Successful in 35s
CI / Backend Unit Tests (pull_request) Failing after 3m16s
CI / Unit & Component Tests (push) Failing after 3m23s
CI / OCR Service Tests (push) Successful in 30s
CI / Backend Unit Tests (push) Failing after 3m19s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 09:01:40 +02:00
Marcel
d69a3abc3b docs(personas): fix stale brand data in ui_expert persona
Update hex values → CSS var references, fix font (Merriweather→Tinos),
card pattern (border-brand-sand→border-line, bg-white→bg-surface),
and contrast table to remove hardcoded hex in favour of --palette-* names.

Addresses Leonie's review blocker on PR #446.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 08:58:39 +02:00
Marcel
5c72364899 docs: fix stale CLAUDE.md content after design-system refactoring
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m45s
CI / OCR Service Tests (push) Successful in 44s
CI / Backend Unit Tests (push) Failing after 3m25s
CI / Unit & Component Tests (pull_request) Failing after 3m29s
CI / OCR Service Tests (pull_request) Successful in 34s
CI / Backend Unit Tests (pull_request) Failing after 3m13s
Brand colors, font name, dev port, route tree, and card pattern were
all outdated relative to layout.css and the current route structure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 08:49:47 +02:00
Marcel
50b18f0849 docs(legibility): fix three review blockers in DOC-7
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m29s
CI / OCR Service Tests (push) Successful in 32s
CI / Backend Unit Tests (push) Failing after 3m29s
- docs/README.md: remove duplicate infrastructure/ entry at end of folder tree
- ocr-service/CLAUDE.md: add **LLM reminder:** prefix to ALLOWED_PDF_HOSTS
  SSRF warning (consistent with all other machine-readable instructions)
- backend/CLAUDE.md: restore ResponseStatusException note for simple controller
  validation — avoids LLMs reaching for DomainException for trivial checks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:41:02 +02:00
Marcel
6cf5405b7a chore: remove accidentally staged familienarchiv-408 submodule
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:41:02 +02:00
Marcel
86c13a230c docs(legibility): migrate CLAUDE.md rules into human docs — DOC-7
Processes all 7 CLAUDE.md files according to the 3-bucket classification.
Migration targets (CONTRIBUTING.md, docs/ARCHITECTURE.md, docs/DEPLOYMENT.md,
domain READMEs) are introduced by DOC-2/4/5/6 — this PR must merge last.

### scripts/CLAUDE.md → scripts/README.md
New `scripts/README.md` with full script documentation (preserving the
⚠️ destructive-operation warning on reset-db.sh). `scripts/CLAUDE.md`
reduced to a pointer + "document new scripts in README.md" reminder.

### .devcontainer/CLAUDE.md → .devcontainer/README.md
New `.devcontainer/README.md` with all configuration, usage, and limitations.
`devcontainer/CLAUDE.md` reduced to a single pointer line.

### docs/CLAUDE.md → docs/README.md
New `docs/README.md` covering the folder structure, ADR guide, infrastructure
docs, and specs folder. `docs/CLAUDE.md` reduced to pointer + ADR reminder.

### ocr-service/CLAUDE.md
Reduced to pointer to `ocr-service/README.md` (content migrated in DOC-6).
Kept LLM reminders: single-node constraint, ALLOWED_PDF_HOSTS SSRF risk.

### backend/CLAUDE.md
- Layering Rules → pointer to docs/ARCHITECTURE.md
- Error Handling → pointer to CONTRIBUTING.md + reminder
- Security/Permissions → pointer to docs/ARCHITECTURE.md + reminder
- Package Structure → tagged TODO post-REFACTOR-1
- Fixed errors.ts path to frontend/src/lib/shared/errors.ts
- Added ANNOTATE_ALL + BLOG_WRITE to permission list
- Key Entities, Entity Code Style, Services → kept (Bucket-2)

### root CLAUDE.md
- Stack, Infrastructure, Dev Container → pointers
- Layering Rules, Error Handling, Security, OpenAPI, API Client,
  Date Handling, UI Components, Frontend Error Handling → pointers + reminders
- Package Structure → tagged TODO post-REFACTOR-1
- Domain Model, Entity Code Style, Form Actions, Styling → kept (Bucket-2)

### frontend/CLAUDE.md
- API Client Pattern, Date Handling → pointers + reminders
- Key UI Components → pointer to domain READMEs
- Styling, Form Actions, How to Run, Vite Proxy, i18n → kept (Bucket-2)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:41:02 +02:00
Marcel
513fda2888 fix(docs): correct person/notification domain README signatures
Some checks failed
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
- person/README.md: findAll(String q) and findByName(String firstName, String lastName)
- notification/README.md: replace 'None inbound' with actual outbound dep on DocumentService.findTitlesByIds

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:36:38 +02:00
Marcel
995c696c6a docs(legibility): fix four more signature/accuracy blockers in domain READMEs
- notification: remove phantom NotificationPreferenceRepository entity; fix
  notifyReply signature (DocumentComment + Set<UUID>, not parentComment/reply)
- tag: correct delete(UUID) description — TagService.delete() is called BY
  DocumentService.deleteTagCascading(), not the other way around
- person: fix findOrCreateByAlias to single-String signature; type classification
  is internal to PersonTypeClassifier
- dashboard: replace fabricated cross-domain calls with verified ones
  (removed NotificationService + GeschichteService; added TranscriptionService,
  UserService, CommentService per actual DashboardService imports)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:36:38 +02:00
Marcel
9b2ed48689 docs(legibility): fix two method signature blockers in domain READMEs
- notification/README.md: notifyMentions second param is DocumentComment, not String contextUrl
- document/README.md: transcription queue methods take int limit param

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:36:38 +02:00
Marcel
a1b89670c0 docs(legibility): add 18 per-domain README.md files (DOC-6)
Backend (9): document, person, tag, user, geschichte, notification,
ocr, audit, dashboard.
Frontend (8): document, person, tag, user, geschichte, notification,
ocr, shared.
OCR service (1): ocr-service/README.md.

Each README covers: what the domain owns, explicit non-ownership,
public surface (verified by grep against the codebase), internal
layout, and cross-domain dependencies.

Closes #400
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:36:38 +02:00
Marcel
a3c17750cd fix(docs): correct DEPLOYMENT.md env var name and prod overlay note
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
- Security checklist: OCR_TRAINING_TOKEN → APP_OCR_TRAINING_TOKEN (backend)
  plus TRAINING_TOKEN (OCR service); both must share the same value
- Bootstrap: clarify docker-compose.prod.yml is not committed — must be
  created from docs/infrastructure/production-compose.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:35:23 +02:00
Marcel
83db80b867 docs(legibility): fix two blockers in DEPLOYMENT.md
- Use correct container name archive-db (not familienarchiv-db-1) in
  §5 backup/restore commands — verified against docker-compose.yml
- Add KRAKEN_MODEL_PATH to OCR service env vars table (was missing;
  set at docker-compose.yml:92 as /app/models/german_kurrent.mlmodel)

Refs #399
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:35:23 +02:00
Marcel
a944563560 docs(legibility): write docs/DEPLOYMENT.md — Day-1 checklist and operational reference
Covers: topology diagram (Mermaid), OCR memory/VPS sizing table,
dev-vs-prod differences, complete env vars table (all vars verified
against docker-compose.yml and application.yaml, including APP_ADMIN_*
and ALLOWED_PDF_HOSTS gaps not in .env.example), security checklist
before first boot, bootstrap sequence, logs, backup current state vs
planned, common operational tasks, and known limitations with ADR links.

Closes #399
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:35:23 +02:00
Marcel
8225baf578 docs(legibility): fix two blockers in CONTRIBUTING.md
Some checks failed
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
- Clarify docs/ARCHITECTURE.md link with interim pointer to
  docs/architecture/c4-diagrams.md until DOC-2 PR merges
- Remove ./mvnw checkstyle:check — no checkstyle plugin in pom.xml;
  replace with ./mvnw test and ./mvnw clean package -DskipTests

Refs #398
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:31:55 +02:00
Marcel
bab30fe29c docs(legibility): write CONTRIBUTING.md with three concrete walkthroughs
Covers environment setup, daily workflow, three walkthroughs (add domain,
add endpoint, add frontend page), and a conventions reference. All file
paths verified against current main. Walkthroughs follow TDD order (Red
before Green). Resolves all persona feedback from issue #398.

Closes #398
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:31:55 +02:00
Marcel
69b564b34b docs(legibility): fix three factual errors in ARCHITECTURE.md
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
- Add ANNOTATE_ALL to the Permission enum listing (was missing)
- Fix transcription block autosave endpoint: PUT not PATCH,
  correct path /api/documents/{documentId}/transcription-blocks/{blockId}
- Clarify auth injection: hooks.server.ts handleFetch injects the
  Authorization header, not the SvelteKit action directly

Refs #396
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:30:48 +02:00
Marcel
fc53038af2 docs(legibility): write docs/ARCHITECTURE.md
Human-targeted architecture doc: high-level diagram, 7 Tier-1 + 2
Tier-2 domains, cross-cutting layer, stack-symmetry principle, 6 ADR
summaries, layering rule, permission system, and two data-flow
walkthroughs (document upload, transcription block autosave).

Closes #396
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:30:48 +02:00
Marcel
869885eb78 docs(legibility): update c4-diagrams.md L2 — add ocr-service, SSE, presigned URL
Refs #396
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:30:48 +02:00
Marcel
a9b8e19dea docs(legibility): add README reference line to root CLAUDE.md — DOC-1
Some checks failed
CI / Unit & Component Tests (push) Failing after 4m2s
CI / OCR Service Tests (push) Successful in 1m9s
CI / Backend Unit Tests (push) Failing after 3m43s
Single pointer line at the top: humans read README.md, LLMs read CLAUDE.md.
No existing content removed — full migration is DOC-7's responsibility.

Refs #395

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:01:16 +02:00
Marcel
080e8eb55f docs(legibility): write human-targeted README.md at repo root — DOC-1
Five-section front door for new contributors: product description,
subsystem map, quick-start (local dev + full Docker variant), where-to-go-next
with TODO markers for DOC-2/4/5, and one-line private license.

Corrects stale port reference (3000→5173, per vite.config.ts).
Links docs/GLOSSARY.md, docs/adr/, docs/architecture/c4-diagrams.md,
and Gitea issue tracker with LAN qualifier.

Closes #395

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:01:16 +02:00
Marcel
a5f4b0df31 docs(legibility): link GLOSSARY.md from COLLABORATING.md — DOC-3
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 3m28s
CI / OCR Service Tests (pull_request) Successful in 36s
CI / Backend Unit Tests (pull_request) Failing after 3m29s
CI / Unit & Component Tests (push) Failing after 3m26s
CI / OCR Service Tests (push) Successful in 30s
CI / Backend Unit Tests (push) Failing after 3m17s
Adds a glossary pointer in the Code Style section so contributors
encounter domain terminology (Person vs AppUser, etc.) at the right moment.

Refs #397

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 22:29:07 +02:00
Marcel
9dae044eec docs(legibility): link GLOSSARY.md from c4-diagrams.md — DOC-3
Adds a temporary GLOSSARY link at the top of the C4 diagrams document.
DOC-2 (ARCHITECTURE.md) will own the permanent cross-reference when it lands.

Refs #397

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 22:28:10 +02:00
Marcel
5302075124 docs(legibility): write docs/GLOSSARY.md — DOC-3
Disambiguates all overloaded terms in the codebase: Person vs AppUser,
Chronik (internal) vs Aktivität (user-facing), TranscriptionBlock polygon
vs bounding box, DocumentVersion append-only convention, OcrJob lifecycle,
SenderModel as persistent entity, Audit log DB-layer caveat, and more.

Includes Pending Terms section for audit follow-ups (#388–#392).

Refs #397

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 22:27:13 +02:00
43 changed files with 5874 additions and 779 deletions

View File

@@ -38,10 +38,10 @@ Screen readers and search engines rely on landmarks to navigate. Every page need
2. **Use CSS custom properties for all brand colors** 2. **Use CSS custom properties for all brand colors**
```css ```css
/* layout.css */ /* layout.css — semantic tokens backed by CSS variables (see --palette-* for raw values) */
--color-ink: #002850; --color-ink: var(--c-ink);
--color-accent: #A6DAD8; --color-accent: var(--c-accent);
--color-surface: #E4E2D7; --color-surface: var(--c-surface);
``` ```
```svelte ```svelte
<div class="text-ink bg-surface border-line"> <div class="text-ink bg-surface border-line">
@@ -103,9 +103,9 @@ unsaved work without warning.
1. **Enforce WCAG AA contrast ratios** 1. **Enforce WCAG AA contrast ratios**
``` ```
brand-navy (#002850) on white: 14.5:1 -- AAA pass brand-navy (--palette-navy) on white: ~14.5:1 -- AAA pass (verify exact value in layout.css)
brand-mint (#A6DAD8) on navy: 7.2:1 -- AAA pass for large text brand-mint (--palette-mint) on navy: ~7.2:1 -- AAA pass for large text
Gray-500 on white: check >= 4.5:1 -- AA minimum for body text Gray-500 on white: check >= 4.5:1 -- AA minimum for body text
``` ```
Always verify contrast with a tool. AA is the floor (4.5:1 normal text, 3:1 large text). Target AAA (7:1) for body copy. Always verify contrast with a tool. AA is the floor (4.5:1 normal text, 3:1 large text). Target AAA (7:1) for body copy.
@@ -134,8 +134,8 @@ Color-blind users (8% of men) cannot distinguish status by color alone. Always p
/* Silver #CACAC9 on white = 1.5:1 -- fails all WCAG levels */ /* Silver #CACAC9 on white = 1.5:1 -- fails all WCAG levels */
.caption { color: #CACAC9; } .caption { color: #CACAC9; }
/* brand-mint on white = 2.8:1 -- fails AA for normal text */ /* brand-mint on white = ~2.8:1 -- fails AA for normal text */
.label { color: #A6DAD8; } .label { color: var(--palette-mint); }
``` ```
Test every text color against its background. Decorative palette colors are for borders and backgrounds, not text. Test every text color against its background. Decorative palette colors are for borders and backgrounds, not text.
@@ -338,7 +338,7 @@ Test at 320px (small phone), 768px (tablet), and 1440px (desktop). Review diffs
<table> <table>
<tr><td>Section title</td><td><code>text-xs font-bold uppercase tracking-widest</code></td> <tr><td>Section title</td><td><code>text-xs font-bold uppercase tracking-widest</code></td>
<td>12px / 700</td><td>Most commonly undersized</td></tr> <td>12px / 700</td><td>Most commonly undersized</td></tr>
<tr><td>Card container</td><td><code>bg-white shadow-sm border border-brand-sand rounded-sm p-6</code></td> <tr><td>Card container</td><td><code>bg-surface shadow-sm border border-line rounded-sm p-6</code></td>
<td>padding 24px</td><td></td></tr> <td>padding 24px</td><td></td></tr>
</table> </table>
</div> </div>
@@ -376,10 +376,10 @@ await page.setViewportSize({ width: 1440, height: 900 });
## Domain Expertise ## Domain Expertise
### Brand Palette ### Brand Palette
- **Primary**: brand-navy `#002850` (text, buttons, headers), brand-mint `#A6DAD8` (accents, hover), brand-sand `#E4E2D7` (backgrounds, borders) - **Primary**: `brand-navy` (`--palette-navy`) — text, buttons, headers; `brand-mint` (`--palette-mint`) — accents, hover; sand (`--palette-sand`) — page background (use `bg-canvas` or `bg-surface` as Tailwind utilities, not `bg-brand-sand`)
- **Typography**: `font-serif` (Merriweather) for body/titles, `font-sans` (Montserrat) for labels/UI chrome - **Typography**: `font-serif` (Tinos) for body/titles, `font-sans` (Montserrat) for labels/UI chrome
- **Card pattern**: `bg-white shadow-sm border border-brand-sand rounded-sm p-6` - **Card pattern**: `bg-surface shadow-sm border border-line rounded-sm p-6`
- **Section title**: `text-xs font-bold uppercase tracking-widest text-gray-400 mb-5` - **Section title**: `text-xs font-bold uppercase tracking-widest text-ink-3 mb-5`
### Dual-Audience Design (25-42 AND 60+) ### Dual-Audience Design (25-42 AND 60+)
- Seniors: 16px minimum body text (prefer 18px), 44px touch targets (prefer 48px), redundant cues, calm layouts, persistent navigation, no timed interactions - Seniors: 16px minimum body text (prefer 18px), 44px touch targets (prefer 48px), redundant cues, calm layouts, persistent navigation, no timed interactions

View File

@@ -1,96 +1,3 @@
# Dev Container — Familienarchiv # Dev Container
## Overview → See [.devcontainer/README.md](./README.md) for configuration, usage, and known limitations.
VS Code Dev Container configuration for a pre-configured development environment. Includes Java 21, Maven, and Node.js 24 — everything needed to work on both backend and frontend.
## Configuration
File: `.devcontainer/devcontainer.json`
### Included Features
| Feature | Version | Purpose |
|---|---|---|
| Java | 21 | Spring Boot backend |
| Maven | bundled with Java feature | Build tool |
| Node.js | 24 | SvelteKit frontend |
### VS Code Extensions (Auto-installed)
| Extension | Purpose |
|---|---|
| `vscjava.vscode-java-pack` | Java language support, debugging, testing |
| `vmware.vscode-spring-boot` | Spring Boot tooling |
| `gabrielbb.vscode-lombok` | Lombok annotation support |
| `humao.rest-client` | HTTP request files (for `backend/api_tests/`) |
### Ports
- `8080` forwarded to host — access backend at `http://localhost:8080`
### User
Runs as `vscode` user (not root) for security.
## How to Use
### Prerequisites
- VS Code with the **Dev Containers** extension installed
- Docker running locally
### Open in Dev Container
1. Open the project in VS Code
2. Press `F1` → type "Dev Containers: Reopen in Container"
3. VS Code will:
- Build the container using the root `docker-compose.yml`
- Install Java 21, Maven, and Node 24
- Install the listed extensions
- Mount the workspace folder
### Working Inside the Container
Once inside the container, you have access to both stacks:
```bash
# Backend
cd backend
./mvnw spring-boot:run
# Frontend (in a new terminal)
cd frontend
npm install
npm run dev
```
The container reuses the `docker-compose.yml` services, so PostgreSQL and MinIO are available automatically.
### Forwarding Frontend Port
The devcontainer config only forwards port 8080 by default. To access the frontend dev server (port 5173 or 3000), either:
1. Add `5173` to `forwardPorts` in `devcontainer.json`, or
2. Use the VS Code "Ports" panel to forward it dynamically
## Limitations
- The devcontainer attaches to the `backend` service from `docker-compose.yml`, so it inherits those environment variables
- OCR service and other containers should be started separately via `docker-compose up -d`
- GPU passthrough for OCR training is not configured
## Customization
To add more tools or extensions, edit `.devcontainer/devcontainer.json`:
```json
{
"features": {
"ghcr.io/devcontainers/features/python:1": {
"version": "3.11"
}
},
"forwardPorts": [8080, 5173, 3000]
}
```

94
.devcontainer/README.md Normal file
View File

@@ -0,0 +1,94 @@
# Dev Container — Familienarchiv
VS Code Dev Container configuration for a pre-configured development environment. Includes Java 21, Maven, and Node.js 24 — everything needed to work on both backend and frontend.
## Configuration
File: `.devcontainer/devcontainer.json`
### Included Features
| Feature | Version | Purpose |
| ------- | ------------------------- | ------------------- |
| Java | 21 | Spring Boot backend |
| Maven | bundled with Java feature | Build tool |
| Node.js | 24 | SvelteKit frontend |
### VS Code Extensions (Auto-installed)
| Extension | Purpose |
| --------------------------- | --------------------------------------------- |
| `vscjava.vscode-java-pack` | Java language support, debugging, testing |
| `vmware.vscode-spring-boot` | Spring Boot tooling |
| `gabrielbb.vscode-lombok` | Lombok annotation support |
| `humao.rest-client` | HTTP request files (for `backend/api_tests/`) |
### Ports
- `8080` forwarded to host — access backend at `http://localhost:8080`
### User
Runs as `vscode` user (not root) for security.
## How to Use
### Prerequisites
- VS Code with the **Dev Containers** extension installed
- Docker running locally
### Open in Dev Container
1. Open the project in VS Code
2. Press `F1` → type "Dev Containers: Reopen in Container"
3. VS Code will:
- Build the container using the root `docker-compose.yml`
- Install Java 21, Maven, and Node 24
- Install the listed extensions
- Mount the workspace folder
### Working Inside the Container
Once inside the container, you have access to both stacks:
```bash
# Backend
cd backend
./mvnw spring-boot:run
# Frontend (in a new terminal)
cd frontend
npm install
npm run dev
```
The container reuses the `docker-compose.yml` services, so PostgreSQL and MinIO are available automatically.
### Forwarding Frontend Port
The devcontainer config only forwards port 8080 by default. To access the frontend dev server (port 5173 or 3000), either:
1. Add `5173` to `forwardPorts` in `devcontainer.json`, or
2. Use the VS Code "Ports" panel to forward it dynamically
## Limitations
- The devcontainer attaches to the `backend` service from `docker-compose.yml`, so it inherits those environment variables
- OCR service and other containers should be started separately via `docker-compose up -d`
- GPU passthrough for OCR training is not configured
## Customization
To add more tools or extensions, edit `.devcontainer/devcontainer.json`:
```json
{
"features": {
"ghcr.io/devcontainers/features/python:1": {
"version": "3.11"
}
},
"forwardPorts": [8080, 5173, 3000]
}
```

241
CLAUDE.md
View File

@@ -1,7 +1,11 @@
# CLAUDE.md # CLAUDE.md
> For a human-readable project overview, see [README.md](./README.md).
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
> For a human-readable project overview, see [README.md](./README.md).
## Project Overview ## Project Overview
**Familienarchiv** is a family document archival system — a full-stack web app for digitizing, organizing, and searching family documents. Key features: file uploads (stored in MinIO/S3), metadata management, Excel/ODS batch import, full-text search, conversation threads between family members, and role-based access control. **Familienarchiv** is a family document archival system — a full-stack web app for digitizing, organizing, and searching family documents. Key features: file uploads (stored in MinIO/S3), metadata management, Excel/ODS batch import, full-text search, conversation threads between family members, and role-based access control.
@@ -16,6 +20,8 @@ See [CODESTYLE.md](./CODESTYLE.md) for coding standards: Clean Code, DRY/KISS tr
## Stack ## Stack
→ See [README.md §Tech Stack](./README.md#tech-stack)
- **Backend**: Spring Boot 4.0 (Java 21, Maven, Jetty, JPA/Hibernate, Flyway, Spring Security, Spring Session JDBC) - **Backend**: Spring Boot 4.0 (Java 21, Maven, Jetty, JPA/Hibernate, Flyway, Spring Security, Spring Session JDBC)
- **Frontend**: SvelteKit 2 with Svelte 5, TypeScript, Tailwind CSS 4, Paraglide.js (i18n: de/en/es) - **Frontend**: SvelteKit 2 with Svelte 5, TypeScript, Tailwind CSS 4, Paraglide.js (i18n: de/en/es)
- **Database**: PostgreSQL 16 - **Database**: PostgreSQL 16
@@ -25,12 +31,13 @@ See [CODESTYLE.md](./CODESTYLE.md) for coding standards: Clean Code, DRY/KISS tr
## Common Commands ## Common Commands
### Running the Full Stack ### Running the Full Stack
```bash ```bash
# From repo root — starts PostgreSQL, MinIO, and Spring Boot backend
docker-compose up -d docker-compose up -d
``` ```
### Backend (Spring Boot) ### Backend (Spring Boot)
```bash ```bash
cd backend cd backend
@@ -42,11 +49,12 @@ cd backend
``` ```
### Frontend (SvelteKit) ### Frontend (SvelteKit)
```bash ```bash
cd frontend cd frontend
npm install npm install
npm run dev # Dev server (port 3000) npm run dev # Dev server (port 5173)
npm run build # Production build npm run build # Production build
npm run preview # Preview production build npm run preview # Preview production build
@@ -64,7 +72,7 @@ npm run generate:api # Regenerate TypeScript API types from OpenAPI spec
### Package Structure ### Package Structure
Package-by-domain: each domain owns its controller, service, repository, entities, and DTOs. <!-- TODO: rewrite post-REFACTOR-1 — see Epic 4 -->
``` ```
backend/src/main/java/org/raddatz/familienarchiv/ backend/src/main/java/org/raddatz/familienarchiv/
@@ -88,27 +96,21 @@ backend/src/main/java/org/raddatz/familienarchiv/
└── user/ User domain — AppUser, UserGroup, UserService, auth controllers └── user/ User domain — AppUser, UserGroup, UserService, auth controllers
``` ```
### Layering Rules (strictly enforced) ### Layering Rules
``` → See [docs/ARCHITECTURE.md §Layering rule](./docs/ARCHITECTURE.md#layering-rule)
Controller → Service → Repository → DB
```
- **Controllers** never inject or call repositories directly. **LLM reminder:** controllers never call repositories directly; services never reach into another domain's repository — always call the other domain's service instead.
- **Services** never reach into another domain's repository. Call the other domain's service instead.
-`DocumentService``PersonService.getById()``PersonRepository`
-`DocumentService``PersonRepository` directly
- This keeps domain boundaries clear and business logic testable in isolation.
### Domain Model ### Domain Model
| Entity | Table | Key relationships | | Entity | Table | Key relationships |
|---|---|---| | ----------- | ------------- | ------------------------------------------------------------------------------------- |
| `Document` | `documents` | ManyToOne `sender` (Person), ManyToMany `receivers` (Person), ManyToMany `tags` (Tag) | | `Document` | `documents` | ManyToOne `sender` (Person), ManyToMany `receivers` (Person), ManyToMany `tags` (Tag) |
| `Person` | `persons` | Referenced by documents as sender/receiver | | `Person` | `persons` | Referenced by documents as sender/receiver |
| `Tag` | `tag` | ManyToMany with documents via `document_tags` | | `Tag` | `tag` | ManyToMany with documents via `document_tags` |
| `AppUser` | `app_users` | ManyToMany `groups` (UserGroup) | | `AppUser` | `app_users` | ManyToMany `groups` (UserGroup) |
| `UserGroup` | `user_groups` | Has a `Set<String> permissions` | | `UserGroup` | `user_groups` | Has a `Set<String> permissions` |
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED` **`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
@@ -118,6 +120,7 @@ Controller → Service → Repository → DB
### Entity Code Style ### Entity Code Style
All entities use these Lombok annotations: All entities use these Lombok annotations:
```java ```java
@Entity @Entity
@Table(name = "table_name") @Table(name = "table_name")
@@ -146,65 +149,29 @@ Services are annotated with `@Service`, `@RequiredArgsConstructor`, and optional
- Read methods are not annotated (default non-transactional is fine). - Read methods are not annotated (default non-transactional is fine).
- Each service owns its domain's repository. Cross-domain data access goes through the other domain's service. - Each service owns its domain's repository. Cross-domain data access goes through the other domain's service.
**Existing services:**
| Service | Responsibility |
|---|---|
| `DocumentService` | Document CRUD, search, tag cascade delete |
| `PersonService` | Person CRUD, find-or-create by alias |
| `TagService` | Tag find/create/update/delete |
| `UserService` | User and group CRUD |
| `FileService` | S3/MinIO upload and download |
| `MassImportService` | Async ODS/Excel import; delegates to PersonService and TagService |
### DTOs ### DTOs
Input DTOs live in `dto/`. Response types are the model entities themselves (no response DTOs). Input DTOs live flat in the domain package. Response types are the model entities themselves (no response DTOs).
- `DocumentUpdateDTO` — used for both create and update (all fields optional) - `@Schema(requiredMode = REQUIRED)` on every field the backend always populates — drives TypeScript generation.
- `CreateUserRequest` — user creation
- `GroupDTO` — group create/update
### Error Handling ### Error Handling
Use `DomainException` for all domain errors. Never throw raw exceptions from service methods. → See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
```java **LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) mirror in `frontend/src/lib/shared/errors.ts`, (3) add i18n keys in `messages/{de,en,es}.json`.
// Static factories match common HTTP status codes:
DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id)
DomainException.forbidden("Access denied")
DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "Already running")
DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED, "Upload failed: " + e.getMessage())
```
`ErrorCode` is an enum in `exception/ErrorCode.java`. When adding a new error case, add the value there **and** mirror it in the frontend's `src/lib/errors.ts` + add a Paraglide translation key.
For simple validation in controllers (not domain logic), `ResponseStatusException` is acceptable:
```java
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "firstName is required");
```
### Security / Permissions ### Security / Permissions
Use `@RequirePermission` on controller methods (or the whole controller class): → See [docs/ARCHITECTURE.md §Permission system](./docs/ARCHITECTURE.md#permission-system)
```java **LLM reminder:** `@RequirePermission(Permission.WRITE_ALL)` is **required** on every `POST`, `PUT`, `PATCH`, `DELETE` endpoint — not optional. Do not mix with Spring Security's `@PreAuthorize`. Available permissions: `READ_ALL`, `WRITE_ALL`, `ADMIN`, `ADMIN_USER`, `ADMIN_TAG`, `ADMIN_PERMISSION`, `ANNOTATE_ALL`, `BLOG_WRITE`.
@RequirePermission(Permission.WRITE_ALL)
public Document updateDocument(...) { ... }
```
Available permissions: `READ_ALL`, `WRITE_ALL`, `ADMIN`, `ADMIN_USER`, `ADMIN_TAG`, `ADMIN_PERMISSION`
`PermissionAspect` (AOP) checks the current user's `UserGroup.permissions` at runtime.
### OpenAPI / API Types ### OpenAPI / API Types
SpringDoc generates the spec at `/v3/api-docs` (only accessible when running with `--spring.profiles.active=dev`). → See [CONTRIBUTING.md §Walkthrough B — Add a new endpoint](./CONTRIBUTING.md#4-walkthrough-b--add-a-new-endpoint)
When changing any model field or endpoint: **LLM reminder:** always run `npm run generate:api` in `frontend/` after any backend model or endpoint change — this is the most common cause of TypeScript type errors.
1. Rebuild the backend JAR with `-DskipTests`
2. Start it with `--spring.profiles.active=dev`
3. Run `npm run generate:api` in `frontend/`
--- ---
@@ -214,147 +181,99 @@ When changing any model field or endpoint:
``` ```
frontend/src/routes/ frontend/src/routes/
├── +layout.svelte Global header (sticky), nav links, logout ├── +layout.svelte / +layout.server.ts Global layout, auth cookie
├── +layout.server.ts Loads current user, injects auth cookie ├── +page.svelte / +page.server.ts Home / document search dashboard
├── +page.svelte Home / document search
├── +page.server.ts Load: search documents; no actions
├── documents/ ├── documents/
│ ├── [id]/+page.svelte Document detail (view + file preview) │ ├── [id]/ Document detail (view + file preview)
── [id]/edit/ Edit form (all metadata + file upload) ── [id]/edit/ Edit form (all metadata + file upload)
── new/ Create form (same fields, empty) ── new/ Upload form
│ └── bulk-edit/ Multi-document edit
├── persons/ ├── persons/
│ ├── +page.svelte Person list with search │ ├── [id]/ Person detail
│ ├── [id]/+page.svelte Person detail (inline edit + merge) │ ├── [id]/edit/ Person edit form
│ └── new/ Create person form │ └── new/ Create person form
├── conversations/ Bilateral conversation timeline ├── briefwechsel/ Bilateral conversation timeline (Briefwechsel)
├── admin/ User + group + tag management ├── aktivitaeten/ Unified activity feed (Chronik)
── login/ logout/ Auth pages ── geschichten/ Stories — list, [id], [id]/edit, new
├── stammbaum/ Family tree (Stammbaum)
├── enrich/ Enrichment workflow — [id], done
├── admin/ User, group, tag, OCR, system management
├── hilfe/transkription/ Transcription help page
├── profile/ User profile settings
├── users/[id]/ Public user profile page
├── login/ logout/ register/
├── forgot-password/ reset-password/
└── demo/ Dev-only demos
``` ```
### API Client Pattern ### API Client Pattern
All server-side API calls use the typed client from `$lib/api.server.ts`: → See [CONTRIBUTING.md §Frontend API client](./CONTRIBUTING.md#frontend-api-client)
```typescript **LLM reminder:** check `!result.response.ok` (not `result.error` — breaks when spec has no error responses defined); cast errors as `result.error as unknown as { code?: string }`; use `result.data!` after an ok check.
const api = createApiClient(fetch);
const result = await api.GET('/api/persons/{id}', { params: { path: { id } } });
// Always check via response.ok, NOT result.error
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
return { person: result.data! };
```
Key rules:
- Use `!result.response.ok` for error checking (not `if (result.error)` — this breaks when the spec has no error responses defined)
- Cast errors as `result.error as unknown as { code?: string }` to extract the backend error code
- Use `result.data!` (non-null assertion) after an ok check — TypeScript knows it's present
For multipart/form-data endpoints (file uploads), bypass the typed client and use raw `fetch`:
```typescript
const res = await fetch(`${baseUrl}/api/documents`, { method: 'POST', body: formData });
```
### Form Actions Pattern ### Form Actions Pattern
```typescript ```typescript
// +page.server.ts // +page.server.ts
export const actions = { export const actions = {
default: async ({ request, fetch }) => { default: async ({ request, fetch }) => {
const formData = await request.formData(); const formData = await request.formData();
const name = formData.get('name') as string; // cast needed — FormData returns FormDataEntryValue const name = formData.get("name") as string;
// ... // ...
return fail(400, { error: 'message' }); // on error return fail(400, { error: "message" }); // on error
throw redirect(303, '/target'); // on success throw redirect(303, "/target"); // on success
} },
}; };
``` ```
### Date Handling ### Date Handling
- **Forms**: German format `dd.mm.yyyy` with auto-dot insertion via `handleDateInput()`. A hidden `<input type="hidden" name="documentDate" value={dateIso}>` sends ISO format to the backend. → See [CONTRIBUTING.md §Date handling](./CONTRIBUTING.md#date-handling)
- **Display**: Always use `Intl.DateTimeFormat` with `T12:00:00` suffix to prevent UTC timezone off-by-one:
```typescript **LLM reminder:** always append `T12:00:00` when constructing `new Date()` from an ISO date string — prevents UTC timezone off-by-one errors.
new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'long', year: 'numeric' })
.format(new Date(doc.documentDate + 'T12:00:00'))
```
### UI Component Library ### UI Component Library
Custom components in `src/lib/components/`: → See per-domain READMEs: [`frontend/src/lib/person/README.md`](./frontend/src/lib/person/README.md), [`frontend/src/lib/tag/README.md`](./frontend/src/lib/tag/README.md), [`frontend/src/lib/document/README.md`](./frontend/src/lib/document/README.md), [`frontend/src/lib/shared/README.md`](./frontend/src/lib/shared/README.md)
| Component | Props | Description |
|---|---|---|
| `PersonTypeahead` | `name`, `label`, `value`, `initialName`, `on:change` | Single-person selector with typeahead dropdown |
| `PersonMultiSelect` | `selectedPersons` (bind) | Chip-based multi-person selector |
| `TagInput` | `tags` (bind), `allowCreation?`, `on:change` | Tag chip input with typeahead |
### Styling Conventions (Tailwind CSS 4) ### Styling Conventions (Tailwind CSS 4)
Brand color utilities (defined in `layout.css`): Brand color tokens (defined in `layout.css`):
| Class | Value | Usage | | Token / Utility | CSS variable | Usage |
|---|---|---| | ---------------- | ---------------- | ------------------------------------------------------- |
| `brand-navy` | `#002850` | Primary text, buttons, headers | | `brand-navy` | `--palette-navy` | Tailwind utility — buttons, headers, primary text |
| `brand-mint` | `#A6DAD8` | Accents, hover underlines, icons | | `brand-mint` | `--palette-mint` | Tailwind utility — accents, hover underlines, icons |
| `brand-sand` | `#E4E2D7` | Page background, card borders | | `--palette-sand` | `--palette-sand` | Palette constant only — use `bg-canvas` or `bg-surface` |
Typography: Typography:
- `font-serif` (Merriweather) — body text, document titles, names
- `font-serif` (Tinos) — body text, document titles, names
- `font-sans` (Montserrat) — labels, metadata, UI chrome - `font-sans` (Montserrat) — labels, metadata, UI chrome
Card pattern for content sections: Card pattern for content sections:
```svelte ```svelte
<div class="bg-white shadow-sm border border-brand-sand rounded-sm p-6"> <div class="rounded-sm border border-line bg-surface shadow-sm p-6">
<h2 class="text-xs font-bold uppercase tracking-widest text-gray-400 mb-5">Section Title</h2> <h2 class="text-xs font-bold uppercase tracking-widest text-ink-3 mb-5">Section Title</h2>
<!-- content --> <!-- content -->
</div> </div>
``` ```
Save bar pattern — use **sticky full-bleed** for long forms (edit document), **card-style with `mt-4`** for short forms (new person): Back button pattern — use the shared `<BackButton>` component from `$lib/shared/primitives/BackButton.svelte`. Do not use a static `<a href>` for back navigation.
```svelte
<!-- Long forms: sticky, full-bleed -->
<div class="sticky bottom-0 z-10 -mx-4 px-6 py-4 bg-white border-t border-brand-sand shadow-[0_-2px_8px_rgba(0,0,0,0.06)] flex items-center justify-between">
<!-- Short forms: card, top margin -->
<div class="mt-4 flex items-center justify-between rounded-sm border border-brand-sand bg-white px-6 py-4 shadow-sm">
```
Back button pattern — use the shared `<BackButton>` component from `$lib/components/BackButton.svelte`:
```svelte
<script lang="ts">
import BackButton from '$lib/components/BackButton.svelte';
</script>
<BackButton />
```
The component calls `history.back()` so the user returns to wherever they came from. Label is always "Zurück" (no contextual suffix — destination is unknown). Touch target ≥ 44px and focus ring are built in. Do not use a static `<a href>` for back navigation.
Subtle action link (e.g. "new document/person"):
```svelte
<a href="/documents/new" class="inline-flex items-center gap-1 text-sm font-medium text-brand-navy/60 hover:text-brand-navy transition-colors">
<svg class="w-4 h-4" ...><!-- plus icon --></svg>
Neues Dokument
</a>
```
### Error Handling (Frontend) ### Error Handling (Frontend)
`src/lib/errors.ts` mirrors the backend `ErrorCode` enum and maps codes to Paraglide translation keys. When adding a new `ErrorCode` on the backend: → See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling)
1. Add it to `ErrorCode.java`
2. Add it to the `ErrorCode` type in `errors.ts` **LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`.
3. Add a `case` in `getErrorMessage()`
4. Add the translation key in `messages/de.json`, `en.json`, `es.json`
--- ---
## Infrastructure ## Infrastructure
The `docker-compose.yml` at the repo root orchestrates everything. A MinIO MC helper container runs at startup to create the `archive-documents` bucket. The backend container depends on both `db` and `minio` being healthy. → See [docs/DEPLOYMENT.md](./docs/DEPLOYMENT.md)
Database migrations live in `backend/src/main/resources/db/migration/` (Flyway, SQL files named `V{n}__{description}.sql`).
## API Testing ## API Testing
@@ -362,4 +281,4 @@ HTTP test files are in `backend/api_tests/` for use with the VS Code REST Client
## Dev Container ## Dev Container
A `.devcontainer/` config is available (Java 21 + Node 24, ports 8080 and 3000 forwarded). Use VS Code's "Reopen in Container" for a pre-configured environment. → See [.devcontainer/README.md](./.devcontainer/README.md)

View File

@@ -180,6 +180,8 @@ When in doubt, commit more often rather than less.
See [CODESTYLE.md](./CODESTYLE.md) for the full guide: Clean Code (Uncle Bob), DRY/KISS trade-offs, and SOLID principles applied to this stack. See [CODESTYLE.md](./CODESTYLE.md) for the full guide: Clean Code (Uncle Bob), DRY/KISS trade-offs, and SOLID principles applied to this stack.
For domain terminology (Person vs AppUser, DocumentStatus lifecycle, Chronik vs Aktivität, etc.) see [docs/GLOSSARY.md](./docs/GLOSSARY.md).
Quick reminders: Quick reminders:
- Pure functions over stateful helpers where possible - Pure functions over stateful helpers where possible
- No premature abstractions — KISS beats DRY - No premature abstractions — KISS beats DRY

305
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,305 @@
# Contributing to Familienarchiv
For the full collaboration rules (issue workflow, PR process, Red/Green TDD, commit conventions) see [COLLABORATING.md](./COLLABORATING.md).
For coding style see [CODESTYLE.md](./CODESTYLE.md).
For the system architecture see [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) (introduced in DOC-2; until that PR merges, see [docs/architecture/c4-diagrams.md](./docs/architecture/c4-diagrams.md)).
For domain terminology see [docs/GLOSSARY.md](./docs/GLOSSARY.md).
---
## 1. Environment setup
**Prerequisites:** Java 21 (SDKMAN), Node 24 (nvm), Docker
**Activate SDKMAN and nvm before running `java`, `mvn`, `node`, or `npm`:**
```bash
source "$HOME/.sdkman/bin/sdkman-init.sh"
export NVM_DIR="$HOME/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
```
---
## 2. Daily development workflow
**Startup order — services must start in this sequence:**
```bash
# 1. Start PostgreSQL and MinIO
docker compose up -d db minio
# 2. Start the backend (separate terminal)
cd backend && ./mvnw spring-boot:run
# 3. Start the frontend (separate terminal)
cd frontend && npm install && npm run dev
```
> `npm install` also wires up the Husky pre-commit hook via the `prepare` script.
> Run it before your first commit, or the hook will fail to execute.
> **Do not use `docker-compose.ci.yml` locally** — it disables the bind mounts that the dev workflow depends on.
**Regenerate TypeScript types after any backend API change:**
```bash
# Backend must be running with dev profile
cd frontend && npm run generate:api
```
> ⚠️ Forgetting this step is the most common cause of "where did my TypeScript type go?" — always regenerate after changing models or endpoints.
**Test commands:**
```bash
cd backend && ./mvnw test # backend unit + slice tests
cd frontend && npm run test # Vitest unit tests
cd frontend && npm run check # svelte-check (type errors)
cd frontend && npx playwright test # Playwright e2e tests
```
**Branch naming:** `<type>/<issue-number>-<short-description>`, e.g. `feat/398-contributing`
**Commits:** one logical change per commit; reference the Gitea issue:
```
feat(person): add aliases endpoint
Closes #42
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
```
### Test-type decision matrix
| What you're testing | Test type | Tool |
|---|---|---|
| Service business logic, calculations | Unit test | JUnit + `@ExtendWith(MockitoExtension.class)` |
| HTTP contract, request validation, error codes | Controller slice test | `@WebMvcTest` |
| Server `load` function | Vitest unit | Import directly, mock `fetch` |
| Shared UI component | Vitest browser-mode | `render()` + `getByRole()` |
| Full user-facing flow, navigation, forms | E2E | Playwright |
---
## 3. Walkthrough A — Add a new domain
**Example:** adding a `citation` domain (formal references to documents).
Both the backend and frontend are organised **domain-first**. A new domain means adding a package on both sides under the same name.
### Backend
1. Create `backend/src/main/java/org/raddatz/familienarchiv/citation/`
2. Add entity, repository, service, controller, and DTOs flat in the package:
- **Entity** `Citation.java` — annotate with `@Entity @Data @Builder @NoArgsConstructor @AllArgsConstructor`; use `@GeneratedValue(strategy = GenerationType.UUID)` for the `id` field; add `@Schema(requiredMode = REQUIRED)` on every field the backend always populates
- **Repository** `CitationRepository.java` — extends `JpaRepository<Citation, UUID>`
- **Service** `CitationService.java``@Service @RequiredArgsConstructor`; write methods `@Transactional`, read methods unannotated; cross-domain data goes through the other domain's service, never its repository
- **Controller** `CitationController.java``@RestController @RequestMapping("/api/citations")`
3. Add `@RequirePermission(Permission.WRITE_ALL)` on every `POST`, `PUT`, `PATCH`, and `DELETE` endpoint — **this is not optional**. Read-only `GET` endpoints stay unannotated.
4. Add a Flyway migration: `backend/src/main/resources/db/migration/V{n}__{description}.sql` (use the next sequential number after the highest existing one).
5. **Write failing tests before any implementation** (Red step):
- Service unit test for business logic (`@ExtendWith(MockitoExtension.class)`)
- `@WebMvcTest` slice test for each HTTP endpoint
6. Rebuild with `--spring.profiles.active=dev` and run `npm run generate:api` in `frontend/`.
### Frontend
7. Create `frontend/src/lib/citation/` — domain-specific Svelte components and TypeScript utilities go here.
8. Add routes under `frontend/src/routes/citations/` as needed.
9. Add a per-domain `README.md` in both the backend package folder and `frontend/src/lib/citation/` (per DOC-6).
### Documentation
10. Update `docs/ARCHITECTURE.md` Section 2 to include the new domain.
11. Update `docs/GLOSSARY.md` if new terms are introduced.
12. Update the ESLint boundary allow-list in `frontend/eslint.config.js` if the domain needs to import from another domain.
---
## 4. Walkthrough B — Add a new endpoint
**Example:** `POST /api/persons/{id}/aliases` — attach a name alias to an existing person.
### Red (write failing tests first)
1. Write a failing `@WebMvcTest` controller slice test:
```java
@Test
void addAlias_returns201_whenAliasCreated() { ... }
```
2. Write a failing service unit test:
```java
@Test
void addAlias_throwsNotFound_whenPersonDoesNotExist() { ... }
```
### Green (implement)
3. Add the service method in `PersonService.java`:
```java
@Transactional
public PersonNameAlias addAlias(UUID personId, PersonNameAliasDTO dto) { ... }
```
4. Add the controller method in `PersonController.java`:
```java
@PostMapping("/{id}/aliases")
@RequirePermission(Permission.WRITE_ALL)
public ResponseEntity<PersonNameAlias> addAlias(@PathVariable UUID id,
@RequestBody PersonNameAliasDTO dto) { ... }
```
`@RequirePermission(Permission.WRITE_ALL)` on every state-mutating endpoint — **not optional**.
5. Validate user-supplied inputs at the controller boundary:
```java
if (dto.name() == null || dto.name().isBlank())
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "name is required");
```
Validate at system boundaries; trust internal service code.
6. Use `DomainException` for domain errors:
```java
DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id)
```
If you need a new error code, add it to `ErrorCode.java`, mirror it in
`frontend/src/lib/shared/errors.ts`, and add translation keys in `messages/{de,en,es}.json`.
7. Mark every field the backend always populates with `@Schema(requiredMode = REQUIRED)` — this drives TypeScript type generation.
### Types and tests
8. Rebuild with `--spring.profiles.active=dev`, then `npm run generate:api` in `frontend/`.
> ⚠️ **Always regenerate types after any API change.** This is the #1 cause of "where did my TypeScript type go?"
9. Run the full test suite — all green before committing.
---
## 5. Walkthrough C — Add a new frontend page
**Example:** `/persons/[id]/timeline` — a chronological event timeline for one person.
### Red (write failing test first)
1. Write a failing Playwright E2E test for the user flow:
```typescript
test('timeline shows events in chronological order', async ({ page }) => {
await page.goto('/persons/1/timeline');
// assertions...
});
```
### Green (implement)
2. Create `frontend/src/routes/persons/[id]/timeline/+page.svelte`
3. Add `frontend/src/routes/persons/[id]/timeline/+page.server.ts` for the SSR load:
```typescript
import { createApiClient } from '$lib/shared/api.server';
export const load: PageServerLoad = async ({ params, fetch }) => {
const api = createApiClient(fetch);
const result = await api.GET('/api/persons/{id}', { params: { path: { id: params.id } } });
if (!result.response.ok) throw error(result.response.status, '...');
return { person: result.data! };
};
```
4. Domain-specific components (e.g. `TimelineEntry.svelte`) → `frontend/src/lib/person/`
5. Shared primitives (e.g. a generic date-range display) → `frontend/src/lib/shared/primitives/`
6. UI patterns to follow:
- Back navigation: `import BackButton from '$lib/shared/primitives/BackButton.svelte'`
- Date display: always append `T12:00:00` — `new Intl.DateTimeFormat('de-DE', …).format(new Date(val + 'T12:00:00'))` — prevents UTC off-by-one errors
- Brand colors: `brand-navy`, `brand-mint`, `brand-sand` (defined in `src/routes/layout.css`)
- Accessibility: touch targets ≥ 44 px (`min-h-[44px]`); focus rings (`focus-visible:ring-2 focus-visible:ring-brand-navy`); `aria-label` on icon-only buttons; `aria-live="polite"` on dynamic status messages
7. Add Paraglide i18n keys in `messages/de.json`, `messages/en.json`, `messages/es.json`.
8. If adding a new error code: mirror in `frontend/src/lib/shared/errors.ts` and add translation keys.
9. Make all tests green before committing.
---
## 6. Conventions reference
### Error handling
| Scenario | Pattern |
|---|---|
| Domain entity not found | `DomainException.notFound(ErrorCode.X, "…")` |
| Permission denied | `DomainException.forbidden("…")` |
| Concurrent edit conflict | `DomainException.conflict(ErrorCode.X, "…")` |
| Infrastructure failure | `DomainException.internal(ErrorCode.X, "…")` |
| Simple controller validation | `throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "…")` |
New error code: `ErrorCode.java` → `frontend/src/lib/shared/errors.ts` → `messages/{de,en,es}.json`.
### DTOs
- Input DTOs live flat in the domain package (e.g. `PersonUpdateDTO.java`)
- Responses are the entity itself — no separate response DTOs
- `@Schema(requiredMode = REQUIRED)` on every field the backend always populates
### Frontend API client
```typescript
const api = createApiClient(fetch); // from $lib/shared/api.server
const result = await api.GET('/api/persons/{id}', { params: { path: { id } } });
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
return { person: result.data! }; // non-null assertion is safe after the ok check
```
For multipart/form-data (file uploads): bypass the typed client and use raw `fetch` — the client cannot handle it.
### Date handling
| Context | Pattern |
|---|---|
| Form display | German `dd.mm.yyyy` with auto-dot insertion via `handleDateInput()` |
| Wire format | ISO 8601 via a hidden `<input type="hidden" name="documentDate" value={dateIso}>` |
| Display | `new Intl.DateTimeFormat('de-DE', …).format(new Date(val + 'T12:00:00'))` |
### Security checklist (new endpoint)
- `@RequirePermission(Permission.WRITE_ALL)` on every `POST`, `PUT`, `PATCH`, `DELETE` — required, not optional
- Validate all user-supplied inputs at the controller boundary before passing to the service
- Parameterised queries only — never interpolate user input into JPQL/SQL strings
- No raw user input in log messages — use `{}` placeholders: `log.warn("Not found: {}", id)`
- Validate content-type and size on upload endpoints before reading the stream
### Accessibility baseline (new frontend page)
- Touch targets ≥ 44 px on all interactive elements (`min-h-[44px]`)
- Focus rings on all focusable elements (`focus-visible:ring-2 focus-visible:ring-brand-navy`)
- `aria-label` on every icon-only button
- `aria-live="polite"` on dynamic status messages
- Color is never the sole status indicator
Full WCAG 2.1 AA reference: [docs/STYLEGUIDE.md](./docs/STYLEGUIDE.md).
### Lint and format
```bash
# Frontend
cd frontend && npm run lint # Prettier + ESLint check
cd frontend && npm run format # Auto-fix formatting
cd frontend && npm run check # svelte-check (type errors)
# Backend — no standalone lint tool; compilation and test runs catch style issues
cd backend && ./mvnw test # compile + test
cd backend && ./mvnw clean package -DskipTests # compile-only check
```

93
README.md Normal file
View File

@@ -0,0 +1,93 @@
# Familienarchiv
Familienarchiv is a private web application for digitising, organising, and searching a family document collection — letters, postcards, and photographs from 1899 to 1950. Family members upload scans, transcribe handwritten text (Kurrent/Sütterlin), and read the archive from any device.
---
## Subsystems
- `frontend/` — SvelteKit 2 / Svelte 5 / TypeScript / Tailwind 4 web app (server-side rendered)
- `backend/` — Spring Boot 4 (Java 21) REST API; handles documents, persons, search, and user management
- `ocr-service/` — Python FastAPI microservice for OCR and handwritten text recognition (HTR); single-node by design — see [ADR-001](docs/adr/001-ocr-python-microservice.md). Not part of the default dev stack (see Quick start below)
- `infra/` — Gitea Actions CI/CD config; future home for infrastructure-as-code
- `scripts/` — operational and data-pipeline helpers (`reset-db.sh`, `clean-e2e-data.sh`, import scripts)
---
## Quick start
**Prerequisites:** Java 21, Node 24, Docker with the `docker compose` plugin (V2).
### 1. Configure environment
```bash
cp .env.example .env
# The defaults in .env.example work for local development without changes.
```
### 2. Start infrastructure
```bash
# Starts PostgreSQL, MinIO (object storage), and Mailpit (dev mail catcher)
docker compose up -d db minio mailpit
```
### 3. Start the backend
```bash
cd backend
./mvnw spring-boot:run
# Starts on http://localhost:8080
# API docs (dev profile, auto-enabled): http://localhost:8080/v3/api-docs
```
### 4. Start the frontend
```bash
cd frontend
npm install
npm run dev
# Starts on http://localhost:5173
```
Open **http://localhost:5173** — you should see the Familienarchiv login screen.
Default development credentials:
```
# local dev only — change before any network-exposed deployment
Email: admin@familyarchive.local
Password: admin123
```
> **Development setup only.** The default `docker compose` config exposes the database port and uses root MinIO credentials. Do not connect this to a network without first reading `docs/DEPLOYMENT.md` _(coming: [DOC-5, #399](http://heim-nas:3005/marcel/familienarchiv/issues/399))_.
### Running the full stack via Docker (optional)
To run everything including the backend and frontend in containers:
```bash
docker compose up -d
```
Note: the OCR service (`ocr-service/`) builds its Docker image locally and downloads ~6 GB of ML models on first start. Expect 3060 minutes on a first run. The rest of the stack starts independently; OCR can be excluded with `--scale ocr-service=0` on memory-constrained machines (requires ≥ 12 GB RAM).
---
## Where to go next
| Resource | Purpose |
|---|---|
| [docs/architecture/c4-diagrams.md](docs/architecture/c4-diagrams.md) | C4 container and component diagrams (current system view) |
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) _(coming: [DOC-2, #396](http://heim-nas:3005/marcel/familienarchiv/issues/396))_ | Full architecture guide with domain list |
| [docs/GLOSSARY.md](docs/GLOSSARY.md) | Overloaded terms: Person vs AppUser, Chronik vs Aktivität, etc. |
| [CONTRIBUTING.md](CONTRIBUTING.md) _(coming: [DOC-4, #398](http://heim-nas:3005/marcel/familienarchiv/issues/398))_ | How to add a domain, endpoint, or SvelteKit route |
| [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) _(coming: [DOC-5, #399](http://heim-nas:3005/marcel/familienarchiv/issues/399))_ | Production deployment checklist and secrets guide |
| [docs/adr/](docs/adr/) | Architecture Decision Records — the "why" behind key choices |
| [Gitea issue tracker](http://heim-nas:3005/marcel/familienarchiv/issues) _(internal — home network only)_ | Bug reports, feature requests, and project planning |
---
## License
Private project — all rights reserved. Not licensed for redistribution.

View File

@@ -11,7 +11,7 @@ Spring Boot 4.0 monolith serving the Familienarchiv REST API. Handles document m
- **Server**: Jetty (not Tomcat — excluded in pom.xml) - **Server**: Jetty (not Tomcat — excluded in pom.xml)
- **Data**: PostgreSQL 16, JPA/Hibernate, Spring Data JPA - **Data**: PostgreSQL 16, JPA/Hibernate, Spring Data JPA
- **Migrations**: Flyway (SQL files in `src/main/resources/db/migration/`) - **Migrations**: Flyway (SQL files in `src/main/resources/db/migration/`)
- **Security**: Spring Security, Spring Session JDBC, JWT tokens - **Security**: Spring Security, Spring Session JDBC
- **File Storage**: MinIO via AWS SDK v2 (S3-compatible) - **File Storage**: MinIO via AWS SDK v2 (S3-compatible)
- **Spreadsheet Import**: Apache POI 5.5.0 (Excel/ODS) - **Spreadsheet Import**: Apache POI 5.5.0 (Excel/ODS)
- **API Docs**: SpringDoc OpenAPI 3.x (`/v3/api-docs` — dev profile only) - **API Docs**: SpringDoc OpenAPI 3.x (`/v3/api-docs` — dev profile only)
@@ -19,7 +19,7 @@ Spring Boot 4.0 monolith serving the Familienarchiv REST API. Handles document m
## Package Structure ## Package Structure
Package-by-domain: each domain owns its controller, service, repository, entities, and DTOs. <!-- TODO: rewrite post-REFACTOR-1 — see Epic 4 -->
``` ```
src/main/java/org/raddatz/familienarchiv/ src/main/java/org/raddatz/familienarchiv/
@@ -43,31 +43,28 @@ src/main/java/org/raddatz/familienarchiv/
└── user/ # User domain — AppUser, UserGroup, UserService, auth controllers └── user/ # User domain — AppUser, UserGroup, UserService, auth controllers
``` ```
## Layering Rules (Strict) For per-domain ownership and public surface, see each domain's `README.md`.
``` ## Layering Rules
Controller → Service → Repository → DB
```
- **Controllers never call repositories directly.** → See [docs/ARCHITECTURE.md §Layering rule](../docs/ARCHITECTURE.md#layering-rule)
- **Services never reach into another domain's repository.** Call the other domain's service instead.
-`DocumentService``PersonService.getById()``PersonRepository` **LLM reminder:** controllers never call repositories directly; services never reach into another domain's repository — always call the other domain's service.
-`DocumentService``PersonRepository` directly
## Key Entities ## Key Entities
| Entity | Table | Key Relationships | | Entity | Table | Key Relationships |
|---|---|---| | --------------------------- | ------------------------------- | ------------------------------------------------------------------------------- |
| `Document` | `documents` | ManyToOne sender (Person), ManyToMany receivers (Person), ManyToMany tags (Tag) | | `Document` | `documents` | ManyToOne sender (Person), ManyToMany receivers (Person), ManyToMany tags (Tag) |
| `Person` | `persons` | Referenced by documents as sender/receiver; name aliases table | | `Person` | `persons` | Referenced by documents as sender/receiver; name aliases table |
| `Tag` | `tag` | ManyToMany with documents via `document_tags`; self-referencing parent for tree | | `Tag` | `tag` | ManyToMany with documents via `document_tags`; self-referencing parent for tree |
| `AppUser` | `app_users` | ManyToMany groups (UserGroup) | | `AppUser` | `app_users` | ManyToMany groups (UserGroup) |
| `UserGroup` | `user_groups` | Has a `Set<String> permissions` | | `UserGroup` | `user_groups` | Has a `Set<String> permissions` |
| `TranscriptionBlock` | `transcription_blocks` | Per-document, per-page text blocks with polygons | | `TranscriptionBlock` | `transcription_blocks` | Per-document, per-page text blocks with polygons |
| `DocumentAnnotation` | `document_annotations` | Free-form annotations on document pages | | `DocumentAnnotation` | `document_annotations` | Free-form annotations on document pages |
| `Comment` | `document_comments` | Threaded comments with mentions | | `Comment` | `document_comments` | Threaded comments with mentions |
| `Notification` | `notifications` | User notification feed | | `Notification` | `notifications` | User notification feed |
| `OcrJob` / `OcrJobDocument` | `ocr_jobs`, `ocr_job_documents` | Batch OCR job tracking | | `OcrJob` / `OcrJobDocument` | `ocr_jobs`, `ocr_job_documents` | Batch OCR job tracking |
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED` **`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
@@ -104,32 +101,15 @@ public class MyEntity {
## Error Handling ## Error Handling
Use `DomainException` for all domain errors: → See [CONTRIBUTING.md §Error handling](../CONTRIBUTING.md#error-handling)
```java **LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` — never throw raw exceptions from service methods. For simple controller validation (not domain logic), `ResponseStatusException` is acceptable: `throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "…")`. When adding a new `ErrorCode`: add to `ErrorCode.java`, mirror in `frontend/src/lib/shared/errors.ts`, add i18n keys in `messages/{de,en,es}.json`.
DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "...")
DomainException.forbidden("...")
DomainException.conflict(ErrorCode.IMPORT_ALREADY_RUNNING, "...")
DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED, "...")
```
When adding a new `ErrorCode`:
1. Add to `ErrorCode.java`
2. Mirror in frontend `src/lib/errors.ts`
3. Add Paraglide translation key in `messages/{de,en,es}.json`
## Security / Permissions ## Security / Permissions
Use `@RequirePermission` on controller methods or classes: → See [docs/ARCHITECTURE.md §Permission system](../docs/ARCHITECTURE.md#permission-system)
```java **LLM reminder:** `@RequirePermission(Permission.WRITE_ALL)` is **required** on every `POST`, `PUT`, `PATCH`, `DELETE` endpoint — not optional. Do not mix with Spring Security's `@PreAuthorize`. Available permissions: `READ_ALL`, `WRITE_ALL`, `ADMIN`, `ADMIN_USER`, `ADMIN_TAG`, `ADMIN_PERMISSION`, `ANNOTATE_ALL`, `BLOG_WRITE`.
@RequirePermission(Permission.WRITE_ALL)
public Document updateDocument(...) { ... }
```
Available permissions: `READ_ALL`, `WRITE_ALL`, `ADMIN`, `ADMIN_USER`, `ADMIN_TAG`, `ADMIN_PERMISSION`
`PermissionAspect` checks the current user's `UserGroup.permissions` at runtime.
## OCR Integration ## OCR Integration
@@ -141,49 +121,35 @@ The backend orchestrates OCR by calling the Python `ocr-service` microservice vi
- `OcrBatchService` — handles batch/job workflows - `OcrBatchService` — handles batch/job workflows
- `OcrAsyncRunner` — async execution of OCR jobs - `OcrAsyncRunner` — async execution of OCR jobs
For ocr-service internals, see [`ocr-service/README.md`](../ocr-service/README.md).
## API Testing ## API Testing
HTTP test files in `backend/api_tests/` for the VS Code REST Client extension. HTTP test files in `backend/api_tests/` for the VS Code REST Client extension.
## How to Run ## How to Run
### Local Development
```bash ```bash
cd backend cd backend
# Run with dev profile (requires PostgreSQL + MinIO running via docker-compose) ./mvnw spring-boot:run # Run with dev profile (requires PostgreSQL + MinIO)
./mvnw spring-boot:run ./mvnw clean package # Build JAR (with tests)
# Build JAR (with tests)
./mvnw clean package
# Build JAR skipping tests
./mvnw clean package -DskipTests ./mvnw clean package -DskipTests
./mvnw test # Run all tests
# Run all tests ./mvnw test -Dtest=ClassName # Run a single test class
./mvnw test ./mvnw clean verify # Run with JaCoCo coverage report
# Run a single test class
./mvnw test -Dtest=ClassName
# Run with coverage (JaCoCo)
./mvnw clean verify
``` ```
### OpenAPI TypeScript Generation **OpenAPI / TypeScript type generation:**
1. Build and start backend with `--spring.profiles.active=dev` 1. Start backend with `--spring.profiles.active=dev`
2. In `frontend/`, run: `npm run generate:api` 2. In `frontend/`: `npm run generate:api`
### Profiles **LLM reminder:** always regenerate types after any model or endpoint change — the most common cause of "where did my TypeScript type go?"
- **dev** (default): Enables OpenAPI, dev configs, e2e seeds
- **prod**: Production profile — no dev endpoints
## Testing ## Testing
- Unit tests: Mockito + JUnit, pure in-memory - Unit tests: Mockito + JUnit, pure in-memory
- Slice tests: `@WebMvcTest`, `@DataJpaTest` with Testcontainers PostgreSQL - Slice tests: `@WebMvcTest`, `@DataJpaTest` with Testcontainers PostgreSQL
- Integration tests: Full Spring context with Testcontainers - Integration tests: Full Spring context with Testcontainers
- Coverage gate: 88% branch coverage overall (JaCoCo) - Coverage gate: 88% branch coverage (JaCoCo)

View File

@@ -0,0 +1,37 @@
# audit
Append-only event store for all domain mutations. Every write across the application produces an `audit_log` row. The activity feed and Family Pulse dashboard aggregate from this table.
## What this domain owns
Table: `audit_log` (append-only by convention — no UPDATE or DELETE in application code).
Features: log mutations, query activity feed, query per-entity history.
**Admission criteria (why this is cross-cutting, not a Tier-1 domain):** consumed by 5+ domains; has no user-facing CRUD of its own; the data model is fixed (event log, not a business entity).
## What this domain does NOT own
Nothing beyond the log table. `audit/` is an infrastructure layer, not a business domain.
## Public surface (called from other domains)
| Method | Consumer | Purpose |
|---|---|---|
| `logAfterCommit(event)` | document, person, user, ocr, geschichte | Record a mutation event after the DB transaction commits |
`logAfterCommit` is the only write-path. Query paths (`AuditLogQueryService`) are consumed by `dashboard/` and the activity feed route.
## Internal layout
- `AuditService``logAfterCommit()` (write)
- `AuditLogQueryService` — query by entity, by user, for the activity feed
- `AuditLog` (entity) → table `audit_log`
- `AuditLogRepository`
## Cross-domain dependencies
None. `audit/` is consumed by other domains; it does not call out to any of them.
## Frontend counterpart
No direct frontend counterpart. Audit data surfaces in the `activity/` and `conversation/` frontend domains via the dashboard API.

View File

@@ -0,0 +1,39 @@
# dashboard
Stats aggregation for the admin dashboard and the Family Pulse widget. This is a derived domain — it has no tables of its own; all data is computed on-the-fly from Tier-1 domain data.
## What this domain owns
No entities. Routes: `/api/dashboard/*`, `/api/stats/*`.
Features: document counts, person counts, publication stats, weekly activity data, incomplete-document list, enrichment queue, Family Pulse widget data, admin statistics.
**Admission criteria (cross-cutting):** aggregates from 3+ domains; no owned entities.
## What this domain does NOT own
None of the underlying data — it reads from `document/`, `person/`, `audit/`, `notification/`, `geschichte/`.
## Public surface
`dashboard/` is a leaf domain — no other domain calls its services. It is the aggregator, not the aggregated.
## Internal layout
- `StatsController` — REST under `/api/stats`
- `DashboardController` — REST under `/api/dashboard`
- `StatsService` — aggregated counts (documents, persons, geschichten, incomplete, etc.)
- `DashboardService` — activity feed composition, Family Pulse data
## Cross-domain dependencies
- `DocumentService.count()` — total document count (StatsService)
- `DocumentService.getDocumentById(UUID)` / `getDocumentsByIds(List<UUID>)` — document enrichment for activity feed (DashboardService)
- `PersonService.count()` — total person count (StatsService)
- `TranscriptionService.listBlocks(UUID)` — transcription block lookup for resume widget (DashboardService)
- `UserService.getById(UUID)` — actor name resolution in activity feed (DashboardService)
- `CommentService.findAnnotationIdsByIds(...)` — annotation context lookup for activity feed (DashboardService)
- `AuditLogQueryService.findMostRecentDocumentForUser()` / `getPulseStats()` / `findActivityFeed()` — audit-sourced feed rows (DashboardService)
## Frontend counterpart
Activity feed and Pulse widget are assembled in `frontend/src/lib/shared/dashboard/` and in the `aktivitaeten` route; no dedicated `dashboard/` lib folder.

View File

@@ -0,0 +1,50 @@
# document
The archive's core concept. A `Document` represents one physical artefact (a letter, a postcard, a photo) stored in MinIO and described by metadata.
## What this domain owns
Entities: `Document`, `DocumentVersion`, `TranscriptionBlock`, `DocumentAnnotation`, `DocumentComment`.
Features: document CRUD, file upload/download, full-text search, bulk editing, transcription workflows, annotation canvas, threaded comments, thumbnail generation (PDFBox).
## What this domain does NOT own
- `Person` (sender / receivers) — referenced by ID, resolved via `PersonService`
- `Tag` — referenced by ID; the join is on the document side but tags are owned by `tag/`
- `AppUser` — comments reference `AppUser` IDs, but user management lives in `user/`
- OCR processing — `ocr/` orchestrates jobs; `ocr-service/` executes them
## Public surface (called from other domains)
| Method | Consumer | Purpose |
|---|---|---|
| `getDocumentById(UUID)` | ocr, notification | Fetch a single document |
| `getDocumentsByIds(List<UUID>)` | ocr | Bulk fetch for OCR job |
| `findByOriginalFilename(String)` | importing | Deduplication during mass import |
| `deleteTagCascading(UUID tagId)` | tag | Remove a tag from all documents before deleting it |
| `findWeeklyStats()` | dashboard | Activity data for Family Pulse widget |
| `count()` | dashboard | Total document count for stats |
| `addTrainingLabel(...)` | ocr | Attach a confirmed sender label to a document |
| `findSegmentationQueue(int limit)` / `findTranscriptionQueue(int limit)` / `findReadyToReadQueue(int limit)` | ocr | OCR pipeline queues |
## Internal layout
- `DocumentController` — REST under `/api/documents`
- `DocumentService` — CRUD, search (JPA Specifications), bulk edit
- `DocumentRepository` — includes bidirectional conversation-thread query
- `DocumentSpecifications` — composable `Specification` predicates for search
- `DocumentVersionService` / `DocumentVersionRepository` — append-only version history
- `ThumbnailService` + `ThumbnailAsyncRunner` — PDFBox thumbnail generation (separate thread pool)
- Sub-packages: `annotation/`, `comment/`, `transcription/`
## Cross-domain dependencies
- `PersonService.getById()` / `getAllById()` — resolve sender and receivers
- `TagService.expandTagNamesToDescendantIdSets()` — tag filter expansion
- `FileService.uploadFile()` / `downloadFile()` / `generatePresignedUrl()` — S3 I/O
- `NotificationService.notifyMentions()` / `.notifyReply()` — comment mentions
- `AuditService.logAfterCommit()` — every mutation is audited
## Frontend counterpart
`frontend/src/lib/document/README.md`

View File

@@ -0,0 +1,38 @@
# geschichte
Family stories — curated narrative pieces that weave together persons, documents, and commentary into a publishable article. German: *Geschichte* (story / history).
## What this domain owns
Entity: `Geschichte`.
Lifecycle: `DRAFT → PUBLISHED` (only published stories are visible to non-authors).
Features: story CRUD, rich-text editing with person and document cross-references, publish/unpublish toggle, comment thread (shared component from `shared/discussion/`).
## What this domain does NOT own
- `Person` or `Document` records — stories reference them by ID. Deleting a Person or Document does not cascade to Geschichte.
- Comment storage — shared comment infrastructure is in `document/comment/` (or `shared/discussion/` on the frontend).
## Public surface (called from other domains)
| Method | Consumer | Purpose |
|---|---|---|
| `getById(UUID)` | notification | Resolve story context in mention notifications |
| `list(...)` | dashboard | Recent stories for the activity feed |
| `count()` | dashboard | Published story count for stats |
## Internal layout
- `GeschichteController` — REST under `/api/geschichten`
- `GeschichteService` — CRUD, publish lifecycle
- `GeschichteRepository` — list by status, author
## Cross-domain dependencies
- `PersonService.getById()` / `getAllById()` — resolve person references in story body
- `DocumentService.getDocumentsByIds()` — resolve document references in story body
- `AuditService.logAfterCommit()` — story mutations are audited
## Frontend counterpart
`frontend/src/lib/geschichte/README.md`

View File

@@ -0,0 +1,41 @@
# notification
In-app messages delivered in real time via SSE and persisted in the bell-icon dropdown. Notifications are created by other domains in response to events (comment mentions, replies).
## What this domain owns
Entity: `Notification`.
Features: create and deliver notifications, unread count, mark-read, SSE real-time push, per-user delivery preferences (stored as fields on `AppUser`, managed by `user/`).
## What this domain does NOT own
- `AppUser` (recipient) — owned by `user/`
- `Document` or `Geschichte` (notification context) — referenced by ID only
## Public surface (called from other domains)
| Method | Consumer | Purpose |
|---|---|---|
| `notifyMentions(mentionedUserIds, comment)` | document (comment) | Push mention notifications when a comment contains @mentions |
| `notifyReply(reply, participantIds)` | document (comment) | Push reply notification to all thread participants |
| `countUnread(userId)` | user session | Unread badge count in the nav bar |
| `getNotifications(userId)` | dashboard / activity | Notification list for bell dropdown |
| `markRead(id)` / `markAllRead(userId)` | notification controller | User-driven read-state updates |
| `updatePreferences(userId, dto)` | notification controller | Per-user delivery preferences |
## Internal layout
- `NotificationController` — REST under `/api/notifications`
- `NotificationService` — create, query, mark-read
- `SseEmitterRegistry` — runtime-stateful component that keeps one `SseEmitter` per connected user. On `notifyMentions()` / `notifyReply()`, the service writes to `SseEmitterRegistry` to push real-time events. SSE connections go **backend → browser directly**, not via the SvelteKit SSR layer.
- `NotificationRepository` — persisted notification rows
- `NotificationPreferenceDTO` — read/write DTO for preference endpoints (prefs stored on `AppUser`)
## Cross-domain dependencies
**Outbound (this domain calls):**
- `DocumentService.findTitlesByIds(List<UUID>)` — enriches notification DTOs with document titles for display in the bell dropdown
## Frontend counterpart
`frontend/src/lib/notification/README.md`

View File

@@ -0,0 +1,44 @@
# ocr
OCR/HTR pipeline orchestration. This domain manages job lifecycle and result ingestion — it does **not** perform OCR. Actual text recognition runs in the Python `ocr-service/` container (port 8000, internal network only).
## What this domain owns
Entities: `OcrJob`, `OcrJobDocument`, `SenderModel`.
Features: start OCR jobs, track job lifecycle (`PENDING → RUNNING → DONE / FAILED`), stream transcription blocks back into `document/transcription/`, sender-model training, segmentation training.
## What this domain does NOT own
- Document content — `Document` and `TranscriptionBlock` are owned by `document/`
- File storage — presigned MinIO URLs are generated by `filestorage/FileService` and passed to the OCR service
- OCR processing — the Python `ocr-service/` executes Surya (typewritten) and Kraken (Kurrent/Sütterlin HTR) and streams results back
## Public surface (called from other domains)
| Method | Consumer | Purpose |
|---|---|---|
| `startOcr(documentId, ...)` | document | Trigger an OCR job for a document |
| `getJob(UUID)` | document | Fetch job status |
| `getDocumentOcrStatus(UUID)` | document | Per-document OCR status summary |
## Internal layout
- `OcrController` — REST under `/api/ocr`
- `OcrService` — job creation, presigned URL generation, result ingestion
- `OcrBatchService` — batch job workflows
- `OcrAsyncRunner``@Async` execution of OCR jobs
- `OcrTrainingService` — calls `/train` and `/segtrain` on the Python service (protected by `X-Training-Token` header)
- `OcrJobRepository` / `OcrJobDocumentRepository`
- `SenderModelRepository` — trained sender-recognition models
- `OcrClient` (interface) / `RestClientOcrClient` — HTTP client for the Python OCR service; mockable for tests
## Cross-domain dependencies
- `DocumentService.getDocumentById()` / `getDocumentsByIds()` — resolve target documents
- `DocumentService.addTrainingLabel()` — attach confirmed sender labels after training
- `FileService.generatePresignedUrl()` — generate MinIO presigned URLs passed to the OCR service (PDF bytes never flow through the backend)
- `AuditService.logAfterCommit()` — OCR job events are audited
## Frontend counterpart
`frontend/src/lib/ocr/README.md`

View File

@@ -0,0 +1,45 @@
# person
Historical individuals referenced by documents. A `Person` is a family member who appears as a sender or receiver in the archive — they are never login accounts.
## What this domain owns
Entities: `Person`, `PersonNameAlias`, `PersonRelationship`.
Features: person CRUD, name alias management, person merge (deduplication), family-member designation, relationship graph, person type classification (FAMILY, CORRESPONDENT, INSTITUTION).
## What this domain does NOT own
- `AppUser` — login accounts are in `user/`. A `Person` record has no login credentials. The separation is deliberate: a historical family member from 1905 is never a system user.
- Document content — `Person` records are referenced by documents (as sender/receiver), not the other way around.
- Relationship rendering — the Stammbaum view is derived by the frontend from `PersonRelationship` data.
## Public surface (called from other domains)
| Method | Consumer | Purpose |
|---|---|---|
| `getById(UUID)` | document, geschichte, ocr | Fetch one person by ID |
| `getAllById(List<UUID>)` | document | Bulk fetch for sender/receiver resolution |
| `findAll(String q)` | document, dashboard | List all persons |
| `findByName(String firstName, String lastName)` | document | Typeahead search |
| `findOrCreateByAlias(String rawName)` | importing | Idempotent create during mass import; type classification happens internally |
| `findAllFamilyMembers()` | dashboard | Family member list for stats |
| `findCorrespondents()` | document | Correspondent list for conversation filter |
| `count()` | dashboard | Total person count for stats |
## Internal layout
- `PersonController` — REST under `/api/persons`
- `PersonService` — CRUD, merge, alias management, family-member designation
- `PersonRepository` — sorted list, name search
- `PersonNameAlias` / `PersonNameAliasRepository` — alternative name spellings
- `PersonNameParser` / `PersonTypeClassifier` — name parsing utilities
- `PersonSummaryDTO` — lightweight DTO for typeahead / list views
- Sub-package: `relationship/``PersonRelationship`, `RelationshipService`, `RelationshipController`
## Cross-domain dependencies
- `AuditService.logAfterCommit()` — person mutations are audited
## Frontend counterpart
`frontend/src/lib/person/README.md`

View File

@@ -0,0 +1,35 @@
# tag
Hierarchical document categories. Tags form a tree via a self-referencing `parent_id` column and are applied to documents for filtering and browse navigation.
## What this domain owns
Entity: `Tag` (self-referencing `parent_id` tree).
Features: tag CRUD, hierarchical deletion (cascade to descendants), tag typeahead, admin tag management (rename, reparent, merge).
## What this domain does NOT own
- Documents — the `document_tags` join table is on the document side. `Tag` does not hold document references.
- Tag assignment — adding/removing a tag from a document is handled by `DocumentService`.
## Public surface (called from other domains)
| Method | Consumer | Purpose |
|---|---|---|
| `delete(UUID)` | document | Remove the tag record; called by `DocumentService.deleteTagCascading()` after all document references are unlinked |
| `deleteWithDescendants(UUID)` | admin tag UI | Recursive subtree deletion |
| `expandTagNamesToDescendantIdSets(List<String>)` | document | Expand tag filter to include descendant tags |
## Internal layout
- `TagController` — REST under `/api/tags`
- `TagService` — CRUD, hierarchy traversal, cascade-delete coordination
- `TagRepository` — find-or-create by name (case-insensitive), subtree queries
## Cross-domain dependencies
None. Documents reference tags; tags do not reference documents or other domains.
## Frontend counterpart
`frontend/src/lib/tag/README.md`

View File

@@ -0,0 +1,35 @@
# user
Login accounts and permission groups. An `AppUser` is a system user who can authenticate and act in the application — they are never a historical family member.
## What this domain owns
Entities: `AppUser`, `UserGroup`, password-reset tokens, invite tokens.
Features: user CRUD, group CRUD, password change, password reset flow, invite links.
## What this domain does NOT own
- `Person` records — historical family members. An `AppUser` is never linked to a `Person`. This separation is intentional: a person who digitized letters in 2024 is not the same entity as their great-grandmother who wrote them in 1912. See `docs/GLOSSARY.md`.
- Permission enforcement — `security/` owns `@RequirePermission` and `PermissionAspect`. `user/` only manages which permissions are stored on `UserGroup`.
## Public surface
`UserService` methods are consumed primarily by the security infrastructure and the admin UI. No other business-logic domain calls `UserService` directly.
The Spring Security chain (via `CustomUserDetailsService` in `security/`) calls `AppUserRepository.findByUsername()` on every authenticated request.
## Internal layout
- `UserController` — REST under `/api/users` (current user, CRUD)
- `AuthController` — password reset, invite flow
- `UserService` — BCrypt-encoded passwords, group assignment
- `AppUserRepository` — find by username (used by Spring Security)
- `UserGroupRepository` — group and permission management
## Cross-domain dependencies
- `AuditService.logAfterCommit()` — user-management mutations are audited
## Frontend counterpart
`frontend/src/lib/user/README.md`

146
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,146 @@
<!-- Last reviewed: 2026-05-05 -->
# Familienarchiv — Architecture
**Target reader:** a PM-with-CS background who has read the README.
**Goal:** accurate mental model after one read — enough to sketch the system on a whiteboard.
For domain terminology, see [docs/GLOSSARY.md](GLOSSARY.md).
For security policies and hardening, see [docs/security-guide.md](security-guide.md).
For low-level ADR details, see [docs/adr/](adr/).
---
## 1. High-level diagram
The updated container diagram below shows all six deployable units and their communication paths.
See [docs/architecture/c4-diagrams.md](architecture/c4-diagrams.md) for the full C4 L1/L2/L3 diagrams (Mermaid, Gitea-rendered).
Key points not visible in the diagram:
- **OCR network boundary:** the OCR service has no external port — it is reachable only on the internal Docker Compose network. Only the backend calls it. The OCR service fetches PDF files from MinIO using a presigned URL that the backend generates and passes in the request body; the PDF bytes never pass through the backend.
- **SSE path:** server-sent event notifications go directly from the backend to the user's browser (not via the SvelteKit SSR layer) over a long-lived HTTP connection managed by `SseEmitterRegistry`.
---
## 2. Domain set
Both stacks are organised **package-by-domain**: each domain owns its entities, service, controller, repository, and DTOs. Domain names are identical across `backend/src/main/java/.../` and `frontend/src/lib/`.
### Tier-1 domains — have entities and user-facing CRUD
**`document`** — the archive's core concept. Owns `Document`, `DocumentVersion`, `TranscriptionBlock`, `DocumentAnnotation`, `DocumentComment`. Does NOT own persons or tags (references them by ID). Cross-domain deps: `person` (sender/receivers), `tag` (labels), `ocr` (HTR pipeline), `notification` (comment mentions), `audit` (every mutation).
**`person`** — historical individuals referenced by documents. Owns `Person`, `PersonNameAlias`, `PersonRelationship`. Does NOT own `AppUser` (login accounts are a separate domain). Cross-domain deps: `document` (relationship queries).
**`tag`** — hierarchical document categories. Owns `Tag` (self-referencing `parent_id` tree). Does NOT own documents; the join is document-side. No cross-domain deps.
**`user`** — login accounts and permission groups. Owns `AppUser`, `UserGroup`, invite tokens. Does NOT own `Person` records. Cross-domain deps: `audit` (user management events).
**`geschichte`** — family stories. Owns `Geschichte` (`DRAFT → PUBLISHED` lifecycle). Cross-domain deps: `person`, `document` (linked entities in the story body).
**`notification`** — in-app messages. Owns `Notification`. Delivers via `SseEmitterRegistry` (live) and persisted rows (bell dropdown). Cross-domain deps: `user` (recipient), `document` (context).
**`ocr`** — OCR/HTR pipeline orchestration. Owns `OcrJob`, `OcrJobDocument`, `SenderModel`. Calls the Python OCR service; maps streamed transcription blocks back to `document`. Cross-domain deps: `document` (target), `filestorage` (presigned URLs).
### Tier-2 domains — derived (UI without dedicated tables)
A **derived domain** has its own routes and UI but no database tables of its own; it is assembled from data owned by Tier-1 domains.
**`conversation`** (route: `/briefwechsel`) — bilateral letter timeline between two `Person`s. Derived from `Document` sender/receiver relationships. The `DocumentRepository` bidirectional query is the only data source.
**`activity`** (route: `/aktivitaeten`) — family activity feed. Derived from `audit_log`, `notifications`, and document events. No aggregation table; computed on-the-fly by `DashboardService` and composed in the SvelteKit load function.
---
## 3. Cross-cutting layer
Members of the cross-cutting layer have no entity of their own, no user-facing CRUD, and are consumed by two or more domains — or are framework infrastructure that every domain depends on.
| Member (backend package) | Purpose | Admission criteria |
|---|---|---|
| `audit` | Append-only event store (`audit_log`) for all domain mutations. Feeds the activity feed and Family Pulse dashboard. | Consumed by 5+ domains; no user-facing CRUD of its own |
| `config` | Infrastructure bean definitions: `MinioConfig`, `AsyncConfig`, `WebConfig` | Framework infra; no business logic |
| `dashboard` | Stats aggregation for the admin dashboard and Family Pulse widget | Aggregates from 3+ domains; no owned entities |
| `exception` | `DomainException`, `ErrorCode` enum, `GlobalExceptionHandler` | Framework infra; consumed by every controller and service |
| `filestorage` | `FileService` — MinIO/S3 upload, download, presigned-URL generation | Generic service; consumed by `document` and `ocr` |
| `importing` | `MassImportService` — async ODS/Excel batch import | Orchestrates across `person`, `tag`, `document` |
| `security` | `SecurityConfig`, `Permission` enum, `@RequirePermission` annotation, `PermissionAspect` (AOP) | Framework infra; enforced globally across all controllers |
**Frontend `shared/`** follows the same admission criteria. Key members: `api.server.ts` (typed openapi-fetch client factory), `errors.ts` (backend `ErrorCode` → i18n mapping), `shared/primitives/` (generic UI components used across ≥2 domains), `shared/discussion/` (comment/mention editor used by `document` and `geschichte`), `shared/utils/` (pure date/sort/debounce utilities).
---
## 4. Stack-symmetry principle
**Rule:** a domain has the same name on both stacks.
| Backend | Frontend |
|---|---|
| `backend/src/main/java/.../document/` | `frontend/src/lib/document/` |
| `backend/src/main/java/.../person/` | `frontend/src/lib/person/` |
| … | … |
Adding a new Tier-1 domain means creating a package on **both** sides under the same name. Adding only a backend package without a corresponding frontend folder (or vice versa) is a red flag in code review.
The backend has been domain-first since the project started. The frontend `src/lib/` was restructured from flat-by-type to domain-first in issue #408 (May 2026).
---
## 5. Key architectural decisions
### ADR-001 — OCR as a Python microservice
The two OCR engines required (Surya for typewritten text, Kraken for Kurrent/Sütterlin HTR) exist only in the Python ecosystem. A separate `ocr-service` Python container exposes a simple HTTP API; the Spring Boot backend calls it via `RestClient`. All job tracking and business logic remain in Spring Boot. See [ADR-001](adr/001-ocr-python-microservice.md).
### ADR-002 — Polygon JSONB storage for annotations
Kraken outputs polygon boundaries for historical handwriting; axis-aligned bounding boxes approximate them poorly. Annotation and transcription-block positions are stored as `polygon JSONB` columns. Display-only — server-side geometry continues to use the AABB fields. See [ADR-002](adr/002-polygon-jsonb-storage.md).
### ADR-003 — Unified activity feed (Chronik/Aktivität)
Personal notifications and ambient activity (uploads, transcriptions, comments) are merged into one `/aktivitaeten` page. The SvelteKit load function composes data from `/api/dashboard/activity` and `/api/notifications` — no new backend orchestrator endpoint. See [ADR-003](adr/003-chronik-unified-activity-feed.md).
### ADR-004 — In-process PDFBox thumbnails
Thumbnails are rendered in Spring Boot using Apache PDFBox (already a dependency) rather than delegating to the OCR service. A dedicated `thumbnailExecutor` pool isolates the work. See [ADR-004](adr/004-pdfbox-thumbnails.md).
### ADR-005 — thumbnailAspect + pageCount
Aspect ratio (`PORTRAIT` / `LANDSCAPE`) and page count are persisted alongside the thumbnail JPEG at generation time — cheap to derive then, expensive to re-derive later. See [ADR-005](adr/005-thumbnail-aspect-and-page-count.md).
### ADR-006 — Synchronous domain events inside the publisher's transaction
When a `Person` display name changes, all `TranscriptionBlock` `@mention` text must be rewritten atomically. This is done via Spring `ApplicationEventPublisher` + `@EventListener @Transactional` to avoid a circular dependency between `PersonService` and `TranscriptionBlockService`. See [ADR-006](adr/006-synchronous-domain-events-in-transaction.md).
### Layering rule
```
Controller → Service → Repository → DB
```
Controllers never call repositories directly. Services never reach into another domain's repository — they call the other domain's service. This keeps domain boundaries clear and business logic testable without a running database.
### Permission system
Permissions are enforced via `@RequirePermission(Permission.X)` on controller methods, checked at runtime by `PermissionAspect` (Spring AOP). The `Permission` enum defines the available capabilities (`READ_ALL`, `WRITE_ALL`, `ADMIN`, `ADMIN_USER`, `ADMIN_TAG`, `ADMIN_PERMISSION`, `ANNOTATE_ALL`, `BLOG_WRITE`). This is not Spring Security's `@PreAuthorize` — do not mix the two mechanisms.
Sessions use a Base64-encoded Basic Auth token stored in an `httpOnly`, `SameSite=strict` cookie (`auth_token`, maxAge=86400 s). CSRF protection is disabled because this cookie configuration structurally prevents cross-origin credential theft. See [docs/security-guide.md](security-guide.md) for the full security reference.
---
## 6. Data flow walkthroughs
### Document upload
1. User submits the edit form (file + metadata) from the browser.
2. The SvelteKit server action sends `PUT /api/documents/{id}` as `multipart/form-data`. `hooks.server.ts` (`handleFetch`) transparently injects the `Authorization` header from the `auth_token` cookie — the action itself is unaware of auth.
3. `PermissionAspect` intercepts the controller method, verifies the user has `WRITE_ALL`, and proceeds.
4. `DocumentController` delegates to `DocumentService.updateDocument()`.
5. `DocumentService` resolves the `Person` sender by ID (via `PersonService`), resolves or creates `Tag`s (via `TagService`), then calls `FileService.uploadFile()`.
6. `FileService` generates a key (`documents/{UUID}_{filename}`), streams the file to MinIO via the AWS SDK v2 S3Client.
7. `DocumentService` persists the S3 key, sets `status = UPLOADED`, and saves to PostgreSQL.
8. `AuditService` writes an `UPLOADED` event to `audit_log` in the same transaction.
9. Backend returns the updated `Document` JSON; SvelteKit refreshes the document detail page.
### Transcription block autosave
1. The transcriber pauses typing; the frontend's `useBlockAutoSave` factory fires after a debounce interval.
2. The browser sends `PUT /api/documents/{documentId}/transcription-blocks/{blockId}` with the new text and the block's current `version` (optimistic lock). `hooks.server.ts` (`handleFetch`) injects the `Authorization` header from the cookie.
3. `TranscriptionService.saveBlock()` loads the block, checks the `@Version` field for concurrent edits, updates `block.text` and any `@mention` sidecars, and calls `saveAndFlush`.
4. If a concurrent save collides (version mismatch), the backend returns `409 Conflict`; the frontend's `saveBlockWithConflictRetry` helper re-fetches and retries.
5. On success, `AuditService` logs a `BLOCK_SAVED` event.
6. If the block text contains a new `@PersonName` mention, `NotificationService` creates a `Notification` row for the mentioned person's `AppUser`.
7. `SseEmitterRegistry` broadcasts the notification over the open SSE connection to that user's browser in real time.

View File

@@ -1,97 +1,5 @@
# Docs — Familienarchiv # docs/
## Overview → See [docs/README.md](./README.md) for the folder structure and documentation guide.
Project documentation organized into four categories: architecture decision records (ADRs), system architecture diagrams, infrastructure runbooks, and detailed UI/UX specifications. **LLM reminder:** ADRs are sequential — use the next number after the highest existing one in `docs/adr/`. When making a significant architectural change (new service, data model change, technology swap), write a new ADR before implementing.
## Folder Structure
```
docs/
├── adr/ # Architecture Decision Records
├── architecture/ # C4 model diagrams and system architecture docs
├── infrastructure/ # Deployment, CI/CD, and ops guides
├── specs/ # UI/UX feature specifications (HTML)
├── app-analysis-*.md # Application analysis reports
├── mail.md # Mail system documentation
├── security-guide.md # Security policies and hardening guide
├── STYLEGUIDE.md # Coding and design style guide
├── TODO-backend.md # Backend backlog
└── TODO-frontend.md # Frontend backlog
```
## ADR (`adr/`)
Architecture Decision Records capture major technical decisions and their rationale.
| ADR | Title | Status |
|---|---|---|
| `001-ocr-python-microservice.md` | OCR as a separate Python container | Accepted |
| `002-polygon-jsonb-storage.md` | Polygon coordinates in JSONB columns | Accepted |
| `003-chronik-unified-activity-feed.md` | Unified activity feed (Chronik) | Accepted |
When making a significant architectural change (new service, data model change, technology swap), write a new ADR following the format:
- Status (Proposed / Accepted / Deprecated / Superseded)
- Context (forces at play)
- Decision (what we decided)
- Consequences (trade-offs)
- Alternatives Considered (table format)
## Architecture (`architecture/`)
Contains C4 model diagrams describing the system at different zoom levels:
- **Context diagram** — How Familienarchiv fits into the user and system ecosystem
- **Container diagram** — The high-level technology choices (Spring Boot, SvelteKit, PostgreSQL, MinIO, OCR service)
- **Component diagram** — Major structural components within the backend
Written in Markdown with embedded Mermaid or PlantUML diagrams (`c4-diagrams.md`).
## Infrastructure (`infrastructure/`)
Operational documentation for running Familienarchiv in production and CI.
| Document | Purpose |
|---|---|
| `ci-gitea.md` | Gitea CI/CD pipeline configuration |
| `production-compose.md` | Production Docker Compose setup |
| `s3-migration.md` | Migrating documents between S3 buckets |
| `self-hosted-catalogue.md` | Self-hosted software catalogue |
## Specs (`specs/`)
High-fidelity UI/UX specifications written as standalone HTML files. These are design documents that describe exact layout, interactions, and responsive behavior before implementation.
Each spec typically includes:
- Visual mockups with CSS-in-HTML styling
- Interaction flows and state transitions
- Responsive breakpoint behavior
- Accessibility requirements
Examples of active spec areas:
- Document detail page (`document-topbar-*.html`, `documents-page-spec.html`)
- Admin interfaces (`admin-redesign-*.html`, `admin-tag-overhaul.html`)
- Transcription workflows (`inline-transcription-*.html`, `annotation-transcription-*.html`)
- Dashboard and activity feeds (`dashboard-*.html`, `chronik-spec.html`)
- OCR admin (`ocr-admin-spec.html`)
## How to Use
1. **Before implementing a feature**, check `specs/` for an existing specification.
2. **When proposing a new architecture**, draft an ADR in `adr/` and discuss before coding.
3. **When deploying**, follow `infrastructure/production-compose.md`.
4. **Keep TODO files updated** — they serve as lightweight backlogs.
## Style Guide
`STYLEGUIDE.md` covers:
- Code formatting and linting rules
- Component naming conventions
- Color palette and typography
- Accessibility standards (WCAG 2.1 AA)
## Contributing
- ADRs should be sequential (`NNN-descriptive-name.md`).
- Specs should be self-contained HTML files viewable in a browser.
- Infrastructure docs should include copy-pasteable commands.

274
docs/DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,274 @@
<!-- Last reviewed: 2026-05-05 — reviewed at every milestone close -->
# Familienarchiv — Deployment Reference
> **If the app is down right now → jump to [§4 Logs](#4-logs--observability).**
This doc is the Day-1 checklist and operational reference. It links to the canonical infrastructure docs in `docs/infrastructure/` rather than duplicating them.
**Audience:** operator bringing up a fresh instance, or Successor-X debugging a live incident.
**Ownership:** project owner. Update this file in any PR that changes the container topology, env vars, or backup procedure.
## Table of Contents
1. [Deployment topology](#1-deployment-topology)
2. [Environment variables](#2-environment-variables)
3. [Bootstrap from scratch](#3-bootstrap-from-scratch)
4. [Logs + observability](#4-logs--observability)
5. [Backup + recovery](#5-backup--recovery)
6. [Common operational tasks](#6-common-operational-tasks)
7. [Known limitations](#7-known-limitations)
---
## 1. Deployment topology
```mermaid
graph TD
Browser -->|HTTPS| Caddy["Caddy (TLS termination)"]
Caddy -->|HTTP :5173| Frontend["Web Frontend\nSvelteKit / Node.js"]
Caddy -->|HTTP :8080| Backend["API Backend\nSpring Boot / Jetty :8080"]
Backend -->|JDBC :5432| DB[(PostgreSQL 16)]
Backend -->|S3 API :9000| MinIO[(MinIO / Hetzner OBS)]
Backend -->|HTTP :8000 internal| OCR["OCR Service\nPython FastAPI"]
OCR -->|presigned URL| MinIO
Browser -->|SSE direct| Backend
```
**Key facts:**
- Caddy terminates TLS and reverse-proxies to frontend and backend. See the Caddyfile in [`docs/infrastructure/production-compose.md`](infrastructure/production-compose.md).
- The OCR service has **no external port** — reachable only on the internal Docker network from the backend.
- SSE notifications go directly backend → browser (not via the SvelteKit SSR layer).
- Management port 8081 (Spring Actuator / Prometheus scrape) is internal only — the Caddy config blocks `/actuator/*` externally.
### OCR memory requirements
The OCR service requires significant RAM for model loading. The dev compose sets `mem_limit: 12g`.
| Production target | RAM | Recommended OCR limit | Notes |
|---|---|---|---|
| Hetzner CX42 | 16 GB | 12 GB | Recommended for OCR-enabled production |
| Hetzner CX32 | 8 GB | 6 GB | Accept reduced batch sizes and slower throughput |
| Hetzner CX22 | 4 GB | — | Disable the OCR service (`profiles: [ocr]`); run OCR on demand only |
A CX32 cannot honour a `mem_limit: 12g` — set it to `6g` in the prod overlay or use CX42.
### Dev vs production differences
| Concern | Dev compose | Prod overlay |
|---|---|---|
| MinIO image tag | `minio/minio:latest` (unpinned) | Pinned in prod overlay |
| Data persistence | Bind mounts `./data/postgres`, `./data/minio` | Named Docker volumes |
| Bucket creation | `create-buckets` helper container | Pre-created in Hetzner console |
| Spring profile | `dev,e2e` (enables OpenAPI + Swagger UI) | `prod` |
| Mail | Mailpit (local catcher) | Real SMTP |
Full prod overlay: [`docs/infrastructure/production-compose.md`](infrastructure/production-compose.md).
---
## 2. Environment variables
All vars are set in `.env` at the repo root (copy from `.env.example`). The backend resolves them via `application.yaml`; the Docker Compose file wires them into each container.
**Any var found in `docker-compose.yml` or `application*.yaml` that is not in this table is a blocking review comment on any PR that changes those files.**
### Backend
| Variable | Purpose | Default | Required? | Sensitive? |
|---|---|---|---|---|
| `SPRING_DATASOURCE_URL` | PostgreSQL JDBC URL | — | YES | — |
| `SPRING_DATASOURCE_USERNAME` | DB username | — | YES | — |
| `SPRING_DATASOURCE_PASSWORD` | DB password | — | YES | YES |
| `S3_ENDPOINT` | MinIO / OBS endpoint URL | — | YES | — |
| `S3_ACCESS_KEY` | MinIO access key (use service account, not root in prod) | — | YES | YES |
| `S3_SECRET_KEY` | MinIO secret key | — | YES | YES |
| `S3_BUCKET_NAME` | Target bucket name | — | YES | — |
| `S3_REGION` | S3 region string | `us-east-1` | YES | — |
| `APP_ADMIN_USERNAME` | Bootstrap admin username (⚠ not in .env.example) | `admin` | YES | — |
| `APP_ADMIN_PASSWORD` | Bootstrap admin password (⚠ ships as `admin123`) | `admin123` | YES | YES |
| `APP_BASE_URL` | Public-facing URL for email links | `http://localhost:3000` | YES (prod) | — |
| `APP_OCR_BASE_URL` | Internal URL of the OCR service | — | YES | — |
| `APP_OCR_TRAINING_TOKEN` | Secret token for OCR training endpoints | — | YES (prod) | YES |
| `MAIL_HOST` | SMTP host | `mailpit` (dev) | YES (prod) | — |
| `MAIL_PORT` | SMTP port | `1025` (dev) | YES (prod) | — |
| `MAIL_USERNAME` | SMTP username | — | YES (prod) | YES |
| `MAIL_PASSWORD` | SMTP password | — | YES (prod) | YES |
| `APP_MAIL_FROM` | From address for outbound mail | `noreply@familienarchiv.local` | YES (prod) | — |
| `MAIL_SMTP_AUTH` | SMTP auth enabled | `false` (dev) | YES (prod) | — |
| `MAIL_STARTTLS_ENABLE` | STARTTLS enabled | `false` (dev) | YES (prod) | — |
| `SPRING_PROFILES_ACTIVE` | Spring profile | `dev,e2e` (compose) | YES | — |
### PostgreSQL container
| Variable | Purpose | Default | Required? | Sensitive? |
|---|---|---|---|---|
| `POSTGRES_USER` | DB superuser | `archive_user` | YES | — |
| `POSTGRES_PASSWORD` | DB password | `change-me` | YES | YES |
| `POSTGRES_DB` | Database name | `family_archive_db` | YES | — |
### MinIO container
| Variable | Purpose | Default | Required? | Sensitive? |
|---|---|---|---|---|
| `MINIO_ROOT_USER` | MinIO root username | `minio_admin` | YES | — |
| `MINIO_ROOT_PASSWORD` | MinIO root password | `change-me` | YES | YES |
| `MINIO_DEFAULT_BUCKETS` | Bucket name | `archive-documents` | YES | — |
### OCR service
| Variable | Purpose | Default | Required? | Sensitive? |
|---|---|---|---|---|
| `TRAINING_TOKEN` | Guards `/train` and `/segtrain` endpoints (accepts file uploads) | — | YES (prod) | YES |
| `ALLOWED_PDF_HOSTS` | SSRF protection — comma-separated list of allowed PDF source hosts. **Do not widen to `*`** | `minio,localhost,127.0.0.1` | YES | — |
| `KRAKEN_MODEL_PATH` | Directory containing Kraken HTR models (populated by `download-kraken-models.sh`) | `/app/models/` | — | — |
| `BLLA_MODEL_PATH` | Kraken baseline layout analysis model path | `/app/models/blla.mlmodel` | — | — |
---
## 3. Bootstrap from scratch
> Full VPS provisioning steps are in [`docs/infrastructure/production-compose.md`](infrastructure/production-compose.md). This section covers the sequence and the security-critical steps.
### Security checklist — complete before first boot
> ⚠️ **These defaults ship in `.env.example` and `application.yaml`. Change them or you will have an insecure installation.**
- [ ] Set `APP_ADMIN_PASSWORD` (default: `admin123` — change before starting the backend)
- [ ] Set `APP_ADMIN_USERNAME` if you want a non-default admin login name (add to `.env` — not in `.env.example`)
- [ ] Rotate `POSTGRES_PASSWORD` from `change-me`
- [ ] Rotate `MINIO_ROOT_PASSWORD` from `change-me`
- [ ] Set a strong `APP_OCR_TRAINING_TOKEN` (backend) and the matching `TRAINING_TOKEN` (OCR service) — both must be the same value (`python3 -c "import secrets; print(secrets.token_hex(32))"`)
- [ ] Confirm `ALLOWED_PDF_HOSTS` is locked to your MinIO/S3 hostname — widening to `*` opens SSRF
- [ ] Set `SPRING_PROFILES_ACTIVE=prod` in the prod overlay (not `dev,e2e` — that exposes Swagger UI and `/v3/api-docs`)
- [ ] Use a dedicated MinIO service account for `S3_ACCESS_KEY` / `S3_SECRET_KEY`, not the root credentials
### Bootstrap sequence
```bash
# 1. Copy and fill the env file
cp .env.example .env
# edit .env — complete the security checklist above first
# 2. (Production only) Create the MinIO / Hetzner OBS bucket in the console
# The dev compose has a create-buckets helper; production does not.
# Create the bucket named $MINIO_DEFAULT_BUCKETS with private access.
# 3. Start the stack (prod overlay — see docs/infrastructure/production-compose.md)
# docker-compose.prod.yml is NOT committed — create it from the guide above
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
# 4. Flyway migrations run automatically on backend start.
# Watch the backend log to confirm:
docker compose logs --follow --tail=100 backend
# 5. Verify the stack is healthy
curl http://localhost:8080/actuator/health
# Expected: {"status":"UP"}
# 6. Open the app and log in with the admin credentials from .env
```
> **Do not use `docker-compose.ci.yml` locally** — it disables bind mounts that the dev workflow depends on.
---
## 4. Logs + observability
### First-response commands
```bash
# Stream backend logs (most useful first)
docker compose logs --follow --tail=100 backend
# Stream all services
docker compose logs --follow
# Single snapshot
docker compose logs --tail=200 <service>
# services: frontend, backend, db, minio, ocr-service
```
### Log locations
- **Backend application log**: stdout (captured by Docker). Access inside the container at `/app/logs/` via `docker exec`.
- **Spring Actuator health**: `http://localhost:8080/actuator/health` (internal only in prod — port 8081 for Prometheus scraping)
- **Prometheus scraping**: management port 8081, path `/actuator/prometheus`. Internal only; Caddy blocks `/actuator/*` externally.
### Future observability
Phase 7 of the Production v1 milestone adds Prometheus + Loki + Grafana. No monitoring infrastructure is in place yet.
---
## 5. Backup + recovery
### Current state — no automated backup
No automated backup is configured. Manual procedure for a point-in-time backup:
```bash
# PostgreSQL dump
docker exec archive-db pg_dump -U ${POSTGRES_USER} ${POSTGRES_DB} > backup-$(date +%Y%m%d).sql
# MinIO data (bind-mounted in dev)
# Copy ./data/minio/ to external storage
```
Restoration:
```bash
# Restore Postgres
docker exec -i archive-db psql -U ${POSTGRES_USER} ${POSTGRES_DB} < backup-YYYYMMDD.sql
```
### Planned — phase 5 of Production v1 milestone
Automated backup (PostgreSQL WAL archiving + MinIO bucket replication) is planned in the Production v1 milestone phase 5. Until that ships: **manual backups are the only recovery option.**
---
## 6. Common operational tasks
### Reset dev database (truncates data, keeps schema)
```bash
bash scripts/reset-db.sh
```
> Truncates all data but does **not** drop the schema or re-run Flyway. Use for E2E test resets, not full reinstalls.
> ⚠️ Script hardcodes `DB_USER=archive_user` and `DB_NAME=family_archive_db` — if you customised these in `.env`, edit the script accordingly.
### Rebuild frontend container (clears node_modules volume)
```bash
bash scripts/rebuild-frontend.sh
```
> Assumes the Docker Compose volume is named `familienarchiv_frontend_node_modules`. If your project directory is not named `familienarchiv`, edit line 16 of the script.
### Download Kraken OCR models
```bash
bash scripts/download-kraken-models.sh
```
> Downloads the Kurrent/Sütterlin HTR models. Run once after a fresh clone or when models are updated.
### Trigger a mass import (Excel/ODS)
1. Place the import file in the `import/` bind mount on the backend container.
2. Call `POST /api/admin/trigger-import` (requires `ADMIN` permission).
3. The import runs asynchronously — poll `GET /api/admin/import-status` or watch backend logs.
---
## 7. Known limitations
| Limitation | Reason | Reference |
|---|---|---|
| **Single-node OCR service** | The two required OCR engines (Surya + Kraken) exist only in the Python ecosystem; horizontal scaling would require a job queue not currently implemented | [ADR-001](adr/001-ocr-python-microservice.md) |
| **No multi-tenancy** | Designed as a single-family private archive; all authenticated users share the same document space | Deliberate scope decision (family-only product frame) |
| **No multi-region** | Single PostgreSQL + MinIO instance; no replication or failover | Deliberate scope decision |
| **Max upload size** | 50 MB per file (500 MB per request for multi-file) | Configurable in `application.yaml` (`spring.servlet.multipart`) |
| **No automated backup** | Phase 5 of Production v1 milestone is not yet implemented | See §5 above |

113
docs/GLOSSARY.md Normal file
View File

@@ -0,0 +1,113 @@
# Familienarchiv — Glossary
Domain-specific and overloaded terms used in this codebase.
Each entry: **Term** — definition (≤ 2 sentences). Where two terms are easily confused, a _Not to be confused with_ note follows.
For architecture context see [`docs/architecture/c4-diagrams.md`](architecture/c4-diagrams.md).
For domain package structure see [`docs/ARCHITECTURE.md`](ARCHITECTURE.md) _(coming: DOC-2)_.
---
## Identity Terms
**AppUser** (`AppUser`) — a real person who can log into the system (a family member or administrator). `AppUser` records carry login credentials, group memberships, and notification history.
_Not to be confused with [Person](#person-person)_ — an AppUser is never recorded as a document sender, receiver, or historical individual.
**Permission** — a discrete capability string assigned to a `UserGroup` (e.g. `READ_ALL`, `WRITE_ALL`, `ADMIN`, `ADMIN_USER`, `ADMIN_TAG`, `ADMIN_PERMISSION`). Enforced via the `@RequirePermission` AOP annotation on controller methods, checked at runtime by `PermissionAspect`; not via Spring Security's `@PreAuthorize`.
**Person** (`Person`) — a historical individual in the family archive (sender, receiver of letters, person mentioned in transcriptions). NEVER has a login account and NEVER appears as an `AppUser`.
_Not to be confused with [AppUser](#appuser-appuser)_`Person` is a historical record; `AppUser` is someone who can log in today.
**PersonNameAlias** (`PersonNameAlias`) — an alternate or historical name form associated with a `Person` (e.g. maiden name, nickname, abbreviated form). Used to locate `Person` records during mass import via `PersonNameAliasType`.
**UserGroup** (`UserGroup`) — a named permission bundle assigned to one or more `AppUser`s. A user's effective permissions are the union of all permissions across all groups they belong to.
---
## Document-Related Terms
**Annotation** (`DocumentAnnotation`) — a free-form polygon or shape drawn over a document page image to highlight a region of interest. Always scoped to a specific page of a `Document`; stored as a polygon (JSONB).
_See also [TranscriptionBlock](#transcriptionblock-transcriptionblock)._
**Comment** (`DocumentComment`, table `document_comments`) — a threaded discussion message attached to a `Document`. Always scoped to a `Document`; optionally further contextualized by a specific `DocumentAnnotation` or `TranscriptionBlock`.
**Document** (`Document`) — a single archival item (letter, postcard, photograph) with a file stored in MinIO/S3 and associated metadata (sender, receivers, date, tags, transcription blocks).
**DocumentVersion** (`DocumentVersion`) — an append-only snapshot of a `Document`'s metadata at a point in time. Append-only by convention; no consumer-facing create or update endpoint exists. The entity uses Lombok `@Data` (which generates setters), so immutability is enforced by application convention, not at the Java level.
**Tag** (`Tag`) — a hierarchical category that can be applied to `Document`s. Tags are self-referencing via a `parent_id` foreign key, forming a tree structure.
**TranscriptionBlock** (`TranscriptionBlock`) — a paragraph-level segment of a `Document`'s transcribed text, with a polygon region (stored as JSONB) identifying its position on the page. One document can have many blocks across multiple pages.
_See also [Annotation](#annotation-documentannotation)._
---
## Workflow Terms
**DocumentStatus lifecycle** — the ordered states a `Document` moves through:
`PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
- `PLACEHOLDER`: created during mass import; no file attached yet.
- `UPLOADED`: a file has been stored in MinIO/S3.
- `TRANSCRIBED`: all transcription blocks have been marked done.
- `REVIEWED`: a reviewer has approved the transcription.
- `ARCHIVED`: the document is finalized and read-only.
**Mass import** — an asynchronous batch process (`MassImportService`) that reads an Excel or ODS file and creates `Person`s, `Tag`s, and `PLACEHOLDER` `Document`s in one shot. Only one import can run at a time (`IMPORT_ALREADY_RUNNING` error if attempted concurrently).
**Transcription queue** — the set of `Document`s and `TranscriptionBlock`s awaiting work, computed on-the-fly from `Document`/`Block` status. Three views: segmentation queue, transcription queue, ready-to-read queue. NOT a persistent entity — no `transcription_queues` table exists.
_See also [DocumentStatus lifecycle](#documentstatus-lifecycle)._
---
## OCR-Specific Terms
**HTR** — Handwritten Text Recognition. Recognizes cursive and historical handwriting (contrasted with OCR for printed/typewritten text). The primary mode used for letters in this archive.
**Kurrent** — Old German cursive handwriting style, the primary historical script appearing in letters from the 18991950 period covered by this archive.
**OCR** — Optical Character Recognition. Recognizes printed or typewritten text. Used for typed documents; HTR is used for handwritten ones.
**OcrJob** (`OcrJob`, table `ocr_jobs`) — a first-class persistent entity tracking a batch OCR run across one or more documents (`OcrJobDocument`, table `ocr_job_documents`). Distinct from the concept of "running OCR on a single document." Lifecycle: `PENDING → RUNNING → DONE / FAILED` (see `OcrJobStatus`).
**SenderModel** (`SenderModel`, table `sender_models`) — a fine-tuned Kraken HTR model trained on a specific historical correspondent's handwriting. Both an OCR-service concept (the model weights) and a persistent entity linking a `Person` to the path of their trained model file.
**Sütterlin** — A specific standardized style of Kurrent taught in German schools from 1915 to 1941.
---
## Other Domain Terms
**Aktivität / Aktivitäten** `[user-facing]` — the family activity feed accessible at `/aktivitaeten`. Shows recent documents, transcriptions, comments, and Geschichten as a chronological timeline.
_See also [Chronik](#chronik-internal)._
**Briefwechsel** `[user-facing]` — the bilateral conversation timeline between two `Person`s, derived from `Document` sender/receiver relationships. Accessible at `/briefwechsel`. Not a persistent entity — data is computed from existing `Document` records.
_See also [Derived domain](#derived-domain)._
**Chronik** `[internal]` — the conceptual and code-level name for the unified activity feed (per ADR-003 `003-chronik-unified-activity-feed.md`). Used in code, architecture documents, and ADRs. The user-facing label for the same concept is [Aktivität](#aktivitat--aktivitaten-user-facing).
**Geschichte** (`Geschichte`) `[user-facing]` — a narrative story or article published in the archive, linking `Person`s and `Document`s. Lifecycle: `DRAFT → PUBLISHED` (see `GeschichteStatus`). DRAFT stories are hidden from users without the `BLOG_WRITE` permission.
**Notification** (`Notification`) — an in-app message delivered to an `AppUser`. No email or SMS delivery exists today. Delivered via Server-Sent Events (`SseEmitterRegistry`) and persisted in the `notifications` table.
**Audit log** (`AuditLog`, table `audit_log`) — an append-only event store recording domain-level activity (document edits, user actions, etc.). Append-only by application convention; a `REVOKE UPDATE, DELETE` is attempted at the DB layer (see migrations V46, V47) but is a no-op if the application role is the table owner in PostgreSQL. Do not rely on DB-enforced immutability — the constraint is application-layer only.
---
## Architectural Terms
**Cross-cutting** — code that lives in `lib/shared/` (frontend) or cross-domain packages (backend) because it has no entity of its own, no user-facing CRUD, AND is used by two or more domains OR is framework infrastructure (error handling, API client, i18n utilities).
**Derived domain** — a Tier-2 frontend domain that has its own UI but no backend entities of its own. Data is computed from Tier-1 domain records. Current derived domains: `conversation` (from `Document` sender/receivers) and `activity` (from audit, notifications, document events).
_See also [Briefwechsel](#briefwechsel-user-facing)._
**Domain** — a Tier-1 bounded context with its own entities, controller, service, repository, and DTOs. Backend domains: `document`, `person`, `tag`, `user`, `geschichte`, `notification`, `ocr`, `audit`, `dashboard`. Frontend domains mirror this structure under `src/lib/`.
---
## Pending Terms
_Terms flagged as potentially ambiguous that have not yet been formally defined here. Add an entry above and remove it from this list when resolved._
- Terms surfaced by Epic 1 audit findings (#388#392) — review audit reports under `docs/audits/` when available and add any term flagged as ambiguous.
- `OcrBatchService` vs `OcrAsyncRunner` — both handle async OCR orchestration; their division of responsibility should be clarified here.
- `Stammbaum` — the genealogy tree view; relationship to `PersonRelationship` entity.

85
docs/README.md Normal file
View File

@@ -0,0 +1,85 @@
# docs/
Project documentation organised into four categories: architecture decision records (ADRs), system architecture diagrams, infrastructure runbooks, and detailed UI/UX specifications.
## Folder structure
```
docs/
├── adr/ # Architecture Decision Records
├── architecture/ # C4 model diagrams and system architecture docs
├── infrastructure/ # Deployment, CI/CD, and ops guides
├── specs/ # UI/UX feature specifications (HTML)
├── ARCHITECTURE.md # Human-readable architecture overview (DOC-2)
├── DEPLOYMENT.md # Day-1 checklist and operational reference (DOC-5)
├── GLOSSARY.md # Domain terminology (DOC-3)
├── security-guide.md # Security policies and hardening guide
└── STYLEGUIDE.md # Coding and design style guide
```
## ADR (`adr/`)
Architecture Decision Records capture major technical decisions and their rationale.
| ADR | Title | Status |
| -------------------------------------- | ------------------------------------ | -------- |
| `001-ocr-python-microservice.md` | OCR as a separate Python container | Accepted |
| `002-polygon-jsonb-storage.md` | Polygon coordinates in JSONB columns | Accepted |
| `003-chronik-unified-activity-feed.md` | Unified activity feed (Chronik) | Accepted |
When making a significant architectural change (new service, data model change, technology swap), write a new ADR:
- **Status** (Proposed / Accepted / Deprecated / Superseded)
- **Context** (forces at play)
- **Decision** (what we decided)
- **Consequences** (trade-offs)
- **Alternatives Considered** (table format)
ADRs are sequential (`NNN-descriptive-name.md`). Do not reuse numbers.
## Architecture (`architecture/`)
Contains C4 model diagrams describing the system at different zoom levels:
- **Context diagram** — How Familienarchiv fits into the user and system ecosystem
- **Container diagram** — The high-level technology choices (Spring Boot, SvelteKit, PostgreSQL, MinIO, OCR service)
- **Component diagram** — Major structural components within the backend
Written in Markdown with embedded Mermaid diagrams (`c4-diagrams.md`). Gitea renders these automatically.
For the human-readable architecture narrative, see [`docs/ARCHITECTURE.md`](ARCHITECTURE.md).
## Infrastructure (`infrastructure/`)
Operational documentation for running Familienarchiv in production and CI.
| Document | Purpose |
| -------------------------- | ---------------------------------------------------- |
| `ci-gitea.md` | Gitea CI/CD pipeline configuration |
| `production-compose.md` | Production Docker Compose setup and VPS provisioning |
| `s3-migration.md` | Migrating documents between S3 buckets |
| `self-hosted-catalogue.md` | Self-hosted software catalogue |
For the day-1 deployment checklist, see [`docs/DEPLOYMENT.md`](DEPLOYMENT.md).
## Specs (`specs/`)
High-fidelity UI/UX specifications written as standalone HTML files. These are design documents describing exact layout, interactions, and responsive behavior before implementation.
Each spec typically includes:
- Visual mockups with CSS-in-HTML styling
- Interaction flows and state transitions
- Responsive breakpoint behavior
- Accessibility requirements
Before implementing a feature, check `specs/` for an existing specification.
## Style Guide
[`docs/STYLEGUIDE.md`](STYLEGUIDE.md) covers:
- Code formatting and linting rules
- Component naming conventions
- Color palette and typography
- Accessibility standards (WCAG 2.1 AA)

View File

@@ -1,5 +1,7 @@
# Familienarchiv — C4 Architecture Diagrams # Familienarchiv — C4 Architecture Diagrams
> For domain terminology used in these diagrams, see [docs/GLOSSARY.md](../GLOSSARY.md).
## Level 1 — System Context ## Level 1 — System Context
Who uses the system and what external systems does it interact with. Who uses the system and what external systems does it interact with.
@@ -32,9 +34,11 @@ C4Container
System_Boundary(archiv, "Familienarchiv (Docker Compose)") { System_Boundary(archiv, "Familienarchiv (Docker Compose)") {
Container(frontend, "Web Frontend", "SvelteKit / Node.js", "Server-side rendered UI. Handles session cookies, search UI, document viewer, and admin panel.") Container(frontend, "Web Frontend", "SvelteKit / Node.js", "Server-side rendered UI. Handles session cookies, search UI, document viewer, and admin panel.")
Container(backend, "API Backend", "Spring Boot 4 / Java 21 / Jetty", "REST API. Implements document management, search, user auth, file upload/download, and Excel import.") Container(backend, "API Backend", "Spring Boot 4 / Java 21 / Jetty", "REST API. Implements document management, search, user auth, file upload/download, transcription, OCR orchestration, and SSE notifications.")
ContainerDb(db, "Relational Database", "PostgreSQL 16", "Stores document metadata, persons, users, permission groups, tags, and Spring Session data.") Container(ocr, "OCR Service", "Python FastAPI / port 8000", "Handwritten text recognition (HTR) and OCR microservice. Single-node by design — see ADR-001. Reachable only on the internal Docker network; no external port exposed.")
ContainerDb(db, "Relational Database", "PostgreSQL 16", "Stores document metadata, persons, users, permission groups, tags, transcription blocks, audit log, and Spring Session data.")
ContainerDb(storage, "Object Storage", "MinIO (S3-compatible)", "Stores the actual document files (PDFs, scans). Objects keyed as documents/{UUID}_{filename}.") ContainerDb(storage, "Object Storage", "MinIO (S3-compatible)", "Stores the actual document files (PDFs, scans). Objects keyed as documents/{UUID}_{filename}.")
@@ -43,8 +47,11 @@ C4Container
Rel(user, frontend, "Uses", "HTTPS / Browser") Rel(user, frontend, "Uses", "HTTPS / Browser")
Rel(frontend, backend, "API requests with Basic Auth token", "HTTP / REST / JSON") Rel(frontend, backend, "API requests with Basic Auth token", "HTTP / REST / JSON")
Rel(backend, user, "SSE notifications (server-sent events)", "HTTP / SSE — direct backend-to-browser")
Rel(backend, db, "Reads and writes metadata and sessions", "JDBC / SQL") Rel(backend, db, "Reads and writes metadata and sessions", "JDBC / SQL")
Rel(backend, storage, "Uploads and streams document files", "HTTP / S3 API (AWS SDK v2)") Rel(backend, storage, "Uploads and streams document files", "HTTP / S3 API (AWS SDK v2)")
Rel(backend, ocr, "OCR job requests with presigned MinIO URL", "HTTP / REST / JSON")
Rel(ocr, storage, "Fetches PDF via presigned URL", "HTTP / S3 presigned")
Rel(mc, storage, "Creates bucket on startup", "MinIO Client CLI") Rel(mc, storage, "Creates bucket on startup", "MinIO Client CLI")
``` ```

View File

@@ -0,0 +1,481 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reader Dashboard — Concept A · Herzlich Willkommen · Familienarchiv</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1A1A;line-height:1.5}
.doc{max-width:1440px;margin:0 auto;padding:48px 32px}
/* ── Masthead ─── */
.mast{background:#0D2240;border-radius:10px;padding:32px 40px;margin-bottom:48px}
.mast-top{display:flex;align-items:flex-start;justify-content:space-between;gap:24px;margin-bottom:16px}
.mast h1{font-size:22px;font-weight:900;color:#fff;letter-spacing:-.4px;margin-bottom:6px}
.mast p{font-size:12px;color:rgba(255,255,255,.5);max-width:660px;line-height:1.7}
.mast-badge{font-size:9px;font-weight:800;padding:3px 9px;border-radius:20px;text-transform:uppercase;letter-spacing:.8px;white-space:nowrap;flex-shrink:0;margin-top:4px;background:#A6DAD8;color:#002850}
.decisions{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-top:20px;border-top:1px solid rgba(255,255,255,.1);padding-top:16px}
.dec{background:rgba(255,255,255,.06);border-radius:6px;padding:10px 12px}
.dec-label{font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:rgba(255,255,255,.35);margin-bottom:5px}
.dec-value{font-size:9.5px;font-weight:700;color:#fff;line-height:1.5}
/* ── Section headings ─── */
.sec{margin-bottom:64px}
.sec+.sec{border-top:2px dashed #C8C4BE;padding-top:56px}
.sec-h{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:1.2px;color:#888;margin-bottom:20px;display:flex;align-items:center;gap:10px}
.sec-h::after{content:'';flex:1;height:1px;background:#D8D4CE}
.sec-num{background:#0D2240;color:#fff;font-size:9px;font-weight:900;padding:2px 7px;border-radius:10px}
/* ── Screen grid ─── */
.sg{display:grid;gap:24px;align-items:start}
.sg-2{grid-template-columns:1fr 340px}
.sb{display:flex;flex-direction:column;gap:10px}
.sl{font-size:9px;font-weight:800;color:#888;text-transform:uppercase;letter-spacing:1.5px;margin-bottom:6px;display:flex;align-items:center;gap:6px}
.sz{background:#E8E4DF;color:#666;padding:1px 5px;border-radius:3px;font-size:8px}
/* ── Annotation callouts ─── */
.ann-block{background:#FFF7ED;border:1px solid #FDBA74;border-radius:5px;padding:8px 10px;font-size:10px;color:#7C2D12;line-height:1.5}
.ann-block strong{font-weight:800}
.ann-block ul{padding-left:14px;display:flex;flex-direction:column;gap:3px;margin-top:5px}
.note{background:#F0F9FF;border:1px solid #BAE6FD;border-radius:5px;padding:8px 10px;font-size:10px;color:#0C4A6E;line-height:1.5}
.note strong{font-weight:800}
.note ul{padding-left:14px;display:flex;flex-direction:column;gap:3px;margin-top:5px}
.ok{background:#F0FDF4;border:1px solid #86EFAC;border-radius:5px;padding:8px 10px;font-size:10px;color:#166534;line-height:1.5}
.ok strong{font-weight:800}
.ok ul{padding-left:14px;display:flex;flex-direction:column;gap:3px;margin-top:5px}
/* ── Mock browser chrome ─── */
.wf{background:#fff;border:2px solid #B8B4AE;border-radius:10px;overflow:hidden;box-shadow:0 4px 18px rgba(0,0,0,.08)}
.wf-bar{height:24px;background:#E8E4DF;border-bottom:1px solid #C8C4BE;display:flex;align-items:center;padding:0 9px;gap:4px}
.dot{width:7px;height:7px;border-radius:50%}
.dot.r{background:#F87171}.dot.y{background:#FCD34D}.dot.g{background:#4ADE80}
.urlbar{flex:1;height:11px;background:#D8D4CE;border-radius:3px;margin-left:6px;display:flex;align-items:center;padding:0 5px}
.urlbar span{font-size:7.5px;color:#888;font-family:monospace}
/* ── Nav bar ─── */
.N{height:34px;background:#002850;display:flex;align-items:center;padding:0 14px;gap:10px;flex-shrink:0}
.N-accent{height:2px;background:#A6DAD8}
.logo{font-size:7.5px;font-weight:900;color:#fff;letter-spacing:1px;text-transform:uppercase}
.nl{font-size:6.5px;color:rgba(255,255,255,.45);font-weight:700;text-transform:uppercase;letter-spacing:.5px;padding:3px 6px}
.nl.on{color:#fff}
.nr{margin-left:auto;display:flex;gap:5px;align-items:center}
.av{width:18px;height:18px;background:#A6DAD8;border-radius:50%;font-size:5.5px;font-weight:900;color:#002850;display:flex;align-items:center;justify-content:center}
.nico{width:18px;height:18px;background:rgba(255,255,255,.1);border-radius:3px;display:flex;align-items:center;justify-content:center}
/* ── Page body ─── */
.MAIN{padding:12px 16px;background:#F5F4EF;display:flex;flex-direction:column;gap:8px}
/* ── Greeting card ─── */
.GREET{background:#FDFAF5;border:1px solid #E8E4DC;border-radius:3px;padding:12px 14px}
.GREET-time{font-size:6px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#C8B8A0;margin-bottom:4px}
.GREET-name{font-family:Georgia,serif;font-size:14px;color:#002850;line-height:1.2}
/* ── Stats strip ─── */
.STATS{display:grid;grid-template-columns:repeat(3,1fr);gap:6px}
.STAT{background:#fff;border:1px solid #E0DDD5;border-bottom:2px solid #A6DAD8;border-radius:3px;padding:9px 11px;text-decoration:none;display:block}
.STAT-num{font-size:18px;font-weight:900;color:#002850;line-height:1;margin-bottom:3px;font-family:'Helvetica Neue',Arial,sans-serif}
.STAT-label{font-size:6px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#B8B4AE}
/* ── Person chips ─── */
.PERSONS-WRAP{display:flex;flex-direction:column;gap:5px}
.PERSONS-HEADER{display:flex;align-items:center;justify-content:space-between}
.PERSONS-TITLE{font-size:6.5px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#AAA}
.PERSONS-ALL{font-size:6.5px;color:#002850;font-weight:600;text-decoration:none}
.CHIPS{display:flex;flex-wrap:wrap;gap:5px}
.CHIP{display:inline-flex;align-items:center;gap:4px;padding:3px 8px 3px 3px;background:#fff;border:1px solid #E0DDD5;border-radius:20px;font-size:7px;color:#002850;font-weight:600;text-decoration:none}
.CHIP-AV{width:16px;height:16px;border-radius:50%;color:#fff;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:900;flex-shrink:0}
.CHIP-COUNT{font-size:6px;color:#AAA;font-weight:400;margin-left:2px}
/* ── Two-column content row ─── */
.CONTENT-ROW{display:grid;grid-template-columns:3fr 2fr;gap:6px}
.CARD{background:#fff;border:1px solid #E0DDD5;border-radius:3px;overflow:hidden;display:flex;flex-direction:column}
.CARD-HEAD{display:flex;align-items:center;justify-content:space-between;padding:7px 10px 6px;border-bottom:1px solid #E0DDD5}
.CARD-HEAD h3{font-size:6.5px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;color:#999}
.CARD-HEAD a{font-size:6.5px;font-weight:600;color:#002850;opacity:.4;text-decoration:none}
/* ── Doc rows ─── */
.DOC-ROW{display:flex;align-items:center;gap:7px;padding:5px 10px;border-bottom:1px solid #F0EDE6}
.DOC-ROW:last-child{border-bottom:none}
.DOC-THUMB{width:18px;height:18px;background:#F0EDE6;border-radius:2px;flex-shrink:0;display:flex;align-items:center;justify-content:center}
.DOC-INFO{flex:1;min-width:0}
.DOC-TITLE{font-family:Georgia,serif;font-size:7.5px;color:#002850;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.DOC-META{font-size:6px;color:#AAA;margin-top:1px}
.DOC-DATE{font-size:6px;color:#C8C4BE;white-space:nowrap;flex-shrink:0}
/* ── Story rows ─── */
.STORY-ROW{padding:7px 10px;border-bottom:1px solid #F0EDE6}
.STORY-ROW:last-child{border-bottom:none}
.STORY-TITLE{font-family:Georgia,serif;font-size:8px;color:#002850;font-style:italic;margin-bottom:2px;line-height:1.4}
.STORY-META{font-size:6px;color:#B8B4AE}
/* ── Spec disclaimer ─── */
.spec-disclaimer{background:#FFF8E1;border:1.5px solid #FFC107;border-radius:6px;padding:11px 16px;font-size:11px;color:#6D4C00;margin-bottom:32px;line-height:1.6}
.spec-disclaimer strong{font-weight:800}
</style>
</head>
<body>
<div class="doc">
<!-- ══ MASTHEAD ══ -->
<div class="mast">
<div class="mast-top">
<div>
<h1>Reader Dashboard — Konzept A · „Herzlich Willkommen"</h1>
<p>Warme, einladende Gestaltung mit klarer Hierarchie: Begrüßung → Statistik → Personen → Inhalte. Die Statistikkacheln sind großzügig bemessen und wirken als Einstiegspunkte. Personen erscheinen als kompakte Pills. Geschichten erhalten einen reduzierten, redaktionellen Stil ohne Auszug.</p>
</div>
<span class="mast-badge">Konzept A · Entwurf</span>
</div>
<div class="decisions">
<div class="dec">
<div class="dec-label">Schwerpunkt</div>
<div class="dec-value">Begrüßung &amp; große Statistik als Einstieg</div>
</div>
<div class="dec">
<div class="dec-label">Personen-Chips</div>
<div class="dec-value">Schlanke Pills mit Initialen-Avatar</div>
</div>
<div class="dec">
<div class="dec-label">Geschichten-Spalte</div>
<div class="dec-value">Kursiver Serif-Titel · kein Auszug · schlicht</div>
</div>
<div class="dec">
<div class="dec-label">Spaltenbreite</div>
<div class="dec-value">3 : 2 — Dokumente : Geschichten</div>
</div>
</div>
</div>
<div class="spec-disclaimer">
<strong>📐 Mockup-Skalierung —</strong> alle Schriftgrößen, Abstände und Höhen im Mockup sind auf ca. 55 % der tatsächlichen Implementierungswerte skaliert. <strong>Werte nicht aus dem Mockup-CSS kopieren.</strong>
</div>
<!-- ══ SECTION 1: DESKTOP, REINER LESER ══ -->
<div class="sec">
<div class="sec-h"><span class="sec-num">1</span> Desktop · Leser ohne BLOG_WRITE (READ_ALL only)</div>
<div class="sg sg-2">
<div class="sb">
<div class="sl">Vollansicht <span class="sz">≥ 1024 px</span></div>
<div class="wf">
<div class="wf-bar">
<div class="dot r"></div><div class="dot y"></div><div class="dot g"></div>
<div class="urlbar"><span>familienarchiv.local /</span></div>
</div>
<!-- Nav -->
<div class="N">
<span class="logo">Familienarchiv</span>
<span class="nl on">Startseite</span>
<span class="nl">Dokumente</span>
<span class="nl">Personen</span>
<span class="nl">Geschichten</span>
<div class="nr">
<div class="nico">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,.5)" stroke-width="2"><path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 01-3.46 0"/></svg>
</div>
<div class="av">BK</div>
</div>
</div>
<div class="N-accent"></div>
<div class="MAIN">
<!-- Zone 1: Greeting -->
<div class="GREET">
<div class="GREET-time">Guten Abend</div>
<div class="GREET-name">Herzlich willkommen, Brigitte.</div>
</div>
<!-- Zone 2: Stats -->
<div class="STATS">
<a class="STAT" href="#">
<div class="STAT-num">847</div>
<div class="STAT-label">Dokumente</div>
</a>
<a class="STAT" href="#">
<div class="STAT-num">94</div>
<div class="STAT-label">Personen</div>
</a>
<a class="STAT" href="#">
<div class="STAT-num">12</div>
<div class="STAT-label">Geschichten</div>
</a>
</div>
<!-- Zone 4: Person chips -->
<div class="PERSONS-WRAP">
<div class="PERSONS-HEADER">
<span class="PERSONS-TITLE">Personen im Fokus</span>
<a class="PERSONS-ALL" href="#">Alle 94 Personen →</a>
</div>
<div class="CHIPS">
<a class="CHIP" href="#">
<div class="CHIP-AV" style="background:#002850">KR</div>
Käthe Raddatz<span class="CHIP-COUNT">47 Dok.</span>
</a>
<a class="CHIP" href="#">
<div class="CHIP-AV" style="background:#1A4A6B">ER</div>
Ernst Raddatz<span class="CHIP-COUNT">31 Dok.</span>
</a>
<a class="CHIP" href="#">
<div class="CHIP-AV" style="background:#3D5A7A">FM</div>
Frieda Müller<span class="CHIP-COUNT">28 Dok.</span>
</a>
<a class="CHIP" href="#">
<div class="CHIP-AV" style="background:#4A7A5A">HW</div>
Heinrich Weber<span class="CHIP-COUNT">19 Dok.</span>
</a>
</div>
</div>
<!-- Zone 5: Two-column content -->
<div class="CONTENT-ROW">
<!-- Left: Recent docs -->
<div class="CARD">
<div class="CARD-HEAD">
<h3>Zuletzt aktualisiert</h3>
<a href="#">Alle Dokumente</a>
</div>
<div class="DOC-ROW">
<div class="DOC-THUMB">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="#C8C4BE" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
</div>
<div class="DOC-INFO">
<div class="DOC-TITLE">Brief von Ernst an Käthe, März 1923</div>
<div class="DOC-META">Käthe Raddatz</div>
</div>
<div class="DOC-DATE">vor 2 Tagen</div>
</div>
<div class="DOC-ROW">
<div class="DOC-THUMB">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="#C8C4BE" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
</div>
<div class="DOC-INFO">
<div class="DOC-TITLE">Heiratsurkunde Raddatz-Müller, 1898</div>
<div class="DOC-META" style="color:#DDD"></div>
</div>
<div class="DOC-DATE">vor 4 Tagen</div>
</div>
<div class="DOC-ROW">
<div class="DOC-THUMB">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="#C8C4BE" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
</div>
<div class="DOC-INFO">
<div class="DOC-TITLE">Familienfoto, Sommer 1928</div>
<div class="DOC-META">Ernst Raddatz</div>
</div>
<div class="DOC-DATE">vor 1 Woche</div>
</div>
<div class="DOC-ROW">
<div class="DOC-THUMB">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="#C8C4BE" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
</div>
<div class="DOC-INFO">
<div class="DOC-TITLE">Taufregister Heinrich Weber, 1902</div>
<div class="DOC-META" style="color:#DDD"></div>
</div>
<div class="DOC-DATE">vor 2 Wo.</div>
</div>
<div class="DOC-ROW">
<div class="DOC-THUMB">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="#C8C4BE" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
</div>
<div class="DOC-INFO">
<div class="DOC-TITLE">Postkarte aus Berlin, 1930</div>
<div class="DOC-META">Frieda Müller</div>
</div>
<div class="DOC-DATE">vor 3 Wo.</div>
</div>
</div>
<!-- Right: Stories -->
<div class="CARD">
<div class="CARD-HEAD">
<h3>Geschichten</h3>
<a href="#">Alle Geschichten</a>
</div>
<div class="STORY-ROW">
<div class="STORY-TITLE">Die Reise nach Berlin</div>
<div class="STORY-META">vor 3 Tagen</div>
</div>
<div class="STORY-ROW">
<div class="STORY-TITLE">Sommer 1934 in Köln</div>
<div class="STORY-META">vor 2 Wochen</div>
</div>
<div class="STORY-ROW">
<div class="STORY-TITLE">Briefe aus dem Krieg</div>
<div class="STORY-META">vor 1 Monat</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Annotations -->
<div style="display:flex;flex-direction:column;gap:10px;padding-top:40px">
<div class="ann-block">
<strong>Zone 1 — Begrüßung</strong>
<ul>
<li>Sand-getönter Hintergrund (#FDFAF5) grenzt die Karte vom Grau der Seite ab</li>
<li>Tageszeit-Label (z.B. „Guten Abend") in Capitalcase, 12 px, gedämpftes Beige</li>
<li>„Herzlich willkommen, Brigitte." in Georgia Serif, 28 px real (14 px skaliert)</li>
</ul>
</div>
<div class="ann-block">
<strong>Zone 2 — Statistik</strong>
<ul>
<li>Jede Kachel ist ein vollflächiges &lt;a&gt;-Element</li>
<li>Mintfarbener Bottom-Border (2 px, #A6DAD8) als einziger visueller Akzent</li>
<li>Zahl: 48 px bold Navy — wirkt wie ein Einladungsschild</li>
</ul>
</div>
<div class="note">
<strong>Zone 4 — Personen-Chips</strong>
<ul>
<li>Pill-Form: Initialen-Avatar (16 px) + Name + Dokumentzahl in gedämpftem Grau</li>
<li>Avatar-Hintergrundfarbe variiert innerhalb der Navy-Familie</li>
<li>Anzahl-Link „Alle 94 Personen →" rechtsbündig zum Section-Titel</li>
</ul>
</div>
<div class="note">
<strong>Zone 5 — Geschichten-Spalte</strong>
<ul>
<li>Nur kursiver Titel + Datum — bewusst reduziert, literarisch</li>
<li>Kein Auszug: ruhigere Gestaltung, weniger Ablenkung</li>
<li>Passend für Leser, die Titel als Entscheidungsgrundlage kennen</li>
</ul>
</div>
<div class="ok">
<strong>Stärken dieses Konzepts</strong>
<ul>
<li>Klar und vertraut — wenig kognitive Last für ältere Nutzer</li>
<li>Statistik-Kacheln motivieren zum Erkunden des Archivs</li>
<li>Konsistent mit bestehendem Card-Stil des Contributor-Dashboards</li>
</ul>
</div>
</div>
</div>
</div>
<!-- ══ SECTION 2: MIT ENTWÜRFE-MODUL ══ -->
<div class="sec">
<div class="sec-h"><span class="sec-num">2</span> Variante · BLOG_WRITE — mit Zone 3 „Meine Entwürfe"</div>
<div class="sg sg-2">
<div class="sb">
<div class="sl">BLOG_WRITE-Nutzer <span class="sz">READ_ALL + BLOG_WRITE</span></div>
<div class="wf">
<div class="wf-bar">
<div class="dot r"></div><div class="dot y"></div><div class="dot g"></div>
<div class="urlbar"><span>familienarchiv.local /</span></div>
</div>
<div class="N">
<span class="logo">Familienarchiv</span>
<span class="nl on">Startseite</span>
<span class="nl">Dokumente</span>
<span class="nl">Personen</span>
<span class="nl">Geschichten</span>
<div class="nr"><div class="av">MR</div></div>
</div>
<div class="N-accent"></div>
<div class="MAIN">
<!-- Zone 1: Greeting -->
<div class="GREET">
<div class="GREET-time">Guten Morgen</div>
<div class="GREET-name">Herzlich willkommen, Marcel.</div>
</div>
<!-- Zone 2: Stats -->
<div class="STATS">
<a class="STAT" href="#"><div class="STAT-num">847</div><div class="STAT-label">Dokumente</div></a>
<a class="STAT" href="#"><div class="STAT-num">94</div><div class="STAT-label">Personen</div></a>
<a class="STAT" href="#"><div class="STAT-num">12</div><div class="STAT-label">Geschichten</div></a>
</div>
<!-- Zone 3: Drafts — conditional -->
<div class="CARD" style="border-left:3px solid #A6DAD8">
<div class="CARD-HEAD">
<h3>Meine Entwürfe</h3>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:6px 10px;border-bottom:1px solid #F0EDE6">
<div>
<div style="font-family:Georgia,serif;font-size:8px;color:#002850">Onkel Friedrichs Wanderjahre</div>
<div style="font-size:6px;color:#AAA;margin-top:1px">Entwurf · zuletzt bearbeitet vor 1 Tag</div>
</div>
<svg width="7" height="7" viewBox="0 0 24 24" fill="none" stroke="#CCC" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:6px 10px">
<div>
<div style="font-family:Georgia,serif;font-size:8px;color:#002850">Die Raddatz-Kinder</div>
<div style="font-size:6px;color:#AAA;margin-top:1px">Entwurf · zuletzt bearbeitet vor 5 Tagen</div>
</div>
<svg width="7" height="7" viewBox="0 0 24 24" fill="none" stroke="#CCC" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
<!-- Zone 4: Person chips -->
<div class="PERSONS-WRAP">
<div class="PERSONS-HEADER">
<span class="PERSONS-TITLE">Personen im Fokus</span>
<a class="PERSONS-ALL" href="#">Alle 94 Personen →</a>
</div>
<div class="CHIPS">
<a class="CHIP" href="#"><div class="CHIP-AV" style="background:#002850">KR</div>Käthe Raddatz<span class="CHIP-COUNT">47 Dok.</span></a>
<a class="CHIP" href="#"><div class="CHIP-AV" style="background:#1A4A6B">ER</div>Ernst Raddatz<span class="CHIP-COUNT">31 Dok.</span></a>
<a class="CHIP" href="#"><div class="CHIP-AV" style="background:#3D5A7A">FM</div>Frieda Müller<span class="CHIP-COUNT">28 Dok.</span></a>
<a class="CHIP" href="#"><div class="CHIP-AV" style="background:#4A7A5A">HW</div>Heinrich Weber<span class="CHIP-COUNT">19 Dok.</span></a>
</div>
</div>
<!-- Zone 5 (abbreviated) -->
<div class="CONTENT-ROW">
<div class="CARD">
<div class="CARD-HEAD"><h3>Zuletzt aktualisiert</h3><a href="#">Alle Dokumente</a></div>
<div class="DOC-ROW">
<div class="DOC-THUMB"><svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="#C8C4BE" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></div>
<div class="DOC-INFO"><div class="DOC-TITLE">Brief von Ernst an Käthe, März 1923</div><div class="DOC-META">Käthe Raddatz</div></div>
<div class="DOC-DATE">vor 2 Tagen</div>
</div>
<div class="DOC-ROW">
<div class="DOC-THUMB"><svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="#C8C4BE" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></div>
<div class="DOC-INFO"><div class="DOC-TITLE">Heiratsurkunde Raddatz-Müller, 1898</div><div class="DOC-META" style="color:#DDD"></div></div>
<div class="DOC-DATE">vor 4 Tagen</div>
</div>
<div style="padding:5px 10px;font-size:6px;color:#C8C4BE;border-top:1px solid #F0EDE6">+ 3 weitere Dokumente …</div>
</div>
<div class="CARD">
<div class="CARD-HEAD"><h3>Geschichten</h3><a href="#">Alle</a></div>
<div class="STORY-ROW"><div class="STORY-TITLE">Die Reise nach Berlin</div><div class="STORY-META">vor 3 Tagen</div></div>
<div class="STORY-ROW"><div class="STORY-TITLE">Sommer 1934 in Köln</div><div class="STORY-META">vor 2 Wochen</div></div>
<div class="STORY-ROW"><div class="STORY-TITLE">Briefe aus dem Krieg</div><div class="STORY-META">vor 1 Monat</div></div>
</div>
</div>
</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:10px;padding-top:40px">
<div class="ann-block">
<strong>Zone 3 — Meine Entwürfe (nur BLOG_WRITE)</strong>
<ul>
<li>Mintfarbener linker Rand (3 px solid #A6DAD8) hebt die Zone ohne visuellen Lärm hervor</li>
<li>Erscheint zwischen Stats (Zone 2) und Personen (Zone 4) — gemäß Issue #447</li>
<li>Jeder Entwurf: Serif-Titel + Metazeile → Link auf /geschichten/[id]/edit</li>
<li>Leer-Zustand: „Keine Entwürfe" — kein CTA benötigt</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,480 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reader Dashboard — Concept B · Überblick · Familienarchiv</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1A1A;line-height:1.5}
.doc{max-width:1440px;margin:0 auto;padding:48px 32px}
/* ── Masthead ─── */
.mast{background:#0D2240;border-radius:10px;padding:32px 40px;margin-bottom:48px}
.mast-top{display:flex;align-items:flex-start;justify-content:space-between;gap:24px;margin-bottom:16px}
.mast h1{font-size:22px;font-weight:900;color:#fff;letter-spacing:-.4px;margin-bottom:6px}
.mast p{font-size:12px;color:rgba(255,255,255,.5);max-width:660px;line-height:1.7}
.mast-badge{font-size:9px;font-weight:800;padding:3px 9px;border-radius:20px;text-transform:uppercase;letter-spacing:.8px;white-space:nowrap;flex-shrink:0;margin-top:4px;background:#A6DAD8;color:#002850}
.decisions{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-top:20px;border-top:1px solid rgba(255,255,255,.1);padding-top:16px}
.dec{background:rgba(255,255,255,.06);border-radius:6px;padding:10px 12px}
.dec-label{font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:rgba(255,255,255,.35);margin-bottom:5px}
.dec-value{font-size:9.5px;font-weight:700;color:#fff;line-height:1.5}
/* ── Section headings ─── */
.sec{margin-bottom:64px}
.sec+.sec{border-top:2px dashed #C8C4BE;padding-top:56px}
.sec-h{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:1.2px;color:#888;margin-bottom:20px;display:flex;align-items:center;gap:10px}
.sec-h::after{content:'';flex:1;height:1px;background:#D8D4CE}
.sec-num{background:#0D2240;color:#fff;font-size:9px;font-weight:900;padding:2px 7px;border-radius:10px}
/* ── Screen grid ─── */
.sg{display:grid;gap:24px;align-items:start}
.sg-2{grid-template-columns:1fr 340px}
.sb{display:flex;flex-direction:column;gap:10px}
.sl{font-size:9px;font-weight:800;color:#888;text-transform:uppercase;letter-spacing:1.5px;margin-bottom:6px;display:flex;align-items:center;gap:6px}
.sz{background:#E8E4DF;color:#666;padding:1px 5px;border-radius:3px;font-size:8px}
/* ── Annotation callouts ─── */
.ann-block{background:#FFF7ED;border:1px solid #FDBA74;border-radius:5px;padding:8px 10px;font-size:10px;color:#7C2D12;line-height:1.5}
.ann-block strong{font-weight:800}
.ann-block ul{padding-left:14px;display:flex;flex-direction:column;gap:3px;margin-top:5px}
.note{background:#F0F9FF;border:1px solid #BAE6FD;border-radius:5px;padding:8px 10px;font-size:10px;color:#0C4A6E;line-height:1.5}
.note strong{font-weight:800}
.note ul{padding-left:14px;display:flex;flex-direction:column;gap:3px;margin-top:5px}
.ok{background:#F0FDF4;border:1px solid #86EFAC;border-radius:5px;padding:8px 10px;font-size:10px;color:#166534;line-height:1.5}
.ok strong{font-weight:800}
.ok ul{padding-left:14px;display:flex;flex-direction:column;gap:3px;margin-top:5px}
/* ── Mock browser chrome ─── */
.wf{background:#fff;border:2px solid #B8B4AE;border-radius:10px;overflow:hidden;box-shadow:0 4px 18px rgba(0,0,0,.08)}
.wf-bar{height:24px;background:#E8E4DF;border-bottom:1px solid #C8C4BE;display:flex;align-items:center;padding:0 9px;gap:4px}
.dot{width:7px;height:7px;border-radius:50%}
.dot.r{background:#F87171}.dot.y{background:#FCD34D}.dot.g{background:#4ADE80}
.urlbar{flex:1;height:11px;background:#D8D4CE;border-radius:3px;margin-left:6px;display:flex;align-items:center;padding:0 5px}
.urlbar span{font-size:7.5px;color:#888;font-family:monospace}
/* ── Nav bar ─── */
.N{height:34px;background:#002850;display:flex;align-items:center;padding:0 14px;gap:10px;flex-shrink:0}
.N-accent{height:2px;background:#A6DAD8}
.logo{font-size:7.5px;font-weight:900;color:#fff;letter-spacing:1px;text-transform:uppercase}
.nl{font-size:6.5px;color:rgba(255,255,255,.45);font-weight:700;text-transform:uppercase;letter-spacing:.5px;padding:3px 6px}
.nl.on{color:#fff}
.nr{margin-left:auto;display:flex;gap:5px;align-items:center}
.av{width:18px;height:18px;background:#A6DAD8;border-radius:50%;font-size:5.5px;font-weight:900;color:#002850;display:flex;align-items:center;justify-content:center}
.nico{width:18px;height:18px;background:rgba(255,255,255,.1);border-radius:3px;display:flex;align-items:center;justify-content:center}
/* ── Page body ─── */
.MAIN{padding:12px 16px;background:#F5F4EF;display:flex;flex-direction:column;gap:8px}
/* ── Combined greeting + stats bar ─── */
.HEADER-BAR{background:#fff;border:1px solid #E0DDD5;border-radius:3px;padding:10px 14px;display:flex;align-items:center;justify-content:space-between;gap:16px}
.HEADER-LEFT{}
.HEADER-TIME{font-size:6px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#C8B8A0;margin-bottom:2px}
.HEADER-NAME{font-family:Georgia,serif;font-size:12px;color:#002850}
.HEADER-STATS{display:flex;gap:0;border-left:1px solid #F0EDE6;padding-left:14px}
.HSTAT{text-align:center;padding:0 12px;border-right:1px solid #F0EDE6}
.HSTAT:last-child{border-right:none;padding-right:0}
.HSTAT a{text-decoration:none;display:block}
.HSTAT-NUM{font-size:14px;font-weight:900;color:#002850;line-height:1;display:block}
.HSTAT-LABEL{font-size:5.5px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#B8B4AE;display:block;margin-top:2px}
.HSTAT a:hover .HSTAT-NUM{color:#A6DAD8}
/* ── Person cards (larger than pills) ─── */
.PERSON-CARDS{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}
.PCARD{background:#fff;border:1px solid #E0DDD5;border-radius:3px;padding:8px 10px;text-decoration:none;display:flex;flex-direction:column;align-items:center;text-align:center;gap:4px}
.PCARD-AV{width:28px;height:28px;border-radius:50%;color:#fff;display:flex;align-items:center;justify-content:center;font-size:8px;font-weight:900;flex-shrink:0}
.PCARD-NAME{font-size:7px;font-weight:700;color:#002850;line-height:1.3}
.PCARD-COUNT{font-size:6px;color:#A6DAD8;font-weight:800;background:#E8F4F4;padding:1px 6px;border-radius:10px}
.PERSONS-FOOTER{display:flex;justify-content:flex-end;margin-top:2px}
.PERSONS-ALL-LINK{font-size:6.5px;color:#002850;font-weight:600;text-decoration:none;opacity:.6}
/* ── Two-column content row ─── */
.CONTENT-ROW{display:grid;grid-template-columns:1fr 1fr;gap:6px}
.CARD{background:#fff;border:1px solid #E0DDD5;border-radius:3px;overflow:hidden;display:flex;flex-direction:column}
.CARD-HEAD{display:flex;align-items:center;justify-content:space-between;padding:7px 10px 6px;border-bottom:1px solid #E0DDD5}
.CARD-HEAD h3{font-size:6.5px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;color:#999}
.CARD-HEAD a{font-size:6.5px;font-weight:600;color:#002850;opacity:.4;text-decoration:none}
/* ── Doc rows with thumbnail ─── */
.DOC-ROW{display:flex;align-items:center;gap:7px;padding:5px 10px;border-bottom:1px solid #F0EDE6}
.DOC-ROW:last-child{border-bottom:none}
.DOC-THUMB{width:20px;height:24px;background:#ECEAE4;border-radius:2px;flex-shrink:0;display:flex;align-items:center;justify-content:center;border:1px solid #E0DDD5}
.DOC-INFO{flex:1;min-width:0}
.DOC-TITLE{font-family:Georgia,serif;font-size:7.5px;color:#002850;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.DOC-SENDER{font-size:6px;color:#888;margin-top:1px}
.DOC-SENDER a{color:#002850;text-decoration:none;opacity:.7}
.DOC-DATE{font-size:6px;color:#C8C4BE;white-space:nowrap;flex-shrink:0}
/* ── Story rows with excerpt ─── */
.STORY-ROW{padding:7px 10px;border-bottom:1px solid #F0EDE6}
.STORY-ROW:last-child{border-bottom:none}
.STORY-TITLE{font-family:Georgia,serif;font-size:8px;color:#002850;font-style:italic;margin-bottom:3px;line-height:1.3}
.STORY-EXCERPT{font-size:6.5px;color:#888;line-height:1.5;margin-bottom:3px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.STORY-META{font-size:6px;color:#B8B4AE}
/* ── Spec disclaimer ─── */
.spec-disclaimer{background:#FFF8E1;border:1.5px solid #FFC107;border-radius:6px;padding:11px 16px;font-size:11px;color:#6D4C00;margin-bottom:32px;line-height:1.6}
.spec-disclaimer strong{font-weight:800}
</style>
</head>
<body>
<div class="doc">
<!-- ══ MASTHEAD ══ -->
<div class="mast">
<div class="mast-top">
<div>
<h1>Reader Dashboard — Konzept B · „Überblick"</h1>
<p>Kompakte, strukturierte Gestaltung. Begrüßung und Statistik werden in einem gemeinsamen Querbalken kombiniert — spart vertikalen Platz. Personen erscheinen als quadratische Portrait-Karten mit großem Avatar. Geschichten zeigen einen kurzen Auszug für schnelle Orientierung.</p>
</div>
<span class="mast-badge">Konzept B · Entwurf</span>
</div>
<div class="decisions">
<div class="dec">
<div class="dec-label">Schwerpunkt</div>
<div class="dec-value">Effizienz — alles auf einen Blick</div>
</div>
<div class="dec">
<div class="dec-label">Header</div>
<div class="dec-value">Gruß + Statistik in einem Querbalken</div>
</div>
<div class="dec">
<div class="dec-label">Personen</div>
<div class="dec-value">Portrait-Kacheln mit großem Avatar</div>
</div>
<div class="dec">
<div class="dec-label">Geschichten</div>
<div class="dec-value">Titel + Auszug (~2 Zeilen) + Datum</div>
</div>
</div>
</div>
<div class="spec-disclaimer">
<strong>📐 Mockup-Skalierung —</strong> alle Schriftgrößen, Abstände und Höhen im Mockup sind auf ca. 55 % der tatsächlichen Implementierungswerte skaliert. <strong>Werte nicht aus dem Mockup-CSS kopieren.</strong>
</div>
<!-- ══ SECTION 1: DESKTOP, REINER LESER ══ -->
<div class="sec">
<div class="sec-h"><span class="sec-num">1</span> Desktop · Leser ohne BLOG_WRITE (READ_ALL only)</div>
<div class="sg sg-2">
<div class="sb">
<div class="sl">Vollansicht <span class="sz">≥ 1024 px</span></div>
<div class="wf">
<div class="wf-bar">
<div class="dot r"></div><div class="dot y"></div><div class="dot g"></div>
<div class="urlbar"><span>familienarchiv.local /</span></div>
</div>
<!-- Nav -->
<div class="N">
<span class="logo">Familienarchiv</span>
<span class="nl on">Startseite</span>
<span class="nl">Dokumente</span>
<span class="nl">Personen</span>
<span class="nl">Geschichten</span>
<div class="nr">
<div class="nico">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,.5)" stroke-width="2"><path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 01-3.46 0"/></svg>
</div>
<div class="av">BK</div>
</div>
</div>
<div class="N-accent"></div>
<div class="MAIN">
<!-- Zone 1 + 2: Combined header bar -->
<div class="HEADER-BAR">
<div class="HEADER-LEFT">
<div class="HEADER-TIME">Guten Abend</div>
<div class="HEADER-NAME">Herzlich willkommen, Brigitte.</div>
</div>
<div class="HEADER-STATS">
<div class="HSTAT">
<a href="#">
<span class="HSTAT-NUM">847</span>
<span class="HSTAT-LABEL">Dokumente</span>
</a>
</div>
<div class="HSTAT">
<a href="#">
<span class="HSTAT-NUM">94</span>
<span class="HSTAT-LABEL">Personen</span>
</a>
</div>
<div class="HSTAT">
<a href="#">
<span class="HSTAT-NUM">12</span>
<span class="HSTAT-LABEL">Geschichten</span>
</a>
</div>
</div>
</div>
<!-- Zone 4: Person cards -->
<div>
<div class="PERSON-CARDS">
<a class="PCARD" href="#">
<div class="PCARD-AV" style="background:#002850">KR</div>
<div class="PCARD-NAME">Käthe Raddatz</div>
<div class="PCARD-COUNT">47 Dok.</div>
</a>
<a class="PCARD" href="#">
<div class="PCARD-AV" style="background:#1A4A6B">ER</div>
<div class="PCARD-NAME">Ernst Raddatz</div>
<div class="PCARD-COUNT">31 Dok.</div>
</a>
<a class="PCARD" href="#">
<div class="PCARD-AV" style="background:#3D5A7A">FM</div>
<div class="PCARD-NAME">Frieda Müller</div>
<div class="PCARD-COUNT">28 Dok.</div>
</a>
<a class="PCARD" href="#">
<div class="PCARD-AV" style="background:#4A7A5A">HW</div>
<div class="PCARD-NAME">Heinrich Weber</div>
<div class="PCARD-COUNT">19 Dok.</div>
</a>
</div>
<div class="PERSONS-FOOTER">
<a class="PERSONS-ALL-LINK" href="#">Alle 94 Personen →</a>
</div>
</div>
<!-- Zone 5: Two-column (equal split) -->
<div class="CONTENT-ROW">
<!-- Left: Recent docs with thumbnail + sender -->
<div class="CARD">
<div class="CARD-HEAD">
<h3>Zuletzt aktualisiert</h3>
<a href="#">Alle Dokumente</a>
</div>
<div class="DOC-ROW">
<div class="DOC-THUMB">
<svg width="8" height="10" viewBox="0 0 24 30" fill="none" stroke="#C8C4BE" stroke-width="2"><path d="M4 2h10l6 6v20H4V2z"/><polyline points="14 2 14 8 20 8"/></svg>
</div>
<div class="DOC-INFO">
<div class="DOC-TITLE">Brief von Ernst an Käthe, März 1923</div>
<div class="DOC-SENDER">von <a href="#">Käthe Raddatz</a></div>
</div>
<div class="DOC-DATE">vor 2 Tagen</div>
</div>
<div class="DOC-ROW">
<div class="DOC-THUMB">
<svg width="8" height="10" viewBox="0 0 24 30" fill="none" stroke="#C8C4BE" stroke-width="2"><path d="M4 2h10l6 6v20H4V2z"/><polyline points="14 2 14 8 20 8"/></svg>
</div>
<div class="DOC-INFO">
<div class="DOC-TITLE">Heiratsurkunde Raddatz-Müller, 1898</div>
<div class="DOC-SENDER" style="color:#DDD"></div>
</div>
<div class="DOC-DATE">vor 4 Tagen</div>
</div>
<div class="DOC-ROW">
<div class="DOC-THUMB">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#A6DAD8" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
</div>
<div class="DOC-INFO">
<div class="DOC-TITLE">Familienfoto, Sommer 1928</div>
<div class="DOC-SENDER">von <a href="#">Ernst Raddatz</a></div>
</div>
<div class="DOC-DATE">vor 1 Woche</div>
</div>
<div class="DOC-ROW">
<div class="DOC-THUMB">
<svg width="8" height="10" viewBox="0 0 24 30" fill="none" stroke="#C8C4BE" stroke-width="2"><path d="M4 2h10l6 6v20H4V2z"/><polyline points="14 2 14 8 20 8"/></svg>
</div>
<div class="DOC-INFO">
<div class="DOC-TITLE">Taufregister Heinrich Weber, 1902</div>
<div class="DOC-SENDER" style="color:#DDD"></div>
</div>
<div class="DOC-DATE">vor 2 Wo.</div>
</div>
<div class="DOC-ROW">
<div class="DOC-THUMB">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#A6DAD8" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
</div>
<div class="DOC-INFO">
<div class="DOC-TITLE">Postkarte aus Berlin, 1930</div>
<div class="DOC-SENDER">von <a href="#">Frieda Müller</a></div>
</div>
<div class="DOC-DATE">vor 3 Wo.</div>
</div>
</div>
<!-- Right: Stories with excerpt -->
<div class="CARD">
<div class="CARD-HEAD">
<h3>Geschichten</h3>
<a href="#">Alle Geschichten</a>
</div>
<div class="STORY-ROW">
<div class="STORY-TITLE">Die Reise nach Berlin</div>
<div class="STORY-EXCERPT">Im Frühjahr 1921 bestieg Ernst Raddatz erstmals den Zug nach Berlin, um Arbeit in der aufstrebenden Metropole zu finden …</div>
<div class="STORY-META">vor 3 Tagen</div>
</div>
<div class="STORY-ROW">
<div class="STORY-TITLE">Sommer 1934 in Köln</div>
<div class="STORY-EXCERPT">Die Sommerferien 1934 verbrachte die Familie Raddatz bei Verwandten in Köln — die letzte unbeschwerte Reise vor dem Krieg …</div>
<div class="STORY-META">vor 2 Wochen</div>
</div>
<div class="STORY-ROW">
<div class="STORY-TITLE">Briefe aus dem Krieg</div>
<div class="STORY-EXCERPT">Zwischen 1914 und 1918 schrieb Ernst Raddatz insgesamt 47 Briefe aus dem Feld an seine Frau Käthe …</div>
<div class="STORY-META">vor 1 Monat</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Annotations -->
<div style="display:flex;flex-direction:column;gap:10px;padding-top:40px">
<div class="ann-block">
<strong>Zone 1 + 2 — Kombinierter Header-Balken</strong>
<ul>
<li>Begrüßung links, drei Statistiken rechts — alles in einer Karte</li>
<li>Statistiken durch vertikale Trennlinien gegliedert, jede als &lt;a&gt;-Link</li>
<li>Spart vertikalen Platz: weniger Scrollen für Nutzer auf kleineren Bildschirmen</li>
<li>Stats-Zahlen: 24 px (real) — kleiner als Konzept A, aber immer noch gut lesbar</li>
</ul>
</div>
<div class="ann-block">
<strong>Zone 4 — Portrait-Kacheln</strong>
<ul>
<li>4-spaltig gleichbreit; Avatar 28 px (skaliert), Name + Zahl darunter</li>
<li>Mint-farbene Zahl-Badge (#E8F4F4 / #A6DAD8) macht Dokumentanzahl prominent</li>
<li>Kacheln sind vollflächige &lt;a&gt;-Links — barrierefrei</li>
</ul>
</div>
<div class="note">
<strong>Zone 5 — Gleiches Spaltenbreite 1:1</strong>
<ul>
<li>Beide Spalten erhalten gleich viel Platz — Parität zwischen Dokumente und Geschichten</li>
<li>Dokumente: Thumbnail-Platzhalter + Absender-Link in zweiter Zeile</li>
<li>Geschichten: Auszug mit 2-Zeilen-Clamp — gibt Lesern mehr Kontext zum Klicken</li>
</ul>
</div>
<div class="ok">
<strong>Stärken dieses Konzepts</strong>
<ul>
<li>Kompakteste der drei Varianten — wenig Scrollbedarf</li>
<li>Portrait-Kacheln helfen, Personen visuell zu „erkennen" (Avatar wird vertraut)</li>
<li>Auszüge in der Geschichten-Spalte erhöhen die Klickwahrscheinlichkeit</li>
</ul>
</div>
</div>
</div>
</div>
<!-- ══ SECTION 2: BLOG_WRITE VARIANT ══ -->
<div class="sec">
<div class="sec-h"><span class="sec-num">2</span> Variante · BLOG_WRITE — mit Zone 3 „Meine Entwürfe"</div>
<div class="sg sg-2">
<div class="sb">
<div class="sl">BLOG_WRITE-Nutzer <span class="sz">READ_ALL + BLOG_WRITE</span></div>
<div class="wf">
<div class="wf-bar">
<div class="dot r"></div><div class="dot y"></div><div class="dot g"></div>
<div class="urlbar"><span>familienarchiv.local /</span></div>
</div>
<div class="N">
<span class="logo">Familienarchiv</span>
<span class="nl on">Startseite</span>
<span class="nl">Dokumente</span>
<span class="nl">Personen</span>
<span class="nl">Geschichten</span>
<div class="nr"><div class="av">MR</div></div>
</div>
<div class="N-accent"></div>
<div class="MAIN">
<!-- Zone 1+2: Combined bar -->
<div class="HEADER-BAR">
<div class="HEADER-LEFT">
<div class="HEADER-TIME">Guten Morgen</div>
<div class="HEADER-NAME">Herzlich willkommen, Marcel.</div>
</div>
<div class="HEADER-STATS">
<div class="HSTAT"><a href="#"><span class="HSTAT-NUM">847</span><span class="HSTAT-LABEL">Dokumente</span></a></div>
<div class="HSTAT"><a href="#"><span class="HSTAT-NUM">94</span><span class="HSTAT-LABEL">Personen</span></a></div>
<div class="HSTAT"><a href="#"><span class="HSTAT-NUM">12</span><span class="HSTAT-LABEL">Geschichten</span></a></div>
</div>
</div>
<!-- Zone 3: Drafts -->
<div class="CARD">
<div class="CARD-HEAD" style="border-left:3px solid #A6DAD8;padding-left:8px">
<h3>Meine Entwürfe</h3>
<a href="#" style="font-size:6px;color:#002850;opacity:.5;text-decoration:none">→ Alle Geschichten</a>
</div>
<div style="display:flex;align-items:center;gap:8px;padding:6px 10px;border-bottom:1px solid #F0EDE6">
<div style="width:4px;height:4px;border-radius:50%;background:#A6DAD8;flex-shrink:0;margin-left:2px"></div>
<div style="flex:1;min-width:0">
<div style="font-family:Georgia,serif;font-size:8px;color:#002850">Onkel Friedrichs Wanderjahre</div>
<div style="font-size:6px;color:#AAA;margin-top:1px">Entwurf · zuletzt bearbeitet vor 1 Tag</div>
</div>
<svg width="7" height="7" viewBox="0 0 24 24" fill="none" stroke="#CCC" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</div>
<div style="display:flex;align-items:center;gap:8px;padding:6px 10px">
<div style="width:4px;height:4px;border-radius:50%;background:#A6DAD8;flex-shrink:0;margin-left:2px"></div>
<div style="flex:1;min-width:0">
<div style="font-family:Georgia,serif;font-size:8px;color:#002850">Die Raddatz-Kinder</div>
<div style="font-size:6px;color:#AAA;margin-top:1px">Entwurf · zuletzt bearbeitet vor 5 Tagen</div>
</div>
<svg width="7" height="7" viewBox="0 0 24 24" fill="none" stroke="#CCC" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
<!-- Zone 4 -->
<div>
<div class="PERSON-CARDS">
<a class="PCARD" href="#"><div class="PCARD-AV" style="background:#002850">KR</div><div class="PCARD-NAME">Käthe Raddatz</div><div class="PCARD-COUNT">47 Dok.</div></a>
<a class="PCARD" href="#"><div class="PCARD-AV" style="background:#1A4A6B">ER</div><div class="PCARD-NAME">Ernst Raddatz</div><div class="PCARD-COUNT">31 Dok.</div></a>
<a class="PCARD" href="#"><div class="PCARD-AV" style="background:#3D5A7A">FM</div><div class="PCARD-NAME">Frieda Müller</div><div class="PCARD-COUNT">28 Dok.</div></a>
<a class="PCARD" href="#"><div class="PCARD-AV" style="background:#4A7A5A">HW</div><div class="PCARD-NAME">Heinrich Weber</div><div class="PCARD-COUNT">19 Dok.</div></a>
</div>
<div class="PERSONS-FOOTER"><a class="PERSONS-ALL-LINK" href="#">Alle 94 Personen →</a></div>
</div>
<!-- Zone 5 (abbreviated) -->
<div class="CONTENT-ROW">
<div class="CARD">
<div class="CARD-HEAD"><h3>Zuletzt aktualisiert</h3><a href="#">Alle Dokumente</a></div>
<div class="DOC-ROW"><div class="DOC-THUMB"><svg width="8" height="10" viewBox="0 0 24 30" fill="none" stroke="#C8C4BE" stroke-width="2"><path d="M4 2h10l6 6v20H4V2z"/><polyline points="14 2 14 8 20 8"/></svg></div><div class="DOC-INFO"><div class="DOC-TITLE">Brief von Ernst an Käthe, März 1923</div><div class="DOC-SENDER">von <a href="#">Käthe Raddatz</a></div></div><div class="DOC-DATE">vor 2 Tagen</div></div>
<div class="DOC-ROW"><div class="DOC-THUMB"><svg width="8" height="10" viewBox="0 0 24 30" fill="none" stroke="#C8C4BE" stroke-width="2"><path d="M4 2h10l6 6v20H4V2z"/><polyline points="14 2 14 8 20 8"/></svg></div><div class="DOC-INFO"><div class="DOC-TITLE">Heiratsurkunde Raddatz-Müller, 1898</div><div class="DOC-SENDER" style="color:#DDD"></div></div><div class="DOC-DATE">vor 4 Tagen</div></div>
<div style="padding:5px 10px;font-size:6px;color:#C8C4BE;border-top:1px solid #F0EDE6">+ 3 weitere Dokumente …</div>
</div>
<div class="CARD">
<div class="CARD-HEAD"><h3>Geschichten</h3><a href="#">Alle</a></div>
<div class="STORY-ROW"><div class="STORY-TITLE">Die Reise nach Berlin</div><div class="STORY-EXCERPT">Im Frühjahr 1921 bestieg Ernst Raddatz erstmals den Zug nach Berlin …</div><div class="STORY-META">vor 3 Tagen</div></div>
<div class="STORY-ROW"><div class="STORY-TITLE">Sommer 1934 in Köln</div><div class="STORY-EXCERPT">Die Sommerferien 1934 verbrachte die Familie Raddatz bei Verwandten in Köln …</div><div class="STORY-META">vor 2 Wochen</div></div>
</div>
</div>
</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:10px;padding-top:40px">
<div class="ann-block">
<strong>Zone 3 — Entwürfe mit Mint-Dot-Indikator</strong>
<ul>
<li>Mint-Punkt vor jedem Entwurf signalisiert „unveröffentlicht" visuell</li>
<li>Mint-Randakzent links (3 px) ohne Störung des Card-Rahmens</li>
<li>Kompakter als Konzept A — fügt sich harmonisch zwischen Header und Personen ein</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,385 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reader Dashboard — B.1 · Hell &amp; Klar · Familienarchiv</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1A1A;line-height:1.5}
.doc{max-width:1440px;margin:0 auto;padding:48px 32px}
/* ── Masthead ─── */
.mast{background:#0D2240;border-radius:10px;padding:32px 40px;margin-bottom:48px}
.mast-top{display:flex;align-items:flex-start;justify-content:space-between;gap:24px;margin-bottom:16px}
.mast h1{font-size:22px;font-weight:900;color:#fff;letter-spacing:-.4px;margin-bottom:6px}
.mast p{font-size:12px;color:rgba(255,255,255,.5);max-width:660px;line-height:1.7}
.mast-badge{font-size:9px;font-weight:800;padding:3px 9px;border-radius:20px;text-transform:uppercase;letter-spacing:.8px;white-space:nowrap;flex-shrink:0;margin-top:4px;background:#A6DAD8;color:#002850}
.decisions{display:grid;grid-template-columns:repeat(5,1fr);gap:10px;margin-top:20px;border-top:1px solid rgba(255,255,255,.1);padding-top:16px}
.dec{background:rgba(255,255,255,.06);border-radius:6px;padding:10px 12px}
.dec-label{font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:rgba(255,255,255,.35);margin-bottom:5px}
.dec-value{font-size:9.5px;font-weight:700;color:#fff;line-height:1.5}
/* ── Section headings ─── */
.sec{margin-bottom:64px}
.sec+.sec{border-top:2px dashed #C8C4BE;padding-top:56px}
.sec-h{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:1.2px;color:#888;margin-bottom:20px;display:flex;align-items:center;gap:10px}
.sec-h::after{content:'';flex:1;height:1px;background:#D8D4CE}
.sec-num{background:#0D2240;color:#fff;font-size:9px;font-weight:900;padding:2px 7px;border-radius:10px}
/* ── Screen grid ─── */
.sg{display:grid;gap:24px;align-items:start}
.sg-2{grid-template-columns:1fr 340px}
.sb{display:flex;flex-direction:column;gap:10px}
.sl{font-size:9px;font-weight:800;color:#888;text-transform:uppercase;letter-spacing:1.5px;margin-bottom:6px;display:flex;align-items:center;gap:6px}
.sz{background:#E8E4DF;color:#666;padding:1px 5px;border-radius:3px;font-size:8px}
/* ── Annotation callouts ─── */
.ann-block{background:#FFF7ED;border:1px solid #FDBA74;border-radius:5px;padding:8px 10px;font-size:10px;color:#7C2D12;line-height:1.5}
.ann-block strong{font-weight:800}
.ann-block ul{padding-left:14px;display:flex;flex-direction:column;gap:3px;margin-top:5px}
.note{background:#F0F9FF;border:1px solid #BAE6FD;border-radius:5px;padding:8px 10px;font-size:10px;color:#0C4A6E;line-height:1.5}
.note strong{font-weight:800}
.note ul{padding-left:14px;display:flex;flex-direction:column;gap:3px;margin-top:5px}
.ok{background:#F0FDF4;border:1px solid #86EFAC;border-radius:5px;padding:8px 10px;font-size:10px;color:#166534;line-height:1.5}
.ok strong{font-weight:800}
.ok ul{padding-left:14px;display:flex;flex-direction:column;gap:3px;margin-top:5px}
/* ── Mock browser chrome ─── */
.wf{background:#fff;border:2px solid #B8B4AE;border-radius:10px;overflow:hidden;box-shadow:0 4px 18px rgba(0,0,0,.08)}
.wf-bar{height:24px;background:#E8E4DF;border-bottom:1px solid #C8C4BE;display:flex;align-items:center;padding:0 9px;gap:4px}
.dot{width:7px;height:7px;border-radius:50%}
.dot.r{background:#F87171}.dot.y{background:#FCD34D}.dot.g{background:#4ADE80}
.urlbar{flex:1;height:11px;background:#D8D4CE;border-radius:3px;margin-left:6px;display:flex;align-items:center;padding:0 5px}
.urlbar span{font-size:7.5px;color:#888;font-family:monospace}
/* ── Nav bar ─── */
.N{height:34px;background:#002850;display:flex;align-items:center;padding:0 14px;gap:10px}
.N-accent{height:2px;background:#A6DAD8}
.logo{font-size:7.5px;font-weight:900;color:#fff;letter-spacing:1px;text-transform:uppercase}
.nl{font-size:6.5px;color:rgba(255,255,255,.45);font-weight:700;text-transform:uppercase;letter-spacing:.5px;padding:3px 6px}
.nl.on{color:#fff}
.nr{margin-left:auto;display:flex;gap:5px;align-items:center}
.av{width:18px;height:18px;background:#A6DAD8;border-radius:50%;font-size:5.5px;font-weight:900;color:#002850;display:flex;align-items:center;justify-content:center}
.nico{width:18px;height:18px;background:rgba(255,255,255,.1);border-radius:3px;display:flex;align-items:center;justify-content:center}
/* ── Page body ─── */
.MAIN{padding:12px 16px;background:#F5F4EF;display:flex;flex-direction:column;gap:8px}
/* ════════════════════════════════════
B.1 — HELL & KLAR
Header: pure white, divider line between greeting and stats
Person cards: round avatar, mint underline, white bg
Stories: clean 2-line excerpt
Columns: 1:1
════════════════════════════════════ */
/* Combined header bar — white, clean divider */
.HEADER-BAR{background:#fff;border:1px solid #E0DDD5;border-radius:3px;padding:10px 14px;display:flex;align-items:center;gap:16px}
.HEADER-LEFT{flex:1;min-width:0}
.HEADER-TIME{font-size:6px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#C8B8A0;margin-bottom:2px}
.HEADER-NAME{font-family:Georgia,serif;font-size:12px;color:#002850}
.DIVIDER{width:1px;background:#E8E4DF;align-self:stretch;flex-shrink:0}
.HEADER-STATS{display:flex;align-items:center;gap:0}
.HSTAT{text-align:center;padding:0 12px;border-right:1px solid #F0EDE6}
.HSTAT:last-child{border-right:none;padding-right:0}
.HSTAT a{text-decoration:none;display:block}
.HSTAT-NUM{font-size:14px;font-weight:900;color:#002850;line-height:1;display:block}
.HSTAT-LABEL{font-size:5.5px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#B8B4AE;display:block;margin-top:2px}
/* Person portrait cards — round avatar, mint bottom-border on card */
.PERSON-GRID{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}
.PCARD{background:#fff;border:1px solid #E0DDD5;border-bottom:2px solid #A6DAD8;border-radius:3px;padding:9px 10px 8px;text-decoration:none;display:flex;flex-direction:column;align-items:center;text-align:center;gap:5px}
.PCARD-AV{width:28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:8px;font-weight:900;color:#fff;flex-shrink:0}
.PCARD-NAME{font-size:7px;font-weight:700;color:#002850;line-height:1.3}
.PCARD-COUNT{font-size:6px;color:#888;font-weight:400}
.PERSONS-FOOTER{text-align:right;margin-top:3px}
.PERSONS-ALL{font-size:6.5px;color:#002850;font-weight:600;text-decoration:none;opacity:.55}
/* Two-column 1:1 */
.CONTENT-ROW{display:grid;grid-template-columns:1fr 1fr;gap:6px}
/* Cards */
.CARD{background:#fff;border:1px solid #E0DDD5;border-radius:3px;overflow:hidden;display:flex;flex-direction:column}
.CARD-HEAD{display:flex;align-items:center;justify-content:space-between;padding:7px 10px 6px;border-bottom:1px solid #E0DDD5}
.CARD-HEAD h3{font-size:6.5px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;color:#999}
.CARD-HEAD a{font-size:6.5px;font-weight:600;color:#002850;opacity:.4;text-decoration:none}
/* Doc rows */
.DOC-ROW{display:flex;align-items:center;gap:7px;padding:5px 10px;border-bottom:1px solid #F0EDE6}
.DOC-ROW:last-child{border-bottom:none}
.DOC-THUMB{width:20px;height:24px;background:#ECEAE4;border:1px solid #E0DDD5;border-radius:2px;flex-shrink:0;display:flex;align-items:center;justify-content:center}
.DOC-INFO{flex:1;min-width:0}
.DOC-TITLE{font-family:Georgia,serif;font-size:7.5px;color:#002850;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.DOC-SENDER{font-size:6px;color:#888;margin-top:1px}
.DOC-SENDER a{color:#002850;text-decoration:none;opacity:.7}
.DOC-DATE{font-size:6px;color:#C8C4BE;white-space:nowrap;flex-shrink:0}
/* Story rows — clean 2-line excerpt */
.STORY-ROW{padding:7px 10px;border-bottom:1px solid #F0EDE6}
.STORY-ROW:last-child{border-bottom:none}
.STORY-TITLE{font-family:Georgia,serif;font-size:8px;color:#002850;font-style:italic;margin-bottom:3px;line-height:1.3}
.STORY-EXCERPT{font-size:6.5px;color:#888;line-height:1.5;margin-bottom:3px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.STORY-META{font-size:6px;color:#B8B4AE}
/* Drafts card */
.DRAFTS-CARD{background:#fff;border:1px solid #E0DDD5;border-left:3px solid #A6DAD8;border-radius:3px;overflow:hidden}
.DRAFTS-HEAD{padding:6px 10px 5px;border-bottom:1px solid #F0EDE6;display:flex;align-items:center;justify-content:space-between}
.DRAFTS-HEAD h3{font-size:6.5px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;color:#999}
.DRAFT-ROW{display:flex;align-items:center;justify-content:space-between;padding:6px 10px;border-bottom:1px solid #F0EDE6}
.DRAFT-ROW:last-child{border-bottom:none}
.DRAFT-TITLE{font-family:Georgia,serif;font-size:8px;color:#002850}
.DRAFT-META{font-size:6px;color:#AAA;margin-top:1px}
/* Spec disclaimer */
.spec-disclaimer{background:#FFF8E1;border:1.5px solid #FFC107;border-radius:6px;padding:11px 16px;font-size:11px;color:#6D4C00;margin-bottom:32px;line-height:1.6}
.spec-disclaimer strong{font-weight:800}
</style>
</head>
<body>
<div class="doc">
<div class="mast">
<div class="mast-top">
<div>
<h1>Reader Dashboard — B.1 · „Hell &amp; Klar"</h1>
<p>Die reinste Iteration von Konzept B. Weißer Header-Balken mit sauberer vertikaler Trennlinie zwischen Gruß und Statistik. Personen-Kacheln mit mintfarbener Bottom-Border als einzigem Akzent. Gleiches 1:1-Spaltenraster. Geschichten mit 2-Zeilen-Auszug. Minimalste Chrome-Dichte.</p>
</div>
<span class="mast-badge">B.1 · Entwurf</span>
</div>
<div class="decisions">
<div class="dec"><div class="dec-label">Header-Bg</div><div class="dec-value">Weiß — maximale Helligkeit</div></div>
<div class="dec"><div class="dec-label">Stats-Trennlinie</div><div class="dec-value">Vertikale 1 px Linie zwischen Gruß + Stats</div></div>
<div class="dec"><div class="dec-label">Personen-Kacheln</div><div class="dec-value">Runder Avatar · Mint Bottom-Border</div></div>
<div class="dec"><div class="dec-label">Spaltenbreite</div><div class="dec-value">1 : 1 — gleichwertig</div></div>
<div class="dec"><div class="dec-label">Geschichten</div><div class="dec-value">Kursiver Titel + 2-Zeilen-Auszug</div></div>
</div>
</div>
<div class="spec-disclaimer">
<strong>📐 Mockup-Skalierung —</strong> alle Schriftgrößen, Abstände und Höhen sind auf ca. 55 % der Implementierungswerte skaliert.
</div>
<!-- ══ SECTION 1: LESER ══ -->
<div class="sec">
<div class="sec-h"><span class="sec-num">1</span> Desktop · READ_ALL — ohne BLOG_WRITE</div>
<div class="sg sg-2">
<div class="sb">
<div class="sl">Vollansicht <span class="sz">≥ 1024 px</span></div>
<div class="wf">
<div class="wf-bar">
<div class="dot r"></div><div class="dot y"></div><div class="dot g"></div>
<div class="urlbar"><span>familienarchiv.local /</span></div>
</div>
<div class="N">
<span class="logo">Familienarchiv</span>
<span class="nl on">Startseite</span>
<span class="nl">Dokumente</span>
<span class="nl">Personen</span>
<span class="nl">Geschichten</span>
<div class="nr">
<div class="nico"><svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,.5)" stroke-width="2"><path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 01-3.46 0"/></svg></div>
<div class="av">BK</div>
</div>
</div>
<div class="N-accent"></div>
<div class="MAIN">
<!-- Zone 1+2: Combined header — white, clean divider -->
<div class="HEADER-BAR">
<div class="HEADER-LEFT">
<div class="HEADER-TIME">Guten Abend</div>
<div class="HEADER-NAME">Herzlich willkommen, Brigitte.</div>
</div>
<div class="DIVIDER"></div>
<div class="HEADER-STATS">
<div class="HSTAT"><a href="#"><span class="HSTAT-NUM">847</span><span class="HSTAT-LABEL">Dokumente</span></a></div>
<div class="HSTAT"><a href="#"><span class="HSTAT-NUM">94</span><span class="HSTAT-LABEL">Personen</span></a></div>
<div class="HSTAT"><a href="#"><span class="HSTAT-NUM">12</span><span class="HSTAT-LABEL">Geschichten</span></a></div>
</div>
</div>
<!-- Zone 4: Person cards — mint bottom-border variant -->
<div>
<div class="PERSON-GRID">
<a class="PCARD" href="#">
<div class="PCARD-AV" style="background:#002850">KR</div>
<div class="PCARD-NAME">Käthe Raddatz</div>
<div class="PCARD-COUNT">47 Dokumente</div>
</a>
<a class="PCARD" href="#">
<div class="PCARD-AV" style="background:#1A4A6B">ER</div>
<div class="PCARD-NAME">Ernst Raddatz</div>
<div class="PCARD-COUNT">31 Dokumente</div>
</a>
<a class="PCARD" href="#">
<div class="PCARD-AV" style="background:#3D5A7A">FM</div>
<div class="PCARD-NAME">Frieda Müller</div>
<div class="PCARD-COUNT">28 Dokumente</div>
</a>
<a class="PCARD" href="#">
<div class="PCARD-AV" style="background:#4A7A5A">HW</div>
<div class="PCARD-NAME">Heinrich Weber</div>
<div class="PCARD-COUNT">19 Dokumente</div>
</a>
</div>
<div class="PERSONS-FOOTER"><a class="PERSONS-ALL" href="#">Alle 94 Personen →</a></div>
</div>
<!-- Zone 5: 1:1 split -->
<div class="CONTENT-ROW">
<div class="CARD">
<div class="CARD-HEAD"><h3>Zuletzt aktualisiert</h3><a href="#">Alle Dokumente</a></div>
<div class="DOC-ROW">
<div class="DOC-THUMB"><svg width="8" height="10" viewBox="0 0 20 26" fill="none" stroke="#C8C4BE" stroke-width="1.5"><path d="M3 1h9l5 5v19H3V1z"/><polyline points="12 1 12 6 18 6"/></svg></div>
<div class="DOC-INFO"><div class="DOC-TITLE">Brief von Ernst an Käthe, März 1923</div><div class="DOC-SENDER">von <a href="#">Käthe Raddatz</a></div></div>
<div class="DOC-DATE">vor 2 Tagen</div>
</div>
<div class="DOC-ROW">
<div class="DOC-THUMB"><svg width="8" height="10" viewBox="0 0 20 26" fill="none" stroke="#C8C4BE" stroke-width="1.5"><path d="M3 1h9l5 5v19H3V1z"/><polyline points="12 1 12 6 18 6"/></svg></div>
<div class="DOC-INFO"><div class="DOC-TITLE">Heiratsurkunde Raddatz-Müller, 1898</div><div class="DOC-SENDER" style="color:#E0DDD5"></div></div>
<div class="DOC-DATE">vor 4 Tagen</div>
</div>
<div class="DOC-ROW">
<div class="DOC-THUMB"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#A6DAD8" stroke-width="1.5"><rect x="2" y="2" width="20" height="20" rx="2"/><circle cx="8" cy="8" r="2"/><polyline points="22 14 16 9 4 22"/></svg></div>
<div class="DOC-INFO"><div class="DOC-TITLE">Familienfoto, Sommer 1928</div><div class="DOC-SENDER">von <a href="#">Ernst Raddatz</a></div></div>
<div class="DOC-DATE">vor 1 Woche</div>
</div>
<div class="DOC-ROW">
<div class="DOC-THUMB"><svg width="8" height="10" viewBox="0 0 20 26" fill="none" stroke="#C8C4BE" stroke-width="1.5"><path d="M3 1h9l5 5v19H3V1z"/><polyline points="12 1 12 6 18 6"/></svg></div>
<div class="DOC-INFO"><div class="DOC-TITLE">Taufregister Heinrich Weber, 1902</div><div class="DOC-SENDER" style="color:#E0DDD5"></div></div>
<div class="DOC-DATE">vor 2 Wo.</div>
</div>
<div class="DOC-ROW">
<div class="DOC-THUMB"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#A6DAD8" stroke-width="1.5"><rect x="2" y="2" width="20" height="20" rx="2"/><circle cx="8" cy="8" r="2"/><polyline points="22 14 16 9 4 22"/></svg></div>
<div class="DOC-INFO"><div class="DOC-TITLE">Postkarte aus Berlin, 1930</div><div class="DOC-SENDER">von <a href="#">Frieda Müller</a></div></div>
<div class="DOC-DATE">vor 3 Wo.</div>
</div>
</div>
<div class="CARD">
<div class="CARD-HEAD"><h3>Geschichten</h3><a href="#">Alle Geschichten</a></div>
<div class="STORY-ROW">
<div class="STORY-TITLE">Die Reise nach Berlin</div>
<div class="STORY-EXCERPT">Im Frühjahr 1921 bestieg Ernst Raddatz erstmals den Zug nach Berlin, um Arbeit in der aufstrebenden Metropole zu finden …</div>
<div class="STORY-META">vor 3 Tagen</div>
</div>
<div class="STORY-ROW">
<div class="STORY-TITLE">Sommer 1934 in Köln</div>
<div class="STORY-EXCERPT">Die Sommerferien 1934 verbrachte die Familie Raddatz bei Verwandten in Köln — die letzte unbeschwerte Reise vor dem Krieg …</div>
<div class="STORY-META">vor 2 Wochen</div>
</div>
<div class="STORY-ROW">
<div class="STORY-TITLE">Briefe aus dem Krieg</div>
<div class="STORY-EXCERPT">Zwischen 1914 und 1918 schrieb Ernst Raddatz insgesamt 47 Briefe aus dem Feld an seine Frau Käthe in der Heimat …</div>
<div class="STORY-META">vor 1 Monat</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:10px;padding-top:40px">
<div class="ann-block">
<strong>Header-Balken — weiß mit Trennlinie</strong>
<ul>
<li>Kein Hintergrund-Farbton — pure weiß, gleicher Hintergrund wie alle Cards</li>
<li>1 px vertikale Trennlinie (#E8E4DF) trennt Gruß von Stats klar ohne schweren Rand</li>
<li>Stats: 24 px Zahlen (skaliert 14 px), 11 px uppercase Label darunter</li>
</ul>
</div>
<div class="ann-block">
<strong>Personen-Kacheln — Mint Bottom-Border</strong>
<ul>
<li>Einziger farbiger Akzent: 2 px mintfarbener Bottom-Border (#A6DAD8)</li>
<li>Runder Avatar in Navy-Farbfamilie — gleiche Logik wie bestehende Avatare in der App</li>
<li>Dokumentzahl in Grau, ohne Badge — so wenig Chrome wie möglich</li>
</ul>
</div>
<div class="note">
<strong>Stärken gegenüber Basis-B</strong>
<ul>
<li>Höchste Konsistenz mit dem restlichen App-Stil (alle Cards weiß)</li>
<li>Leichteste visuelle Belastung — ideal für ältere Nutzer</li>
<li>Mint nur als funktionaler Akzent (Bottom-Border auf Personenkacheln)</li>
</ul>
</div>
</div>
</div>
</div>
<!-- ══ SECTION 2: BLOG_WRITE ══ -->
<div class="sec">
<div class="sec-h"><span class="sec-num">2</span> Variante · BLOG_WRITE — Zone 3 „Meine Entwürfe"</div>
<div class="sg sg-2">
<div class="sb">
<div class="sl">READ_ALL + BLOG_WRITE</div>
<div class="wf">
<div class="wf-bar"><div class="dot r"></div><div class="dot y"></div><div class="dot g"></div><div class="urlbar"><span>familienarchiv.local /</span></div></div>
<div class="N"><span class="logo">Familienarchiv</span><span class="nl on">Startseite</span><span class="nl">Dokumente</span><span class="nl">Personen</span><span class="nl">Geschichten</span><div class="nr"><div class="av">MR</div></div></div>
<div class="N-accent"></div>
<div class="MAIN">
<div class="HEADER-BAR">
<div class="HEADER-LEFT"><div class="HEADER-TIME">Guten Morgen</div><div class="HEADER-NAME">Herzlich willkommen, Marcel.</div></div>
<div class="DIVIDER"></div>
<div class="HEADER-STATS">
<div class="HSTAT"><a href="#"><span class="HSTAT-NUM">847</span><span class="HSTAT-LABEL">Dokumente</span></a></div>
<div class="HSTAT"><a href="#"><span class="HSTAT-NUM">94</span><span class="HSTAT-LABEL">Personen</span></a></div>
<div class="HSTAT"><a href="#"><span class="HSTAT-NUM">12</span><span class="HSTAT-LABEL">Geschichten</span></a></div>
</div>
</div>
<!-- Drafts -->
<div class="DRAFTS-CARD">
<div class="DRAFTS-HEAD"><h3>Meine Entwürfe</h3></div>
<div class="DRAFT-ROW">
<div><div class="DRAFT-TITLE">Onkel Friedrichs Wanderjahre</div><div class="DRAFT-META">Entwurf · zuletzt bearbeitet vor 1 Tag</div></div>
<svg width="7" height="7" viewBox="0 0 24 24" fill="none" stroke="#CCC" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</div>
<div class="DRAFT-ROW">
<div><div class="DRAFT-TITLE">Die Raddatz-Kinder</div><div class="DRAFT-META">Entwurf · zuletzt bearbeitet vor 5 Tagen</div></div>
<svg width="7" height="7" viewBox="0 0 24 24" fill="none" stroke="#CCC" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
<!-- Persons -->
<div>
<div class="PERSON-GRID">
<a class="PCARD" href="#"><div class="PCARD-AV" style="background:#002850">KR</div><div class="PCARD-NAME">Käthe Raddatz</div><div class="PCARD-COUNT">47 Dok.</div></a>
<a class="PCARD" href="#"><div class="PCARD-AV" style="background:#1A4A6B">ER</div><div class="PCARD-NAME">Ernst Raddatz</div><div class="PCARD-COUNT">31 Dok.</div></a>
<a class="PCARD" href="#"><div class="PCARD-AV" style="background:#3D5A7A">FM</div><div class="PCARD-NAME">Frieda Müller</div><div class="PCARD-COUNT">28 Dok.</div></a>
<a class="PCARD" href="#"><div class="PCARD-AV" style="background:#4A7A5A">HW</div><div class="PCARD-NAME">Heinrich Weber</div><div class="PCARD-COUNT">19 Dok.</div></a>
</div>
<div class="PERSONS-FOOTER"><a class="PERSONS-ALL" href="#">Alle 94 Personen →</a></div>
</div>
<div class="CONTENT-ROW">
<div class="CARD">
<div class="CARD-HEAD"><h3>Zuletzt aktualisiert</h3><a href="#">Alle Dokumente</a></div>
<div class="DOC-ROW"><div class="DOC-THUMB"><svg width="8" height="10" viewBox="0 0 20 26" fill="none" stroke="#C8C4BE" stroke-width="1.5"><path d="M3 1h9l5 5v19H3V1z"/><polyline points="12 1 12 6 18 6"/></svg></div><div class="DOC-INFO"><div class="DOC-TITLE">Brief von Ernst an Käthe, März 1923</div><div class="DOC-SENDER">von <a href="#">Käthe Raddatz</a></div></div><div class="DOC-DATE">vor 2 Tagen</div></div>
<div class="DOC-ROW"><div class="DOC-THUMB"><svg width="8" height="10" viewBox="0 0 20 26" fill="none" stroke="#C8C4BE" stroke-width="1.5"><path d="M3 1h9l5 5v19H3V1z"/><polyline points="12 1 12 6 18 6"/></svg></div><div class="DOC-INFO"><div class="DOC-TITLE">Heiratsurkunde Raddatz-Müller, 1898</div><div class="DOC-SENDER" style="color:#E0DDD5"></div></div><div class="DOC-DATE">vor 4 Tagen</div></div>
<div style="padding:5px 10px;font-size:6px;color:#C8C4BE;border-top:1px solid #F0EDE6">+ 3 weitere Dokumente …</div>
</div>
<div class="CARD">
<div class="CARD-HEAD"><h3>Geschichten</h3><a href="#">Alle</a></div>
<div class="STORY-ROW"><div class="STORY-TITLE">Die Reise nach Berlin</div><div class="STORY-EXCERPT">Im Frühjahr 1921 bestieg Ernst Raddatz erstmals den Zug nach Berlin …</div><div class="STORY-META">vor 3 Tagen</div></div>
<div class="STORY-ROW"><div class="STORY-TITLE">Sommer 1934 in Köln</div><div class="STORY-EXCERPT">Die Sommerferien 1934 verbrachte die Familie Raddatz bei Verwandten in Köln …</div><div class="STORY-META">vor 2 Wochen</div></div>
</div>
</div>
</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:10px;padding-top:40px">
<div class="ann-block">
<strong>Entwürfe-Card — Mint Left-Border</strong>
<ul>
<li>3 px mintfarbener Left-Border auf Card-Ebene — harmoniert mit den Personen-Kacheln</li>
<li>Kein separater Card-Hintergrund — fügt sich natürlich in den White-Card-Stil ein</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,421 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reader Dashboard — B.2 · Sanft &amp; Warm · Familienarchiv</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1A1A;line-height:1.5}
.doc{max-width:1440px;margin:0 auto;padding:48px 32px}
/* ── Masthead ─── */
.mast{background:#0D2240;border-radius:10px;padding:32px 40px;margin-bottom:48px}
.mast-top{display:flex;align-items:flex-start;justify-content:space-between;gap:24px;margin-bottom:16px}
.mast h1{font-size:22px;font-weight:900;color:#fff;letter-spacing:-.4px;margin-bottom:6px}
.mast p{font-size:12px;color:rgba(255,255,255,.5);max-width:660px;line-height:1.7}
.mast-badge{font-size:9px;font-weight:800;padding:3px 9px;border-radius:20px;text-transform:uppercase;letter-spacing:.8px;white-space:nowrap;flex-shrink:0;margin-top:4px;background:#A6DAD8;color:#002850}
.decisions{display:grid;grid-template-columns:repeat(5,1fr);gap:10px;margin-top:20px;border-top:1px solid rgba(255,255,255,.1);padding-top:16px}
.dec{background:rgba(255,255,255,.06);border-radius:6px;padding:10px 12px}
.dec-label{font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:rgba(255,255,255,.35);margin-bottom:5px}
.dec-value{font-size:9.5px;font-weight:700;color:#fff;line-height:1.5}
/* ── Section headings ─── */
.sec{margin-bottom:64px}
.sec+.sec{border-top:2px dashed #C8C4BE;padding-top:56px}
.sec-h{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:1.2px;color:#888;margin-bottom:20px;display:flex;align-items:center;gap:10px}
.sec-h::after{content:'';flex:1;height:1px;background:#D8D4CE}
.sec-num{background:#0D2240;color:#fff;font-size:9px;font-weight:900;padding:2px 7px;border-radius:10px}
/* ── Screen grid ─── */
.sg{display:grid;gap:24px;align-items:start}
.sg-2{grid-template-columns:1fr 340px}
.sb{display:flex;flex-direction:column;gap:10px}
.sl{font-size:9px;font-weight:800;color:#888;text-transform:uppercase;letter-spacing:1.5px;margin-bottom:6px;display:flex;align-items:center;gap:6px}
.sz{background:#E8E4DF;color:#666;padding:1px 5px;border-radius:3px;font-size:8px}
/* ── Annotation callouts ─── */
.ann-block{background:#FFF7ED;border:1px solid #FDBA74;border-radius:5px;padding:8px 10px;font-size:10px;color:#7C2D12;line-height:1.5}
.ann-block strong{font-weight:800}
.ann-block ul{padding-left:14px;display:flex;flex-direction:column;gap:3px;margin-top:5px}
.note{background:#F0F9FF;border:1px solid #BAE6FD;border-radius:5px;padding:8px 10px;font-size:10px;color:#0C4A6E;line-height:1.5}
.note strong{font-weight:800}
.note ul{padding-left:14px;display:flex;flex-direction:column;gap:3px;margin-top:5px}
.ok{background:#F0FDF4;border:1px solid #86EFAC;border-radius:5px;padding:8px 10px;font-size:10px;color:#166534;line-height:1.5}
.ok strong{font-weight:800}
.ok ul{padding-left:14px;display:flex;flex-direction:column;gap:3px;margin-top:5px}
/* ── Mock browser chrome ─── */
.wf{background:#fff;border:2px solid #B8B4AE;border-radius:10px;overflow:hidden;box-shadow:0 4px 18px rgba(0,0,0,.08)}
.wf-bar{height:24px;background:#E8E4DF;border-bottom:1px solid #C8C4BE;display:flex;align-items:center;padding:0 9px;gap:4px}
.dot{width:7px;height:7px;border-radius:50%}
.dot.r{background:#F87171}.dot.y{background:#FCD34D}.dot.g{background:#4ADE80}
.urlbar{flex:1;height:11px;background:#D8D4CE;border-radius:3px;margin-left:6px;display:flex;align-items:center;padding:0 5px}
.urlbar span{font-size:7.5px;color:#888;font-family:monospace}
/* ── Nav bar ─── */
.N{height:34px;background:#002850;display:flex;align-items:center;padding:0 14px;gap:10px}
.N-accent{height:2px;background:#A6DAD8}
.logo{font-size:7.5px;font-weight:900;color:#fff;letter-spacing:1px;text-transform:uppercase}
.nl{font-size:6.5px;color:rgba(255,255,255,.45);font-weight:700;text-transform:uppercase;letter-spacing:.5px;padding:3px 6px}
.nl.on{color:#fff}
.nr{margin-left:auto;display:flex;gap:5px;align-items:center}
.av{width:18px;height:18px;background:#A6DAD8;border-radius:50%;font-size:5.5px;font-weight:900;color:#002850;display:flex;align-items:center;justify-content:center}
.nico{width:18px;height:18px;background:rgba(255,255,255,.1);border-radius:3px;display:flex;align-items:center;justify-content:center}
/* ── Page body ─── */
.MAIN{padding:12px 16px;background:#F5F4EF;display:flex;flex-direction:column;gap:8px}
/* ════════════════════════════════════
B.2 — SANFT & WARM
Header: sand-warm background (#FDFAF5), rounded stat pills
Person cards: thin colored top accent strip per card
Stories: 2-line excerpt + serif "Geschichte" label
Columns: 3:2 docs wider
════════════════════════════════════ */
/* Combined header — warm sand bg, rounded stat pills */
.HEADER-BAR{background:#FDFAF5;border:1px solid #E8E3D8;border-radius:3px;padding:10px 14px;display:flex;align-items:center;gap:16px}
.HEADER-LEFT{flex:1;min-width:0}
.HEADER-TIME{font-size:6px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#C8A87A;margin-bottom:2px}
.HEADER-NAME{font-family:Georgia,serif;font-size:12px;color:#002850}
.HEADER-STATS{display:flex;gap:5px;align-items:center}
.HSTAT-PILL{text-align:center;padding:5px 10px;border:1px solid #E0DDD5;border-radius:20px;background:#fff;text-decoration:none;display:flex;flex-direction:column;align-items:center;gap:1px}
.HSTAT-NUM{font-size:12px;font-weight:900;color:#002850;line-height:1;display:block}
.HSTAT-LABEL{font-size:5.5px;font-weight:800;text-transform:uppercase;letter-spacing:.7px;color:#B8B4AE;display:block}
/* Person cards — colored top accent strip */
.PERSON-GRID{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}
.PCARD{background:#fff;border:1px solid #E0DDD5;border-radius:3px;overflow:hidden;text-decoration:none;display:flex;flex-direction:column;align-items:center;text-align:center}
.PCARD-STRIP{height:3px;width:100%;flex-shrink:0}
.PCARD-INNER{padding:8px 10px 9px;display:flex;flex-direction:column;align-items:center;gap:4px;width:100%}
.PCARD-AV{width:28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:8px;font-weight:900;color:#fff;flex-shrink:0}
.PCARD-NAME{font-size:7px;font-weight:700;color:#002850;line-height:1.3}
.PCARD-COUNT{font-size:5.5px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;color:#AAA}
.PERSONS-FOOTER{text-align:right;margin-top:3px}
.PERSONS-ALL{font-size:6.5px;color:#002850;font-weight:600;text-decoration:none;opacity:.55}
/* Two-column 3:2 */
.CONTENT-ROW{display:grid;grid-template-columns:3fr 2fr;gap:6px}
/* Cards */
.CARD{background:#fff;border:1px solid #E0DDD5;border-radius:3px;overflow:hidden;display:flex;flex-direction:column}
.CARD-HEAD{display:flex;align-items:center;justify-content:space-between;padding:7px 10px 6px;border-bottom:1px solid #E0DDD5}
.CARD-HEAD h3{font-size:6.5px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;color:#999}
.CARD-HEAD a{font-size:6.5px;font-weight:600;color:#002850;opacity:.4;text-decoration:none}
/* Doc rows */
.DOC-ROW{display:flex;align-items:center;gap:7px;padding:5px 10px;border-bottom:1px solid #F0EDE6}
.DOC-ROW:last-child{border-bottom:none}
.DOC-THUMB{width:20px;height:24px;background:#ECEAE4;border:1px solid #E0DDD5;border-radius:2px;flex-shrink:0;display:flex;align-items:center;justify-content:center}
.DOC-INFO{flex:1;min-width:0}
.DOC-TITLE{font-family:Georgia,serif;font-size:7.5px;color:#002850;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.DOC-SENDER{font-size:6px;color:#888;margin-top:1px}
.DOC-SENDER a{color:#002850;text-decoration:none;opacity:.7}
.DOC-DATE{font-size:6px;color:#C8C4BE;white-space:nowrap;flex-shrink:0}
/* Story rows — with "Geschichte" label */
.STORY-ROW{padding:7px 10px;border-bottom:1px solid #F0EDE6}
.STORY-ROW:last-child{border-bottom:none}
.STORY-LABEL{font-size:5.5px;font-weight:800;text-transform:uppercase;letter-spacing:.6px;color:#C8C4BE;margin-bottom:2px}
.STORY-TITLE{font-family:Georgia,serif;font-size:8px;color:#002850;font-style:italic;margin-bottom:3px;line-height:1.3}
.STORY-EXCERPT{font-size:6.5px;color:#888;line-height:1.5;margin-bottom:3px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.STORY-META{font-size:6px;color:#B8B4AE}
/* Drafts */
.DRAFTS-CARD{background:#FDFAF5;border:1px solid #E8E3D8;border-radius:3px;overflow:hidden}
.DRAFTS-HEAD{padding:6px 10px 5px;border-bottom:1px solid #EEE9E0;display:flex;align-items:center;gap:5px}
.DRAFTS-HEAD-DOT{width:6px;height:6px;border-radius:50%;background:#A6DAD8;flex-shrink:0}
.DRAFTS-HEAD h3{font-size:6.5px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;color:#999}
.DRAFT-ROW{display:flex;align-items:center;justify-content:space-between;padding:6px 10px;border-bottom:1px solid #EEE9E0}
.DRAFT-ROW:last-child{border-bottom:none}
.DRAFT-TITLE{font-family:Georgia,serif;font-size:8px;color:#002850}
.DRAFT-META{font-size:6px;color:#AAA;margin-top:1px}
/* Spec disclaimer */
.spec-disclaimer{background:#FFF8E1;border:1.5px solid #FFC107;border-radius:6px;padding:11px 16px;font-size:11px;color:#6D4C00;margin-bottom:32px;line-height:1.6}
.spec-disclaimer strong{font-weight:800}
</style>
</head>
<body>
<div class="doc">
<div class="mast">
<div class="mast-top">
<div>
<h1>Reader Dashboard — B.2 · „Sanft &amp; Warm"</h1>
<p>Wärmere Iteration von Konzept B. Der Header-Balken erhält einen Sand-Hintergrund (#FDFAF5) statt Weiß — dies erzeugt eine sanfte Abgrenzung zur Begrüßungszone ohne harte Kante. Statistiken erscheinen als abgerundete Pill-Kacheln. Jede Personenkachel bekommt einen schmalen farbigen Top-Akzentstreifen. Die Story-Spalte zeigt ein zusätzliches Kategorie-Label. Spalten 3:2 — Dokumente etwas breiter.</p>
</div>
<span class="mast-badge">B.2 · Entwurf</span>
</div>
<div class="decisions">
<div class="dec"><div class="dec-label">Header-Bg</div><div class="dec-value">Sand-warm #FDFAF5 — Begrüßungszone spürbar abgegrenzt</div></div>
<div class="dec"><div class="dec-label">Stats-Form</div><div class="dec-value">Abgerundete Pill-Kacheln (border-radius: 20 px)</div></div>
<div class="dec"><div class="dec-label">Personen</div><div class="dec-value">3 px Top-Akzentstreifen je Kachel (Avatarfarbe)</div></div>
<div class="dec"><div class="dec-label">Spaltenbreite</div><div class="dec-value">3 : 2 — Dokumente · Geschichten</div></div>
<div class="dec"><div class="dec-label">Geschichten</div><div class="dec-value">Label „Geschichte" + kursiver Titel + Auszug</div></div>
</div>
</div>
<div class="spec-disclaimer">
<strong>📐 Mockup-Skalierung —</strong> alle Schriftgrößen, Abstände und Höhen sind auf ca. 55 % der Implementierungswerte skaliert.
</div>
<!-- ══ SECTION 1: LESER ══ -->
<div class="sec">
<div class="sec-h"><span class="sec-num">1</span> Desktop · READ_ALL — ohne BLOG_WRITE</div>
<div class="sg sg-2">
<div class="sb">
<div class="sl">Vollansicht <span class="sz">≥ 1024 px</span></div>
<div class="wf">
<div class="wf-bar">
<div class="dot r"></div><div class="dot y"></div><div class="dot g"></div>
<div class="urlbar"><span>familienarchiv.local /</span></div>
</div>
<div class="N">
<span class="logo">Familienarchiv</span>
<span class="nl on">Startseite</span>
<span class="nl">Dokumente</span>
<span class="nl">Personen</span>
<span class="nl">Geschichten</span>
<div class="nr">
<div class="nico"><svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,.5)" stroke-width="2"><path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 01-3.46 0"/></svg></div>
<div class="av">BK</div>
</div>
</div>
<div class="N-accent"></div>
<div class="MAIN">
<!-- Zone 1+2: warm sand header + pill stats -->
<div class="HEADER-BAR">
<div class="HEADER-LEFT">
<div class="HEADER-TIME">Guten Abend</div>
<div class="HEADER-NAME">Herzlich willkommen, Brigitte.</div>
</div>
<div class="HEADER-STATS">
<a class="HSTAT-PILL" href="#">
<span class="HSTAT-NUM">847</span>
<span class="HSTAT-LABEL">Dokumente</span>
</a>
<a class="HSTAT-PILL" href="#">
<span class="HSTAT-NUM">94</span>
<span class="HSTAT-LABEL">Personen</span>
</a>
<a class="HSTAT-PILL" href="#">
<span class="HSTAT-NUM">12</span>
<span class="HSTAT-LABEL">Geschichten</span>
</a>
</div>
</div>
<!-- Zone 4: person cards with colored top strip -->
<div>
<div class="PERSON-GRID">
<a class="PCARD" href="#">
<div class="PCARD-STRIP" style="background:#002850"></div>
<div class="PCARD-INNER">
<div class="PCARD-AV" style="background:#002850">KR</div>
<div class="PCARD-NAME">Käthe Raddatz</div>
<div class="PCARD-COUNT">47 Dokumente</div>
</div>
</a>
<a class="PCARD" href="#">
<div class="PCARD-STRIP" style="background:#1A4A6B"></div>
<div class="PCARD-INNER">
<div class="PCARD-AV" style="background:#1A4A6B">ER</div>
<div class="PCARD-NAME">Ernst Raddatz</div>
<div class="PCARD-COUNT">31 Dokumente</div>
</div>
</a>
<a class="PCARD" href="#">
<div class="PCARD-STRIP" style="background:#3D5A7A"></div>
<div class="PCARD-INNER">
<div class="PCARD-AV" style="background:#3D5A7A">FM</div>
<div class="PCARD-NAME">Frieda Müller</div>
<div class="PCARD-COUNT">28 Dokumente</div>
</div>
</a>
<a class="PCARD" href="#">
<div class="PCARD-STRIP" style="background:#4A7A5A"></div>
<div class="PCARD-INNER">
<div class="PCARD-AV" style="background:#4A7A5A">HW</div>
<div class="PCARD-NAME">Heinrich Weber</div>
<div class="PCARD-COUNT">19 Dokumente</div>
</div>
</a>
</div>
<div class="PERSONS-FOOTER"><a class="PERSONS-ALL" href="#">Alle 94 Personen →</a></div>
</div>
<!-- Zone 5: 3:2 columns, stories with label -->
<div class="CONTENT-ROW">
<div class="CARD">
<div class="CARD-HEAD"><h3>Zuletzt aktualisiert</h3><a href="#">Alle Dokumente</a></div>
<div class="DOC-ROW">
<div class="DOC-THUMB"><svg width="8" height="10" viewBox="0 0 20 26" fill="none" stroke="#C8C4BE" stroke-width="1.5"><path d="M3 1h9l5 5v19H3V1z"/><polyline points="12 1 12 6 18 6"/></svg></div>
<div class="DOC-INFO"><div class="DOC-TITLE">Brief von Ernst an Käthe, März 1923</div><div class="DOC-SENDER">von <a href="#">Käthe Raddatz</a></div></div>
<div class="DOC-DATE">vor 2 Tagen</div>
</div>
<div class="DOC-ROW">
<div class="DOC-THUMB"><svg width="8" height="10" viewBox="0 0 20 26" fill="none" stroke="#C8C4BE" stroke-width="1.5"><path d="M3 1h9l5 5v19H3V1z"/><polyline points="12 1 12 6 18 6"/></svg></div>
<div class="DOC-INFO"><div class="DOC-TITLE">Heiratsurkunde Raddatz-Müller, 1898</div><div class="DOC-SENDER" style="color:#E0DDD5"></div></div>
<div class="DOC-DATE">vor 4 Tagen</div>
</div>
<div class="DOC-ROW">
<div class="DOC-THUMB"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#A6DAD8" stroke-width="1.5"><rect x="2" y="2" width="20" height="20" rx="2"/><circle cx="8" cy="8" r="2"/><polyline points="22 14 16 9 4 22"/></svg></div>
<div class="DOC-INFO"><div class="DOC-TITLE">Familienfoto, Sommer 1928</div><div class="DOC-SENDER">von <a href="#">Ernst Raddatz</a></div></div>
<div class="DOC-DATE">vor 1 Woche</div>
</div>
<div class="DOC-ROW">
<div class="DOC-THUMB"><svg width="8" height="10" viewBox="0 0 20 26" fill="none" stroke="#C8C4BE" stroke-width="1.5"><path d="M3 1h9l5 5v19H3V1z"/><polyline points="12 1 12 6 18 6"/></svg></div>
<div class="DOC-INFO"><div class="DOC-TITLE">Taufregister Heinrich Weber, 1902</div><div class="DOC-SENDER" style="color:#E0DDD5"></div></div>
<div class="DOC-DATE">vor 2 Wo.</div>
</div>
<div class="DOC-ROW">
<div class="DOC-THUMB"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#A6DAD8" stroke-width="1.5"><rect x="2" y="2" width="20" height="20" rx="2"/><circle cx="8" cy="8" r="2"/><polyline points="22 14 16 9 4 22"/></svg></div>
<div class="DOC-INFO"><div class="DOC-TITLE">Postkarte aus Berlin, 1930</div><div class="DOC-SENDER">von <a href="#">Frieda Müller</a></div></div>
<div class="DOC-DATE">vor 3 Wo.</div>
</div>
</div>
<div class="CARD">
<div class="CARD-HEAD"><h3>Geschichten</h3><a href="#">Alle Geschichten</a></div>
<div class="STORY-ROW">
<div class="STORY-LABEL">Geschichte</div>
<div class="STORY-TITLE">Die Reise nach Berlin</div>
<div class="STORY-EXCERPT">Im Frühjahr 1921 bestieg Ernst Raddatz erstmals den Zug nach Berlin, um Arbeit in der aufstrebenden Metropole zu finden …</div>
<div class="STORY-META">vor 3 Tagen</div>
</div>
<div class="STORY-ROW">
<div class="STORY-LABEL">Geschichte</div>
<div class="STORY-TITLE">Sommer 1934 in Köln</div>
<div class="STORY-EXCERPT">Die Sommerferien 1934 verbrachte die Familie Raddatz bei Verwandten in Köln — die letzte unbeschwerte Reise …</div>
<div class="STORY-META">vor 2 Wochen</div>
</div>
<div class="STORY-ROW">
<div class="STORY-LABEL">Geschichte</div>
<div class="STORY-TITLE">Briefe aus dem Krieg</div>
<div class="STORY-EXCERPT">Zwischen 1914 und 1918 schrieb Ernst Raddatz insgesamt 47 Briefe aus dem Feld an seine Frau Käthe …</div>
<div class="STORY-META">vor 1 Monat</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:10px;padding-top:40px">
<div class="ann-block">
<strong>Header-Balken — Sandfarben + Pill-Stats</strong>
<ul>
<li>Hintergrund #FDFAF5 — kaum wahrnehmbar wärmer als Weiß, aber spürbar verschieden</li>
<li>Stats als Pill-Kacheln (border-radius: 20 px) — weicher, weniger „Tabellen"-Charakter</li>
<li>Pills sind vollständige &lt;a&gt;-Elemente; große Touch-Target auch auf Tablet</li>
</ul>
</div>
<div class="ann-block">
<strong>Personen-Kacheln — Top-Akzentstreifen</strong>
<ul>
<li>3 px hoher Streifen oben: gleiche Farbe wie Avatar — verbindet Streifen und Bild visuell</li>
<li>Erzeugt ein „Lesezeichen"-Gefühl: jede Person hat ihre eigene Erkennungsfarbe</li>
<li>Umsetzung: border-top: 3px solid [avatarfarbe] auf .PCARD — kein extra Element nötig</li>
</ul>
</div>
<div class="note">
<strong>3:2 Spaltenbreite</strong>
<ul>
<li>Dokumente bekommen mehr Platz (3) — als primäres Archiv-Element gewichtet</li>
<li>Geschichten-Spalte ist schmaler, aber durch das Story-Label visuell nicht schwächer</li>
<li>Auf &lt; md stacken beide Spalten vertikal (wie alle anderen Varianten)</li>
</ul>
</div>
<div class="ok">
<strong>Stärken dieser Iteration</strong>
<ul>
<li>Warmster Charakter der drei B-Iterationen — Archiv fühlt sich familiärer an</li>
<li>Top-Streifen gibt Personenkacheln Persönlichkeit ohne Foto oder mehr Daten</li>
<li>Pill-Stats wirken weniger formal — besser für nicht-technische Leser</li>
</ul>
</div>
</div>
</div>
</div>
<!-- ══ SECTION 2: BLOG_WRITE ══ -->
<div class="sec">
<div class="sec-h"><span class="sec-num">2</span> Variante · BLOG_WRITE — Zone 3 „Meine Entwürfe"</div>
<div class="sg sg-2">
<div class="sb">
<div class="sl">READ_ALL + BLOG_WRITE</div>
<div class="wf">
<div class="wf-bar"><div class="dot r"></div><div class="dot y"></div><div class="dot g"></div><div class="urlbar"><span>familienarchiv.local /</span></div></div>
<div class="N"><span class="logo">Familienarchiv</span><span class="nl on">Startseite</span><span class="nl">Dokumente</span><span class="nl">Personen</span><span class="nl">Geschichten</span><div class="nr"><div class="av">MR</div></div></div>
<div class="N-accent"></div>
<div class="MAIN">
<!-- Header -->
<div class="HEADER-BAR">
<div class="HEADER-LEFT"><div class="HEADER-TIME">Guten Morgen</div><div class="HEADER-NAME">Herzlich willkommen, Marcel.</div></div>
<div class="HEADER-STATS">
<a class="HSTAT-PILL" href="#"><span class="HSTAT-NUM">847</span><span class="HSTAT-LABEL">Dokumente</span></a>
<a class="HSTAT-PILL" href="#"><span class="HSTAT-NUM">94</span><span class="HSTAT-LABEL">Personen</span></a>
<a class="HSTAT-PILL" href="#"><span class="HSTAT-NUM">12</span><span class="HSTAT-LABEL">Geschichten</span></a>
</div>
</div>
<!-- Drafts — sand bg matches header -->
<div class="DRAFTS-CARD">
<div class="DRAFTS-HEAD">
<div class="DRAFTS-HEAD-DOT"></div>
<h3>Meine Entwürfe</h3>
</div>
<div class="DRAFT-ROW">
<div><div class="DRAFT-TITLE">Onkel Friedrichs Wanderjahre</div><div class="DRAFT-META">Entwurf · zuletzt bearbeitet vor 1 Tag</div></div>
<svg width="7" height="7" viewBox="0 0 24 24" fill="none" stroke="#CCC" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</div>
<div class="DRAFT-ROW">
<div><div class="DRAFT-TITLE">Die Raddatz-Kinder</div><div class="DRAFT-META">Entwurf · zuletzt bearbeitet vor 5 Tagen</div></div>
<svg width="7" height="7" viewBox="0 0 24 24" fill="none" stroke="#CCC" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
<!-- Persons -->
<div>
<div class="PERSON-GRID">
<a class="PCARD" href="#"><div class="PCARD-STRIP" style="background:#002850"></div><div class="PCARD-INNER"><div class="PCARD-AV" style="background:#002850">KR</div><div class="PCARD-NAME">Käthe Raddatz</div><div class="PCARD-COUNT">47 Dok.</div></div></a>
<a class="PCARD" href="#"><div class="PCARD-STRIP" style="background:#1A4A6B"></div><div class="PCARD-INNER"><div class="PCARD-AV" style="background:#1A4A6B">ER</div><div class="PCARD-NAME">Ernst Raddatz</div><div class="PCARD-COUNT">31 Dok.</div></div></a>
<a class="PCARD" href="#"><div class="PCARD-STRIP" style="background:#3D5A7A"></div><div class="PCARD-INNER"><div class="PCARD-AV" style="background:#3D5A7A">FM</div><div class="PCARD-NAME">Frieda Müller</div><div class="PCARD-COUNT">28 Dok.</div></div></a>
<a class="PCARD" href="#"><div class="PCARD-STRIP" style="background:#4A7A5A"></div><div class="PCARD-INNER"><div class="PCARD-AV" style="background:#4A7A5A">HW</div><div class="PCARD-NAME">Heinrich Weber</div><div class="PCARD-COUNT">19 Dok.</div></div></a>
</div>
<div class="PERSONS-FOOTER"><a class="PERSONS-ALL" href="#">Alle 94 Personen →</a></div>
</div>
<div class="CONTENT-ROW">
<div class="CARD">
<div class="CARD-HEAD"><h3>Zuletzt aktualisiert</h3><a href="#">Alle Dokumente</a></div>
<div class="DOC-ROW"><div class="DOC-THUMB"><svg width="8" height="10" viewBox="0 0 20 26" fill="none" stroke="#C8C4BE" stroke-width="1.5"><path d="M3 1h9l5 5v19H3V1z"/><polyline points="12 1 12 6 18 6"/></svg></div><div class="DOC-INFO"><div class="DOC-TITLE">Brief von Ernst an Käthe, März 1923</div><div class="DOC-SENDER">von <a href="#">Käthe Raddatz</a></div></div><div class="DOC-DATE">vor 2 Tagen</div></div>
<div class="DOC-ROW"><div class="DOC-THUMB"><svg width="8" height="10" viewBox="0 0 20 26" fill="none" stroke="#C8C4BE" stroke-width="1.5"><path d="M3 1h9l5 5v19H3V1z"/><polyline points="12 1 12 6 18 6"/></svg></div><div class="DOC-INFO"><div class="DOC-TITLE">Heiratsurkunde Raddatz-Müller, 1898</div><div class="DOC-SENDER" style="color:#E0DDD5"></div></div><div class="DOC-DATE">vor 4 Tagen</div></div>
<div style="padding:5px 10px;font-size:6px;color:#C8C4BE;border-top:1px solid #F0EDE6">+ 3 weitere …</div>
</div>
<div class="CARD">
<div class="CARD-HEAD"><h3>Geschichten</h3><a href="#">Alle</a></div>
<div class="STORY-ROW"><div class="STORY-LABEL">Geschichte</div><div class="STORY-TITLE">Die Reise nach Berlin</div><div class="STORY-EXCERPT">Im Frühjahr 1921 bestieg Ernst Raddatz erstmals den Zug nach Berlin …</div><div class="STORY-META">vor 3 Tagen</div></div>
<div class="STORY-ROW"><div class="STORY-LABEL">Geschichte</div><div class="STORY-TITLE">Sommer 1934 in Köln</div><div class="STORY-EXCERPT">Die Sommerferien 1934 verbrachte die Familie Raddatz bei Verwandten …</div><div class="STORY-META">vor 2 Wochen</div></div>
</div>
</div>
</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:10px;padding-top:40px">
<div class="ann-block">
<strong>Entwürfe-Card — Sand-Hintergrund + Mint-Dot</strong>
<ul>
<li>Card-Hintergrund #FDFAF5 — gleich wie der Header-Balken: Entwürfe fühlen sich als Teil des „eigenen Bereichs" an</li>
<li>Mint-Dot vor dem Titel statt Border — subtiler, luftiger Akzent</li>
<li>Kontext: BLOG_WRITE-Nutzer schreibt Geschichten → sand-getönte Karte passt zur redaktionellen Rolle</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,437 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reader Dashboard — B.3 · Navy &amp; Kontrast · Familienarchiv</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1A1A;line-height:1.5}
.doc{max-width:1440px;margin:0 auto;padding:48px 32px}
/* ── Masthead ─── */
.mast{background:#0D2240;border-radius:10px;padding:32px 40px;margin-bottom:48px}
.mast-top{display:flex;align-items:flex-start;justify-content:space-between;gap:24px;margin-bottom:16px}
.mast h1{font-size:22px;font-weight:900;color:#fff;letter-spacing:-.4px;margin-bottom:6px}
.mast p{font-size:12px;color:rgba(255,255,255,.5);max-width:660px;line-height:1.7}
.mast-badge{font-size:9px;font-weight:800;padding:3px 9px;border-radius:20px;text-transform:uppercase;letter-spacing:.8px;white-space:nowrap;flex-shrink:0;margin-top:4px;background:#A6DAD8;color:#002850}
.decisions{display:grid;grid-template-columns:repeat(5,1fr);gap:10px;margin-top:20px;border-top:1px solid rgba(255,255,255,.1);padding-top:16px}
.dec{background:rgba(255,255,255,.06);border-radius:6px;padding:10px 12px}
.dec-label{font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:rgba(255,255,255,.35);margin-bottom:5px}
.dec-value{font-size:9.5px;font-weight:700;color:#fff;line-height:1.5}
/* ── Section headings ─── */
.sec{margin-bottom:64px}
.sec+.sec{border-top:2px dashed #C8C4BE;padding-top:56px}
.sec-h{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:1.2px;color:#888;margin-bottom:20px;display:flex;align-items:center;gap:10px}
.sec-h::after{content:'';flex:1;height:1px;background:#D8D4CE}
.sec-num{background:#0D2240;color:#fff;font-size:9px;font-weight:900;padding:2px 7px;border-radius:10px}
/* ── Screen grid ─── */
.sg{display:grid;gap:24px;align-items:start}
.sg-2{grid-template-columns:1fr 340px}
.sb{display:flex;flex-direction:column;gap:10px}
.sl{font-size:9px;font-weight:800;color:#888;text-transform:uppercase;letter-spacing:1.5px;margin-bottom:6px;display:flex;align-items:center;gap:6px}
.sz{background:#E8E4DF;color:#666;padding:1px 5px;border-radius:3px;font-size:8px}
/* ── Annotation callouts ─── */
.ann-block{background:#FFF7ED;border:1px solid #FDBA74;border-radius:5px;padding:8px 10px;font-size:10px;color:#7C2D12;line-height:1.5}
.ann-block strong{font-weight:800}
.ann-block ul{padding-left:14px;display:flex;flex-direction:column;gap:3px;margin-top:5px}
.note{background:#F0F9FF;border:1px solid #BAE6FD;border-radius:5px;padding:8px 10px;font-size:10px;color:#0C4A6E;line-height:1.5}
.note strong{font-weight:800}
.note ul{padding-left:14px;display:flex;flex-direction:column;gap:3px;margin-top:5px}
.ok{background:#F0FDF4;border:1px solid #86EFAC;border-radius:5px;padding:8px 10px;font-size:10px;color:#166534;line-height:1.5}
.ok strong{font-weight:800}
.ok ul{padding-left:14px;display:flex;flex-direction:column;gap:3px;margin-top:5px}
/* ── Mock browser chrome ─── */
.wf{background:#fff;border:2px solid #B8B4AE;border-radius:10px;overflow:hidden;box-shadow:0 4px 18px rgba(0,0,0,.08)}
.wf-bar{height:24px;background:#E8E4DF;border-bottom:1px solid #C8C4BE;display:flex;align-items:center;padding:0 9px;gap:4px}
.dot{width:7px;height:7px;border-radius:50%}
.dot.r{background:#F87171}.dot.y{background:#FCD34D}.dot.g{background:#4ADE80}
.urlbar{flex:1;height:11px;background:#D8D4CE;border-radius:3px;margin-left:6px;display:flex;align-items:center;padding:0 5px}
.urlbar span{font-size:7.5px;color:#888;font-family:monospace}
/* ── Nav bar ─── */
.N{height:34px;background:#002850;display:flex;align-items:center;padding:0 14px;gap:10px}
.N-accent{height:2px;background:#A6DAD8}
.logo{font-size:7.5px;font-weight:900;color:#fff;letter-spacing:1px;text-transform:uppercase}
.nl{font-size:6.5px;color:rgba(255,255,255,.45);font-weight:700;text-transform:uppercase;letter-spacing:.5px;padding:3px 6px}
.nl.on{color:#fff}
.nr{margin-left:auto;display:flex;gap:5px;align-items:center}
.av{width:18px;height:18px;background:#A6DAD8;border-radius:50%;font-size:5.5px;font-weight:900;color:#002850;display:flex;align-items:center;justify-content:center}
.nico{width:18px;height:18px;background:rgba(255,255,255,.1);border-radius:3px;display:flex;align-items:center;justify-content:center}
/* ── Page body ─── */
.MAIN{padding:0;background:#F5F4EF;display:flex;flex-direction:column;gap:0}
/* ════════════════════════════════════
B.3 — NAVY & KONTRAST
Header: full navy bg (#002850), white greeting, mint stats
Person cards: larger avatar (34px), mint count badge prominent
Stories: 2-line excerpt, mint left accent on hover row
Columns: 1:1 equal
Note: MAIN has no padding — navy header bleeds to edges
════════════════════════════════════ */
/* Combined header — full navy, white text, mint stats */
.HEADER-NAVY{background:#002850;padding:12px 16px;display:flex;align-items:center;gap:16px}
.HEADER-LEFT{flex:1;min-width:0}
.HEADER-TIME{font-size:6px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:rgba(166,218,216,.6);margin-bottom:3px}
.HEADER-NAME{font-family:Georgia,serif;font-size:12px;color:#fff;line-height:1.2}
.HEADER-DIVIDER{width:1px;background:rgba(255,255,255,.12);align-self:stretch;flex-shrink:0}
.HEADER-STATS{display:flex;align-items:center;gap:0}
.HSTAT{text-align:center;padding:0 12px;border-right:1px solid rgba(255,255,255,.1)}
.HSTAT:last-child{border-right:none;padding-right:0}
.HSTAT a{text-decoration:none;display:block}
.HSTAT-NUM{font-size:14px;font-weight:900;color:#A6DAD8;line-height:1;display:block}
.HSTAT-LABEL{font-size:5.5px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:rgba(255,255,255,.4);display:block;margin-top:2px}
/* Thin mint separator between header and page body */
.HEADER-ACCENT{height:2px;background:#A6DAD8;flex-shrink:0}
/* Inner page padding (below the navy header) */
.INNER{padding:12px 16px;display:flex;flex-direction:column;gap:8px}
/* Person cards — larger avatar, mint count badge */
.PERSON-GRID{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}
.PCARD{background:#fff;border:1px solid #E0DDD5;border-radius:3px;padding:10px 10px 9px;text-decoration:none;display:flex;flex-direction:column;align-items:center;text-align:center;gap:5px;box-shadow:0 1px 3px rgba(0,40,80,.04)}
.PCARD-AV{width:34px;height:34px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:900;color:#fff;flex-shrink:0;box-shadow:0 2px 6px rgba(0,40,80,.2)}
.PCARD-NAME{font-size:7px;font-weight:700;color:#002850;line-height:1.3}
.PCARD-BADGE{font-size:5.5px;font-weight:800;color:#002850;background:#D4F0EE;padding:1px 6px;border-radius:10px}
.PERSONS-FOOTER{text-align:right;margin-top:3px}
.PERSONS-ALL{font-size:6.5px;color:#002850;font-weight:600;text-decoration:none;opacity:.55}
/* Two-column 1:1 */
.CONTENT-ROW{display:grid;grid-template-columns:1fr 1fr;gap:6px}
/* Cards */
.CARD{background:#fff;border:1px solid #E0DDD5;border-radius:3px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 1px 3px rgba(0,40,80,.04)}
.CARD-HEAD{display:flex;align-items:center;justify-content:space-between;padding:7px 10px 6px;border-bottom:1px solid #E0DDD5}
.CARD-HEAD h3{font-size:6.5px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;color:#999}
.CARD-HEAD a{font-size:6.5px;font-weight:600;color:#002850;opacity:.4;text-decoration:none}
/* Doc rows */
.DOC-ROW{display:flex;align-items:center;gap:7px;padding:5px 10px;border-bottom:1px solid #F0EDE6}
.DOC-ROW:last-child{border-bottom:none}
.DOC-THUMB{width:20px;height:24px;background:#ECEAE4;border:1px solid #E0DDD5;border-radius:2px;flex-shrink:0;display:flex;align-items:center;justify-content:center}
.DOC-INFO{flex:1;min-width:0}
.DOC-TITLE{font-family:Georgia,serif;font-size:7.5px;color:#002850;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.DOC-SENDER{font-size:6px;color:#888;margin-top:1px}
.DOC-SENDER a{color:#002850;text-decoration:none;opacity:.7}
.DOC-DATE{font-size:6px;color:#C8C4BE;white-space:nowrap;flex-shrink:0}
/* Story rows — mint left accent strip */
.STORY-ROW{display:flex;gap:0;border-bottom:1px solid #F0EDE6}
.STORY-ROW:last-child{border-bottom:none}
.STORY-ACCENT{width:2px;background:#A6DAD8;flex-shrink:0;margin:6px 0}
.STORY-BODY{padding:6px 10px;flex:1;min-width:0}
.STORY-TITLE{font-family:Georgia,serif;font-size:8px;color:#002850;font-style:italic;margin-bottom:3px;line-height:1.3}
.STORY-EXCERPT{font-size:6.5px;color:#888;line-height:1.5;margin-bottom:3px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.STORY-META{font-size:6px;color:#B8B4AE}
/* Drafts — navy tint card */
.DRAFTS-CARD{background:#fff;border:1px solid #E0DDD5;border-radius:3px;overflow:hidden;box-shadow:0 1px 3px rgba(0,40,80,.04)}
.DRAFTS-HEAD{background:#002850;padding:6px 10px 5px;display:flex;align-items:center;justify-content:space-between}
.DRAFTS-HEAD h3{font-size:6.5px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;color:rgba(166,218,216,.8)}
.DRAFT-ROW{display:flex;align-items:center;gap:8px;padding:6px 10px;border-bottom:1px solid #F0EDE6}
.DRAFT-ROW:last-child{border-bottom:none}
.DRAFT-DOT{width:5px;height:5px;border-radius:50%;background:#A6DAD8;flex-shrink:0}
.DRAFT-INFO{flex:1;min-width:0}
.DRAFT-TITLE{font-family:Georgia,serif;font-size:8px;color:#002850}
.DRAFT-META{font-size:6px;color:#AAA;margin-top:1px}
/* Spec disclaimer */
.spec-disclaimer{background:#FFF8E1;border:1.5px solid #FFC107;border-radius:6px;padding:11px 16px;font-size:11px;color:#6D4C00;margin-bottom:32px;line-height:1.6}
.spec-disclaimer strong{font-weight:800}
</style>
</head>
<body>
<div class="doc">
<div class="mast">
<div class="mast-top">
<div>
<h1>Reader Dashboard — B.3 · „Navy &amp; Kontrast"</h1>
<p>Markanteste Iteration von Konzept B. Der Header-Balken übernimmt die volle Navy-Farbe der App-Navigation (#002850) — Begrüßung in Weiß, Statistik-Zahlen in Mintgrün. Die Kacheln im Seitenbereich (weiß + Schatten) heben sich stark ab. Personen-Avatare sind größer (34 px) mit leichtem Schatten. Story-Zeilen haben einen fixen mintgrünen Akzentstreifen links.</p>
</div>
<span class="mast-badge">B.3 · Entwurf</span>
</div>
<div class="decisions">
<div class="dec"><div class="dec-label">Header-Bg</div><div class="dec-value">Navy #002850 — gespiegelt aus App-Nav</div></div>
<div class="dec"><div class="dec-label">Stats-Farbe</div><div class="dec-value">Mint #A6DAD8 auf Navy — maximaler Kontrast</div></div>
<div class="dec"><div class="dec-label">Personen</div><div class="dec-value">Größerer Avatar (34 px) + Badge mit mint Bg</div></div>
<div class="dec"><div class="dec-label">Geschichten</div><div class="dec-value">2 px Mint-Streifen links + 2-Zeilen-Auszug</div></div>
<div class="dec"><div class="dec-label">Spaltenbreite</div><div class="dec-value">1 : 1 gleichwertig</div></div>
</div>
</div>
<div class="spec-disclaimer">
<strong>📐 Mockup-Skalierung —</strong> alle Schriftgrößen, Abstände und Höhen sind auf ca. 55 % der Implementierungswerte skaliert.
</div>
<!-- ══ SECTION 1: LESER ══ -->
<div class="sec">
<div class="sec-h"><span class="sec-num">1</span> Desktop · READ_ALL — ohne BLOG_WRITE</div>
<div class="sg sg-2">
<div class="sb">
<div class="sl">Vollansicht <span class="sz">≥ 1024 px</span></div>
<div class="wf">
<div class="wf-bar">
<div class="dot r"></div><div class="dot y"></div><div class="dot g"></div>
<div class="urlbar"><span>familienarchiv.local /</span></div>
</div>
<!-- Nav bar -->
<div class="N">
<span class="logo">Familienarchiv</span>
<span class="nl on">Startseite</span>
<span class="nl">Dokumente</span>
<span class="nl">Personen</span>
<span class="nl">Geschichten</span>
<div class="nr">
<div class="nico"><svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,.5)" stroke-width="2"><path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 01-3.46 0"/></svg></div>
<div class="av">BK</div>
</div>
</div>
<!-- Note: no N-accent here — header bleeds directly into navy header bar -->
<div class="MAIN">
<!-- Zone 1+2: full navy header — blends with nav visually -->
<div class="HEADER-NAVY">
<div class="HEADER-LEFT">
<div class="HEADER-TIME">Guten Abend</div>
<div class="HEADER-NAME">Herzlich willkommen, Brigitte.</div>
</div>
<div class="HEADER-DIVIDER"></div>
<div class="HEADER-STATS">
<div class="HSTAT"><a href="#"><span class="HSTAT-NUM">847</span><span class="HSTAT-LABEL">Dokumente</span></a></div>
<div class="HSTAT"><a href="#"><span class="HSTAT-NUM">94</span><span class="HSTAT-LABEL">Personen</span></a></div>
<div class="HSTAT"><a href="#"><span class="HSTAT-NUM">12</span><span class="HSTAT-LABEL">Geschichten</span></a></div>
</div>
</div>
<div class="HEADER-ACCENT"></div>
<div class="INNER">
<!-- Zone 4: Person cards — larger avatar, mint badge -->
<div>
<div class="PERSON-GRID">
<a class="PCARD" href="#">
<div class="PCARD-AV" style="background:#002850">KR</div>
<div class="PCARD-NAME">Käthe Raddatz</div>
<div class="PCARD-BADGE">47 Dok.</div>
</a>
<a class="PCARD" href="#">
<div class="PCARD-AV" style="background:#1A4A6B">ER</div>
<div class="PCARD-NAME">Ernst Raddatz</div>
<div class="PCARD-BADGE">31 Dok.</div>
</a>
<a class="PCARD" href="#">
<div class="PCARD-AV" style="background:#3D5A7A">FM</div>
<div class="PCARD-NAME">Frieda Müller</div>
<div class="PCARD-BADGE">28 Dok.</div>
</a>
<a class="PCARD" href="#">
<div class="PCARD-AV" style="background:#4A7A5A">HW</div>
<div class="PCARD-NAME">Heinrich Weber</div>
<div class="PCARD-BADGE">19 Dok.</div>
</a>
</div>
<div class="PERSONS-FOOTER"><a class="PERSONS-ALL" href="#">Alle 94 Personen →</a></div>
</div>
<!-- Zone 5: 1:1, stories with mint accent strip -->
<div class="CONTENT-ROW">
<div class="CARD">
<div class="CARD-HEAD"><h3>Zuletzt aktualisiert</h3><a href="#">Alle Dokumente</a></div>
<div class="DOC-ROW">
<div class="DOC-THUMB"><svg width="8" height="10" viewBox="0 0 20 26" fill="none" stroke="#C8C4BE" stroke-width="1.5"><path d="M3 1h9l5 5v19H3V1z"/><polyline points="12 1 12 6 18 6"/></svg></div>
<div class="DOC-INFO"><div class="DOC-TITLE">Brief von Ernst an Käthe, März 1923</div><div class="DOC-SENDER">von <a href="#">Käthe Raddatz</a></div></div>
<div class="DOC-DATE">vor 2 Tagen</div>
</div>
<div class="DOC-ROW">
<div class="DOC-THUMB"><svg width="8" height="10" viewBox="0 0 20 26" fill="none" stroke="#C8C4BE" stroke-width="1.5"><path d="M3 1h9l5 5v19H3V1z"/><polyline points="12 1 12 6 18 6"/></svg></div>
<div class="DOC-INFO"><div class="DOC-TITLE">Heiratsurkunde Raddatz-Müller, 1898</div><div class="DOC-SENDER" style="color:#E0DDD5"></div></div>
<div class="DOC-DATE">vor 4 Tagen</div>
</div>
<div class="DOC-ROW">
<div class="DOC-THUMB"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#A6DAD8" stroke-width="1.5"><rect x="2" y="2" width="20" height="20" rx="2"/><circle cx="8" cy="8" r="2"/><polyline points="22 14 16 9 4 22"/></svg></div>
<div class="DOC-INFO"><div class="DOC-TITLE">Familienfoto, Sommer 1928</div><div class="DOC-SENDER">von <a href="#">Ernst Raddatz</a></div></div>
<div class="DOC-DATE">vor 1 Woche</div>
</div>
<div class="DOC-ROW">
<div class="DOC-THUMB"><svg width="8" height="10" viewBox="0 0 20 26" fill="none" stroke="#C8C4BE" stroke-width="1.5"><path d="M3 1h9l5 5v19H3V1z"/><polyline points="12 1 12 6 18 6"/></svg></div>
<div class="DOC-INFO"><div class="DOC-TITLE">Taufregister Heinrich Weber, 1902</div><div class="DOC-SENDER" style="color:#E0DDD5"></div></div>
<div class="DOC-DATE">vor 2 Wo.</div>
</div>
<div class="DOC-ROW">
<div class="DOC-THUMB"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#A6DAD8" stroke-width="1.5"><rect x="2" y="2" width="20" height="20" rx="2"/><circle cx="8" cy="8" r="2"/><polyline points="22 14 16 9 4 22"/></svg></div>
<div class="DOC-INFO"><div class="DOC-TITLE">Postkarte aus Berlin, 1930</div><div class="DOC-SENDER">von <a href="#">Frieda Müller</a></div></div>
<div class="DOC-DATE">vor 3 Wo.</div>
</div>
</div>
<div class="CARD">
<div class="CARD-HEAD"><h3>Geschichten</h3><a href="#">Alle Geschichten</a></div>
<div class="STORY-ROW">
<div class="STORY-ACCENT"></div>
<div class="STORY-BODY">
<div class="STORY-TITLE">Die Reise nach Berlin</div>
<div class="STORY-EXCERPT">Im Frühjahr 1921 bestieg Ernst Raddatz erstmals den Zug nach Berlin, um Arbeit in der aufstrebenden Metropole zu finden …</div>
<div class="STORY-META">vor 3 Tagen</div>
</div>
</div>
<div class="STORY-ROW">
<div class="STORY-ACCENT"></div>
<div class="STORY-BODY">
<div class="STORY-TITLE">Sommer 1934 in Köln</div>
<div class="STORY-EXCERPT">Die Sommerferien 1934 verbrachte die Familie Raddatz bei Verwandten in Köln — die letzte unbeschwerte Reise vor dem Krieg …</div>
<div class="STORY-META">vor 2 Wochen</div>
</div>
</div>
<div class="STORY-ROW">
<div class="STORY-ACCENT"></div>
<div class="STORY-BODY">
<div class="STORY-TITLE">Briefe aus dem Krieg</div>
<div class="STORY-EXCERPT">Zwischen 1914 und 1918 schrieb Ernst Raddatz insgesamt 47 Briefe aus dem Feld an seine Frau Käthe in der Heimat …</div>
<div class="STORY-META">vor 1 Monat</div>
</div>
</div>
</div>
</div>
</div><!-- /INNER -->
</div><!-- /MAIN -->
</div>
</div>
<div style="display:flex;flex-direction:column;gap:10px;padding-top:40px">
<div class="ann-block">
<strong>Header-Balken — Vollflächiges Navy</strong>
<ul>
<li>Gleiche Farbe (#002850) wie die App-Navigation direkt darüber</li>
<li>Effekt: Nav und Header verschmelzen optisch zu einer „Kopfzone" der Seite</li>
<li>Mint-Trennlinie (2 px, wie N-accent) trennt Header sauber vom Canvas</li>
<li>Begrüßungstext weiß → maximale Lesbarkeit, serif bleibt erhalten</li>
</ul>
</div>
<div class="ann-block">
<strong>Stats in Mint auf Navy</strong>
<ul>
<li>Zahlen in #A6DAD8 — gleiche Farbe wie N-accent und bestehende Mint-Akzente</li>
<li>Sehr hoher Kontrast; klar als Zahlen erkennbar ohne Tooltip oder Label-Hilfe</li>
<li>Labels in rgba(255,255,255,.4) — gedämpft, aber lesbar</li>
</ul>
</div>
<div class="note">
<strong>Personen-Avatar — 34 px mit Schatten</strong>
<ul>
<li>5 px größerer Avatar als B.1/B.2 — gibt Kacheln mehr „Gesicht"</li>
<li>Leichter box-shadow auf Avatar macht ihn dreidimensionaler, erinnert an Foto</li>
<li>Mint-Badge (#D4F0EE / #002850) statt einfachem Grau-Text für Dokumentzahl</li>
</ul>
</div>
<div class="ok">
<strong>Stärken dieser Iteration</strong>
<ul>
<li>Stärkste Markenpräsenz — Navy + Mint als Leitfarben treten klar hervor</li>
<li>Header-Zone kommuniziert „Willkommen im Archiv" — persönlicher Empfang</li>
<li>Mint-Akzent auf Story-Zeilen verankert den Geschichten-Bereich im Brand</li>
</ul>
</div>
</div>
</div>
</div>
<!-- ══ SECTION 2: BLOG_WRITE ══ -->
<div class="sec">
<div class="sec-h"><span class="sec-num">2</span> Variante · BLOG_WRITE — Zone 3 „Meine Entwürfe"</div>
<div class="sg sg-2">
<div class="sb">
<div class="sl">READ_ALL + BLOG_WRITE</div>
<div class="wf">
<div class="wf-bar"><div class="dot r"></div><div class="dot y"></div><div class="dot g"></div><div class="urlbar"><span>familienarchiv.local /</span></div></div>
<div class="N"><span class="logo">Familienarchiv</span><span class="nl on">Startseite</span><span class="nl">Dokumente</span><span class="nl">Personen</span><span class="nl">Geschichten</span><div class="nr"><div class="av">MR</div></div></div>
<div class="MAIN">
<!-- Navy header -->
<div class="HEADER-NAVY">
<div class="HEADER-LEFT"><div class="HEADER-TIME">Guten Morgen</div><div class="HEADER-NAME">Herzlich willkommen, Marcel.</div></div>
<div class="HEADER-DIVIDER"></div>
<div class="HEADER-STATS">
<div class="HSTAT"><a href="#"><span class="HSTAT-NUM">847</span><span class="HSTAT-LABEL">Dokumente</span></a></div>
<div class="HSTAT"><a href="#"><span class="HSTAT-NUM">94</span><span class="HSTAT-LABEL">Personen</span></a></div>
<div class="HSTAT"><a href="#"><span class="HSTAT-NUM">12</span><span class="HSTAT-LABEL">Geschichten</span></a></div>
</div>
</div>
<div class="HEADER-ACCENT"></div>
<div class="INNER">
<!-- Drafts — navy header on card mirrors main header -->
<div class="DRAFTS-CARD">
<div class="DRAFTS-HEAD">
<h3>Meine Entwürfe</h3>
</div>
<div class="DRAFT-ROW">
<div class="DRAFT-DOT"></div>
<div class="DRAFT-INFO">
<div class="DRAFT-TITLE">Onkel Friedrichs Wanderjahre</div>
<div class="DRAFT-META">Entwurf · zuletzt bearbeitet vor 1 Tag</div>
</div>
<svg width="7" height="7" viewBox="0 0 24 24" fill="none" stroke="#CCC" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</div>
<div class="DRAFT-ROW">
<div class="DRAFT-DOT"></div>
<div class="DRAFT-INFO">
<div class="DRAFT-TITLE">Die Raddatz-Kinder</div>
<div class="DRAFT-META">Entwurf · zuletzt bearbeitet vor 5 Tagen</div>
</div>
<svg width="7" height="7" viewBox="0 0 24 24" fill="none" stroke="#CCC" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
<!-- Persons -->
<div>
<div class="PERSON-GRID">
<a class="PCARD" href="#"><div class="PCARD-AV" style="background:#002850">KR</div><div class="PCARD-NAME">Käthe Raddatz</div><div class="PCARD-BADGE">47 Dok.</div></a>
<a class="PCARD" href="#"><div class="PCARD-AV" style="background:#1A4A6B">ER</div><div class="PCARD-NAME">Ernst Raddatz</div><div class="PCARD-BADGE">31 Dok.</div></a>
<a class="PCARD" href="#"><div class="PCARD-AV" style="background:#3D5A7A">FM</div><div class="PCARD-NAME">Frieda Müller</div><div class="PCARD-BADGE">28 Dok.</div></a>
<a class="PCARD" href="#"><div class="PCARD-AV" style="background:#4A7A5A">HW</div><div class="PCARD-NAME">Heinrich Weber</div><div class="PCARD-BADGE">19 Dok.</div></a>
</div>
<div class="PERSONS-FOOTER"><a class="PERSONS-ALL" href="#">Alle 94 Personen →</a></div>
</div>
<!-- Content row (abbreviated) -->
<div class="CONTENT-ROW">
<div class="CARD">
<div class="CARD-HEAD"><h3>Zuletzt aktualisiert</h3><a href="#">Alle Dokumente</a></div>
<div class="DOC-ROW"><div class="DOC-THUMB"><svg width="8" height="10" viewBox="0 0 20 26" fill="none" stroke="#C8C4BE" stroke-width="1.5"><path d="M3 1h9l5 5v19H3V1z"/><polyline points="12 1 12 6 18 6"/></svg></div><div class="DOC-INFO"><div class="DOC-TITLE">Brief von Ernst an Käthe, März 1923</div><div class="DOC-SENDER">von <a href="#">Käthe Raddatz</a></div></div><div class="DOC-DATE">vor 2 Tagen</div></div>
<div class="DOC-ROW"><div class="DOC-THUMB"><svg width="8" height="10" viewBox="0 0 20 26" fill="none" stroke="#C8C4BE" stroke-width="1.5"><path d="M3 1h9l5 5v19H3V1z"/><polyline points="12 1 12 6 18 6"/></svg></div><div class="DOC-INFO"><div class="DOC-TITLE">Heiratsurkunde Raddatz-Müller, 1898</div><div class="DOC-SENDER" style="color:#E0DDD5"></div></div><div class="DOC-DATE">vor 4 Tagen</div></div>
<div style="padding:5px 10px;font-size:6px;color:#C8C4BE;border-top:1px solid #F0EDE6">+ 3 weitere …</div>
</div>
<div class="CARD">
<div class="CARD-HEAD"><h3>Geschichten</h3><a href="#">Alle</a></div>
<div class="STORY-ROW"><div class="STORY-ACCENT"></div><div class="STORY-BODY"><div class="STORY-TITLE">Die Reise nach Berlin</div><div class="STORY-EXCERPT">Im Frühjahr 1921 bestieg Ernst Raddatz erstmals den Zug nach Berlin …</div><div class="STORY-META">vor 3 Tagen</div></div></div>
<div class="STORY-ROW"><div class="STORY-ACCENT"></div><div class="STORY-BODY"><div class="STORY-TITLE">Sommer 1934 in Köln</div><div class="STORY-EXCERPT">Die Sommerferien 1934 verbrachte die Familie Raddatz bei Verwandten …</div><div class="STORY-META">vor 2 Wochen</div></div></div>
</div>
</div>
</div>
</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:10px;padding-top:40px">
<div class="ann-block">
<strong>Entwürfe-Card — Navy Header auf Card</strong>
<ul>
<li>Card-Header in #002850 mit Mint-Label — spiegelt den Haupt-Header-Balken</li>
<li>Schafft visuelle Verbindung: „Meine Entwürfe" gehört zur Autorenrolle, die auch im Gruß adressiert wird</li>
<li>Mint-Dot pro Entwurf-Zeile als einziges farbiges Element im White-Bereich der Card</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,480 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reader Dashboard — Concept C · Entdecken · Familienarchiv</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1A1A;line-height:1.5}
.doc{max-width:1440px;margin:0 auto;padding:48px 32px}
/* ── Masthead ─── */
.mast{background:#0D2240;border-radius:10px;padding:32px 40px;margin-bottom:48px}
.mast-top{display:flex;align-items:flex-start;justify-content:space-between;gap:24px;margin-bottom:16px}
.mast h1{font-size:22px;font-weight:900;color:#fff;letter-spacing:-.4px;margin-bottom:6px}
.mast p{font-size:12px;color:rgba(255,255,255,.5);max-width:660px;line-height:1.7}
.mast-badge{font-size:9px;font-weight:800;padding:3px 9px;border-radius:20px;text-transform:uppercase;letter-spacing:.8px;white-space:nowrap;flex-shrink:0;margin-top:4px;background:#A6DAD8;color:#002850}
.decisions{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-top:20px;border-top:1px solid rgba(255,255,255,.1);padding-top:16px}
.dec{background:rgba(255,255,255,.06);border-radius:6px;padding:10px 12px}
.dec-label{font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:rgba(255,255,255,.35);margin-bottom:5px}
.dec-value{font-size:9.5px;font-weight:700;color:#fff;line-height:1.5}
/* ── Section headings ─── */
.sec{margin-bottom:64px}
.sec+.sec{border-top:2px dashed #C8C4BE;padding-top:56px}
.sec-h{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:1.2px;color:#888;margin-bottom:20px;display:flex;align-items:center;gap:10px}
.sec-h::after{content:'';flex:1;height:1px;background:#D8D4CE}
.sec-num{background:#0D2240;color:#fff;font-size:9px;font-weight:900;padding:2px 7px;border-radius:10px}
/* ── Screen grid ─── */
.sg{display:grid;gap:24px;align-items:start}
.sg-2{grid-template-columns:1fr 340px}
.sb{display:flex;flex-direction:column;gap:10px}
.sl{font-size:9px;font-weight:800;color:#888;text-transform:uppercase;letter-spacing:1.5px;margin-bottom:6px;display:flex;align-items:center;gap:6px}
.sz{background:#E8E4DF;color:#666;padding:1px 5px;border-radius:3px;font-size:8px}
/* ── Annotation callouts ─── */
.ann-block{background:#FFF7ED;border:1px solid #FDBA74;border-radius:5px;padding:8px 10px;font-size:10px;color:#7C2D12;line-height:1.5}
.ann-block strong{font-weight:800}
.ann-block ul{padding-left:14px;display:flex;flex-direction:column;gap:3px;margin-top:5px}
.note{background:#F0F9FF;border:1px solid #BAE6FD;border-radius:5px;padding:8px 10px;font-size:10px;color:#0C4A6E;line-height:1.5}
.note strong{font-weight:800}
.note ul{padding-left:14px;display:flex;flex-direction:column;gap:3px;margin-top:5px}
.ok{background:#F0FDF4;border:1px solid #86EFAC;border-radius:5px;padding:8px 10px;font-size:10px;color:#166534;line-height:1.5}
.ok strong{font-weight:800}
.ok ul{padding-left:14px;display:flex;flex-direction:column;gap:3px;margin-top:5px}
/* ── Mock browser chrome ─── */
.wf{background:#fff;border:2px solid #B8B4AE;border-radius:10px;overflow:hidden;box-shadow:0 4px 18px rgba(0,0,0,.08)}
.wf-bar{height:24px;background:#E8E4DF;border-bottom:1px solid #C8C4BE;display:flex;align-items:center;padding:0 9px;gap:4px}
.dot{width:7px;height:7px;border-radius:50%}
.dot.r{background:#F87171}.dot.y{background:#FCD34D}.dot.g{background:#4ADE80}
.urlbar{flex:1;height:11px;background:#D8D4CE;border-radius:3px;margin-left:6px;display:flex;align-items:center;padding:0 5px}
.urlbar span{font-size:7.5px;color:#888;font-family:monospace}
/* ── Nav bar ─── */
.N{height:34px;background:#002850;display:flex;align-items:center;padding:0 14px;gap:10px;flex-shrink:0}
.N-accent{height:2px;background:#A6DAD8}
.logo{font-size:7.5px;font-weight:900;color:#fff;letter-spacing:1px;text-transform:uppercase}
.nl{font-size:6.5px;color:rgba(255,255,255,.45);font-weight:700;text-transform:uppercase;letter-spacing:.5px;padding:3px 6px}
.nl.on{color:#fff}
.nr{margin-left:auto;display:flex;gap:5px;align-items:center}
.av{width:18px;height:18px;background:#A6DAD8;border-radius:50%;font-size:5.5px;font-weight:900;color:#002850;display:flex;align-items:center;justify-content:center}
.nico{width:18px;height:18px;background:rgba(255,255,255,.1);border-radius:3px;display:flex;align-items:center;justify-content:center}
/* ── Page body ─── */
.MAIN{padding:12px 16px;background:#F5F4EF;display:flex;flex-direction:column;gap:8px}
/* ── Greeting (inline, minimal) ─── */
.GREET-INLINE{display:flex;align-items:baseline;justify-content:space-between;padding:0 2px}
.GREET-TEXT{font-family:Georgia,serif;font-size:11px;color:#002850}
.GREET-TEXT span{color:#AAA;font-family:'Helvetica Neue',Arial,sans-serif;font-size:6.5px;margin-right:4px}
/* ── Stats strip (compact inline) ─── */
.STATS-SLIM{background:#fff;border:1px solid #E0DDD5;border-radius:3px;padding:6px 12px;display:flex;align-items:center;gap:0}
.SSTAT{display:flex;align-items:baseline;gap:4px;padding:0 12px;border-right:1px solid #F0EDE6;text-decoration:none}
.SSTAT:first-child{padding-left:0}
.SSTAT:last-child{border-right:none}
.SSTAT-NUM{font-size:13px;font-weight:900;color:#002850;line-height:1}
.SSTAT-LABEL{font-size:6px;font-weight:700;text-transform:uppercase;letter-spacing:.6px;color:#B8B4AE}
/* ── Person chips (horizontal, colored fill) ─── */
.PERSONS-BAND{display:flex;flex-direction:column;gap:5px}
.PERSONS-HEADER{display:flex;align-items:center;justify-content:space-between}
.PERSONS-TITLE{font-size:6.5px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#AAA}
.PERSONS-ALL-LINK{font-size:6.5px;color:#002850;font-weight:600;text-decoration:none;opacity:.6}
.CHIPS-COLORED{display:flex;flex-wrap:wrap;gap:5px}
.CHIP-COL{display:inline-flex;align-items:center;gap:5px;padding:4px 10px 4px 4px;border-radius:3px;text-decoration:none;background:#fff;border:1px solid #E0DDD5}
.CHIP-COL-AV{width:20px;height:20px;border-radius:2px;color:#fff;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:900;flex-shrink:0}
.CHIP-COL-BODY{}
.CHIP-COL-NAME{font-size:7px;font-weight:700;color:#002850;display:block;line-height:1.2}
.CHIP-COL-COUNT{font-size:5.5px;color:#AAA;display:block}
/* ── Two-column content row ─── */
.CONTENT-ROW{display:grid;grid-template-columns:2fr 3fr;gap:6px}
/* ── Cards ─── */
.CARD{background:#fff;border:1px solid #E0DDD5;border-radius:3px;overflow:hidden;display:flex;flex-direction:column}
.CARD-HEAD{display:flex;align-items:center;justify-content:space-between;padding:7px 10px 6px;border-bottom:1px solid #E0DDD5}
.CARD-HEAD h3{font-size:6.5px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;color:#999}
.CARD-HEAD a{font-size:6.5px;font-weight:600;color:#002850;opacity:.4;text-decoration:none}
/* ── Doc rows (slim) ─── */
.DOC-ROW-SLIM{display:flex;align-items:baseline;justify-content:space-between;gap:6px;padding:5px 10px;border-bottom:1px solid #F0EDE6}
.DOC-ROW-SLIM:last-child{border-bottom:none}
.DOC-TITLE-SLIM{font-family:Georgia,serif;font-size:7.5px;color:#002850;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.DOC-DATE-SLIM{font-size:6px;color:#C8C4BE;white-space:nowrap;flex-shrink:0}
/* ── Stories: Featured + slim list ─── */
.STORY-FEATURED{padding:9px 10px;border-bottom:2px solid #F0EDE6;background:#FDFAF5}
.STORY-FEATURED-LABEL{font-size:5.5px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#A6DAD8;margin-bottom:4px}
.STORY-FEATURED-TITLE{font-family:Georgia,serif;font-size:10px;color:#002850;font-style:italic;margin-bottom:4px;line-height:1.4}
.STORY-FEATURED-EXCERPT{font-size:7px;color:#888;line-height:1.55;margin-bottom:4px;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
.STORY-FEATURED-META{font-size:6px;color:#B8B4AE}
.STORY-ROW-SLIM{display:flex;align-items:baseline;gap:6px;padding:5px 10px;border-bottom:1px solid #F0EDE6}
.STORY-ROW-SLIM:last-child{border-bottom:none}
.STORY-TITLE-SLIM{font-family:Georgia,serif;font-size:7.5px;color:#002850;font-style:italic;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.STORY-DATE-SLIM{font-size:6px;color:#C8C4BE;white-space:nowrap;flex-shrink:0}
/* ── Spec disclaimer ─── */
.spec-disclaimer{background:#FFF8E1;border:1.5px solid #FFC107;border-radius:6px;padding:11px 16px;font-size:11px;color:#6D4C00;margin-bottom:32px;line-height:1.6}
.spec-disclaimer strong{font-weight:800}
</style>
</head>
<body>
<div class="doc">
<!-- ══ MASTHEAD ══ -->
<div class="mast">
<div class="mast-top">
<div>
<h1>Reader Dashboard — Konzept C · „Entdecken"</h1>
<p>Leseorientierte Gestaltung — Geschichten stehen im Mittelpunkt. Die Statistik ist auf ein schlankes Querband reduziert. Personen erscheinen als flache quadratische Chips mit vollfarbigem Initialen-Feld. Die aktuellste Geschichte erhält einen eigenen Featured-Block mit Auszug; zwei weitere folgen als kompakte Liste.</p>
</div>
<span class="mast-badge">Konzept C · Entwurf</span>
</div>
<div class="decisions">
<div class="dec">
<div class="dec-label">Schwerpunkt</div>
<div class="dec-value">Geschichten als primäres Erlebnis</div>
</div>
<div class="dec">
<div class="dec-label">Statistik</div>
<div class="dec-value">Inline-Strip — Text statt Kacheln</div>
</div>
<div class="dec">
<div class="dec-label">Personen-Chips</div>
<div class="dec-value">Quadratischer Avatar + Name + Zahl</div>
</div>
<div class="dec">
<div class="dec-label">Geschichten</div>
<div class="dec-value">Featured Block (3 Zeilen Auszug) + 2 slim</div>
</div>
</div>
</div>
<div class="spec-disclaimer">
<strong>📐 Mockup-Skalierung —</strong> alle Schriftgrößen, Abstände und Höhen im Mockup sind auf ca. 55 % der tatsächlichen Implementierungswerte skaliert. <strong>Werte nicht aus dem Mockup-CSS kopieren.</strong>
</div>
<!-- ══ SECTION 1: DESKTOP, REINER LESER ══ -->
<div class="sec">
<div class="sec-h"><span class="sec-num">1</span> Desktop · Leser ohne BLOG_WRITE (READ_ALL only)</div>
<div class="sg sg-2">
<div class="sb">
<div class="sl">Vollansicht <span class="sz">≥ 1024 px</span></div>
<div class="wf">
<div class="wf-bar">
<div class="dot r"></div><div class="dot y"></div><div class="dot g"></div>
<div class="urlbar"><span>familienarchiv.local /</span></div>
</div>
<!-- Nav -->
<div class="N">
<span class="logo">Familienarchiv</span>
<span class="nl on">Startseite</span>
<span class="nl">Dokumente</span>
<span class="nl">Personen</span>
<span class="nl">Geschichten</span>
<div class="nr">
<div class="nico">
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,.5)" stroke-width="2"><path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 01-3.46 0"/></svg>
</div>
<div class="av">BK</div>
</div>
</div>
<div class="N-accent"></div>
<div class="MAIN">
<!-- Zone 1: Greeting (inline minimal) -->
<div class="GREET-INLINE">
<div class="GREET-TEXT">
<span>Guten Abend —</span>Herzlich willkommen, Brigitte.
</div>
</div>
<!-- Zone 2: Stats (compact band) -->
<div class="STATS-SLIM">
<a class="SSTAT" href="#">
<span class="SSTAT-NUM">847</span>
<span class="SSTAT-LABEL">Dokumente</span>
</a>
<a class="SSTAT" href="#">
<span class="SSTAT-NUM">94</span>
<span class="SSTAT-LABEL">Personen</span>
</a>
<a class="SSTAT" href="#">
<span class="SSTAT-NUM">12</span>
<span class="SSTAT-LABEL">Geschichten</span>
</a>
</div>
<!-- Zone 4: Person chips (colored square avatar) -->
<div class="PERSONS-BAND">
<div class="PERSONS-HEADER">
<span class="PERSONS-TITLE">Personen im Fokus</span>
<a class="PERSONS-ALL-LINK" href="#">Alle 94 Personen →</a>
</div>
<div class="CHIPS-COLORED">
<a class="CHIP-COL" href="#">
<div class="CHIP-COL-AV" style="background:#002850">KR</div>
<div class="CHIP-COL-BODY">
<span class="CHIP-COL-NAME">Käthe Raddatz</span>
<span class="CHIP-COL-COUNT">47 Dok.</span>
</div>
</a>
<a class="CHIP-COL" href="#">
<div class="CHIP-COL-AV" style="background:#1A4A6B">ER</div>
<div class="CHIP-COL-BODY">
<span class="CHIP-COL-NAME">Ernst Raddatz</span>
<span class="CHIP-COL-COUNT">31 Dok.</span>
</div>
</a>
<a class="CHIP-COL" href="#">
<div class="CHIP-COL-AV" style="background:#3D5A7A">FM</div>
<div class="CHIP-COL-BODY">
<span class="CHIP-COL-NAME">Frieda Müller</span>
<span class="CHIP-COL-COUNT">28 Dok.</span>
</div>
</a>
<a class="CHIP-COL" href="#">
<div class="CHIP-COL-AV" style="background:#4A7A5A">HW</div>
<div class="CHIP-COL-BODY">
<span class="CHIP-COL-NAME">Heinrich Weber</span>
<span class="CHIP-COL-COUNT">19 Dok.</span>
</div>
</a>
</div>
</div>
<!-- Zone 5: Docs LEFT (slim), Stories RIGHT (featured) — 2:3 -->
<div class="CONTENT-ROW">
<!-- Left: Docs slim list -->
<div class="CARD">
<div class="CARD-HEAD">
<h3>Zuletzt aktualisiert</h3>
<a href="#">Alle Dokumente</a>
</div>
<div class="DOC-ROW-SLIM">
<div class="DOC-TITLE-SLIM">Brief von Ernst an Käthe, März 1923</div>
<div class="DOC-DATE-SLIM">vor 2 Tagen</div>
</div>
<div class="DOC-ROW-SLIM">
<div class="DOC-TITLE-SLIM">Heiratsurkunde Raddatz-Müller, 1898</div>
<div class="DOC-DATE-SLIM">vor 4 Tagen</div>
</div>
<div class="DOC-ROW-SLIM">
<div class="DOC-TITLE-SLIM">Familienfoto, Sommer 1928</div>
<div class="DOC-DATE-SLIM">vor 1 Woche</div>
</div>
<div class="DOC-ROW-SLIM">
<div class="DOC-TITLE-SLIM">Taufregister Heinrich Weber, 1902</div>
<div class="DOC-DATE-SLIM">vor 2 Wo.</div>
</div>
<div class="DOC-ROW-SLIM">
<div class="DOC-TITLE-SLIM">Postkarte aus Berlin, 1930</div>
<div class="DOC-DATE-SLIM">vor 3 Wo.</div>
</div>
</div>
<!-- Right: Stories — Featured + slim -->
<div class="CARD">
<div class="CARD-HEAD">
<h3>Geschichten</h3>
<a href="#">Alle Geschichten</a>
</div>
<!-- Featured story -->
<div class="STORY-FEATURED">
<div class="STORY-FEATURED-LABEL">Neueste Geschichte</div>
<div class="STORY-FEATURED-TITLE">Die Reise nach Berlin</div>
<div class="STORY-FEATURED-EXCERPT">Im Frühjahr 1921 bestieg Ernst Raddatz erstmals den Zug nach Berlin, um Arbeit in der aufstrebenden Metropole zu finden. Was als abenteuerliche Reise begann, wurde zur Schicksalswende für die gesamte Familie …</div>
<div class="STORY-FEATURED-META">vor 3 Tagen</div>
</div>
<!-- Slim list -->
<div class="STORY-ROW-SLIM">
<div class="STORY-TITLE-SLIM">Sommer 1934 in Köln</div>
<div class="STORY-DATE-SLIM">vor 2 Wochen</div>
</div>
<div class="STORY-ROW-SLIM">
<div class="STORY-TITLE-SLIM">Briefe aus dem Krieg</div>
<div class="STORY-DATE-SLIM">vor 1 Monat</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Annotations -->
<div style="display:flex;flex-direction:column;gap:10px;padding-top:40px">
<div class="ann-block">
<strong>Zone 1 — Minimale Begrüßung</strong>
<ul>
<li>Kein eigener Card-Container — reine Textzeile, platzsparend</li>
<li>„Guten Abend —" in gedämpftem Grau vor Serif-Begrüßungstext</li>
<li>Beibehaltung der Tageszeit-Dynamik ohne visuelle Lautstärke</li>
</ul>
</div>
<div class="ann-block">
<strong>Zone 2 — Kompakter Stats-Strip</strong>
<ul>
<li>Zahlen inline mit Label — keine separaten Kacheln</li>
<li>Gleicher Informationsgehalt wie Konzept A/B, aber ~60 % weniger Höhe</li>
<li>Klickbar als vollständige &lt;a&gt;-Elemente</li>
</ul>
</div>
<div class="note">
<strong>Zone 4 — Quadratische Personen-Chips</strong>
<ul>
<li>Quadratischer Avatar (border-radius: 2 px statt 50 %) — wirkt archiv-artig, leicht dokumentarisch</li>
<li>Name + Dokumentzahl übereinander — kompakt und lesbar</li>
<li>Chips wrappen bei schmalem Viewport automatisch</li>
</ul>
</div>
<div class="note">
<strong>Zone 5 — Geschichten als Featured Block</strong>
<ul>
<li>Neueste Geschichte erhält sand-getönten Hintergrund + 3-Zeilen-Auszug</li>
<li>Mint-Label „Neueste Geschichte" differenziert vom restlichen Geschichten-Content</li>
<li>Die zwei weiteren Geschichten folgen als slim-Zeilen → reduziert auf Titel + Datum</li>
<li>Dokumente-Spalte ist bewusst schlanker (2:3) — gibt Geschichten Raum</li>
</ul>
</div>
<div class="ok">
<strong>Stärken dieses Konzepts</strong>
<ul>
<li>Stärkt die narrative Dimension des Archivs — Geschichten als Hauptfenster</li>
<li>Featured-Block lädt zum Lesen ein, ohne dass ein gesonderter Story-Bereich nötig ist</li>
<li>Kompaktester Header → mehr Inhalt „above the fold"</li>
</ul>
</div>
</div>
</div>
</div>
<!-- ══ SECTION 2: BLOG_WRITE VARIANT ══ -->
<div class="sec">
<div class="sec-h"><span class="sec-num">2</span> Variante · BLOG_WRITE — mit Zone 3 „Meine Entwürfe"</div>
<div class="sg sg-2">
<div class="sb">
<div class="sl">BLOG_WRITE-Nutzer <span class="sz">READ_ALL + BLOG_WRITE</span></div>
<div class="wf">
<div class="wf-bar">
<div class="dot r"></div><div class="dot y"></div><div class="dot g"></div>
<div class="urlbar"><span>familienarchiv.local /</span></div>
</div>
<div class="N">
<span class="logo">Familienarchiv</span>
<span class="nl on">Startseite</span>
<span class="nl">Dokumente</span>
<span class="nl">Personen</span>
<span class="nl">Geschichten</span>
<div class="nr"><div class="av">MR</div></div>
</div>
<div class="N-accent"></div>
<div class="MAIN">
<!-- Zone 1 inline -->
<div class="GREET-INLINE">
<div class="GREET-TEXT"><span>Guten Morgen —</span>Herzlich willkommen, Marcel.</div>
</div>
<!-- Zone 2 stats -->
<div class="STATS-SLIM">
<a class="SSTAT" href="#"><span class="SSTAT-NUM">847</span><span class="SSTAT-LABEL">Dokumente</span></a>
<a class="SSTAT" href="#"><span class="SSTAT-NUM">94</span><span class="SSTAT-LABEL">Personen</span></a>
<a class="SSTAT" href="#"><span class="SSTAT-NUM">12</span><span class="SSTAT-LABEL">Geschichten</span></a>
</div>
<!-- Zone 3: Drafts -->
<div style="background:#fff;border:1px solid #E0DDD5;border-radius:3px;overflow:hidden">
<div style="display:flex;align-items:center;justify-content:space-between;padding:6px 10px 5px;border-bottom:1px solid #E0DDD5;background:rgba(166,218,216,.08)">
<div style="display:flex;align-items:center;gap:5px">
<div style="width:2px;height:12px;background:#A6DAD8;border-radius:1px"></div>
<span style="font-size:6.5px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;color:#999">Meine Entwürfe</span>
</div>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:5px 10px;border-bottom:1px solid #F0EDE6">
<div>
<div style="font-family:Georgia,serif;font-size:8px;color:#002850">Onkel Friedrichs Wanderjahre</div>
<div style="font-size:6px;color:#AAA;margin-top:1px">Entwurf · zuletzt bearbeitet vor 1 Tag</div>
</div>
<svg width="7" height="7" viewBox="0 0 24 24" fill="none" stroke="#CCC" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:5px 10px">
<div>
<div style="font-family:Georgia,serif;font-size:8px;color:#002850">Die Raddatz-Kinder</div>
<div style="font-size:6px;color:#AAA;margin-top:1px">Entwurf · zuletzt bearbeitet vor 5 Tagen</div>
</div>
<svg width="7" height="7" viewBox="0 0 24 24" fill="none" stroke="#CCC" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
<!-- Zone 4 -->
<div class="PERSONS-BAND">
<div class="PERSONS-HEADER">
<span class="PERSONS-TITLE">Personen im Fokus</span>
<a class="PERSONS-ALL-LINK" href="#">Alle 94 Personen →</a>
</div>
<div class="CHIPS-COLORED">
<a class="CHIP-COL" href="#"><div class="CHIP-COL-AV" style="background:#002850">KR</div><div class="CHIP-COL-BODY"><span class="CHIP-COL-NAME">Käthe Raddatz</span><span class="CHIP-COL-COUNT">47 Dok.</span></div></a>
<a class="CHIP-COL" href="#"><div class="CHIP-COL-AV" style="background:#1A4A6B">ER</div><div class="CHIP-COL-BODY"><span class="CHIP-COL-NAME">Ernst Raddatz</span><span class="CHIP-COL-COUNT">31 Dok.</span></div></a>
<a class="CHIP-COL" href="#"><div class="CHIP-COL-AV" style="background:#3D5A7A">FM</div><div class="CHIP-COL-BODY"><span class="CHIP-COL-NAME">Frieda Müller</span><span class="CHIP-COL-COUNT">28 Dok.</span></div></a>
<a class="CHIP-COL" href="#"><div class="CHIP-COL-AV" style="background:#4A7A5A">HW</div><div class="CHIP-COL-BODY"><span class="CHIP-COL-NAME">Heinrich Weber</span><span class="CHIP-COL-COUNT">19 Dok.</span></div></a>
</div>
</div>
<!-- Zone 5 (abbreviated) -->
<div class="CONTENT-ROW">
<div class="CARD">
<div class="CARD-HEAD"><h3>Zuletzt aktualisiert</h3><a href="#">Alle Dokumente</a></div>
<div class="DOC-ROW-SLIM"><div class="DOC-TITLE-SLIM">Brief von Ernst an Käthe, März 1923</div><div class="DOC-DATE-SLIM">vor 2 Tagen</div></div>
<div class="DOC-ROW-SLIM"><div class="DOC-TITLE-SLIM">Heiratsurkunde Raddatz-Müller, 1898</div><div class="DOC-DATE-SLIM">vor 4 Tagen</div></div>
<div class="DOC-ROW-SLIM"><div class="DOC-TITLE-SLIM">Familienfoto, Sommer 1928</div><div class="DOC-DATE-SLIM">vor 1 Woche</div></div>
<div style="padding:5px 10px;font-size:6px;color:#C8C4BE;border-top:1px solid #F0EDE6">+ 2 weitere …</div>
</div>
<div class="CARD">
<div class="CARD-HEAD"><h3>Geschichten</h3><a href="#">Alle</a></div>
<div class="STORY-FEATURED" style="background:#FDFAF5">
<div class="STORY-FEATURED-LABEL">Neueste Geschichte</div>
<div class="STORY-FEATURED-TITLE">Die Reise nach Berlin</div>
<div class="STORY-FEATURED-EXCERPT">Im Frühjahr 1921 bestieg Ernst Raddatz erstmals den Zug nach Berlin, um Arbeit in der aufstrebenden Metropole zu finden …</div>
<div class="STORY-FEATURED-META">vor 3 Tagen</div>
</div>
<div class="STORY-ROW-SLIM"><div class="STORY-TITLE-SLIM">Sommer 1934 in Köln</div><div class="STORY-DATE-SLIM">vor 2 Wochen</div></div>
<div class="STORY-ROW-SLIM"><div class="STORY-TITLE-SLIM">Briefe aus dem Krieg</div><div class="STORY-DATE-SLIM">vor 1 Monat</div></div>
</div>
</div>
</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:10px;padding-top:40px">
<div class="ann-block">
<strong>Zone 3 — Entwürfe mit Mint-Vertikallinie</strong>
<ul>
<li>Mintfarbene 2 px Vertikallinie links neben dem Card-Titel — diskreter als ein Randakzent</li>
<li>Leicht mintfarbenes Card-Header-Background (rgba(166,218,216,.08)) grenzt Zone ab</li>
<li>Gut integriert in den kompakten Gesamtstil von Konzept C</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -30,11 +30,18 @@ src/
│ ├── documents/ # Document CRUD, detail, edit, upload │ ├── documents/ # Document CRUD, detail, edit, upload
│ ├── persons/ # Person directory, detail, edit, merge │ ├── persons/ # Person directory, detail, edit, merge
│ ├── briefwechsel/ # Bilateral conversation timeline │ ├── briefwechsel/ # Bilateral conversation timeline
│ ├── chronik/ # Unified activity feed │ ├── aktivitaeten/ # Unified activity feed (Chronik)
│ ├── admin/ # User, group, tag, OCR, system management │ ├── admin/ # User, group, tag, OCR, system management
│ ├── api/ # Internal API proxies (server-side only) │ ├── api/ # Internal API proxies (server-side only)
│ ├── login/ logout/ # Auth pages │ ├── geschichten/ # Stories (list, [id], [id]/edit, new)
── ... ── stammbaum/ # Family tree
│ ├── enrich/ # Enrichment workflow ([id], done)
│ ├── hilfe/transkription/ # Transcription help page
│ ├── profile/ # User profile settings
│ ├── users/[id]/ # Public user profile page
│ ├── login/ logout/ register/
│ ├── forgot-password/ reset-password/
│ └── demo/ # Dev-only demos
├── lib/ # Domain-based package structure (mirrors backend) ├── lib/ # Domain-based package structure (mirrors backend)
│ ├── document/ # Document domain: components, stores, services, utils │ ├── document/ # Document domain: components, stores, services, utils
│ │ ├── annotation/ # Annotation overlay components │ │ ├── annotation/ # Annotation overlay components
@@ -71,29 +78,13 @@ src/
└── ... # Other SvelteKit config files └── ... # Other SvelteKit config files
``` ```
For per-domain component inventories, see the domain READMEs in `src/lib/<domain>/README.md`.
## API Client Pattern ## API Client Pattern
All server-side API calls use the typed client from `$lib/api.server.ts`: → See [CONTRIBUTING.md §Frontend API client](../CONTRIBUTING.md#frontend-api-client)
```typescript **LLM reminder:** check `!result.response.ok` (not `result.error` — breaks when spec has no error responses); cast errors as `result.error as unknown as { code?: string }`; use `result.data!` after an ok check. For multipart/form-data (file uploads), bypass the typed client and use raw `fetch`.
const api = createApiClient(fetch);
const result = await api.GET('/api/persons/{id}', { params: { path: { id } } });
// Always check via response.ok, NOT result.error
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
throw error(result.response.status, getErrorMessage(code));
}
return { person: result.data! };
```
Key rules:
- Use `!result.response.ok` for error checking (not `if (result.error)` — breaks when spec has no error responses defined)
- Cast errors as `result.error as unknown as { code?: string }` to extract backend error code
- Use `result.data!` after an ok check
For multipart/form-data (file uploads), bypass the typed client and use raw `fetch`.
## Form Actions Pattern ## Form Actions Pattern
@@ -102,7 +93,7 @@ For multipart/form-data (file uploads), bypass the typed client and use raw `fet
export const actions = { export const actions = {
default: async ({ request, fetch }) => { default: async ({ request, fetch }) => {
const formData = await request.formData(); const formData = await request.formData();
const name = formData.get('name') as string; const name = formData.get('name') as string; // cast needed — FormData returns FormDataEntryValue
// ... // ...
return fail(400, { error: 'message' }); // on error return fail(400, { error: 'message' }); // on error
throw redirect(303, '/target'); // on success throw redirect(303, '/target'); // on success
@@ -112,49 +103,39 @@ export const actions = {
## Date Handling ## Date Handling
- **Forms**: German format `dd.mm.yyyy` with auto-dot insertion via `handleDateInput()`. A hidden `<input type="hidden" name="documentDate" value={dateIso}>` sends ISO to the backend. → See [CONTRIBUTING.md §Date handling](../CONTRIBUTING.md#date-handling)
- **Display**: Always use `Intl.DateTimeFormat` with `T12:00:00` suffix to prevent UTC off-by-one:
```typescript **LLM reminder:** always append `T12:00:00` when constructing `new Date()` from an ISO date string — prevents UTC timezone off-by-one errors. Forms use German `dd.mm.yyyy` format via `handleDateInput()` with a hidden ISO input.
new Intl.DateTimeFormat('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }).format(
new Date(doc.documentDate + 'T12:00:00')
);
```
## Styling Conventions (Tailwind CSS 4) ## Styling Conventions (Tailwind CSS 4)
Brand color utilities (defined in `layout.css`): Brand color tokens (defined in `layout.css`):
| Class | Value | Usage | | Token / Utility | CSS variable | Usage |
| ------------ | --------- | -------------------------------- | | ---------------- | ---------------- | ------------------------------------------------------- |
| `brand-navy` | `#002850` | Primary text, buttons, headers | | `brand-navy` | `--palette-navy` | Tailwind utility — buttons, headers, primary text |
| `brand-mint` | `#A6DAD8` | Accents, hover underlines, icons | | `brand-mint` | `--palette-mint` | Tailwind utility — accents, hover underlines, icons |
| `brand-sand` | `#E4E2D7` | Page background, card borders | | `--palette-sand` | `--palette-sand` | Palette constant only — use `bg-canvas` or `bg-surface` |
Typography: Typography:
- `font-serif` (Merriweather) — body text, document titles, names - `font-serif` (Tinos) — body text, document titles, names
- `font-sans` (Montserrat) — labels, metadata, UI chrome - `font-sans` (Montserrat) — labels, metadata, UI chrome
Card pattern for content sections: Card pattern for content sections:
```svelte ```svelte
<div class="bg-white shadow-sm border border-brand-sand rounded-sm p-6"> <div class="rounded-sm border border-line bg-surface shadow-sm p-6">
<h2 class="text-xs font-bold uppercase tracking-widest text-gray-400 mb-5">Section</h2> <h2 class="text-xs font-bold uppercase tracking-widest text-ink-3 mb-5">Section</h2>
<!-- content --> <!-- content -->
</div> </div>
``` ```
## Key UI Components ## Key UI Components
| Component | Location | Props | Description | → See per-domain READMEs: [`src/lib/person/README.md`](src/lib/person/README.md), [`src/lib/tag/README.md`](src/lib/tag/README.md), [`src/lib/document/README.md`](src/lib/document/README.md), [`src/lib/shared/README.md`](src/lib/shared/README.md)
| -------------------- | ------------------------------ | --------------------------------------- | ------------------------------------------ |
| `PersonTypeahead` | `$lib/person/` | `name`, `label`, `value`, `initialName` | Single-person selector with typeahead | **LLM reminder:** `BackButton` is at `$lib/shared/primitives/BackButton.svelte` — use it for all back navigation; never a static `<a href>`. API client is at `$lib/shared/api.server`.
| `PersonMultiSelect` | `$lib/person/` | `selectedPersons` (bind) | Chip-based multi-person selector |
| `TagInput` | `$lib/tag/` | `tags` (bind), `allowCreation?` | Tag chip input with typeahead |
| `PdfViewer` | `$lib/document/` | `url`, `annotations` | PDF rendering with annotation overlay |
| `TranscriptionBlock` | `$lib/document/transcription/` | `block`, `mode` | Read/edit transcription block |
| `DocumentTopBar` | `$lib/document/` | `document` | Responsive document metadata header |
| `BackButton` | `$lib/shared/primitives/` | — | Calls `history.back()`; 44 px touch target |
## How to Run ## How to Run
@@ -163,7 +144,7 @@ Card pattern for content sections:
```bash ```bash
cd frontend cd frontend
npm install npm install
npm run dev # Dev server on port 5173 (or 3000 if --port 3000) npm run dev # Dev server on port 5173
``` ```
### Build & Preview ### Build & Preview

View File

@@ -0,0 +1,36 @@
# document (frontend)
UI for the archive's core concept: viewing, uploading, editing, searching, bulk-selecting, and transcribing documents.
## What this domain owns
Components: `DocumentRow`, `DocumentThumbnail`, `DocumentTopBar`, `DocumentViewer`, `DocumentMetadataDrawer`, `DocumentEditLayout`, `DocumentStatusChip`, `UploadZone`, `BulkSelectionBar`, `BulkDropZone`.
Utilities: `search.ts` (search-param helpers), `filename.ts` (filename formatting), `documentStatusLabel.ts` (i18n label mapping), `validateFile.ts` (upload validation), `groupDocuments.ts` (list grouping).
Sub-folders: `annotation/`, `transcription/`, `viewer/`.
## What this domain does NOT own
- Person typeahead — `person/PersonTypeahead.svelte` (cross-domain import, allowed by ESLint rule)
- Tag input — `tag/TagInput.svelte` (cross-domain import, allowed)
- Shared discussion — `shared/discussion/` (comment/mention editor)
## Key components
| Component | Route used in | Notes |
| --------------------------- | ---------------------------------- | ------------------------------------ |
| `DocumentRow.svelte` | `/` (search results), admin queues | Compact document card with thumbnail |
| `DocumentViewer.svelte` | `/documents/[id]` | PDF/image inline viewer |
| `DocumentEditLayout.svelte` | `/documents/[id]/edit` | Full edit form with sticky save bar |
| `UploadZone.svelte` | `/documents/new`, bulk upload | Drag-and-drop file drop area |
| `BulkSelectionBar.svelte` | `/documents` bulk mode | Multi-select action bar |
## Cross-domain imports
- `person/PersonTypeahead.svelte` — sender / receiver selection
- `tag/TagInput.svelte` — tag chip input
- `ocr/OcrProgress.svelte` — job status indicator in the document header
- `shared/primitives/BackButton.svelte`, `shared/discussion/` — shared UI
## Backend counterpart
`backend/src/main/java/org/raddatz/familienarchiv/document/README.md`

View File

@@ -0,0 +1,34 @@
# geschichte (frontend)
UI for family stories: the rich-text editor, story cards, and story list view.
## What this domain owns
Components: `GeschichteEditor.svelte`, `GeschichtenCard.svelte`.
## What this domain does NOT own
- Comment/discussion UI — shared via `shared/discussion/` (same component used for document comments)
- Person display — `person/PersonChip.svelte` is used inside story content (cross-domain import)
- Document display — document references in stories use components from `document/`
## Key components
| Component | Used in | Notes |
| ------------------------- | -------------------------------------------- | ------------------------------------------------------------------ |
| `GeschichteEditor.svelte` | `/geschichten/new`, `/geschichten/[id]/edit` | Rich-text editor with person/document @-mentions and inline embeds |
| `GeschichtenCard.svelte` | `/geschichten` (list), dashboard | Story preview card with cover image and publish status |
## Audience note
The `/geschichten` route primarily serves readers (younger family members on mobile). Cards must have ≥ 44 px touch targets. Status must not rely on color alone.
## Cross-domain imports
- `person/PersonChip.svelte` — inline person references in story content
- `document/DocumentThumbnail.svelte` — inline document references
- `shared/discussion/` — comment thread below published stories
## Backend counterpart
`backend/src/main/java/org/raddatz/familienarchiv/geschichte/README.md`

View File

@@ -0,0 +1,36 @@
# notification (frontend)
Bell-icon dropdown and real-time SSE connection for in-app notifications.
## What this domain owns
Components: `NotificationBell.svelte`, `NotificationDropdown.svelte`.
Utilities: `notifications.svelte.ts` (Svelte 5 reactive store), `notifications.ts` (API helpers).
## What this domain does NOT own
- SSE infrastructure — the backend's `SseEmitterRegistry` manages the server-side emitter. The frontend establishes one `EventSource` connection per session. Connection management lives in `notifications.svelte.ts`.
- Notification content rendering — notification payloads contain a `contextUrl`; the frontend navigates there on click.
## Key design: SSE connection
The SSE path is **backend → browser directly** (not proxied through SvelteKit SSR). The `EventSource` connects to `/api/notifications/stream`. On receive, the reactive store updates the unread count and the bell dropdown in real time.
```
Backend SseEmitterRegistry → /api/notifications/stream → EventSource in browser
```
## Key components
| Component | Used in | Notes |
| ----------------------------- | ----------------------------- | --------------------------------------------------------- |
| `NotificationBell.svelte` | global nav (`+layout.svelte`) | Bell icon with unread badge; opens `NotificationDropdown` |
| `NotificationDropdown.svelte` | global nav | Scrollable list of recent notifications with mark-read |
## Cross-domain imports
- `shared/primitives/` — icon, button primitives only
## Backend counterpart
`backend/src/main/java/org/raddatz/familienarchiv/notification/README.md`

View File

@@ -0,0 +1,27 @@
# ocr (frontend)
UI for OCR job management, progress display, and sender-model training in the admin/enrichment panel.
## What this domain owns
Components: `OcrProgress.svelte`, `OcrTrigger.svelte`, `OcrTrainingCard.svelte`, `SegmentationTrainingCard.svelte`, `TrainingHistory.svelte`.
Utilities: `translateOcrProgress.ts` (progress-state → display-string mapping), `training.ts` (training API helpers).
## What this domain does NOT own
- OCR processing — all text recognition runs in the Python `ocr-service/` container. The frontend shows job state; it does not run OCR.
- Transcription block display — rendered by `document/transcription/` components.
## Key components
| Component | Used in | Notes |
| --------------------------------- | ----------------------------- | -------------------------------------------------------- |
| `OcrProgress.svelte` | document header, enrich panel | Progress bar and status label for an active OCR job |
| `OcrTrigger.svelte` | enrich panel, document detail | Button to start an OCR job; disabled when one is running |
| `OcrTrainingCard.svelte` | `/admin/ocr` | Trigger sender-model training; shows training history |
| `SegmentationTrainingCard.svelte` | `/admin/ocr` | Trigger segmentation training |
| `TrainingHistory.svelte` | `/admin/ocr` | List of past training runs with status |
## Backend counterpart
`backend/src/main/java/org/raddatz/familienarchiv/ocr/README.md`

View File

@@ -0,0 +1,37 @@
# person (frontend)
UI for historical family members: typeahead selection, chip display, hover cards, genealogy graph, relationship management.
## What this domain owns
Components: `PersonTypeahead.svelte`, `PersonMultiSelect.svelte`, `PersonChip.svelte`, `PersonChipRow.svelte`, `PersonHoverCard.svelte`, `PersonTypeBadge.svelte`, `PersonTypeSelector.svelte`.
Utilities: `personFormat.ts` (full-name formatting), `personLifeDates.ts` (birth/death display), `person-validation.ts` (form validation), `personHoverCard.ts` (hover-card controller).
Sub-folders: `genealogy/` (Stammbaum view components), `relationship/` (relationship graph components).
## What this domain does NOT own
- Document content — displayed in `document/`
- AppUser accounts — managed in `user/`
## Key components
| Component | Used in | Notes |
| -------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------- |
| `PersonTypeahead.svelte` | document edit, geschichte, search filters | Single-person selector with debounced typeahead. Exported for use by other domains. |
| `PersonMultiSelect.svelte` | document edit (receivers) | Chip-based multi-person selector |
| `PersonChip.svelte` | document rows, conversation view | Compact display chip with link and hover card |
| `PersonHoverCard.svelte` | person chips | Floating card with person summary on hover |
## Cross-domain imports
- `shared/primitives/` — generic UI primitives
- `shared/hooks/useTypeahead.svelte.ts` — typeahead keyboard/focus logic
## Accessibility notes
- `PersonChip` focus ring: `focus-visible:ring-2 focus-visible:ring-brand-navy`
- `PersonTypeahead` dropdown navigable via keyboard (↑↓ Enter Escape)
## Backend counterpart
`backend/src/main/java/org/raddatz/familienarchiv/person/README.md`

View File

@@ -0,0 +1,40 @@
# shared (frontend)
Cross-domain utilities and UI primitives. Any file here is consumed by two or more domain folders and has no domain identity of its own.
## Admission criteria (what belongs here)
A file belongs in `shared/` if it meets **all three** conditions:
1. No domain identity — it does not represent a `Document`, `Person`, `Tag`, etc.
2. Consumed by ≥ 2 domain folders — or is framework infrastructure that every domain depends on.
3. Generic — could work in a different SvelteKit project with zero business-logic changes.
If any condition fails, the file belongs in the domain folder of its primary consumer.
## What this folder owns
| Sub-folder / file | Purpose |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `api.server.ts` | Typed `openapi-fetch` client factory — the standard entry point for all backend API calls in server-side load functions and actions |
| `errors.ts` | Mirror of the backend `ErrorCode` enum + `getErrorMessage()` → Paraglide i18n key mapping |
| `types.ts` | Cross-domain TypeScript interfaces |
| `utils.ts` | Pure utility functions (date formatting, sorting, debounce) |
| `relativeTime.ts` | Human-relative time formatting (`"2 days ago"`) |
| `primitives/` | Generic UI components: `BackButton.svelte`, form inputs, pagination, layout shells |
| `discussion/` | Comment/mention editor shared by `document/` and `geschichte/` |
| `dashboard/` | Family Pulse widget and recent-activity components assembled in the `/` route |
| `hooks/` | Svelte 5 reactive hooks: `useTypeahead`, `useUnsavedWarning` |
| `services/` | Generic client-side service helpers |
| `actions/` | Shared SvelteKit form action utilities |
| `server/` | Server-only shared utilities (load function helpers) |
| `help/` | Coach marks and empty-state components used across multiple domains |
## What does NOT belong here
- Components owned by one domain — move to that domain's folder.
- Domain-specific business logic — even if shared, it belongs in the owning domain's public surface.
## Adding to shared/
If you need to add a file here, confirm it meets all three admission criteria. If it's domain-adjacent, check whether the owning domain should export it as part of its public surface instead.

View File

@@ -0,0 +1,28 @@
# tag (frontend)
UI for hierarchical document categories: tag chip lists, tag input with typeahead, and the admin tag-tree editor.
## What this domain owns
Components: `TagInput.svelte`, `TagChipList.svelte`, `TagParentPicker.svelte`.
## What this domain does NOT own
- Tag data management — CRUD is handled via the backend `tag/` domain
- Document association — adding/removing tags from documents is in `document/`
## Key components
| Component | Used in | Notes |
| ------------------------ | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `TagInput.svelte` | document edit form | Multi-tag chip input with typeahead. Supports free-text creation and selecting existing tags. Exported for use by other domains. |
| `TagChipList.svelte` | document rows, detail pages | Read-only display of a tag set |
| `TagParentPicker.svelte` | admin tag editor | Tree-aware parent selection |
## Cross-domain imports
- `shared/hooks/useTypeahead.svelte.ts` — shared typeahead logic for `TagInput`
## Backend counterpart
`backend/src/main/java/org/raddatz/familienarchiv/tag/README.md`

View File

@@ -0,0 +1,28 @@
# user (frontend)
UI for account management: profile editing, password change, and permission group management in the admin panel.
## What this domain owns
Components: `UserProfileSection.svelte`, `UserPasswordSection.svelte`, `UserGroupsSection.svelte`.
## What this domain does NOT own
- `Person` records — historical family members are entirely separate from login accounts. A user editing their profile is an `AppUser`; the historical persons in documents are `Person` entities. They are never linked.
- User list or admin creation UI — those live in the `/admin` route, which assembles views from multiple domains.
## Key components
| Component | Used in | Notes |
| ---------------------------- | --------------------------- | ------------------------------------ |
| `UserProfileSection.svelte` | `/settings` or profile page | Display name, email editing |
| `UserPasswordSection.svelte` | `/settings` | Password change form |
| `UserGroupsSection.svelte` | `/admin` | Per-user permission group assignment |
## Cross-domain imports
- `shared/primitives/` — generic UI primitives only
## Backend counterpart
`backend/src/main/java/org/raddatz/familienarchiv/user/README.md`

View File

@@ -1,154 +1,7 @@
# OCR Service — Familienarchiv # OCR Service
## Overview → See [ocr-service/README.md](./README.md) for tech stack, architecture, endpoints, environment variables, local development, testing, and training.
Python FastAPI microservice that performs OCR (Optical Character Recognition) and HTR (Handwritten Text Recognition) on historical family documents. It exposes a simple HTTP API consumed by the Spring Boot backend. The service is stateless — all job tracking and business logic remain in Java. **LLM reminder:** the OCR service is a **single-node container** — training reloads the model in-process, so multiple replicas cause model-state divergence (see ADR-001). All job tracking and business logic stay in Spring Boot; the Python service is stateless OCR only.
## Tech Stack **LLM reminder:** `ALLOWED_PDF_HOSTS` must never be set to `*` — that opens SSRF. The default (`minio,localhost,127.0.0.1`) is correct for dev.
- **Framework**: FastAPI 0.115.6 (Python 3.11)
- **OCR Engines**:
- **Surya** (`surya-ocr`) — Transformer-based, handles typewritten and modern Latin handwriting
- **Kraken** (`kraken==7.0`) — Historical HTR model support, required for pre-1941 German Kurrent/Sütterlin scripts
- **ML**: PyTorch 2.7.1 (CPU-only), torchvision, transformers
- **PDF Processing**: `pypdfium2` (rendering), `pillow`
- **Image Processing**: `opencv-python-headless`, `pyvips`
- **Spell Checking**: `pyspellchecker`
- **HTTP Client**: `httpx`
## Architecture
The service is a single-node container (see ADR-001). OCR training reloads the model in-process after each run, so multiple replicas would cause training conflicts and model-state divergence.
### Interface Contract
**Request:**
```json
{
"pdfUrl": "http://minio:9000/archive-documents/abc.pdf?presigned...",
"scriptType": "HANDWRITING_KURRENT",
"language": "de"
}
```
**Response:** Array of `OcrBlock` objects:
```json
[
{
"pageNumber": 0,
"x": 0.12, "y": 0.08, "width": 0.76, "height": 0.04,
"polygon": [[0.12,0.08],[0.88,0.09],[0.87,0.12],[0.13,0.11]],
"text": "Sehr geehrter Herr ..."
}
]
```
Coordinates are normalized (0-1) relative to page dimensions.
### File Structure
```
ocr-service/
├── main.py # FastAPI app, endpoints, request handling
├── models.py # Pydantic models (OcrRequest, OcrBlock)
├── engines/
│ ├── __init__.py
│ ├── kraken.py # Kraken engine wrapper (Kurrent models)
│ └── surya.py # Surya engine wrapper (typewritten/Latin)
├── preprocessing.py # Image preprocessing (CLAHE, deskew, denoise)
├── confidence.py # Confidence scoring and thresholding
├── spell_check.py # Post-OCR spell correction
├── ensure_blla_model.py # Model download / verification helper
├── dictionaries/ # Historical word lists for spell checking
├── requirements.txt # Python dependencies
├── Dockerfile # Production container image
└── entrypoint.sh # Container startup script
```
### Key Endpoints
| Endpoint | Method | Description |
|---|---|---|
| `/health` | GET | Returns 200 only after models are loaded |
| `/ocr` | POST | Extract text blocks from a PDF URL |
| `/ocr/stream` | POST | Streaming OCR with SSE-style progress events |
| `/training/submit` | POST | Submit training data for model fine-tuning |
### Environment Variables
| Variable | Default | Description |
|---|---|---|
| `KRAKEN_MODEL_PATH` | `/app/models/german_kurrent.mlmodel` | Path to Kraken model file |
| `TRAINING_TOKEN` | `""` | Bearer token required for training endpoints |
| `OCR_CONFIDENCE_THRESHOLD` | `0.3` | Minimum confidence for Latin scripts |
| `OCR_CONFIDENCE_THRESHOLD_KURRENT` | `0.5` | Minimum confidence for Kurrent scripts |
| `RECOGNITION_BATCH_SIZE` | `16` | Kraken recognition batch size |
| `DETECTOR_BATCH_SIZE` | `8` | Surya detector batch size |
| `OCR_CLAHE_CLIP_LIMIT` | `2.0` | CLAHE contrast enhancement limit |
| `OCR_CLAHE_TILE_SIZE` | `8` | CLAHE tile grid size |
| `OCR_MAX_CACHED_MODELS` | `2` | LRU model cache size (~500 MB each) |
| `ALLOWED_PDF_HOSTS` | `minio,localhost,127.0.0.1` | SSRF protection — allowed PDF URL hosts |
## How to Run
### Local Development (Python venv)
```bash
cd ocr-service
python -m venv .venv
source .venv/bin/activate
# Install PyTorch CPU first (saves ~2 GB vs CUDA)
pip install torch==2.7.1 torchvision==0.22.1 --index-url https://download.pytorch.org/whl/cpu
# Install remaining dependencies
pip install -r requirements.txt
# Run development server
fastapi dev main.py --host 0.0.0.0 --port 8000
# Or production mode
uvicorn main:app --host 0.0.0.0 --port 8000
```
### Docker (via docker-compose)
The OCR service is included in the root `docker-compose.yml`:
```bash
docker-compose up -d ocr-service
```
The container:
- Exposes port 8000 internally (not mapped to host by default)
- Mounts `ocr_models` and `ocr_cache` volumes for persistence
- Has a 120-second startup grace period for model loading
- Memory limit: 12 GB
### Model Downloads
Use the helper script to download Kraken models:
```bash
./scripts/download-kraken-models.sh
```
Models are stored in the `ocr_models` Docker volume or `./ocr-service/models/` locally.
## Testing
Only a subset of tests can run without the full ML stack:
```bash
cd ocr-service
pip install pytest pytest-asyncio pyspellchecker
# No ML required — pure logic tests
python -m pytest test_spell_check.py test_confidence.py test_sender_registry.py -v
```
Tests requiring PyTorch/Kraken/Surya (e.g., `test_engines.py`) must be run in the Docker container or a fully provisioned venv.
## Training
The service supports in-process model fine-tuning via Kraken's `ketos` training pipeline. Training endpoints require the `TRAINING_TOKEN` bearer token. After training completes, the model is reloaded in-process — this is why only a single replica is supported.

51
ocr-service/README.md Normal file
View File

@@ -0,0 +1,51 @@
# ocr-service
Python FastAPI microservice that performs the actual handwritten text recognition (HTR) and OCR. The Spring Boot backend orchestrates jobs; this service executes them.
## What this service owns
- Text recognition: Surya (typewritten text) and Kraken (Kurrent/Sütterlin historical handwriting)
- Baseline layout analysis: Kraken BLLA model
- Sender recognition: trained per-archive sender models
- HTTP API at port 8000 (internal Docker network — no external port)
## What this service does NOT own
- Job lifecycle — tracked in the backend's `ocr/` domain
- MinIO storage — the service fetches PDFs via presigned URLs generated by the backend; it does not hold credentials
- Transcription block storage — results are streamed back to the backend, which writes them to PostgreSQL
## API endpoints
| Endpoint | Auth | Purpose |
|---|---|---|
| `POST /ocr` | None (internal network only) | Run OCR on a PDF (presigned MinIO URL in request body) |
| `POST /train` | `X-Training-Token` header | Trigger sender-model training |
| `POST /segtrain` | `X-Training-Token` header | Trigger segmentation training |
| `GET /health` | None | Health check |
## Environment variables
| Variable | Default | Required? | Sensitive? | Purpose |
|---|---|---|---|---|
| `TRAINING_TOKEN` | — | YES (prod) | YES | Guards `/train` and `/segtrain`. Do not leave empty in production. |
| `ALLOWED_PDF_HOSTS` | `minio,localhost,127.0.0.1` | YES | — | SSRF protection — comma-separated allowed PDF source hosts. Never set to `*`. |
| `KRAKEN_MODEL_PATH` | `/app/models/` | — | — | Directory where Kraken HTR models are stored (populated by `download-kraken-models.sh`) |
| `BLLA_MODEL_PATH` | `/app/models/blla.mlmodel` | — | — | Kraken baseline layout analysis model. Auto-downloaded via `ensure_blla_model.py` on startup if missing. |
## Key files
| File | Purpose |
|---|---|
| `main.py` | FastAPI app, endpoint definitions, SSRF validation |
| `engines/` | Surya and Kraken engine wrappers |
| `models.py` | Pydantic request/response models |
| `preprocessing.py` | PDF-to-image conversion before OCR |
| `confidence.py` | Per-block confidence scoring |
| `spell_check.py` | Post-OCR spell correction using historical dictionaries |
| `ensure_blla_model.py` | Startup script that downloads the BLLA model if missing |
| `entrypoint.sh` | Docker entrypoint — runs `ensure_blla_model.py` then starts the server |
## Backend counterpart
`backend/src/main/java/org/raddatz/familienarchiv/ocr/README.md`

View File

@@ -1,144 +1,5 @@
# Scripts — Familienarchiv # scripts/
## Overview → See [scripts/README.md](./README.md) for the full list of scripts, their purpose, and usage.
Utility scripts for development, data management, model downloads, and database operations. These are standalone shell and Python scripts used outside the normal application runtime. **LLM reminder:** when adding a new script, document it in `scripts/README.md` (not here).
## Scripts
### `reset-db.sh`
**Purpose**: Hard-reset the development database, wiping all documents, persons, tags, and related data.
**Usage:**
```bash
./scripts/reset-db.sh
# Type 'yes' to confirm
```
**What it truncates:**
- `transcription_block_versions`
- `transcription_blocks`
- `comment_mentions`
- `document_comments`
- `document_annotations`
- `document_versions`
- `notifications`
- `documents`
- `person_name_aliases`
- `persons`
- `tag`
> ⚠️ **Destructive operation** — only for development!
---
### `rebuild-frontend.sh`
**Purpose**: Force a clean rebuild of the frontend Docker container.
**Usage:**
```bash
./scripts/rebuild-frontend.sh
```
---
### `download-kraken-models.sh`
**Purpose**: Download Kraken HTR models for German Kurrent and Sütterlin scripts.
**Usage:**
```bash
./scripts/download-kraken-models.sh
```
Downloads models into `./ocr-service/models/` or the `ocr_models` Docker volume. Models are ~100-500 MB each.
---
### `download-paperless.sh`
**Purpose**: Download exported documents from a Paperless-ngx instance.
**Usage:**
```bash
./scripts/download-paperless.sh
```
Requires environment variables or config for the Paperless API endpoint and token.
---
### `flatten-paperless.sh`
**Purpose**: Flatten nested Paperless export directories into a single import-ready structure.
**Usage:**
```bash
./scripts/flatten-paperless.sh
```
---
### `generate_data.py`
**Purpose**: Generate synthetic test data for development.
**Usage:**
```bash
python scripts/generate_data.py
```
Generates fake documents, persons, and tags suitable for load testing or UI development.
---
### `prepare_historical_dict.py`
**Purpose**: Build a historical German word dictionary for the OCR spell-checker.
**Usage:**
```bash
python scripts/prepare_historical_dict.py
```
Processes raw word lists into the format expected by `ocr-service/spell_check.py`.
---
### `schema.sql`
**Purpose**: Complete database schema dump for reference.
**Note**: Flyway migrations in `backend/src/main/resources/db/migration/` are the source of truth for schema evolution. `schema.sql` is a snapshot for quick reference only.
---
### `large-data.sql`
**Purpose**: Pre-seeded dataset with a large number of documents for performance testing.
**Usage:**
```bash
# Import into PostgreSQL
docker exec -i archive-db psql -U archive_user -d family_archive_db < scripts/large-data.sql
```
## How to Use
Most scripts should be run from the **repository root**:
```bash
# Database reset
./scripts/reset-db.sh
# Model download
./scripts/download-kraken-models.sh
# Data generation
cd scripts && python generate_data.py
```
Ensure scripts are executable:
```bash
chmod +x scripts/*.sh
```
## Adding New Scripts
1. Place the script in `scripts/`
2. Add a header comment describing purpose and usage
3. Make it executable (`chmod +x`)
4. Document it in this `CLAUDE.md`

161
scripts/README.md Normal file
View File

@@ -0,0 +1,161 @@
# scripts/
Utility scripts for development, data management, model downloads, and database operations. These are standalone shell and Python scripts used outside the normal application runtime.
## Scripts
### `reset-db.sh`
**Purpose**: Hard-reset the development database, wiping all documents, persons, tags, and related data.
**Usage:**
```bash
./scripts/reset-db.sh
# Type 'yes' to confirm
```
**What it truncates:**
- `transcription_block_versions`
- `transcription_blocks`
- `comment_mentions`
- `document_comments`
- `document_annotations`
- `document_versions`
- `notifications`
- `documents`
- `person_name_aliases`
- `persons`
- `tag`
> ⚠️ **Destructive operation — only for development!** This wipes ALL data. Not reversible without a backup.
---
### `rebuild-frontend.sh`
**Purpose**: Force a clean rebuild of the frontend Docker container.
**Usage:**
```bash
./scripts/rebuild-frontend.sh
```
---
### `download-kraken-models.sh`
**Purpose**: Download Kraken HTR models for German Kurrent and Sütterlin scripts.
**Usage:**
```bash
./scripts/download-kraken-models.sh
```
Downloads models into `./ocr-service/models/` or the `ocr_models` Docker volume. Models are ~100500 MB each.
---
### `download-paperless.sh`
**Purpose**: Download exported documents from a Paperless-ngx instance.
**Usage:**
```bash
./scripts/download-paperless.sh
```
Requires environment variables or config for the Paperless API endpoint and token.
---
### `flatten-paperless.sh`
**Purpose**: Flatten nested Paperless export directories into a single import-ready structure.
**Usage:**
```bash
./scripts/flatten-paperless.sh
```
---
### `generate_data.py`
**Purpose**: Generate synthetic test data for development.
**Usage:**
```bash
python scripts/generate_data.py
```
Generates fake documents, persons, and tags suitable for load testing or UI development.
---
### `prepare_historical_dict.py`
**Purpose**: Build a historical German word dictionary for the OCR spell-checker.
**Usage:**
```bash
python scripts/prepare_historical_dict.py
```
Processes raw word lists into the format expected by `ocr-service/spell_check.py`.
---
### `schema.sql`
**Purpose**: Complete database schema dump for reference.
**Note**: Flyway migrations in `backend/src/main/resources/db/migration/` are the source of truth for schema evolution. `schema.sql` is a snapshot for quick reference only.
---
### `large-data.sql`
**Purpose**: Pre-seeded dataset with a large number of documents for performance testing.
**Usage:**
```bash
# Import into PostgreSQL
docker exec -i archive-db psql -U archive_user -d family_archive_db < scripts/large-data.sql
```
## How to Use
Most scripts should be run from the **repository root**:
```bash
# Database reset
./scripts/reset-db.sh
# Model download
./scripts/download-kraken-models.sh
# Data generation
cd scripts && python generate_data.py
```
Ensure scripts are executable:
```bash
chmod +x scripts/*.sh
```
## Adding New Scripts
1. Place the script in `scripts/`
2. Add a header comment describing purpose and usage
3. Make it executable (`chmod +x`)
4. Document it in this `README.md`