From 14dae8e55040edeabd8496de6e6716409f838f31 Mon Sep 17 00:00:00 2001 From: Till Heidrich Date: Tue, 1 Sep 2026 03:41:10 +0000 Subject: [PATCH] fix: design critique findings, and the settings card that saved nothing The critique found a functional bug I had shipped an hour earlier: the whole e-mail configuration was never sent. `saveSettings` builds its body from a fixed field list, and none of the smtp_* fields or share_default_days were in it, and the password input had no state at all. Filling in the card and pressing save lost everything while the toast said "Gespeichert." That card is the precondition for password reset, job notifications and mailing share links, so it would have looked like the whole feature was broken. Now sent, with the password held separately like the other secrets, and a failed PATCH no longer reports success. The public page told recipients how to bypass "view only": the hint about long-pressing to save was printed regardless of allow_download, two lines under a caption saying "Nur ansehen". The backend takes that setting seriously enough to withhold the original even for cutouts; the page undid it. Now the two cases have separate copy, worded for Android as well as iOS, and the hint sits above the grid where it is actually read rather than under twenty tiles. The share dialog had two blue buttons in step two, the exact mistake DESIGN.md records as having happened once before, and asked five questions before a link existed when the common case ("one picture for grandma") needs none of them. One blue button now, and the rarely-used settings collapse behind a summary that states what currently applies. /konto was unreachable on a phone: the only way in was the username in the top bar, which is hidden below 480px. It is a tab bar entry now, with short labels so eight items still fit at 390px (49x48px each). Network failures rendered as content: a failed load showed "nothing shared yet" alongside an invitation to share something, and /konto and /admin hung on "loading" forever. All three have an error state with a retry now. Also: the public page nested a second
inside the app shell and inherited padding for a tab bar anonymous visitors never see; admin inputs were 14px, so iOS zoomed on focus at tablet width; the admin had no media queries at all, so "Postausgangsserver" got ~156px on a phone; "Jetzt umbenennen" was clickable without the dry run PRODUCT.md requires; share_default_days did not reach the dialog; partial mail success was coloured as failure by a regex on the message text; the passphrase sheet ignored the home indicator; the privacy line claimed "encrypted" for what is a salted hash. Added PRODUCT.md and DESIGN.md, written from the code and the wiki rather than invented, so future design work has something to be measured against. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1 --- DESIGN.md | 96 ++++++++++++++++++++++++++++++++++ PRODUCT.md | 75 ++++++++++++++++++++++++++ src/components/AdminApp.tsx | 32 ++++++++++-- src/components/KontoApp.tsx | 22 ++++++-- src/components/ShareDialog.tsx | 85 ++++++++++++++++++++---------- src/components/SharesApp.tsx | 21 ++++++-- src/layouts/Base.astro | 36 +++++++++---- src/pages/api/me.ts | 4 +- src/pages/s/[slug].astro | 34 ++++++++---- 9 files changed, 345 insertions(+), 60 deletions(-) create mode 100644 DESIGN.md create mode 100644 PRODUCT.md diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..f174082 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,96 @@ +# Klarbild — Gestaltung + +> Aus dem Code gelesen, nicht erfunden. Quelle der Wahrheit bleibt +> `src/styles/tokens.css`; diese Datei erklärt, **warum** die Werte so sind und wo sie +> hingehören. + +## Haltung + +Technisch-editorial. Das Vorbild ist der Andruckbogen, nicht das Dashboard: warmes +Papier, scharfe Ecken, feine Linien, eine Passermarke als Zeichen. Die Oberfläche soll +aussehen wie etwas, mit dem man **arbeitet** — nicht wie etwas, das verkauft werden will. + +Farbstrategie: **restrained**. Getönte Neutrale tragen die Fläche, ein einziges kräftiges +Blau markiert das, was man anfassen soll. Der Akzent bleibt deutlich unter 10 % der +Fläche. Bewusst: Auf dem Bildschirm liegen ständig **Fotos**, und die sollen die +einzigen bunten Dinge sein. + +## Theme + +**Hell, ohne Alternative.** Die Szene: Till oder Lea am Küchentisch oder im Büro, Tageslicht, +ein Foto von Phoenix auf dem Schirm, gleich soll es auf Papier. Wer Bilder beurteilt, die +auf hellem Papier landen, braucht einen hellen Untergrund — auf Dunkel wirkt jedes Bild +heller und wärmer, als es gedruckt wird. Ein Dunkelmodus wäre hier kein Komfort, sondern +eine Fehlerquelle. + +## Farben + +Alle Werte in OKLCH, alle Neutralen zum Papierton hin getönt (Hue 95, Chroma 0.004–0.01). +Kein `#000`, kein `#fff` — nirgends. + +| Rolle | Token | Verwendung | +|---|---|---| +| Papier | `--paper` | Seitengrund, vertiefte Flächen, Eingabefelder | +| Karte | `--card` | erhobene Flächen, Kopfleiste | +| Linie | `--line` | 1-px-Trennung; **nie** dicker als Zierde | +| Schrift | `--ink` / `--ink-soft` | Haupttext / Nebentext | +| Akzent | `--accent` | genau eine Sache je Ansicht: die Handlung, die weiterführt | +| Akzentgrund | `--accent-bg` | aktiver Zustand, 6 % Deckung | +| Zustände | `--ok` `--warn` `--err` | nur für Zustände, nie zur Zierde | + +**Regel für den Akzent:** Pro Ansicht ein blauer Knopf. Steht ein zweiter daneben, ist +einer davon falsch eingestuft. (Genau dieser Fehler stand kurz im Teilen-Dialog — +„Per E-Mail schicken" und „Fertig" waren beide blau.) + +## Typografie + +- **Space Grotesk** für alles Gelesene, **Space Mono** für Marken, Maße, Kennungen. + Beide selbst gehostet (`@fontsource`) — keine Anfrage an einen fremden Server. +- `.mono-label`: 10 px, `letter-spacing: .18em`, Großbuchstaben, `--soft`. Das ist die + **einzige** Art, Abschnitte zu betiteln. Sie kommt aus der Druckvorstufe und hält die + Oberfläche ruhig, weil sie nie mit dem Inhalt konkurriert. +- Fließtext 15–16 px, Zeilenhöhe 1.55–1.6, Zeilenlänge höchstens ~68ch. +- Überschriften mit `letter-spacing: -0.02em`; Stufenverhältnis mindestens 1.25. +- **16 px in jedem Eingabefeld** — darunter zoomt iOS beim Fokus hinein. Steht als Regel + in `tokens.css` und gilt ohne Ausnahme. + +## Form + +- Radius **4 px** (`--radius`), innen 3 px. Nie mehr. Weiche Ecken erzählen „App-Store", + scharfe erzählen „Werkzeug". +- Schatten praktisch unsichtbar (`0 1px 2px` bei 5 % Deckung). Tiefe entsteht über + Fläche und Linie, nicht über Weichzeichner. +- **Glasoptik nirgends.** Einzige Ausnahme: der Hintergrund hinter einem Dialog, damit + klar ist, was gerade nicht anfassbar ist. + +## Layout + +- `--maxw: 1060px` für Textseiten. Arbeitsansichten dürfen breiter werden, wenn sie den + Platz auch nutzen. +- Karten gruppieren, sie dekorieren nicht. **Nie verschachtelt.** +- Umbruchpunkte, die tatsächlich benutzt werden: **1200** (Einstellungen zweispaltig), + **1080** (eine Spalte, Vorschau nach vorn), **768** (Kopfleiste → Tab-Leiste unten), + **720** (Bildkarten werden zu Zeilen), **640/560** (Dialoge werden zu Blättern von unten). +- Geprüft wird bei **390 / 820 / 1440 / 1680 px**. Kein waagerechtes Scrollen, nirgends. + +## Bewegung + +Sparsam. `transition` auf Farbe und Hintergrund (0.15 s), `transform: scale(0.97)` beim +Drücken (0.06 s). Kein Federn, kein Nachwippen. `prefers-reduced-motion` schaltet alles ab. + +## Muster + +- **Dialog**: mittig am Rechner, **Blatt von unten** am Handy. Fokus wird eingefangen, + Hintergrund festgehalten, Escape schließt. +- **Zustandsmarke**: Mono, 9–10 px, Großbuchstaben, gerundete Pille, getönter Grund. + Für „aktiv / abgeschaltet / abgelaufen / geschützt". +- **Leerer Zustand**: Passermarke, eine Überschrift, ein Satz, der sagt, was zu tun ist, + und ein Weg dorthin. Nie nur „Keine Einträge". +- **Gefahr**: Rot **nur** in der Schrift, nie als gefüllter Knopf. Ein rotes Rechteck lädt + zum Draufklicken ein; genau das will man beim Löschen nicht. + +## Was hier nicht vorkommt + +Farbverläufe. Farbige Seitenstreifen an Karten. Glasoptik als Deko. Verlaufstext. +Illustrationen. Emoji in der Oberfläche. Gleich große Icon-Karten in Reihe. +Em-Gedankenstriche in der Oberflächensprache. diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..e5f1c73 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,75 @@ +# Klarbild — Produktkontext + +> Zusammengetragen aus dem, was da ist: `README.md`, `src/styles/tokens.css`, dem Wiki +> (Sammlung „Klarbild") und dem Verhalten der Anwendung. Keine Wunschbeschreibung. + +**register:** product + +## Was es ist + +Eine selbst gehostete Bildwerkstatt für einen Ein-Personen-Digitalbetrieb mit Familie. +Zwei Hälften, die dieselbe Oberfläche teilen: + +1. **Mit KI** — Screenshots und Fundstücke bereinigen, freistellen, aufs Format bringen, + kombinieren, neu erzeugen. Läuft über OpenRouter, kostet Geld, dauert. +2. **Ohne KI** — Drucken: Bilder millimetergenau aufs physische Maß bringen und mehrere + davon in 100-%-Größe mit Schnittmarken auf einen Bogen setzen. Reine Geometrie, + kein Modell, keine Kosten, kein Warten. + +Fertige Bilder gehen an Picdrop (Kundenauslieferung), aufs NAS (zweite Sicherung) und +seit 08/2026 über **Teilen-Links** direkt an Menschen ohne Konto. + +## Wer es benutzt + +- **Till** — Betreiber, Admin, baut es selbst. Arbeitet am Rechner und am Handy, oft + zwischendurch. Erwartet, dass Maße stimmen und Dateien nicht verschwinden. +- **Lea** — nutzt es, ohne es warten zu wollen. Sagt Dinge wie „drucke das bitte in + zwanzig mal dreißig". Für sie muss der Weg vom Bild zum Ergebnis kurz und + selbsterklärend sein. +- **Empfänger von Teilen-Links** — Großeltern, Kunden. Bekommen einen Link per WhatsApp + oder Mail, haben **kein Konto**, sind oft am Handy, und sollen ohne Erklärung + verstehen, was zu tun ist. Können in Bezug auf Technik nichts vorausgesetzt bekommen. + +Auf vielen Bildern sind **Kinder**. Das ist keine Randnotiz, sondern der Grund, warum +Voreinstellungen zur Vorsicht neigen. + +## Ton + +Deutsch, per Du, sachlich und knapp. Erklärt **warum**, nicht nur was. Keine Ausrufezeichen, +keine Jubelsprache, keine Emoji in der Oberfläche (Telegram ausgenommen, dort tragen sie +Funktion). Fehlermeldungen sagen, was zu tun ist, statt was kaputt ist. + +Fachbegriffe werden benutzt, wo sie stimmen (Beschnittzugabe, Eckmarken, dpi) — und beim +ersten Auftreten in einem Halbsatz erklärt. Das Werkzeug spricht mit jemandem, der etwas +kann, aber nicht alles wissen muss. + +## Anti-Referenzen + +- **SaaS-Startseiten-Ästhetik.** Keine Farbverläufe, keine schwebenden Karten mit Schatten, + keine „Hero-Kennzahl" mit großer Zahl und kleinem Label, keine Kachelraster aus + Icon + Überschrift + drei Zeilen Text. +- **Adobe-Dichte.** Keine Werkzeugpaletten, keine verschachtelten Reiter, keine + Einstellung, die man nur mit Handbuch findet. +- **Verspieltes.** Keine abgerundeten Riesenradien, keine Illustrationen mit lachenden + Menschen, keine Konfetti-Animation nach dem Speichern. +- **Alles-ist-eine-Karte.** Karten nur, wo sie wirklich etwas gruppieren. Nie verschachtelt. + +## Grundsätze + +1. **Maße sind keine Meinung.** Wo etwas physisch wird, wird gerechnet und nachgemessen, + nicht geschätzt. Millimeter ist die führende Einheit; Pixel entstehen erst beim Rendern. +2. **Alle Wege können alles.** Web, Telegram und MCP sind gleichberechtigt. Eine neue + Funktion, die nur im Web geht, ist nicht fertig. +3. **Voreinstellungen neigen zur Vorsicht.** Teilen-Links laufen ab. Private Sitzungen + hinterlassen nichts. Beim Aufräumen fremder Dateien wird angehalten und gefragt. +4. **Sag, was passiert, bevor es passiert.** „Überstehen (−3 mm)" statt „geht nicht". + Probelauf vor der Umbenennung. Vorschau, die zeigt, was gedruckt wird. +5. **Fehler gehören ins Log, nicht ins Gesicht des Nutzers** — aber sie gehören + *irgendwohin*. Nichts still verschlucken. +6. **Es läuft auf einem Server.** Ein Node-Prozess, begrenzter Speicher. Teure Rechnung + braucht eine Obergrenze, bevor sie beginnt. + +## Register-Begründung + +Die Oberfläche **dient** der Arbeit, sie ist nicht das Produkt. Ausnahme: `/vorstellung` +ist eine Marketingseite und läuft im Brand-Register. diff --git a/src/components/AdminApp.tsx b/src/components/AdminApp.tsx index 30d6b27..2a9021e 100644 --- a/src/components/AdminApp.tsx +++ b/src/components/AdminApp.tsx @@ -12,6 +12,7 @@ export default function AdminApp() { const [storage, setStorage] = useState(null); const [rename, setRename] = useState(null); // Probelauf/Ergebnis der Umbenennung const [mailAn, setMailAn] = useState(''); // Adresse für die Testnachricht + const [smtpPw, setSmtpPw] = useState(''); // neues Postfach-Passwort (nie vorbelegt) const [targets, setTargets] = useState([]); const [nt, setNt] = useState({ protocol: 'sftp' }); const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2600); }; @@ -86,12 +87,21 @@ export default function AdminApp() { private_allowed: !!s.private_allowed, allow_nsfw: !!s.allow_nsfw, nas_enabled: !!s.nas_enabled, nas_host: s.nas_host, nas_protocol: s.nas_protocol, nas_port: s.nas_port, nas_user: s.nas_user, nas_base_path: s.nas_base_path, + // E-Mail und Teilen — ohne diese Zeilen wurde die ganze Karte stillschweigend + // verworfen und die Oberfläche meldete trotzdem „Gespeichert." + smtp_host: s.smtp_host, smtp_port: s.smtp_port, smtp_secure: s.smtp_secure !== false, + smtp_user: s.smtp_user, smtp_from: s.smtp_from, + share_default_days: s.share_default_days, }; if (orKey.trim()) body.openrouter_key = orKey.trim(); if (pdPw.trim()) body.picdrop_password = pdPw.trim(); if (nasPw.trim()) body.nas_password = nasPw.trim(); - await fetch('/api/admin/settings', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); - setOrKey(''); setPdPw(''); setNasPw(''); notify('Gespeichert.'); load(); + if (smtpPw.trim()) body.smtp_password = smtpPw.trim(); + const r = await fetch('/api/admin/settings', { + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), + }).then((x) => x.json()).catch(() => ({ error: 'Netzwerkfehler.' })); + if (r?.error) { notify(r.error); return; } + setOrKey(''); setPdPw(''); setNasPw(''); setSmtpPw(''); notify('Gespeichert.'); load(); }; const testPicdrop = async () => { notify('Teste …'); @@ -151,6 +161,7 @@ export default function AdminApp() { : `Alle ${r.geprueft} Namen sind bereits eindeutig.`); }; const renameApply = async () => { + if (!rename) { notify('Erst „Bibliothek prüfen" — dann siehst du, was sich ändern würde.'); return; } if (!confirm('Alle Bilder der Bibliothek auf eindeutige Namen bringen?\n\nDie Dateien selbst bleiben unberührt — sie liegen unter ihrer ID im Speicher. ' + 'Bereits bei Picdrop abgelegte Kopien behalten ihren alten Namen; dafür gibt es danach „Erneut ausliefern".')) return; notify('Benenne um …'); @@ -338,8 +349,9 @@ export default function AdminApp() {
field('smtp_user', e.target.value)} />
- field('smtp_password', e.target.value)} />
+ setSmtpPw(e.target.value)} />
field('smtp_from', e.target.value)} /> @@ -573,7 +585,10 @@ function AdminStyles() { .zwei{display:grid;grid-template-columns:1fr 1fr;gap:12px;} .feld label{display:block;font-family:var(--font-mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--soft);margin-bottom:6px;} .feld em{text-transform:none;letter-spacing:0;color:var(--accent);font-style:normal;} - .input{width:100%;background:#fff;border:1px solid var(--line);border-radius:3px;padding:9px 11px;font-family:inherit;font-size:14px;color:var(--ink);} + /* 16 px, nicht 14: Darunter zoomt iOS beim Fokus ins Feld hinein — die + Notbremse in tokens.css greift nur bis 767 px, der Admin wird aber auch + auf dem iPad benutzt. */ + .input{width:100%;background:var(--card);border:1px solid var(--line);border-radius:3px;padding:10px 11px;font-family:inherit;font-size:16px;color:var(--ink);} .fein{font-size:11.5px;color:var(--soft);line-height:1.5;} .knopf{border:none;border-radius:3px;padding:12px;cursor:pointer;background:var(--accent);color:#fff;font-family:inherit;font-weight:600;font-size:15px;} .knopf.warn{background:var(--err);} @@ -584,6 +599,13 @@ function AdminStyles() { .zahlen div{background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:11px;} .zahlen b{display:block;font-size:22px;line-height:1;}.zahlen span{font-size:11.5px;color:var(--soft);} .karte>.fein{padding:0 16px 14px;} + /* Am Handy war alles zweispaltig — ein Feld „Postausgangsserver" bekam so + rund 156 px. Und Tippziele unter 44 px trifft man nicht zuverlässig. */ + @media(max-width:560px){ + .zwei{grid-template-columns:1fr;} + .mini{padding:11px 13px;font-size:13px;} + .reihe{flex-wrap:wrap;} + } /* Vorher/Nachher der Umbenennung — lange Namen dürfen die Karte nicht sprengen. */ .namensliste{list-style:none;margin:8px 0 0;padding:0;display:grid;gap:4px;max-height:220px;overflow:auto;} .namensliste li{font-family:var(--font-mono);font-size:10.5px;line-height:1.5;overflow-wrap:anywhere;} diff --git a/src/components/KontoApp.tsx b/src/components/KontoApp.tsx index 5df300c..48b7708 100644 --- a/src/components/KontoApp.tsx +++ b/src/components/KontoApp.tsx @@ -16,10 +16,17 @@ export default function KontoApp() { const [email, setEmail] = useState(''); const [msg, setMsg] = useState<{ t: string; ok: boolean } | null>(null); const [laeuft, setLaeuft] = useState(false); + const [ladefehler, setLadefehler] = useState(false); - useEffect(() => { - fetch('/api/me').then((r) => r.json()).then((m) => { setMe(m); setEmail(m.email || ''); }).catch(() => {}); - }, []); + const holen = () => { + setLadefehler(false); + fetch('/api/me') + .then((r) => { if (!r.ok) throw new Error(String(r.status)); return r.json(); }) + .then((m) => { setMe(m); setEmail(m.email || ''); }) + // Ohne diesen Zweig stand hier für immer „Einen Moment …". + .catch(() => setLadefehler(true)); + }; + useEffect(holen, []); const sagen = (t: string, ok: boolean) => { setMsg({ t, ok }); setTimeout(() => setMsg(null), 5000); }; @@ -39,6 +46,15 @@ export default function KontoApp() { return true; }; + if (ladefehler) return ( +
+
+

Dein Konto ließ sich gerade nicht laden — vielleicht war die + Verbindung kurz weg.

+ +
+
+ ); if (!me) return

Einen Moment …

; return ( diff --git a/src/components/ShareDialog.tsx b/src/components/ShareDialog.tsx index 6474557..a13627d 100644 --- a/src/components/ShareDialog.tsx +++ b/src/components/ShareDialog.tsx @@ -28,17 +28,22 @@ export default function ShareDialog( const [wort, setWort] = useState(''); const [link, setLink] = useState<{ slug: string; id: string } | null>(null); const [laeuft, setLaeuft] = useState(false); - const [msg, setMsg] = useState(null); + const [msg, setMsg] = useState<{ t: string; ok: boolean } | null>(null); const [mailAn, setMailAn] = useState(''); const [mailText, setMailText] = useState(''); - const [mailBereit, setMailBereit] = useState(false); + const [mailBereit, setMailBereit] = useState(null); const [kopiert, setKopiert] = useState(false); const box = useRef(null); const url = link ? `${location.origin}/s/${link.slug}` : ''; useEffect(() => { - fetch('/api/me').then((r) => r.json()).then((m) => setMailBereit(!!m.mail_ready)).catch(() => {}); + fetch('/api/me').then((r) => r.json()).then((m) => { + setMailBereit(!!m.mail_ready); + // Voreinstellung aus dem Admin übernehmen — sonst verspricht die Einstellung + // dort etwas, das hier nicht ankommt. + if (typeof m.share_default_days === 'number') setTage(m.share_default_days); + }).catch(() => setMailBereit(null)); }, []); // Fokus einfangen und Hintergrund festhalten, solange der Dialog offen ist. @@ -69,7 +74,7 @@ export default function ShareDialog( }), }).then((x) => x.json()).catch(() => ({ error: 'Netzwerkfehler.' })); setLaeuft(false); - if (r.error) { setMsg(r.error); return; } + if (r.error) { setMsg({ t: r.error, ok: false }); return; } setLink({ slug: r.share.slug, id: r.share.id }); onFertig?.(); }; @@ -87,17 +92,19 @@ export default function ShareDialog( const waHref = `https://wa.me/?text=${encodeURIComponent(waText)}`; const mailen = async () => { - if (!mailAn.trim()) { setMsg('Bitte eine Empfängeradresse eintragen.'); return; } + if (!mailAn.trim()) { setMsg({ t: 'Bitte eine Empfängeradresse eintragen.', ok: false }); return; } setLaeuft(true); setMsg(null); const r = await fetch('/api/shares/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ shareId: link!.id, to: mailAn, message: mailText }), }).then((x) => x.json()).catch(() => ({ error: 'Netzwerkfehler.' })); setLaeuft(false); - if (r.error) { setMsg(r.error); return; } + if (r.error) { setMsg({ t: r.error, ok: false }); return; } + // Teilweiser Erfolg ist Erfolg: Vorher färbte eine Textmustererkennung + // „2 verschickt, bei 1 hat es nicht geklappt" komplett rot. setMsg(r.fehler?.length - ? `${r.ok} verschickt, bei ${r.fehler.length} hat es nicht geklappt.` - : `Verschickt an ${r.ok === 1 ? 'die Adresse' : `${r.ok} Adressen`}.`); + ? { t: `${r.ok} verschickt, bei ${r.fehler.length} hat es nicht geklappt.`, ok: r.ok > 0 } + : { t: `Verschickt an ${r.ok === 1 ? 'die Adresse' : `${r.ok} Adressen`}.`, ok: true }); setMailAn(''); }; @@ -121,29 +128,40 @@ export default function ShareDialog( placeholder={mehrere ? 'z. B. Kita-Fotos August' : 'z. B. Phoenix im Garten'} maxLength={120} /> -