Compare commits
73 Commits
feat/issue
...
46d64f50a5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46d64f50a5 | ||
|
|
1a57ec2036 | ||
|
|
e362bc4977 | ||
|
|
01ba0d4121 | ||
|
|
2e6366faf7 | ||
|
|
9dd35999e0 | ||
|
|
e94f43264c | ||
|
|
da7f94de84 | ||
|
|
3f0b686963 | ||
|
|
1e9ef63191 | ||
|
|
51348ad26a | ||
|
|
dba1e2a8eb | ||
|
|
654b1283c1 | ||
|
|
c5b98af69b | ||
|
|
03e2382c8a | ||
|
|
528e1e05ea | ||
|
|
c64abccf63 | ||
|
|
47960b5028 | ||
|
|
7f2940f0f2 | ||
|
|
37d728b006 | ||
|
|
965087b787 | ||
|
|
1d2e6d7b86 | ||
|
|
0c40e10743 | ||
|
|
358131ca34 | ||
|
|
c7af33b998 | ||
|
|
eafb566170 | ||
|
|
624eb9e5d6 | ||
|
|
7bd995a045 | ||
|
|
20dbe04d45 | ||
|
|
c9211b3061 | ||
|
|
27254fb0ac | ||
|
|
b5a68e69e2 | ||
|
|
b1e959412f | ||
|
|
19035fbeab | ||
|
|
79faee554a | ||
|
|
5adef7bec5 | ||
|
|
595c2eb987 | ||
|
|
518019f099 | ||
|
|
38b8804b17 | ||
|
|
81ed1ce3ed | ||
|
|
92e7aa127c | ||
|
|
f618364632 | ||
|
|
20923d04b6 | ||
|
|
6d61297182 | ||
|
|
fb636e4152 | ||
|
|
527d174e9c | ||
|
|
f1bf32ee05 | ||
|
|
a5cc8fd16e | ||
|
|
1541afd470 | ||
|
|
d0deb26065 | ||
|
|
f04e4ffa8b | ||
|
|
17889df220 | ||
|
|
fe1121de65 | ||
|
|
2004a80055 | ||
|
|
f70b5ae6bd | ||
|
|
12b8324245 | ||
|
|
a9b648454e | ||
|
|
938a4b07bf | ||
|
|
7e43bd43a4 | ||
|
|
56926efd03 | ||
|
|
a6ee444f3b | ||
|
|
2dd73cf594 | ||
|
|
53038dea68 | ||
|
|
281934529e | ||
|
|
c905f136d2 | ||
|
|
36bf591afe | ||
|
|
550a9704ad | ||
|
|
55e681c209 | ||
|
|
e65ddc655e | ||
|
|
14b1cc7539 | ||
|
|
adc1f343b2 | ||
|
|
3dfaf69fb1 | ||
|
|
fd2a7a8e96 |
963
docs/specs/annotation-transcription-final-spec.html
Normal file
963
docs/specs/annotation-transcription-final-spec.html
Normal file
@@ -0,0 +1,963 @@
|
||||
<!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 — Final Spec</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-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);}
|
||||
.jh-g{background:var(--green-tint);border:1px solid #A0D8A8;}.jh-g .jn{color:var(--green);}.jh-g p,.jh-g .fl{color:var(--green-dark);}
|
||||
.jh-o{background:var(--orange-tint);border:1px solid #F0C89A;}.jh-o .jn{color:var(--orange);}.jh-o p,.jh-o .fl{color:var(--orange-dark);}
|
||||
|
||||
.scr{margin-bottom:56px;}
|
||||
.scr-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;}
|
||||
.scr-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
|
||||
.scr-id{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);padding:2px 8px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-page);}
|
||||
.scr-desc{font-size:12px;color:var(--color-text-muted);line-height:1.6;max-width:720px;margin-bottom:6px;}
|
||||
.scr-var{font-size:11px;color:var(--color-text-muted);margin-bottom:20px;}.scr-var strong{color:var(--color-text);}
|
||||
|
||||
.previews{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;align-items:flex-start;margin-bottom:20px;}
|
||||
.prev-col{display:flex;flex-direction:column;align-items:center;gap:10px;}
|
||||
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
|
||||
|
||||
.desk{width:100%;max-width:1040px;background:var(--color-page);border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.06);display:flex;flex-direction:column;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%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;flex-shrink:0;}
|
||||
.fa-chip .av.navy{background:var(--navy);color:var(--mint);}
|
||||
.fa-chip .av.purple{background:#5A3080;color:#fff;}
|
||||
.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;}
|
||||
|
||||
.details-toggle{display:inline-flex;align-items:center;gap:3px;padding:2px 8px 2px 6px;border-radius:4px;font-size:7px;font-weight:600;color:var(--color-text-muted);cursor:pointer;border:1px solid var(--color-border);background:transparent;white-space:nowrap;}
|
||||
|
||||
/* ── 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);}
|
||||
|
||||
/* ── Inline comment thread ── */
|
||||
.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;}
|
||||
|
||||
/* ── 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;}
|
||||
|
||||
/* ── Transcript toolbar ── */
|
||||
.trans-toolbar{background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:4px 8px;gap:6px;flex-shrink:0;}
|
||||
.trans-toolbar-btn{font-size:6px;font-weight:600;padding:2px 6px;border-radius:3px;border:1px solid var(--color-border);color:var(--color-text-muted);background:transparent;cursor:pointer;display:flex;align-items:center;gap:2px;}
|
||||
.trans-toolbar-btn:hover{background:var(--sand);color:var(--color-text);}
|
||||
.trans-toolbar-btn.active{background:var(--navy);color:#fff;border-color:var(--navy);}
|
||||
|
||||
/* ── History panel (in-toolbar) ── */
|
||||
.history-panel{background:var(--color-page);border:1px solid var(--color-border);border-radius:5px;margin:4px 8px;padding:6px 8px;font-size:7px;}
|
||||
.history-entry{display:flex;align-items:center;gap:4px;padding:3px 0;border-bottom:1px solid var(--color-subtle);}
|
||||
.history-entry:last-child{border-bottom:none;}
|
||||
.history-entry .he-date{font-size:6px;color:var(--color-text-muted);min-width:40px;}
|
||||
.history-entry .he-user{font-size:6px;font-weight:600;color:var(--color-text);min-width:40px;}
|
||||
.history-entry .he-diff{font-size:7px;color:var(--color-text);}
|
||||
.he-add{background:var(--green-tint);color:var(--green-dark);padding:0 2px;border-radius:1px;}
|
||||
.he-del{background:#FEE2E2;color:#991B1B;padding:0 2px;border-radius:1px;text-decoration:line-through;}
|
||||
|
||||
/* ── Status bar ── */
|
||||
.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);}
|
||||
|
||||
/* ── 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 h4{font-size:12px;font-weight:600;margin:14px 0 6px;color:var(--color-text-muted);}
|
||||
.llm p,.llm li{font-size:13px;color:var(--color-text-muted);line-height:1.65;}
|
||||
.llm ul,.llm ol{padding-left:20px;margin-bottom:12px;}
|
||||
.llm li{margin-bottom:4px;}
|
||||
.llm code{font-family:var(--font-mono);font-size:11px;background:var(--color-surface);padding:1px 5px;border-radius:3px;}
|
||||
.llm table{width:100%;border-collapse:collapse;margin:12px 0;font-size:12px;}
|
||||
.llm th,.llm td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--color-border);}
|
||||
.llm th{font-weight:500;color:var(--color-text);font-size:11px;text-transform:uppercase;letter-spacing:.05em;}
|
||||
.llm td{color:var(--color-text-muted);}
|
||||
|
||||
@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>Final spec for the collaborative inline transcription system. Draw turquoise rectangles on the scanned letter → numbered transcript blocks appear in a side panel → type what you read. Block-level comment threads with quoted selections for discussion. History in the transcript toolbar. No bottom panel.</p>
|
||||
</div>
|
||||
<div class="doc-meta">
|
||||
Familienarchiv<br/>
|
||||
<span class="pill pill-g">Final spec</span><br/>
|
||||
2026-04-04 · @leonievoss
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══ CORE CONCEPT ═══ -->
|
||||
<div class="section">
|
||||
<div class="section-title">Core concept — Draw-to-Transcribe</div>
|
||||
<p class="prose">Today, annotations are rectangles on the PDF that open a comment thread in the side panel. By adding a <code>type</code> field to <code>DocumentAnnotation</code>, the same draw-a-rectangle gesture can create a <strong>transcription annotation</strong> (turquoise). A transcription annotation links a PDF region to an editable text block in the right panel.</p>
|
||||
<p class="prose">Comments live <strong>inside transcript blocks</strong> as block-level threads. Users can select a word or phrase before commenting — the selection is <strong>auto-quoted</strong> into the comment message (e.g. <code>> “Breslau”</code>) rather than structurally anchored to character offsets. This avoids fragile offset tracking that breaks when text is edited. The quote is a display hint, not a structural anchor. Yellow comment annotations are <strong>disabled in transcribe mode</strong> — only turquoise transcription rectangles appear on the PDF.</p>
|
||||
</div>
|
||||
|
||||
<div class="jh jh-b">
|
||||
<div class="jn">T</div>
|
||||
<div><h2>Draw-to-transcribe workflow</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 simultaneously.</p><div class="fl">Reuses: AnnotationLayer + PdfViewer + CommentThread · New: TranscriptBlock + TranscriptEditor + type:transcription</div></div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══ WHAT STAYS / CHANGES / NEW ═══ -->
|
||||
<div class="section">
|
||||
<div class="section-title">What stays, what changes, what’s new</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>
|
||||
</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> slot → becomes the transcript editor panel</li>
|
||||
<li><code>annotateMode</code> state → split into <code>annotateMode</code> + <code>transcribeMode</code></li>
|
||||
<li>Annotation color → turquoise only in transcribe mode, yellow only in annotate mode (mutually exclusive)</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(--green);margin-bottom:6px;">New</div>
|
||||
<ul style="padding-left:16px;color:var(--color-text-muted);">
|
||||
<li><code>transcription_blocks</code> table</li>
|
||||
<li>Transcript editor component (right panel)</li>
|
||||
<li>Block-level comment threads (quoted selections)</li>
|
||||
<li><code>type</code> column on <code>document_annotations</code></li>
|
||||
<li>History in transcript toolbar</li>
|
||||
<li>Bottom panel removed (all modes)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
SCREEN 1 — DESKTOP TRANSCRIBE MODE
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="scr" id="desktop">
|
||||
<div class="scr-head"><h3>Desktop — transcribe mode active</h3><span class="scr-id">S1</span></div>
|
||||
<div class="scr-desc">Two users are collaborating. <strong>Only turquoise</strong> transcription rectangles appear on the PDF — no yellow comment annotations in transcribe mode. One user (Oma Inge, purple) is editing Block 2. The current user (blue) is editing Block 3. Block 2 has a comment thread where Oma Inge quoted “Breslau” to discuss the reading. Each block has a “Kommentieren” button in its footer. The transcript toolbar shows “Verlauf” (history). No bottom panel.</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="details-toggle">Details ▼</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:400px;">
|
||||
<!-- PDF with annotation rectangles -->
|
||||
<div class="split-left">
|
||||
<div class="pdf-area" style="flex:1;">
|
||||
<div class="paper" style="width:55%;min-height:240px;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>
|
||||
|
||||
<!-- No yellow comment annotations in transcribe mode —
|
||||
only turquoise transcription rects on the PDF -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="split-handle"></div>
|
||||
|
||||
<!-- Transcript editor panel -->
|
||||
<div class="split-right" style="width:380px;">
|
||||
<!-- Transcript toolbar -->
|
||||
<div class="trans-toolbar">
|
||||
<span style="font-size:7px;font-weight:600;color:var(--navy);">4 Blöcke</span>
|
||||
<div style="flex:1;"></div>
|
||||
<div class="trans-toolbar-btn">☰ Sortieren</div>
|
||||
<div class="trans-toolbar-btn">🕑 Verlauf</div>
|
||||
<span style="font-size:7px;color:var(--green-dark);">✓ Gespeichert</span>
|
||||
</div>
|
||||
|
||||
<!-- Block list -->
|
||||
<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 (edited by Oma Inge) -->
|
||||
<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 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>
|
||||
|
||||
<!-- Block-level thread with quoted selection -->
|
||||
<div class="inline-thread">
|
||||
<div class="thread-head">💬 2 Kommentare</div>
|
||||
<div class="thread-msg">
|
||||
<div class="thread-av" style="background:var(--purple);">OI</div>
|
||||
<div>
|
||||
<strong style="font-size:7px;">Oma Inge</strong> · <span style="font-size:7px;color:var(--color-text-muted);">vor 12 Min.</span>
|
||||
<div style="border-left:2px solid var(--color-border);padding-left:4px;margin:2px 0;font-size:7px;font-style:italic;color:var(--color-text-muted);">“Breslau”</div>
|
||||
<div>Ich bin sicher, das ist “Breslau” — Heinrich war dort im Lazarett.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="thread-msg">
|
||||
<div class="thread-av" style="background:var(--blue);">DU</div>
|
||||
<div>
|
||||
<strong style="font-size:7px;">Du</strong> · <span style="font-size:7px;color:var(--color-text-muted);">vor 8 Min.</span>
|
||||
<div>Stimmt, danke! Lass ich so.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="thread-reply">
|
||||
<input placeholder="Antworten..."/>
|
||||
<div class="resolve-btn">✓ Lösen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Block footer with comment button -->
|
||||
<div class="tblock-footer">
|
||||
<span style="cursor:pointer;color:var(--orange);display:flex;align-items:center;gap:2px;">💬 Kommentieren</span>
|
||||
<span style="margin-left:auto;font-size:5px;color:var(--color-text-muted);">Text markieren für Zitat</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Block 3 — Family (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 class="tblock-footer">
|
||||
<span style="cursor:pointer;color:var(--color-text-muted);display:flex;align-items:center;gap:2px;">💬 Kommentieren</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 3 aktiv</span>
|
||||
<span>Oma Inge · Block 2</span>
|
||||
<span style="margin-left:auto;">1 offene Diskussion</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NO bottom panel in transcribe mode -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
SCREEN 2 — COMMENT FLOW: SELECT → QUOTE → DISCUSS
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="scr" id="comment-flow">
|
||||
<div class="scr-head"><h3>Comment flow — select, quote, discuss</h3><span class="scr-id">S2</span></div>
|
||||
<div class="scr-desc">The user has selected “[unleserlich]” in Block 2 and clicked “Kommentieren”. The comment input opens with the selection auto-quoted. After posting, the comment appears in the thread with the quote displayed as an indented blockquote. This shows the full lifecycle: selection → quoted input → posted comment.</div>
|
||||
<div class="scr-var"><strong>Block-level threads + quoted selections</strong> — no char-offset anchoring, no fragile highlights. The quote is frozen text in the message body.</div>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col">
|
||||
<div class="bp-lbl">Desktop · comment input open with auto-quote</div>
|
||||
<div class="desk" style="min-height:380px;">
|
||||
<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="details-toggle">Details ▼</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>
|
||||
|
||||
<div class="split" style="height:290px;">
|
||||
<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="ann-rect trans active" style="left:2%;top:14%;width:96%;height:40%;"><div class="ann-num">2</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="split-handle"></div>
|
||||
|
||||
<div class="split-right" style="width:380px;">
|
||||
<div class="trans-toolbar">
|
||||
<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 2 with text selection + open comment input -->
|
||||
<div class="tblock active">
|
||||
<div class="tblock-head active-bg"><div class="num">2</div> Hauptteil</div>
|
||||
<div class="tblock-body editing">ich schreibe Dir heute aus dem Lazarett in Breslau. Mach Dir keine Sorgen. Der Arzt sagt <span style="background:rgba(45,125,210,.25);border-radius:1px;padding:0 1px;">[unleserlich]</span> Wochen noch dauern wird.</div>
|
||||
|
||||
<!-- Comment input — open, with auto-quoted selection -->
|
||||
<div style="margin:0 8px 5px;padding:6px 8px;border-radius:4px;border:1px solid var(--orange);background:#fff;">
|
||||
<div style="font-size:6px;font-weight:600;color:var(--orange-dark);margin-bottom:3px;">Neuer Kommentar zu Block 2</div>
|
||||
<!-- Auto-quoted selection shown as editable blockquote -->
|
||||
<div style="border-left:2px solid var(--color-border);padding-left:4px;margin-bottom:3px;font-size:7px;font-style:italic;color:var(--color-text-muted);display:flex;align-items:center;gap:3px;">
|
||||
> “[unleserlich]”
|
||||
<span style="font-size:5px;color:var(--color-text-muted);font-style:normal;cursor:pointer;margin-left:auto;">✕ Zitat entfernen</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:3px;">
|
||||
<input style="flex:1;font-size:7px;padding:3px 5px;border:1px solid var(--color-border);border-radius:3px;background:var(--color-page);" value='Könnte "sechs" oder "acht" sein. Wer hat die Originale?'/>
|
||||
<button style="font-size:6px;font-weight:600;padding:3px 8px;border-radius:3px;background:var(--navy);color:#fff;border:none;cursor:pointer;">Senden</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tblock-footer">
|
||||
<span style="cursor:pointer;color:var(--orange);display:flex;align-items:center;gap:2px;font-weight:600;">💬 Kommentieren</span>
|
||||
<span style="margin-left:auto;font-size:5px;color:var(--color-text-muted);">Text markieren für Zitat</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Block 3 — with an existing posted comment showing the quote -->
|
||||
<div class="tblock">
|
||||
<div class="tblock-head"><div class="num">3</div> Familie</div>
|
||||
<div class="tblock-body">Die Kinder sollen wissen, dass ich an sie denke.</div>
|
||||
|
||||
<!-- Posted comment thread with quoted selection -->
|
||||
<div class="inline-thread">
|
||||
<div class="thread-head">💬 1 Kommentar</div>
|
||||
<div class="thread-msg">
|
||||
<div class="thread-av" style="background:var(--purple);">OI</div>
|
||||
<div>
|
||||
<strong style="font-size:7px;">Oma Inge</strong> · <span style="font-size:7px;color:var(--color-text-muted);">vor 5 Min.</span>
|
||||
<div style="border-left:2px solid var(--color-border);padding-left:4px;margin:2px 0;font-size:7px;font-style:italic;color:var(--color-text-muted);">“Die Kinder”</div>
|
||||
<div>Fritz und Lotte. Fritz war damals 4, Lotte 7.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="thread-reply">
|
||||
<input placeholder="Antworten..."/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tblock-footer">
|
||||
<span style="cursor:pointer;color:var(--color-text-muted);display:flex;align-items:center;gap:2px;">💬 Kommentieren</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-bar"><span>Block 2 aktiv</span><span style="margin-left:auto;">2 Kommentare</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>Comment flow · Select → Quote → Discuss</h4>
|
||||
<pre>/* Comment flow for block-level threads with quoted selections:
|
||||
*
|
||||
* 1. TRIGGER: user clicks "Kommentieren" in block footer.
|
||||
* Alternatively: Ctrl+Shift+K when block is focused.
|
||||
*
|
||||
* 2. AUTO-QUOTE: if text is selected in the block body (via mouse or keyboard),
|
||||
* the selection is captured and pre-filled as a blockquote in the comment input:
|
||||
* > "[unleserlich]"
|
||||
* The user can edit or remove the quote before sending (× button).
|
||||
* If no text was selected → input opens empty (general block comment).
|
||||
*
|
||||
* 3. STORAGE: the quote is stored as part of the comment `content` field.
|
||||
* Markdown blockquote syntax: "> \"Breslau\"\nI think this is Breslau."
|
||||
* The block_id FK on DocumentComment links the comment to its block.
|
||||
* NO char_offset_start/end columns. The quote is just text.
|
||||
*
|
||||
* 4. DISPLAY: the quote renders as an indented italic line with a left border,
|
||||
* above the comment text. It's visually distinct but structurally just content.
|
||||
*
|
||||
* 5. RESILIENCE: if the transcription text changes after quoting, nothing breaks.
|
||||
* The quote is a frozen snapshot. The discussion context is preserved.
|
||||
* Compare to char-offset anchoring where an edit would shift all offsets
|
||||
* and potentially point to the wrong text.
|
||||
*
|
||||
* 6. THREAD: replies to a quoted comment don't need their own quotes —
|
||||
* the parent comment provides context. Standard CommentThread reply flow.
|
||||
*
|
||||
* 7. MOBILE: "Kommentieren" button always visible in footer.
|
||||
* Selecting text → auto-quote works the same via touch selection.
|
||||
* Thread collapsed to "N Kommentare" row, tap to expand. */</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 input</td></tr>
|
||||
<tr><td>Container</td><td>border:orange, bg:white, radius:4px, mx:8px</td><td>Appears below block body, above footer</td></tr>
|
||||
<tr><td>Quote display</td><td>left-border:2px line, italic, 7px muted</td><td>Editable — user can modify or remove</td></tr>
|
||||
<tr><td>Remove quote</td><td>"× Zitat entfernen" link, 5px, top-right of quote</td><td>Converts to general block comment</td></tr>
|
||||
<tr><td>Input field</td><td>flex:1, 7px, border:line, bg:page, radius:3px</td><td>Auto-focuses when opened</td></tr>
|
||||
<tr><td>Send button</td><td>"Senden", 6px/600, navy bg, white text</td><td>Enter to send, Shift+Enter for newline</td></tr>
|
||||
<tr class="grp"><td colspan="3">Posted comment with quote</td></tr>
|
||||
<tr><td>Quote in thread</td><td>left-border:2px line, italic, 7px muted</td><td>Read-only — frozen snapshot of selected text</td></tr>
|
||||
<tr><td>Message below</td><td>8px normal text, below the quote</td><td>Standard CommentThread message styling</td></tr>
|
||||
<tr class="grp"><td colspan="3">Data model</td></tr>
|
||||
<tr><td>block_id</td><td>UUID FK → transcription_blocks (nullable)</td><td>Links comment to its block</td></tr>
|
||||
<tr><td>content</td><td>TEXT with markdown blockquote</td><td>> "quoted text"\nComment message</td></tr>
|
||||
<tr><td>No char offsets</td><td>—</td><td>Intentional. See spec rationale.</td></tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
SCREEN 3 — HISTORY IN TRANSCRIPT TOOLBAR
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="scr" id="history">
|
||||
<div class="scr-head"><h3>Desktop — history panel open</h3><span class="scr-id">S3</span></div>
|
||||
<div class="scr-desc">Clicking “Verlauf” in the transcript toolbar opens a collapsible history panel between the toolbar and the block list. Shows recent changes with word-level diffs, just like the existing <code>PanelHistory</code> component but embedded in the transcript panel instead of the bottom panel.</div>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col">
|
||||
<div class="bp-lbl">Desktop · 1040px · history open</div>
|
||||
<div class="desk" style="min-height:540px;">
|
||||
<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="details-toggle">Details ▼</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>
|
||||
|
||||
<div class="hint-strip trans-hint">
|
||||
<span>Transkribieren</span>
|
||||
<span class="hint-step">— Markiere eine Textpassage im Scan</span>
|
||||
</div>
|
||||
|
||||
<div class="split" style="height:420px;">
|
||||
<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="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" 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>
|
||||
|
||||
<div class="split-right" style="width:380px;">
|
||||
<!-- Toolbar with history active -->
|
||||
<div class="trans-toolbar">
|
||||
<span style="font-size:7px;font-weight:600;color:var(--navy);">4 Blöcke</span>
|
||||
<div style="flex:1;"></div>
|
||||
<div class="trans-toolbar-btn">☰ Sortieren</div>
|
||||
<div class="trans-toolbar-btn active">🕑 Verlauf</div>
|
||||
<span style="font-size:7px;color:var(--green-dark);">✓ Gespeichert</span>
|
||||
</div>
|
||||
|
||||
<!-- History panel (collapsible) -->
|
||||
<div class="history-panel">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px;">
|
||||
<span style="font-size:6px;font-weight:600;color:var(--navy);text-transform:uppercase;letter-spacing:.06em;">Letzte Änderungen</span>
|
||||
<span style="font-size:6px;color:var(--color-text-muted);cursor:pointer;">Alle anzeigen →</span>
|
||||
</div>
|
||||
<div class="history-entry">
|
||||
<span class="he-date">14:23</span>
|
||||
<span class="he-user">Oma Inge</span>
|
||||
<span class="he-diff">Block 2: ...Lazarett in <span class="he-add">Breslau</span><span class="he-del">Bresla</span>...</span>
|
||||
</div>
|
||||
<div class="history-entry">
|
||||
<span class="he-date">14:18</span>
|
||||
<span class="he-user">Du</span>
|
||||
<span class="he-diff">Block 3: <span class="he-add">Die Kinder sollen wissen, dass ich an sie denke.</span></span>
|
||||
</div>
|
||||
<div class="history-entry">
|
||||
<span class="he-date">14:12</span>
|
||||
<span class="he-user">Oma Inge</span>
|
||||
<span class="he-diff">Block 2: <span class="he-add">ich schreibe Dir heute aus dem Lazarett</span></span>
|
||||
</div>
|
||||
<div class="history-entry">
|
||||
<span class="he-date">14:05</span>
|
||||
<span class="he-user">Du</span>
|
||||
<span class="he-diff">Block 1: <span class="he-add">Liebe Martha,</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Blocks below history -->
|
||||
<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</div>
|
||||
<div class="tblock-body">ich schreibe Dir heute aus dem Lazarett in Breslau...</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>
|
||||
<div class="tblock-body editing hl-blue">Die Kinder sollen wissen...<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;">✓ Gespeichert</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
SCREEN 3 — MOBILE TRANSCRIBE MODE
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="scr" id="mobile">
|
||||
<div class="scr-head"><h3>Mobile — transcribe mode</h3><span class="scr-id">S4</span></div>
|
||||
<div class="scr-desc">On mobile, the PDF collapses to a 90px strip at the top. Annotation rectangles are visible as thin outlines. Transcript blocks stack vertically below. The history button is in the toolbar above the blocks. Inline threads expand in-place.</div>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col">
|
||||
<div class="bp-lbl">Mobile · 320px</div>
|
||||
<div class="phone" style="height:600px;">
|
||||
<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 annotations -->
|
||||
<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>
|
||||
<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>
|
||||
<!-- Toolbar -->
|
||||
<div style="background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:4px 12px;gap:4px;">
|
||||
<span style="font-size:8px;font-weight:600;color:var(--navy);">4 Blöcke</span>
|
||||
<div style="flex:1;"></div>
|
||||
<div style="font-size:7px;font-weight:600;padding:3px 6px;border-radius:3px;border:1px solid var(--color-border);color:var(--color-text-muted);">🕑 Verlauf</div>
|
||||
<span style="font-size:7px;color:var(--green-dark);">✓</span>
|
||||
</div>
|
||||
<!-- Block list -->
|
||||
<div style="flex:1;overflow-y:auto;padding:8px 12px;background:#fff;">
|
||||
<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>
|
||||
<!-- Inline thread (collapsed on mobile — tap to expand) -->
|
||||
<div style="padding:3px 8px;border-top:1px solid var(--color-subtle);display:flex;align-items:center;gap:3px;">
|
||||
<span style="font-size:7px;color:var(--orange);">💬</span>
|
||||
<span style="font-size:7px;color:var(--color-text-muted);">1 Diskussion · “Breslau”</span>
|
||||
<span style="font-size:7px;color:var(--color-text-muted);margin-left:auto;">▼</span>
|
||||
</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 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;">4</div> Schluss <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;">In ewiger Liebe,<br/>Dein Heinrich</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══ AGENT TABLES ═══ -->
|
||||
<div class="agent">
|
||||
<h4>Annotation-backed transcription · Core implementation spec</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: block-level threads with quoted selections.
|
||||
* - Each block has a "Kommentieren" button in its footer.
|
||||
* - If text is selected when clicking "Kommentieren", the selection is auto-quoted
|
||||
* into the comment (> "Breslau"). The quote is plain text in the message body,
|
||||
* NOT a structural char-offset anchor. It doesn't break when text changes.
|
||||
* - Threads are anchored to block_id only (no char offsets).
|
||||
* - Yellow comment annotations are DISABLED in transcribe mode.
|
||||
* Only turquoise transcription rects on the PDF. One annotation type per mode.
|
||||
*
|
||||
* History: "Verlauf" button in transcript toolbar toggles a collapsible panel
|
||||
* showing recent changes with word-level diffs per block.
|
||||
* Auto-save: debounced PATCH to /api/transcription-blocks/{blockId} (500ms).
|
||||
* Bottom panel: removed entirely (all modes). Metadata → topbar drawer. */</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 annotations disabled in transcribe mode</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>Block label</td><td>Editable text, defaults: Anrede, Hauptteil, Schluss</td><td>Double-click to rename</td></tr>
|
||||
<tr><td>Empty state</td><td>Dashed border, "noch leer" italic text</td><td>Focus to start typing</td></tr>
|
||||
<tr><td>Add block CTA</td><td>Dashed card: "Markiere eine Passage im Scan..."</td><td>Not clickable — directs user to draw on PDF</td></tr>
|
||||
<tr class="grp"><td colspan="3">Block-level comment threads</td></tr>
|
||||
<tr><td>Trigger</td><td>"Kommentieren" button in block footer</td><td>Always visible — no hover-reveal</td></tr>
|
||||
<tr><td>Quoted selection</td><td>If text selected → auto-quoted into comment body</td><td>Plain text quote (> "Breslau"), NOT char-offset anchor</td></tr>
|
||||
<tr><td>Quote display</td><td>Left border + italic, above the comment text</td><td>Decorative only — doesn't link to text range</td></tr>
|
||||
<tr><td>Thread UI</td><td>orange left-border, orange-tint bg, below block body</td><td>Block-level anchor (block_id). Reuses CommentThread.</td></tr>
|
||||
<tr><td>Footer hint</td><td>"Text markieren für Zitat" in 5px muted text</td><td>Only shown when block is active/focused</td></tr>
|
||||
<tr><td>Resolve</td><td>"✓ Lösen" button collapses thread</td><td>Resolved threads hidden by default, toggle to show</td></tr>
|
||||
<tr><td>Mobile</td><td>Threads collapsed to "2 Kommentare" row, tap to expand</td><td>Saves vertical space on small screens</td></tr>
|
||||
<tr class="grp"><td colspan="3">Yellow annotations in transcribe mode</td></tr>
|
||||
<tr><td>Status</td><td>Disabled — draw gesture only creates turquoise rects</td><td>Existing yellow annotations still visible (read-only)</td></tr>
|
||||
<tr><td>Annotate mode</td><td>Still available via topbar "Annotieren" button</td><td>Exits transcribe mode, enters annotate mode (yellow)</td></tr>
|
||||
<tr class="grp"><td colspan="3">History (transcript toolbar)</td></tr>
|
||||
<tr><td>Toggle</td><td>"🕗 Verlauf" button in transcript toolbar</td><td>Active state: navy bg, white text</td></tr>
|
||||
<tr><td>Panel</td><td>Collapsible, between toolbar and block list</td><td>bg:color-page, border:line, radius:5px</td></tr>
|
||||
<tr><td>Entries</td><td>Time + user + block ref + word-level diff</td><td>Reuses diffWords from 'diff' library</td></tr>
|
||||
<tr><td>"Alle anzeigen"</td><td>Link to full history view (reuses PanelHistory)</td><td>Opens in a modal or replaces block list temporarily</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>
|
||||
<tr><td>Reorder blocks</td><td>Drag handle in block header</td><td>Updates sort_order via PATCH</td></tr>
|
||||
<tr class="grp"><td colspan="3">Presence (collaborative)</td></tr>
|
||||
<tr><td>Dots in topbar</td><td>Colored dot + user name, flex row</td><td>Max 3 shown, "+N" overflow</td></tr>
|
||||
<tr><td>Block-level presence</td><td>Colored dot + name in block header</td><td>Left border color matches user</td></tr>
|
||||
<tr><td>Implementation</td><td>WebSocket presence via Y.js (future)</td><td>MVP: polling-based, 5s interval</td></tr>
|
||||
<tr class="grp"><td colspan="3">Auto-save</td></tr>
|
||||
<tr><td>Debounce</td><td>500ms after last keystroke</td><td>PATCH /api/transcription-blocks/{blockId}</td></tr>
|
||||
<tr><td>Status</td><td>"✓ Gespeichert" in toolbar, fades after 3s</td><td>"Speichern..." while request in-flight</td></tr>
|
||||
<tr><td>Conflict</td><td>Last-write-wins for MVP</td><td>Y.js CRDT for future collaborative editing</td></tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══ LLM IMPLEMENTATION GUIDE ═══ -->
|
||||
<div class="llm">
|
||||
<h2>Implementation Guide — Annotation-Backed Transcription</h2>
|
||||
|
||||
<h3>1. Data Model Changes</h3>
|
||||
|
||||
<h4>Flyway migration: <code>document_annotations</code></h4>
|
||||
<ul>
|
||||
<li>Add <code>type VARCHAR(20) NOT NULL DEFAULT 'comment'</code>.</li>
|
||||
<li>Values: <code>'comment'</code> (existing behavior) or <code>'transcription'</code>.</li>
|
||||
<li>Backward compatible — all existing annotations default to <code>'comment'</code>.</li>
|
||||
</ul>
|
||||
|
||||
<h4>New table: <code>transcription_blocks</code></h4>
|
||||
<table>
|
||||
<thead><tr><th>Column</th><th>Type</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>id</code></td><td>UUID PK</td><td>Generated</td></tr>
|
||||
<tr><td><code>annotation_id</code></td><td>UUID FK → document_annotations</td><td>Links block to its PDF rectangle</td></tr>
|
||||
<tr><td><code>document_id</code></td><td>UUID FK → documents</td><td>Denormalized for efficient queries</td></tr>
|
||||
<tr><td><code>text</code></td><td>TEXT</td><td>The transcription content</td></tr>
|
||||
<tr><td><code>label</code></td><td>VARCHAR(100)</td><td>"Anrede", "Hauptteil", etc.</td></tr>
|
||||
<tr><td><code>sort_order</code></td><td>INT</td><td>Display order in the editor</td></tr>
|
||||
<tr><td><code>created_by</code></td><td>UUID FK → app_users</td><td></td></tr>
|
||||
<tr><td><code>updated_by</code></td><td>UUID FK → app_users</td><td></td></tr>
|
||||
<tr><td><code>created_at</code></td><td>TIMESTAMP</td><td>@CreationTimestamp</td></tr>
|
||||
<tr><td><code>updated_at</code></td><td>TIMESTAMP</td><td>@UpdateTimestamp</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h4>Block-level comments: <code>document_comments</code></h4>
|
||||
<ul>
|
||||
<li>Add <code>block_id UUID FK → transcription_blocks</code> (nullable).</li>
|
||||
<li><strong>No char_offset columns.</strong> Quoted selections are stored as plain text in the comment <code>content</code> field using blockquote markdown syntax (<code>> “Breslau”</code>). This is intentional — char offsets break when text is edited and require OT/CRDT to maintain. Quotes are a display hint, not a structural anchor.</li>
|
||||
<li>Backward compatible — <code>block_id</code> is nullable, existing comments unaffected.</li>
|
||||
</ul>
|
||||
|
||||
<h4>Backward compatibility: <code>Document.transcription</code></h4>
|
||||
<p>The existing <code>transcription</code> TEXT field becomes a <strong>computed read-only view</strong>: <code>SELECT string_agg(text, E'\n\n' ORDER BY sort_order) FROM transcription_blocks WHERE document_id = ?</code>. Write operations go through the block API. This keeps search indexing, export, and the read-only <code>PanelTranscription</code> working without changes.</p>
|
||||
|
||||
<h3>2. Annotation Color Convention & Mode Exclusivity</h3>
|
||||
<table>
|
||||
<thead><tr><th>Type</th><th>Color</th><th>Hex</th><th>On click</th><th>When active</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Comment</td><td>Yellow</td><td><code>#FFC800</code></td><td>Opens AnnotationSidePanel (existing)</td><td>Annotate mode only</td></tr>
|
||||
<tr><td>Transcription</td><td>Turquoise</td><td><code>#00C7B1</code></td><td>Highlights matching block in transcript editor</td><td>Transcribe mode only</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p><strong>Mode exclusivity:</strong> In transcribe mode, only turquoise rects can be drawn. Existing yellow comment annotations from annotate mode are still <em>visible</em> on the PDF (read-only, dimmed) but cannot be created or interacted with. The “Annotieren” button exits transcribe mode and enters annotate mode (and vice versa). This prevents overlapping annotation types and avoids user confusion about which comment system to use.</p>
|
||||
|
||||
<h3>3. Component Architecture</h3>
|
||||
<table>
|
||||
<thead><tr><th>Component</th><th>Change</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 (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 in annotate mode. Hidden in transcribe mode.</td></tr>
|
||||
<tr><td><code>TranscriptEditor.svelte</code> (new)</td><td>Right panel. Renders transcript toolbar + block list. Manages block CRUD, auto-save, block-level comment threads.</td></tr>
|
||||
<tr><td><code>TranscriptBlock.svelte</code> (new)</td><td>Single block card. contenteditable body, header with number/label/presence, footer with “Kommentieren” button, thread slot below body.</td></tr>
|
||||
<tr><td><code>BlockCommentThread.svelte</code> (new)</td><td>Comment thread anchored to a block. Shows quoted selections as blockquotes. Reuses <code>CommentThread</code> internally for replies/mentions.</td></tr>
|
||||
<tr><td><code>TranscriptToolbar.svelte</code> (new)</td><td>Block count, sort button, history toggle, save status.</td></tr>
|
||||
<tr><td><code>TranscriptHistory.svelte</code> (new)</td><td>Collapsible panel. Reuses <code>diffWords</code> from the <code>diff</code> library. Shows recent changes per block.</td></tr>
|
||||
<tr><td><code>DocumentBottomPanel.svelte</code></td><td>Removed entirely. Metadata lives in the topbar drawer (see companion spec). Discussion, transcription, and history are all inline.</td></tr>
|
||||
<tr><td><code>documents/[id]/+page.svelte</code></td><td>Add <code>transcribeMode</code> state. Conditionally render TranscriptEditor vs bottom panel.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>4. API Endpoints</h3>
|
||||
<table>
|
||||
<thead><tr><th>Method</th><th>Path</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>POST</td><td><code>/api/documents/{id}/annotations</code></td><td>Existing, but now accepts <code>type</code> field. If <code>type="transcription"</code>, also creates a TranscriptionBlock.</td></tr>
|
||||
<tr><td>GET</td><td><code>/api/documents/{id}/transcription-blocks</code></td><td>Returns all blocks ordered by sort_order.</td></tr>
|
||||
<tr><td>PATCH</td><td><code>/api/transcription-blocks/{blockId}</code></td><td>Update text, label, or sort_order. Auto-save target.</td></tr>
|
||||
<tr><td>DELETE</td><td><code>/api/transcription-blocks/{blockId}</code></td><td>Deletes block + its annotation + any anchored comments.</td></tr>
|
||||
<tr><td>PATCH</td><td><code>/api/transcription-blocks/reorder</code></td><td>Bulk update sort_order for drag-and-drop reordering.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>5. Draw-to-Transcribe Workflow</h3>
|
||||
<ol>
|
||||
<li>User enters <strong>Transcribe mode</strong> (topbar button, turquoise). Hint strip appears. Yellow comment annotations become read-only/dimmed. Only turquoise rects can be drawn.</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>6. Comment Flow — Block-Level Threads with Quoted Selections</h3>
|
||||
<p>Comments are anchored to <strong>blocks</strong>, not character offsets. This is a deliberate simplification:</p>
|
||||
|
||||
<h4>Why not char-offset anchoring?</h4>
|
||||
<ul>
|
||||
<li>When someone edits the transcription text, all character offsets downstream shift.</li>
|
||||
<li>Keeping offsets in sync requires operational transforms (OT) or CRDT — that’s the Y.js future work, not MVP.</li>
|
||||
<li>A stale offset pointing to the wrong word is worse than a quoted snippet that no longer matches but still shows what was discussed.</li>
|
||||
</ul>
|
||||
|
||||
<h4>How it works</h4>
|
||||
<ol>
|
||||
<li>User clicks <strong>“Kommentieren”</strong> in a block footer.</li>
|
||||
<li>If text is selected in the block body, the selection is <strong>auto-quoted</strong> into the comment input: <code>> “Breslau”</code>. The user can edit or remove the quote before sending.</li>
|
||||
<li>If no text is selected, the comment input opens empty — a general block-level comment.</li>
|
||||
<li>The comment is saved as a <code>DocumentComment</code> with <code>block_id</code> set. The quoted text is part of the <code>content</code> field (markdown blockquote syntax).</li>
|
||||
<li>The thread renders below the block body with an orange left-border. Quoted text appears as an indented italic blockquote above the comment message.</li>
|
||||
<li>Replies work the same as existing <code>CommentThread</code> — no changes needed.</li>
|
||||
</ol>
|
||||
|
||||
<h4>What happens when text changes after quoting?</h4>
|
||||
<p>Nothing breaks. The quote is a frozen snapshot of what the user selected. If “Bresla” was later corrected to “Breslau”, the original quote still reads <code>> “Bresla”</code> with Oma Inge’s comment “I think this is Breslau.” The context is preserved. No orphaned anchors, no broken highlights.</p>
|
||||
|
||||
<h4>Footer hint</h4>
|
||||
<p>When a block is focused/active, the footer shows a subtle hint: <em>“Text markieren für Zitat”</em> (select text for a quote). This teaches the quoted-selection pattern without requiring documentation.</p>
|
||||
|
||||
<h3>7. History in Transcript Toolbar</h3>
|
||||
<ul>
|
||||
<li>The “Verlauf” button in the toolbar toggles <code>TranscriptHistory.svelte</code>.</li>
|
||||
<li>The panel renders between the toolbar and the block list (pushes blocks down).</li>
|
||||
<li>It shows recent changes per block, using <code>diffWords</code> from the <code>diff</code> library (same as existing <code>PanelHistory</code>).</li>
|
||||
<li>Each entry: timestamp, user name, block reference (e.g. “Block 2”), and a word-level diff snippet.</li>
|
||||
<li>“Alle anzeigen” opens a full history view — can reuse the existing <code>PanelHistory</code> component in a modal.</li>
|
||||
<li>Data source: the existing document version history API, filtered/grouped by block.</li>
|
||||
</ul>
|
||||
|
||||
<h3>8. 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 button: <code>aria-label="Block N kommentieren"</code></li>
|
||||
<li>History toggle: <code>aria-expanded</code>, <code>aria-controls="transcript-history"</code></li>
|
||||
<li>Focus order: topbar → hint strip → PDF (for drawing) → transcript blocks (in sort order) → comment button → status bar</li>
|
||||
<li>Keyboard: Tab between blocks, Enter to edit, Escape to deselect. Ctrl+Shift+N to prompt draw on PDF. Ctrl+Shift+K to open comment on focused block.</li>
|
||||
</ul>
|
||||
|
||||
<h3>9. Companion Spec</h3>
|
||||
<p>The expandable metadata header (labeled “Details ▼” toggle) is specified separately in <code>expandable-metadata-header-spec.html</code>. Together, these two specs fully eliminate the bottom panel in <strong>all modes</strong>: metadata → header drawer, transcription → inline split view, discussion → inline threads, history → transcript toolbar. One consistent pattern — no mode-dependent UI structure.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
700
docs/specs/expandable-metadata-header-spec.html
Normal file
700
docs/specs/expandable-metadata-header-spec.html
Normal file
@@ -0,0 +1,700 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Expandable Metadata Header — Final Spec</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;--green-tint:#E8F5EA;--green:#3D8C4A;--green-dark:#2E6E39;--orange-tint:#FEF0E6;--orange:#E8862A;--orange-dark:#B46820;--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-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;}
|
||||
|
||||
.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);}
|
||||
|
||||
/* ── Topbar ── */
|
||||
.topbar{background:#fff;border-bottom:1px solid #e4e2d7;flex-shrink:0;position:relative;}
|
||||
.topbar-main{display:flex;align-items:center;padding:0 12px;gap:6px;height:42px;}
|
||||
.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);}
|
||||
.topbar .title{font-family:Georgia,serif;font-size:11px;color:var(--navy);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.topbar .date{font-size:8px;color:var(--color-text-muted);}
|
||||
.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%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;flex-shrink:0;}
|
||||
.fa-chip .av.navy{background:var(--navy);color:var(--mint);}
|
||||
.fa-chip .av.purple{background:#5A3080;color:#fff;}
|
||||
.fa-chip a{color:inherit;text-decoration:none;}
|
||||
.fa-chip a:hover{text-decoration:underline;}
|
||||
.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;cursor:pointer;}
|
||||
.fa-topbar-btn.active{background:var(--navy);color:#fff;border-color:var(--navy);}
|
||||
.fa-topbar-btn.transcribe{background:var(--turquoise);color:var(--navy);border-color:var(--turquoise);font-weight:700;}
|
||||
.fa-topbar-btn.ghost{border-color:var(--color-border);color:var(--color-text-muted);font-weight:500;}
|
||||
|
||||
/* ── Details toggle ── */
|
||||
.details-toggle{display:inline-flex;align-items:center;gap:3px;padding:2px 8px 2px 6px;border-radius:4px;font-size:7px;font-weight:600;color:var(--color-text-muted);cursor:pointer;border:1px solid var(--color-border);background:transparent;transition:all .15s ease;white-space:nowrap;}
|
||||
.details-toggle:hover{background:var(--sand);color:var(--color-text);}
|
||||
.details-toggle.open{background:var(--navy);color:#fff;border-color:var(--navy);}
|
||||
.details-toggle .chevron-icon{display:inline-block;font-size:7px;transition:transform .2s ease;}
|
||||
.details-toggle.open .chevron-icon{transform:rotate(180deg);}
|
||||
|
||||
/* ── 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;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 rects ── */
|
||||
.ann-rect{position:absolute;border:1.5px solid var(--turquoise);background:rgba(0,199,177,.1);border-radius:2px;}
|
||||
.ann-num{position:absolute;top:-8px;left:-8px;width:14px;height:14px;border-radius:50%;background:var(--navy);display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:700;color:#fff;box-shadow:0 1px 2px rgba(0,0,0,.3);}
|
||||
|
||||
/* ── Transcript blocks ── */
|
||||
.tblock{margin-bottom:5px;border:1px solid var(--color-border);border-radius:5px;overflow:hidden;}
|
||||
.tblock.active{border-color:var(--turquoise);box-shadow:0 0 0 1px var(--turquoise);}
|
||||
.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 .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:16px;}
|
||||
.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}}
|
||||
|
||||
.split{display:flex;flex:1;overflow:hidden;}
|
||||
.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;}
|
||||
|
||||
.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;}
|
||||
|
||||
/* ── Metadata display elements ── */
|
||||
.meta-icon{width:14px;height:14px;display:flex;align-items:center;justify-content:center;font-size:9px;opacity:.5;flex-shrink:0;}
|
||||
.meta-label{font-size:5px;font-weight:600;color:var(--color-text-muted);text-transform:uppercase;letter-spacing:.06em;}
|
||||
.meta-value{font-family:Georgia,serif;font-size:8px;color:var(--color-text);}
|
||||
.meta-value a{color:var(--navy);text-decoration:none;}.meta-value a:hover{text-decoration:underline;}
|
||||
.tag-chip{display:inline-block;font-size:6px;font-weight:600;padding:1px 5px;border-radius:3px;background:var(--sand);color:var(--color-text-muted);text-transform:uppercase;letter-spacing:.04em;cursor:pointer;}
|
||||
.tag-chip:hover{background:var(--navy);color:#fff;}
|
||||
|
||||
/* ── Person card (for expanded metadata) ── */
|
||||
.person-card{display:flex;align-items:center;gap:5px;padding:4px 6px;border:1px solid var(--color-border);border-radius:5px;background:var(--color-page);cursor:pointer;transition:all .1s;}
|
||||
.person-card:hover{border-color:var(--mint);background:var(--accent-bg);}
|
||||
.person-card .pc-av{width:18px;height:18px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;flex-shrink:0;}
|
||||
.person-card .pc-name{font-family:Georgia,serif;font-size:8px;color:var(--color-text);}
|
||||
.person-card .pc-alias{font-size:6px;color:var(--color-text-muted);}
|
||||
.person-card .pc-action{font-size:7px;color:var(--color-text-muted);margin-left:auto;opacity:0;transition:opacity .1s;}
|
||||
.person-card:hover .pc-action{opacity:1;}
|
||||
|
||||
/* ── 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>Expandable Metadata Header</h1>
|
||||
<p>The document topbar gains a labeled toggle button (<strong>“Details ▼”</strong>) that opens a full-width metadata drawer below the main row. This replaces the bottom panel’s Metadata tab in transcribe mode, keeping all interactive elements (person links, conversation links, tag filters) accessible without consuming permanent viewport space.</p>
|
||||
</div>
|
||||
<div class="doc-meta">
|
||||
Familienarchiv<br/>
|
||||
<span class="pill pill-g">Final spec</span><br/>
|
||||
2026-04-04 · @leonievoss
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══ DESIGN DECISION ═══ -->
|
||||
<div class="section">
|
||||
<div class="section-title">Why a labeled toggle, not just a chevron</div>
|
||||
<p class="prose">User interviews include family members aged 60+. A bare 12–16px chevron icon is easy to miss or misinterpret as decorative. A labeled button — <strong>“Details ▼”</strong> — is self-explanatory, provides a larger click target (min 44×28px), and follows the progressive disclosure pattern: key facts (title, date, person chips) are always visible in the topbar; the toggle reveals the full metadata only when needed.</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">What lives where</div>
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;font-size:12px;">
|
||||
<div style="background:#fff;border:1px solid var(--color-border);border-radius:var(--radius-md);padding:10px 14px;flex:1;min-width:200px;">
|
||||
<div style="font-weight:600;color:var(--navy);margin-bottom:4px;">Always visible in topbar</div>
|
||||
<ul style="padding-left:16px;color:var(--color-text-muted);line-height:1.8;">
|
||||
<li>Document title (truncated)</li>
|
||||
<li>Date (compact format)</li>
|
||||
<li>Sender & receiver chips (abbreviated)</li>
|
||||
<li>Action buttons (Edit, Annotate, Download)</li>
|
||||
<li><strong>“Details”</strong> toggle button</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div style="background:#fff;border:1px solid var(--color-border);border-radius:var(--radius-md);padding:10px 14px;flex:1;min-width:200px;">
|
||||
<div style="font-weight:600;color:var(--orange);margin-bottom:4px;">Revealed in drawer</div>
|
||||
<ul style="padding-left:16px;color:var(--color-text-muted);line-height:1.8;">
|
||||
<li>Full date (long format)</li>
|
||||
<li>Creation location (e.g. “Breslau”)</li>
|
||||
<li>Archive location (e.g. “Ordner A3, Schublade 2”)</li>
|
||||
<li>Tags (clickable → filter documents)</li>
|
||||
<li>Full person cards with avatar, name, alias</li>
|
||||
<li>Person detail links (/persons/{id})</li>
|
||||
<li>Conversation links (/korrespondenz?...)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
DESKTOP — COLLAPSED
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="scr" id="collapsed">
|
||||
<div class="scr-head"><h3>Desktop — collapsed (default)</h3><span class="scr-id">S1</span></div>
|
||||
<div class="scr-desc">The topbar looks identical to today except for the “Details” button between the person chips and the action buttons. The split view gets the full remaining viewport.</div>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col">
|
||||
<div class="bp-lbl">Desktop · 1040px · collapsed</div>
|
||||
<div class="desk" style="min-height:480px;">
|
||||
<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="topbar">
|
||||
<div class="topbar-main">
|
||||
<div class="back">←</div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 2px;"></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 navy">HR</div> <a href="#">Heinrich R.</a></div>
|
||||
<span style="font-size:7px;color:var(--color-text-muted);">→</span>
|
||||
<div class="fa-chip"><div class="av purple">MR</div> <a href="#">Martha R.</a></div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||
<!-- Labeled toggle button -->
|
||||
<div class="details-toggle">Details <span class="chevron-icon">▼</span></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>
|
||||
</div>
|
||||
|
||||
<!-- Split view fills remaining space -->
|
||||
<div class="split" style="flex:1;">
|
||||
<div style="flex:1;">
|
||||
<div class="pdf-area" style="height:100%;">
|
||||
<div class="paper" style="width:50%;min-height:180px;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="ann-rect" style="left:2%;top:0%;width:50%;height:12%;"><div class="ann-num">1</div></div>
|
||||
<div class="ann-rect" style="left:2%;top:16%;width:96%;height:35%;"><div class="ann-num">2</div></div>
|
||||
<div class="ann-rect" style="left:2%;top:55%;width:96%;height:25%;"><div class="ann-num">3</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="split-handle"></div>
|
||||
<div style="width:360px;display:flex;flex-direction:column;border-left:1px solid #e4e2d7;">
|
||||
<div style="background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:4px 8px;gap:4px;flex-shrink:0;">
|
||||
<span style="font-size:7px;font-weight:600;color:var(--navy);">3 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 active"><div class="tblock-head" style="background:rgba(0,199,177,.08);"><div class="num">2</div> Hauptteil</div><div class="tblock-body">ich schreibe Dir heute aus dem Lazarett in Breslau...<span class="trans-cursor"></span></div></div>
|
||||
<div class="tblock"><div class="tblock-head"><div class="num">3</div> Familie</div><div class="tblock-body" style="color:var(--color-text-muted);font-style:italic;">noch leer</div></div>
|
||||
</div>
|
||||
<div class="status-bar"><span>Block 2 aktiv</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
DESKTOP — EXPANDED
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="scr" id="expanded">
|
||||
<div class="scr-head"><h3>Desktop — expanded</h3><span class="scr-id">S2</span></div>
|
||||
<div class="scr-desc">Clicking “Details” slides open a full-width drawer below the topbar. Three-column grid: details (date, location, archive), persons (sender & receiver cards with conversation links), and tags. The drawer <strong>pushes content down</strong> — it is part of the document flow, not an overlay. No clipping, no z-index issues.</div>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col">
|
||||
<div class="bp-lbl">Desktop · 1040px · expanded</div>
|
||||
<div class="desk" style="min-height:540px;">
|
||||
<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="topbar" style="border-bottom:none;">
|
||||
<div class="topbar-main">
|
||||
<div class="back">←</div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 2px;"></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 navy">HR</div> <a href="#">Heinrich R.</a></div>
|
||||
<span style="font-size:7px;color:var(--color-text-muted);">→</span>
|
||||
<div class="fa-chip"><div class="av purple">MR</div> <a href="#">Martha R.</a></div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||
<!-- Toggle: active state -->
|
||||
<div class="details-toggle open">Details <span class="chevron-icon">▼</span></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>
|
||||
|
||||
<!-- Expanded metadata drawer -->
|
||||
<div style="border-top:1px solid #e4e2d7;padding:10px 16px;display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;background:var(--color-page);">
|
||||
<!-- Column 1: Details -->
|
||||
<div>
|
||||
<div class="meta-label" style="margin-bottom:5px;">Details</div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px;">
|
||||
<div style="display:flex;align-items:center;gap:5px;">
|
||||
<div class="meta-icon">📅</div>
|
||||
<div><div class="meta-value">14. Mai 1943</div><div class="meta-label">Datum</div></div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:5px;">
|
||||
<div class="meta-icon">📍</div>
|
||||
<div><div class="meta-value">Breslau</div><div class="meta-label">Entstehungsort</div></div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:5px;">
|
||||
<div class="meta-icon">📁</div>
|
||||
<div><div class="meta-value">Ordner A3, Schublade 2</div><div class="meta-label">Archivstandort</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Column 2: Persons -->
|
||||
<div>
|
||||
<div class="meta-label" style="margin-bottom:5px;">Personen</div>
|
||||
<div style="display:flex;flex-direction:column;gap:4px;">
|
||||
<div class="meta-label" style="margin-top:2px;">Absender</div>
|
||||
<div class="person-card">
|
||||
<div class="pc-av" style="background:var(--navy);color:var(--mint);">HR</div>
|
||||
<div><div class="pc-name">Heinrich Raddatz</div><div class="pc-alias">Opa Heinrich</div></div>
|
||||
<div class="pc-action">💬</div>
|
||||
</div>
|
||||
<div class="meta-label" style="margin-top:4px;">Empfänger</div>
|
||||
<div class="person-card">
|
||||
<div class="pc-av" style="background:#5A3080;color:#fff;">MR</div>
|
||||
<div><div class="pc-name">Martha Raddatz</div><div class="pc-alias">Oma Martha</div></div>
|
||||
<div class="pc-action">💬</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Column 3: Tags -->
|
||||
<div>
|
||||
<div class="meta-label" style="margin-bottom:5px;">Schlagwörter</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:3px;">
|
||||
<div class="tag-chip">Feldpost</div>
|
||||
<div class="tag-chip">2. Weltkrieg</div>
|
||||
<div class="tag-chip">Lazarett</div>
|
||||
<div class="tag-chip">Breslau</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="border-top:1px solid #e4e2d7;"></div>
|
||||
|
||||
<!-- Split view (pushed down) -->
|
||||
<div class="split" style="flex:1;">
|
||||
<div style="flex:1;">
|
||||
<div class="pdf-area" style="height:100%;">
|
||||
<div class="paper" style="width:50%;min-height:140px;position:relative;">
|
||||
<div style="font-size:7px;color:#8A8070;font-style:italic;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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="split-handle"></div>
|
||||
<div style="width:360px;display:flex;flex-direction:column;border-left:1px solid #e4e2d7;">
|
||||
<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</div><div class="tblock-body">Liebe Martha,</div></div>
|
||||
<div class="tblock active"><div class="tblock-head" style="background:rgba(0,199,177,.08);"><div class="num">2</div> Hauptteil</div><div class="tblock-body">ich schreibe Dir heute...<span class="trans-cursor"></span></div></div>
|
||||
</div>
|
||||
<div class="status-bar"><span>Block 2 aktiv</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
MOBILE — COLLAPSED
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="scr" id="mobile-collapsed">
|
||||
<div class="scr-head"><h3>Mobile — collapsed</h3><span class="scr-id">S3</span></div>
|
||||
<div class="scr-desc">On mobile, the topbar shows the title, a compact “Details” toggle, and the transcribe mode pill. Person chips are hidden (shown in drawer instead). The toggle provides a 44px tap target.</div>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col">
|
||||
<div class="bp-lbl">Mobile · 320px · collapsed</div>
|
||||
<div class="phone" style="height:560px;">
|
||||
<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;">
|
||||
<div style="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>
|
||||
<div class="details-toggle" style="font-size:8px;padding:4px 10px 4px 8px;min-height:28px;">Details <span class="chevron-icon" style="font-size:8px;">▼</span></div>
|
||||
<span style="font-size:7px;font-weight:700;padding:2px 6px;border-radius:3px;background:var(--turquoise);color:var(--navy);">Transkr.</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- PDF strip -->
|
||||
<div style="background:#D4D0C8;height:110px;display:flex;align-items:center;justify-content:center;border-bottom:2px solid var(--turquoise);">
|
||||
<div style="background:#FFFEF8;width:40%;padding:6px 8px;box-shadow:0 1px 3px rgba(0,0,0,.1);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:.2;margin:1px 0;width:90%;"></div>
|
||||
<div style="height:1.5px;background:#C4BDB0;opacity:.2;margin:1px 0;width:70%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Transcript blocks -->
|
||||
<div style="flex:1;overflow-y:auto;padding:8px 12px;background:#fff;">
|
||||
<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</div>
|
||||
<div style="padding:4px 8px;font-family:Georgia,serif;font-size:10px;line-height:1.6;">ich schreibe Dir heute aus dem Lazarett in Breslau...<span class="trans-cursor"></span></div>
|
||||
</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;">3</div> Familie</div>
|
||||
<div style="padding:4px 8px;font-family:Georgia,serif;font-size:10px;line-height:1.6;color:var(--color-text-muted);font-style:italic;">noch leer</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
MOBILE — EXPANDED
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="scr" id="mobile-expanded">
|
||||
<div class="scr-head"><h3>Mobile — expanded</h3><span class="scr-id">S4</span></div>
|
||||
<div class="scr-desc">The drawer opens as a single-column stack below the topbar. Person cards are full-width with 44px minimum touch targets. Conversation links are always visible (no hover-reveal on touch). Tags wrap naturally. The PDF strip and transcript blocks are pushed down.</div>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col">
|
||||
<div class="bp-lbl">Mobile · 320px · expanded</div>
|
||||
<div class="phone" style="height:620px;">
|
||||
<div class="pst"><b>14:23</b><span>••• WiFi 🔋</span></div>
|
||||
<div class="pb">
|
||||
<div style="background:#fff;border-bottom:none;padding:6px 12px;">
|
||||
<div style="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>
|
||||
<div class="details-toggle open" style="font-size:8px;padding:4px 10px 4px 8px;min-height:28px;">Details <span class="chevron-icon" style="font-size:8px;">▼</span></div>
|
||||
<span style="font-size:7px;font-weight:700;padding:2px 6px;border-radius:3px;background:var(--turquoise);color:var(--navy);">Transkr.</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Expanded drawer: single-column -->
|
||||
<div style="background:var(--color-page);border-bottom:1px solid #e4e2d7;padding:10px 12px;">
|
||||
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:8px;">
|
||||
<div style="flex:1;min-width:100px;">
|
||||
<div class="meta-label">Datum</div>
|
||||
<div class="meta-value">14. Mai 1943</div>
|
||||
</div>
|
||||
<div style="flex:1;min-width:100px;">
|
||||
<div class="meta-label">Ort</div>
|
||||
<div class="meta-value">Breslau</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:8px;">
|
||||
<div style="flex:1;min-width:100px;">
|
||||
<div class="meta-label">Archivstandort</div>
|
||||
<div class="meta-value">Ordner A3, Schublade 2</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meta-label" style="margin-bottom:3px;">Absender</div>
|
||||
<div class="person-card" style="margin-bottom:4px;min-height:36px;">
|
||||
<div class="pc-av" style="background:var(--navy);color:var(--mint);">HR</div>
|
||||
<div><div class="pc-name">Heinrich Raddatz</div><div class="pc-alias">Opa Heinrich</div></div>
|
||||
<div class="pc-action" style="opacity:1;">💬</div>
|
||||
</div>
|
||||
<div class="meta-label" style="margin-bottom:3px;">Empfänger</div>
|
||||
<div class="person-card" style="margin-bottom:6px;min-height:36px;">
|
||||
<div class="pc-av" style="background:#5A3080;color:#fff;">MR</div>
|
||||
<div><div class="pc-name">Martha Raddatz</div><div class="pc-alias">Oma Martha</div></div>
|
||||
<div class="pc-action" style="opacity:1;">💬</div>
|
||||
</div>
|
||||
<div class="meta-label" style="margin-bottom:3px;">Schlagwörter</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:3px;">
|
||||
<div class="tag-chip">Feldpost</div>
|
||||
<div class="tag-chip">2. Weltkrieg</div>
|
||||
<div class="tag-chip">Lazarett</div>
|
||||
<div class="tag-chip">Breslau</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- PDF strip (pushed down) -->
|
||||
<div style="background:#D4D0C8;height:70px;display:flex;align-items:center;justify-content:center;border-bottom:2px solid var(--turquoise);">
|
||||
<div style="background:#FFFEF8;width:40%;padding:4px 6px;box-shadow:0 1px 3px rgba(0,0,0,.1);border-radius:1px;">
|
||||
<div style="height:2px;background:#C4BDB0;opacity:.4;margin:2px 0;width:80%;"></div>
|
||||
<div style="height:1.5px;background:#C4BDB0;opacity:.2;margin:1px 0;width:90%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Blocks -->
|
||||
<div style="flex:1;overflow-y:auto;padding:8px 12px;background:#fff;">
|
||||
<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</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</div>
|
||||
<div style="padding:4px 8px;font-family:Georgia,serif;font-size:10px;line-height:1.6;">ich schreibe Dir heute...<span class="trans-cursor"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
NON-TRANSCRIBE MODE (standard document view)
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="scr" id="non-transcribe">
|
||||
<div class="scr-head"><h3>Non-transcribe mode — standard document view</h3><span class="scr-id">S5</span></div>
|
||||
<div class="scr-desc">Outside of transcribe mode, the document detail page uses the <strong>same “Details” drawer pattern</strong>. No bottom panel. The PDF gets the full remaining viewport. Discussion and transcription are accessible via dedicated buttons (Transkribieren enters split mode, Annotieren enters annotation mode). One consistent pattern everywhere — no mode-dependent UI structure.</div>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col">
|
||||
<div class="bp-lbl">Desktop · non-transcribe · collapsed</div>
|
||||
<div class="desk" style="min-height:400px;">
|
||||
<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="topbar">
|
||||
<div class="topbar-main">
|
||||
<div class="back">←</div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 2px;"></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 navy">HR</div> <a href="#">Heinrich R.</a></div>
|
||||
<span style="font-size:7px;color:var(--color-text-muted);">→</span>
|
||||
<div class="fa-chip"><div class="av purple">MR</div> <a href="#">Martha R.</a></div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||
<div class="details-toggle">Details <span class="chevron-icon">▼</span></div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||
<div class="fa-topbar-btn ghost">✎ Transkribieren</div>
|
||||
<div class="fa-topbar-btn ghost">Annotieren</div>
|
||||
<div class="fa-topbar-btn ghost" style="padding:3px 5px;">
|
||||
<span style="font-size:8px;">✎</span>
|
||||
</div>
|
||||
<div style="width:14px;height:14px;border-radius:3px;background:var(--sand);display:flex;align-items:center;justify-content:center;font-size:6px;">⇩</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Full PDF — no bottom panel -->
|
||||
<div class="pdf-area" style="flex:1;">
|
||||
<div class="paper" style="width:45%;min-height:240px;">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══ AGENT TABLE ═══ -->
|
||||
<div class="agent">
|
||||
<h4>Expandable metadata header · Implementation spec</h4>
|
||||
<pre>/* The topbar gains a labeled "Details ▼" toggle button that opens a full-width metadata
|
||||
* drawer below the main topbar row.
|
||||
*
|
||||
* Collapsed (default): topbar looks like today + a "Details ▼" button between
|
||||
* the person chips and the action buttons.
|
||||
* Expanded: a new row slides down with a 3-column grid (desktop):
|
||||
* Col 1: date (long format), location, archive location — icon + value + label
|
||||
* Col 2: sender card + receiver cards — clickable, links to /persons/{id}
|
||||
* conversation icon links to /korrespondenz?senderId=X&receiverId=Y
|
||||
* Col 3: tag chips — clickable, link to /?tag=X
|
||||
*
|
||||
* The drawer PUSHES content down (document flow, not overlay).
|
||||
* Background: color-page (sand) to visually separate from white topbar.
|
||||
* Animation: Svelte slide transition or max-height + overflow:hidden, 200ms ease.
|
||||
*
|
||||
* KEY DECISION: "Details ▼" labeled toggle instead of bare chevron icon.
|
||||
* Reason: 60+ year old users in user interviews — bare icons are easy to miss.
|
||||
* The label makes the interaction self-explanatory and provides a 44×28px min tap target.
|
||||
*
|
||||
* Mobile: single-column stack, person cards full-width with 44px min-height,
|
||||
* conversation links always visible (no hover-reveal on touch). */</pre>
|
||||
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
|
||||
<tr class="grp"><td colspan="3">Toggle button</td></tr>
|
||||
<tr><td>Label</td><td>"Details" + ▼ chevron</td><td>i18n key: topbar_details_toggle</td></tr>
|
||||
<tr><td>Size</td><td>min 44×28px tap target, text-xs font-semibold</td><td>WCAG 2.5.5 compliant target size</td></tr>
|
||||
<tr><td>Inactive style</td><td>border border-line, text-ink-2, bg-transparent</td><td>Subtle, doesn't compete with action buttons</td></tr>
|
||||
<tr><td>Active style</td><td>bg-primary, text-primary-fg, border-primary</td><td>Clear open state — matches annotate button pattern</td></tr>
|
||||
<tr><td>Chevron</td><td>▼ (U+25BC), rotates 180deg when open</td><td>CSS transition transform 200ms</td></tr>
|
||||
<tr><td>Aria</td><td>aria-expanded, aria-controls="metadata-drawer"</td><td>Button role implicit</td></tr>
|
||||
<tr><td>Keyboard</td><td>Ctrl+M toggles, Escape closes</td><td>Ctrl+M matches "M for metadata"</td></tr>
|
||||
<tr class="grp"><td colspan="3">Drawer (expanded)</td></tr>
|
||||
<tr><td>Layout</td><td>grid 3-col desktop (1fr 1fr 1fr), 1-col mobile</td><td>bg:color-page, border-top:line, p:12px 16px</td></tr>
|
||||
<tr><td>Animation</td><td>Svelte slide transition, 200ms</td><td>Or CSS max-height 0↔auto with overflow:hidden</td></tr>
|
||||
<tr><td>Push behavior</td><td>In document flow, pushes split view down</td><td>Not absolute/overlay — no clipping</td></tr>
|
||||
<tr><td>ID</td><td>id="metadata-drawer"</td><td>role="region", aria-label="Dokumentmetadaten"</td></tr>
|
||||
<tr class="grp"><td colspan="3">Drawer content — Details column</td></tr>
|
||||
<tr><td>Date</td><td>Long format (14. Mai 1943), icon 📅</td><td>Uses existing formatDate utility</td></tr>
|
||||
<tr><td>Location</td><td>Text, icon 📍</td><td>Only shown if doc.creationLocation exists</td></tr>
|
||||
<tr><td>Archive</td><td>Text, icon 📁</td><td>Only shown if doc.archiveLocation exists</td></tr>
|
||||
<tr class="grp"><td colspan="3">Drawer content — Persons column</td></tr>
|
||||
<tr><td>Person card</td><td>border:line, radius:5px, bg:page, hover:accent-bg</td><td>Entire card is a link to /persons/{id}</td></tr>
|
||||
<tr><td>Card content</td><td>18px avatar + full name + alias</td><td>Alias from person.alias field</td></tr>
|
||||
<tr><td>Conversation icon</td><td>💬 appears on hover (desktop), always visible (mobile)</td><td>Links to /korrespondenz?senderId=X&receiverId=Y</td></tr>
|
||||
<tr><td>Mobile card height</td><td>min-height 44px</td><td>WCAG touch target compliance</td></tr>
|
||||
<tr class="grp"><td colspan="3">Drawer content — Tags column</td></tr>
|
||||
<tr><td>Chip</td><td>text-[10px]/600, sand bg, uppercase, radius:3px</td><td>Click → navigate to /?tag=X</td></tr>
|
||||
<tr><td>Hover</td><td>bg-primary, text-primary-fg</td><td>Visual feedback that chips are interactive</td></tr>
|
||||
<tr class="grp"><td colspan="3">Non-transcribe mode</td></tr>
|
||||
<tr><td>Toggle shown?</td><td>Yes — always present in topbar</td><td>Consistent UX across all modes</td></tr>
|
||||
<tr><td>Bottom panel</td><td>Removed entirely — all modes</td><td>Drawer is the single metadata pattern everywhere</td></tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══ LLM IMPLEMENTATION GUIDE ═══ -->
|
||||
<div class="llm">
|
||||
<h2>Implementation Guide — Expandable Metadata Header</h2>
|
||||
|
||||
<h3>1. Scope</h3>
|
||||
<p>Add a labeled “Details” toggle button and a collapsible metadata drawer to <code>DocumentTopBar.svelte</code>. This spec covers <strong>only the header expansion</strong> — the transcription split view, inline comments, and history toolbar are covered in the companion spec (<code>annotation-transcription-final-spec.html</code>).</p>
|
||||
|
||||
<h3>2. State</h3>
|
||||
<ul>
|
||||
<li><code>let metadataOpen = $state(false)</code> in <code>DocumentTopBar.svelte</code>.</li>
|
||||
<li>Toggle on button click. Close on Escape key. Toggle on Ctrl+M.</li>
|
||||
<li>State is local — not persisted. Defaults to closed on every page load.</li>
|
||||
</ul>
|
||||
|
||||
<h3>3. Component Changes</h3>
|
||||
<table>
|
||||
<thead><tr><th>Component</th><th>Change</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>DocumentTopBar.svelte</code></td><td>Add <code>metadataOpen</code> state, toggle button, and conditional drawer div. New props needed: <code>doc.creationLocation</code>, <code>doc.archiveLocation</code>, <code>doc.tags</code>, full sender/receiver objects with aliases.</td></tr>
|
||||
<tr><td><code>MetadataDrawer.svelte</code> (new)</td><td>Extracted child component. Receives the doc object. Renders the 3-column grid (desktop) or 1-column stack (mobile). Contains person cards, tag chips, and metadata fields.</td></tr>
|
||||
<tr><td><code>PersonChipRow.svelte</code></td><td>No change. Still renders the abbreviated chips in the main topbar row.</td></tr>
|
||||
<tr><td><code>DocumentBottomPanel.svelte</code></td><td>Remove entirely. The metadata drawer replaces the Metadata tab. Transcription, Discussion, and History move to inline UI (see companion transcription spec). No bottom panel in any mode.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>4. Toggle Button Placement</h3>
|
||||
<p>In the topbar’s flex row, the button goes <strong>after the person chips divider and before the action buttons divider</strong>:</p>
|
||||
<p><code>← | Title | chips → | <strong>Details ▼</strong> | Transkribieren | Annotieren | Edit | Download</code></p>
|
||||
<p>On mobile (<375px), person chips are hidden. The toggle sits after the title, before the transcribe pill.</p>
|
||||
|
||||
<h3>5. Drawer Markup</h3>
|
||||
<ul>
|
||||
<li>Use Svelte <code>slide</code> transition: <code>{#if metadataOpen}<div transition:slide={{ duration: 200 }}></code></li>
|
||||
<li>The drawer is a direct child of the topbar wrapper, below the main flex row.</li>
|
||||
<li>Desktop: <code>grid grid-cols-3 gap-4 p-3 sm:p-4 bg-canvas border-t border-line</code></li>
|
||||
<li>Mobile: <code>grid grid-cols-1 gap-3 p-3 bg-canvas border-t border-line</code></li>
|
||||
<li>Breakpoint for 3-col: <code>md:grid-cols-3</code> (768px+).</li>
|
||||
</ul>
|
||||
|
||||
<h3>6. Person Cards in Drawer</h3>
|
||||
<ul>
|
||||
<li>Each card: avatar (using <code>personAvatarColor</code>), full name (font-serif), alias (text-xs text-ink-2).</li>
|
||||
<li>Card wraps an <code><a href="/persons/{id}"></code>.</li>
|
||||
<li>Conversation icon: separate <code><a></code> inside the card, absolute-positioned or flex-end. Links to <code>/korrespondenz?senderId={sender.id}&receiverId={receiver.id}</code>.</li>
|
||||
<li>On mobile: <code>min-h-[44px]</code> for touch targets. Conversation icon always visible (<code>opacity-100</code> instead of <code>opacity-0 group-hover:opacity-100</code>).</li>
|
||||
</ul>
|
||||
|
||||
<h3>7. Tag Chips in Drawer</h3>
|
||||
<ul>
|
||||
<li>Each tag: <code><a href="/?tag={tag.name}"></code> with <code>text-[10px] font-semibold uppercase bg-muted rounded px-2 py-0.5 hover:bg-primary hover:text-primary-fg transition-colors</code>.</li>
|
||||
<li><code>aria-label="Dokumente mit Schlagwort {tag.name} filtern"</code>.</li>
|
||||
</ul>
|
||||
|
||||
<h3>8. Accessibility</h3>
|
||||
<ul>
|
||||
<li>Toggle button: <code>aria-expanded={metadataOpen}</code>, <code>aria-controls="metadata-drawer"</code>.</li>
|
||||
<li>Drawer: <code>id="metadata-drawer"</code>, <code>role="region"</code>, <code>aria-label="Dokumentmetadaten"</code>.</li>
|
||||
<li>Person cards: accessible name includes full name + “Zur Personenseite”.</li>
|
||||
<li>Conversation link: <code>aria-label="Korrespondenz zwischen {sender} und {receiver} anzeigen"</code>.</li>
|
||||
<li>Tab order: toggle button → drawer contents (when open) → action buttons.</li>
|
||||
<li>Escape closes drawer and returns focus to the toggle button.</li>
|
||||
</ul>
|
||||
|
||||
<h3>9. i18n Keys</h3>
|
||||
<table>
|
||||
<thead><tr><th>Key</th><th>de</th><th>en</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>topbar_details_toggle</code></td><td>Details</td><td>Details</td></tr>
|
||||
<tr><td><code>topbar_details_date</code></td><td>Datum</td><td>Date</td></tr>
|
||||
<tr><td><code>topbar_details_location</code></td><td>Entstehungsort</td><td>Location</td></tr>
|
||||
<tr><td><code>topbar_details_archive</code></td><td>Archivstandort</td><td>Archive location</td></tr>
|
||||
<tr><td><code>topbar_details_sender</code></td><td>Absender</td><td>Sender</td></tr>
|
||||
<tr><td><code>topbar_details_receivers</code></td><td>Empfänger</td><td>Receivers</td></tr>
|
||||
<tr><td><code>topbar_details_tags</code></td><td>Schlagwörter</td><td>Tags</td></tr>
|
||||
<tr><td><code>topbar_details_conversation</code></td><td>Korrespondenz anzeigen</td><td>View correspondence</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
1152
docs/specs/focus-rings-spec.html
Normal file
1152
docs/specs/focus-rings-spec.html
Normal file
File diff suppressed because it is too large
Load Diff
804
docs/specs/transcription-read-mode-final-spec.html
Normal file
804
docs/specs/transcription-read-mode-final-spec.html
Normal file
@@ -0,0 +1,804 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Transcription Read Mode — Final Spec (Clean Split)</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&family=Tinos:ital,wght@0,400;0,700;1,400&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;--green-tint:#E8F5EA;--green:#3D8C4A;--green-dark:#2E6E39;--orange-tint:#FEF0E6;--orange:#E8862A;--orange-dark:#B46820;--color-error:#DC4C3E;--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;--font-read:'Tinos',Georgia,serif;--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-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;}
|
||||
|
||||
.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);}
|
||||
|
||||
/* ── Topbar ── */
|
||||
.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%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;flex-shrink:0;}
|
||||
.fa-chip .av.navy{background:var(--navy);color:var(--mint);}
|
||||
.fa-chip .av.purple{background:#5A3080;color:#fff;}
|
||||
.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;cursor:pointer;}
|
||||
.fa-topbar-btn.ghost{border-color:var(--color-border);color:var(--color-text-muted);font-weight:500;}
|
||||
.details-toggle{display:inline-flex;align-items:center;gap:3px;padding:2px 8px 2px 6px;border-radius:4px;font-size:7px;font-weight:600;color:var(--color-text-muted);cursor:pointer;border:1px solid var(--color-border);background:transparent;white-space:nowrap;}
|
||||
|
||||
/* ── Mode switcher ── */
|
||||
.mode-sw{display:inline-flex;border:1px solid var(--color-border);border-radius:4px;overflow:hidden;font-size:6px;font-weight:600;}
|
||||
.mode-sw span{padding:3px 8px;cursor:pointer;color:var(--color-text-muted);}
|
||||
.mode-sw span.active{background:var(--navy);color:#fff;border-color:var(--navy);}
|
||||
.mode-sw span:not(.active):hover{background:var(--sand);}
|
||||
|
||||
/* ── 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;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 rects (dimmed in read mode) ── */
|
||||
.ann-rect{position:absolute;border-radius:2px;}
|
||||
.ann-rect.trans{border:1.5px solid var(--turquoise);background:rgba(0,199,177,.1);}
|
||||
.ann-rect.trans.dimmed{border-color:rgba(0,199,177,.3);background:rgba(0,199,177,.04);}
|
||||
|
||||
/* ── Split ── */
|
||||
.split{display:flex;flex:1;overflow:hidden;}
|
||||
.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;}
|
||||
|
||||
/* ── Read-mode text ── */
|
||||
.read-text{font-family:var(--font-read);font-size:10px;line-height:1.85;color:var(--color-text);padding:16px 20px;}
|
||||
.read-text .para{margin-bottom:10px;cursor:pointer;padding:2px 4px;border-radius:3px;transition:background .15s ease;}
|
||||
.read-text .para:hover{background:rgba(0,199,177,.06);}
|
||||
.read-text .para.highlighted{background:rgba(0,199,177,.1);transition:background .3s ease;}
|
||||
.read-text .greeting{font-style:italic;}
|
||||
.read-text .closing{margin-top:12px;text-align:right;font-style:italic;}
|
||||
.read-text .illegible{color:var(--color-text-muted);font-style:italic;font-size:9px;}
|
||||
|
||||
/* ── Status bar ── */
|
||||
.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;}
|
||||
|
||||
/* ── Scroll sync highlight on PDF ── */
|
||||
.ann-rect.highlight-flash{border-color:var(--turquoise) !important;background:rgba(0,199,177,.18) !important;transition:all .3s ease;animation:flash-fade 1.5s ease-out forwards;}
|
||||
@keyframes flash-fade{0%{background:rgba(0,199,177,.18);border-color:var(--turquoise);}100%{background:rgba(0,199,177,.04);border-color:rgba(0,199,177,.3);}}
|
||||
|
||||
/* ── 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 h4{font-size:12px;font-weight:600;margin:16px 0 6px;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 pre{font-family:var(--font-mono);font-size:11px;background:var(--color-surface);padding:12px 16px;border-radius:var(--radius-md);overflow-x:auto;margin:8px 0 12px;line-height:1.6;}
|
||||
.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>Transcription Read Mode — Final Spec</h1>
|
||||
<p>A focused reading experience for completed transcriptions. Uses the <strong>clean split</strong> layout: PDF scan on the left, flowing prose on the right. All editing chrome is stripped — no block borders, no comment threads, no toolbars. The text reads like a letter, not like an editing interface.</p>
|
||||
</div>
|
||||
<div class="doc-meta">
|
||||
Familienarchiv<br/>
|
||||
<span class="pill pill-g">Final</span><br/>
|
||||
2026-04-05 · @leonievoss
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
SECTION: DESIGN RATIONALE
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="section">
|
||||
<div class="section-title">Design rationale</div>
|
||||
<p class="prose">Transcribe mode is for editing. But most visits to a completed transcription are for <strong>reading</strong> — comparing the handwriting with the typed text, sharing with family, or just revisiting a letter. Read mode strips away all editing chrome and presents the transcription as flowing prose alongside the original scan.</p>
|
||||
<p class="prose">The <strong>clean split</strong> was chosen over the full-page reader (PDF hidden) and the interleaved view (cropped PDF per block) because it preserves the familiar side-by-side layout from transcribe mode while dramatically reducing visual noise. Users can switch between reading and editing without re-learning the spatial layout.</p>
|
||||
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;font-size:12px;margin-top:20px;">
|
||||
<div style="background:#fff;border:1px solid var(--color-border);border-radius:var(--radius-md);padding:10px 14px;flex:1;min-width:180px;">
|
||||
<div style="font-weight:600;color:var(--green);margin-bottom:4px;">Kept</div>
|
||||
<ul style="padding-left:16px;color:var(--color-text-muted);line-height:1.8;">
|
||||
<li>Transcription text (flowing prose)</li>
|
||||
<li>PDF scan viewer (same position)</li>
|
||||
<li>Topbar (title, Details toggle, person chips)</li>
|
||||
<li>Mode switcher (Lesen / Bearbeiten)</li>
|
||||
<li>Resizable split handle</li>
|
||||
<li>Scroll sync (click paragraph ↔ PDF)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div style="background:#fff;border:1px solid var(--color-border);border-radius:var(--radius-md);padding:10px 14px;flex:1;min-width:180px;">
|
||||
<div style="font-weight:600;color:var(--color-error);margin-bottom:4px;">Removed</div>
|
||||
<ul style="padding-left:16px;color:var(--color-text-muted);line-height:1.8;">
|
||||
<li>Block borders & numbered badges</li>
|
||||
<li>Contenteditable / cursor</li>
|
||||
<li>Comment threads & “Kommentieren” buttons</li>
|
||||
<li>Presence dots & user indicators</li>
|
||||
<li>Hint strip (“Markiere eine Passage…”)</li>
|
||||
<li>Drag handles, sort controls</li>
|
||||
<li>Auto-save status indicator</li>
|
||||
<li>Add-block CTA</li>
|
||||
<li>History / “Verlauf” button</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
S1: DESKTOP — READ MODE
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="scr" id="s1">
|
||||
<div class="scr-head"><h3>S1 — Desktop read mode</h3><span class="scr-id">S1</span></div>
|
||||
<div class="scr-desc">Side-by-side split: PDF scan on the left with dimmed annotation outlines, flowing serif prose on the right. The mode switcher shows “Lesen” as active. Clicking a paragraph briefly highlights the matching PDF region (turquoise flash, 1.5s fade).</div>
|
||||
<div class="scr-var"><strong>Primary reading state</strong> — the default view when a transcription exists.</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 navy">HR</div> Heinrich R.</div>
|
||||
<span style="font-size:7px;color:var(--color-text-muted);">→</span>
|
||||
<div class="fa-chip"><div class="av purple">MR</div> Martha R.</div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||
<div class="details-toggle">Details ▼</div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||
<!-- Mode switcher: Lesen active -->
|
||||
<div class="mode-sw"><span class="active">Lesen</span><span>Bearbeiten</span></div>
|
||||
</div>
|
||||
|
||||
<div class="split" style="height:430px;">
|
||||
<!-- PDF scan — annotations dimmed -->
|
||||
<div style="flex:1;display:flex;flex-direction:column;">
|
||||
<div class="pdf-area" style="flex:1;">
|
||||
<div class="paper" style="width:55%;min-height:260px;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>
|
||||
|
||||
<!-- Dimmed annotation outlines — no numbered badges -->
|
||||
<div class="ann-rect trans dimmed" style="left:2%;top:0%;width:50%;height:10%;"></div>
|
||||
<div class="ann-rect trans dimmed" style="left:2%;top:14%;width:96%;height:32%;"></div>
|
||||
<div class="ann-rect trans dimmed" style="left:2%;top:50%;width:96%;height:22%;"></div>
|
||||
<div class="ann-rect trans dimmed" style="left:20%;top:80%;width:60%;height:12%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="split-handle"></div>
|
||||
|
||||
<!-- Right panel: flowing prose, no block chrome -->
|
||||
<div style="width:400px;display:flex;flex-direction:column;border-left:1px solid #e4e2d7;background:#fff;">
|
||||
<div class="read-text" style="flex:1;overflow-y:auto;">
|
||||
<div class="para greeting">Liebe Martha,</div>
|
||||
<div class="para">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 class="para">Die Kinder sollen wissen, dass ich an sie denke. Sag dem kleinen Fritz, er soll auf seine Mutter aufpassen. Und Lotte soll weiter so fleißig in der Schule sein.</div>
|
||||
<div class="para closing">In ewiger Liebe,<br/>Dein Heinrich</div>
|
||||
</div>
|
||||
<div class="status-bar">
|
||||
<span>4 Abschnitte</span>
|
||||
<span style="margin-left:auto;">Zuletzt bearbeitet: Oma Inge, 14:23</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>S1 · Desktop read mode</h4>
|
||||
<pre>/* Same side-by-side layout as transcribe mode, but the right panel renders
|
||||
* the transcription as continuous flowing prose instead of block cards.
|
||||
*
|
||||
* Key differences from transcribe mode:
|
||||
* - No block borders, headers, footers, or numbered badges
|
||||
* - No contenteditable — text is plain rendered HTML
|
||||
* - No comment threads, no "Kommentieren" buttons
|
||||
* - No presence dots, no hint strip, no auto-save indicator
|
||||
* - Annotation rects on PDF are dimmed (opacity ~0.3, no badges)
|
||||
* - Still clickable for scroll-sync
|
||||
* - Status bar shows: "4 Abschnitte · Zuletzt bearbeitet: Oma Inge, 14:23"
|
||||
*
|
||||
* Scroll sync:
|
||||
* - Click paragraph → matching PDF annotation flashes turquoise (1.5s fade)
|
||||
* - Click PDF annotation → matching paragraph gets subtle bg highlight (1.5s fade)
|
||||
* - PDF auto-scrolls to center the annotation in the viewport */</pre>
|
||||
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
|
||||
<tr class="grp"><td colspan="3">Text panel</td></tr>
|
||||
<tr><td>Font</td><td>Tinos (serif), 16px, line-height 1.85</td><td>Generous reading typography</td></tr>
|
||||
<tr><td>Padding</td><td>24px 32px</td><td>Comfortable margins like a book page</td></tr>
|
||||
<tr><td>Paragraphs</td><td>One <p> per transcription block</td><td>mb-4 between paragraphs</td></tr>
|
||||
<tr><td>[unleserlich]</td><td>italic, text-ink-2, font-size: 0.9em</td><td>Subtle but readable</td></tr>
|
||||
<tr><td>Hover</td><td>Subtle turquoise bg at 6% opacity</td><td>Hint that paragraphs are clickable</td></tr>
|
||||
<tr class="grp"><td colspan="3">PDF panel</td></tr>
|
||||
<tr><td>Annotations</td><td>Dimmed: border-opacity 0.3, bg-opacity 0.04</td><td>Still clickable for scroll-sync</td></tr>
|
||||
<tr><td>Badges</td><td>Hidden</td><td>No numbered circles in read mode</td></tr>
|
||||
<tr><td>Scroll sync</td><td>Click para → PDF scrolls, flash 1.5s</td><td>Turquoise tint at 18% → fade to 4%</td></tr>
|
||||
<tr class="grp"><td colspan="3">Status bar</td></tr>
|
||||
<tr><td>Content</td><td>"N Abschnitte · Zuletzt bearbeitet: Name, HH:mm"</td><td>Uses most recent updated_at across blocks</td></tr>
|
||||
<tr><td>Height</td><td>28px, sand background</td><td>Same as transcribe mode status bar</td></tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
S2: DESKTOP — SCROLL SYNC INTERACTION
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="scr" id="s2">
|
||||
<div class="scr-head"><h3>S2 — Scroll sync highlight</h3><span class="scr-id">S2</span></div>
|
||||
<div class="scr-desc">The user clicked the second paragraph. The matching PDF annotation flashes with a turquoise highlight that fades over 1.5 seconds. The paragraph itself gets a subtle background tint. This is the <strong>only interactive element</strong> in read mode — no editing, no comments.</div>
|
||||
<div class="scr-var"><strong>Click-to-highlight interaction</strong> — bidirectional scroll sync between text and scan.</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 navy">HR</div> Heinrich R.</div>
|
||||
<span style="font-size:7px;color:var(--color-text-muted);">→</span>
|
||||
<div class="fa-chip"><div class="av purple">MR</div> Martha R.</div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||
<div class="details-toggle">Details ▼</div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||
<div class="mode-sw"><span class="active">Lesen</span><span>Bearbeiten</span></div>
|
||||
</div>
|
||||
|
||||
<div class="split" style="height:430px;">
|
||||
<div style="flex:1;display:flex;flex-direction:column;">
|
||||
<div class="pdf-area" style="flex:1;">
|
||||
<div class="paper" style="width:55%;min-height:260px;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 dimmed" style="left:2%;top:0%;width:50%;height:10%;"></div>
|
||||
<!-- THIS annotation is highlighted — user clicked paragraph 2 -->
|
||||
<div class="ann-rect trans highlight-flash" style="left:2%;top:14%;width:96%;height:32%;"></div>
|
||||
<div class="ann-rect trans dimmed" style="left:2%;top:50%;width:96%;height:22%;"></div>
|
||||
<div class="ann-rect trans dimmed" style="left:20%;top:80%;width:60%;height:12%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="split-handle"></div>
|
||||
|
||||
<div style="width:400px;display:flex;flex-direction:column;border-left:1px solid #e4e2d7;background:#fff;">
|
||||
<div class="read-text" style="flex:1;overflow-y:auto;">
|
||||
<div class="para greeting">Liebe Martha,</div>
|
||||
<!-- THIS paragraph is highlighted -->
|
||||
<div class="para highlighted">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 class="para">Die Kinder sollen wissen, dass ich an sie denke. Sag dem kleinen Fritz, er soll auf seine Mutter aufpassen. Und Lotte soll weiter so fleißig in der Schule sein.</div>
|
||||
<div class="para closing">In ewiger Liebe,<br/>Dein Heinrich</div>
|
||||
</div>
|
||||
<div class="status-bar">
|
||||
<span>4 Abschnitte</span>
|
||||
<span style="margin-left:auto;">Zuletzt bearbeitet: Oma Inge, 14:23</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>S2 · Scroll sync highlight</h4>
|
||||
<pre>/* Bidirectional scroll sync with visual feedback.
|
||||
*
|
||||
* Text → PDF:
|
||||
* 1. User clicks a paragraph
|
||||
* 2. Paragraph gets .highlighted class (turquoise bg at 10%)
|
||||
* 3. Matching annotation rect gets .highlight-flash class
|
||||
* 4. PDF viewport scrolls to center the annotation
|
||||
* 5. Both highlights fade over 1.5s via CSS animation
|
||||
*
|
||||
* PDF → Text:
|
||||
* 1. User clicks a dimmed annotation rect
|
||||
* 2. Annotation flashes (same .highlight-flash)
|
||||
* 3. Matching paragraph gets .highlighted
|
||||
* 4. Text panel scrolls to center the paragraph
|
||||
* 5. Both fade over 1.5s
|
||||
*
|
||||
* Implementation: each paragraph has data-block-id matching the
|
||||
* transcription block's annotation_id. The annotation rects already
|
||||
* have annotation IDs from transcribe mode. */</pre>
|
||||
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
|
||||
<tr class="grp"><td colspan="3">Highlight animation</td></tr>
|
||||
<tr><td>Paragraph bg</td><td>rgba(0,199,177,.10)</td><td>Turquoise at 10%, fades to 0</td></tr>
|
||||
<tr><td>Annotation flash</td><td>rgba(0,199,177,.18) → .04</td><td>Border returns to .3 opacity</td></tr>
|
||||
<tr><td>Duration</td><td>1.5s ease-out</td><td>CSS animation, no JS timers needed</td></tr>
|
||||
<tr><td>Scroll behavior</td><td>smooth, block: center</td><td>scrollIntoView({ behavior: 'smooth', block: 'center' })</td></tr>
|
||||
<tr class="grp"><td colspan="3">Data binding</td></tr>
|
||||
<tr><td>Paragraph attr</td><td>data-block-id="{annotation_id}"</td><td>Links text to PDF annotation</td></tr>
|
||||
<tr><td>Annotation attr</td><td>data-annotation-id="{id}"</td><td>Already exists from transcribe mode</td></tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
S3: DESKTOP — NO TRANSCRIPTION YET
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="scr" id="s3">
|
||||
<div class="scr-head"><h3>S3 — No transcription (empty state)</h3><span class="scr-id">S3</span></div>
|
||||
<div class="scr-desc">When no transcription blocks exist, the mode switcher defaults to “Bearbeiten” and the right panel shows an empty state encouraging the user to start transcribing. The “Lesen” tab is disabled (greyed out).</div>
|
||||
<div class="scr-var"><strong>Empty state</strong> — no read mode available until at least one block exists.</div>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col">
|
||||
<div class="bp-lbl">Desktop · 1040px</div>
|
||||
<div class="desk" style="min-height:400px;">
|
||||
<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="details-toggle">Details ▼</div>
|
||||
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||
<!-- Mode switcher: Bearbeiten active, Lesen disabled -->
|
||||
<div class="mode-sw"><span style="opacity:.35;cursor:not-allowed;">Lesen</span><span class="active">Bearbeiten</span></div>
|
||||
</div>
|
||||
|
||||
<div class="split" style="height:320px;">
|
||||
<div style="flex:1;display:flex;flex-direction:column;">
|
||||
<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 style="font-size:6px;color:#8A8070;margin-top:6px;text-align:right;opacity:.7;">Dein Heinrich</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="split-handle"></div>
|
||||
|
||||
<div style="width:400px;display:flex;flex-direction:column;border-left:1px solid #e4e2d7;background:#fff;align-items:center;justify-content:center;text-align:center;padding:32px;">
|
||||
<div style="width:48px;height:48px;border-radius:50%;background:var(--sand);display:flex;align-items:center;justify-content:center;margin-bottom:12px;">
|
||||
<span style="font-size:20px;opacity:.5;">✎</span>
|
||||
</div>
|
||||
<div style="font-family:var(--font-sans);font-size:11px;font-weight:600;color:var(--color-text);margin-bottom:4px;">Noch keine Transkription</div>
|
||||
<div style="font-family:var(--font-sans);font-size:10px;color:var(--color-text-muted);max-width:200px;line-height:1.6;">Zeichne Bereiche auf dem Scan und tippe den Text ab, um eine Transkription zu erstellen.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>S3 · Empty state</h4>
|
||||
<pre>/* When transcription_blocks count is 0:
|
||||
* - Mode switcher defaults to "Bearbeiten"
|
||||
* - "Lesen" tab is disabled: opacity 0.35, cursor: not-allowed, not clickable
|
||||
* - Right panel shows empty state with pencil icon, title, and description
|
||||
* - No status bar (nothing to show)
|
||||
*
|
||||
* As soon as the first block is saved, "Lesen" becomes enabled.
|
||||
* The mode does NOT auto-switch — user stays in Bearbeiten. */</pre>
|
||||
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
|
||||
<tr class="grp"><td colspan="3">Empty state</td></tr>
|
||||
<tr><td>Icon</td><td>Pencil in 48px sand circle</td><td>Centered vertically in panel</td></tr>
|
||||
<tr><td>Title</td><td>"Noch keine Transkription"</td><td>i18n key: transcription_empty_title</td></tr>
|
||||
<tr><td>Description</td><td>"Zeichne Bereiche auf dem Scan…"</td><td>i18n key: transcription_empty_desc</td></tr>
|
||||
<tr class="grp"><td colspan="3">Mode switcher</td></tr>
|
||||
<tr><td>Lesen tab</td><td>Disabled: opacity .35, not-allowed</td><td>Enabled when block count > 0</td></tr>
|
||||
<tr><td>Default</td><td>"Bearbeiten" active</td><td>Enters transcribe mode directly</td></tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
S4: MOBILE — READ MODE
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="scr" id="s4">
|
||||
<div class="scr-head"><h3>S4 — Mobile read mode</h3><span class="scr-id">S4</span></div>
|
||||
<div class="scr-desc">On mobile, the split view becomes vertical: a collapsible PDF strip (70px) at the top, flowing text below. The mode switcher abbreviates “Bearbeiten” to “Bearb.” to fit. Tapping the PDF strip expands it; tapping again collapses. Paragraphs are still tappable for scroll-sync.</div>
|
||||
<div class="scr-var"><strong>Mobile layout</strong> — stacked vertical with collapsible scan strip.</div>
|
||||
|
||||
<div class="previews">
|
||||
<div class="prev-col">
|
||||
<div class="bp-lbl">Mobile · 320px</div>
|
||||
<div class="phone" style="height:620px;">
|
||||
<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>
|
||||
<div class="mode-sw"><span class="active" style="font-size:7px;">Lesen</span><span style="font-size:7px;">Bearb.</span></div>
|
||||
</div>
|
||||
<!-- Collapsible PDF strip -->
|
||||
<div style="background:#D4D0C8;height:70px;display:flex;align-items:center;justify-content:center;border-bottom:1px solid #C4C0B8;position:relative;">
|
||||
<div style="background:#FFFEF8;width:42%;padding:5px 7px;box-shadow:0 1px 3px rgba(0,0,0,.1);border-radius:1px;">
|
||||
<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:.2;margin:1px 0;width:90%;"></div>
|
||||
</div>
|
||||
<!-- Expand hint -->
|
||||
<div style="position:absolute;bottom:3px;right:8px;font-size:6px;color:var(--color-text-muted);opacity:.6;">▲ Scan vergrößern</div>
|
||||
</div>
|
||||
<!-- Flowing text -->
|
||||
<div style="flex:1;overflow-y:auto;padding:16px 16px;background:#fff;">
|
||||
<div style="font-family:'Tinos',Georgia,serif;font-size:13px;line-height:1.9;color:var(--color-text);">
|
||||
<p style="margin-bottom:10px;font-style:italic;">Liebe Martha,</p>
|
||||
<p style="margin-bottom:10px;">ich schreibe Dir heute aus dem Lazarett in Breslau. Mach Dir keine Sorgen, es geht mir den Umständen entsprechend gut. Der Arzt sagt <em style="color:var(--color-text-muted);">[unleserlich]</em> Wochen noch dauern wird.</p>
|
||||
<p style="margin-bottom:10px;">Die Kinder sollen wissen, dass ich an sie denke. Sag dem kleinen Fritz, er soll auf seine Mutter aufpassen. Und Lotte soll weiter so fleißig in der Schule sein.</p>
|
||||
<p style="text-align:right;font-style:italic;">In ewiger Liebe,<br/>Dein Heinrich</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Status bar -->
|
||||
<div style="background:var(--sand);border-top:1px solid #e4e2d7;padding:4px 12px;font-size:8px;color:var(--color-text-muted);display:flex;justify-content:space-between;">
|
||||
<span>4 Abschnitte</span>
|
||||
<span>Oma Inge, 14:23</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: expanded PDF strip -->
|
||||
<div class="prev-col">
|
||||
<div class="bp-lbl">Mobile · 320px · scan expanded</div>
|
||||
<div class="phone" style="height:620px;">
|
||||
<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>
|
||||
<div class="mode-sw"><span class="active" style="font-size:7px;">Lesen</span><span style="font-size:7px;">Bearb.</span></div>
|
||||
</div>
|
||||
<!-- Expanded PDF strip -->
|
||||
<div style="background:#D4D0C8;height:200px;display:flex;align-items:center;justify-content:center;border-bottom:1px solid #C4C0B8;position:relative;">
|
||||
<div style="background:#FFFEF8;width:50%;padding:8px 10px;box-shadow:0 2px 6px rgba(0,0,0,.12);border-radius:1px;position:relative;">
|
||||
<div style="font-size:6px;color:#8A8070;font-style:italic;opacity:.7;margin-bottom:3px;">Liebe Martha,</div>
|
||||
<div class="pl" style="width:90%;height:2px;"></div><div class="ps" style="width:85%;height:1.5px;"></div><div class="ps" style="width:92%;height:1.5px;"></div>
|
||||
<div class="pl" style="width:78%;height:2px;"></div><div class="ps" style="width:88%;height:1.5px;"></div>
|
||||
<div class="pl" style="width:84%;height:2px;"></div><div class="ps" style="width:70%;height:1.5px;"></div>
|
||||
<div style="font-size:5px;color:#8A8070;margin-top:4px;text-align:right;opacity:.7;">Dein Heinrich</div>
|
||||
<!-- Dimmed annotations visible when expanded -->
|
||||
<div class="ann-rect trans dimmed" style="left:3%;top:0%;width:45%;height:12%;"></div>
|
||||
<div class="ann-rect trans dimmed" style="left:3%;top:16%;width:94%;height:35%;"></div>
|
||||
</div>
|
||||
<!-- Collapse hint -->
|
||||
<div style="position:absolute;bottom:3px;right:8px;font-size:6px;color:var(--color-text-muted);opacity:.6;">▼ Scan verkleinern</div>
|
||||
</div>
|
||||
<!-- Text below (shorter) -->
|
||||
<div style="flex:1;overflow-y:auto;padding:14px 16px;background:#fff;">
|
||||
<div style="font-family:'Tinos',Georgia,serif;font-size:13px;line-height:1.9;color:var(--color-text);">
|
||||
<p style="margin-bottom:10px;font-style:italic;">Liebe Martha,</p>
|
||||
<p style="margin-bottom:10px;">ich schreibe Dir heute aus dem Lazarett in Breslau…</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>S4 · Mobile read mode</h4>
|
||||
<pre>/* On viewports < 768px, the side-by-side split becomes vertical:
|
||||
* - PDF scan strip at top (collapsed: 70px, expanded: ~50vh)
|
||||
* - Flowing text below, full-width
|
||||
* - Tap PDF strip to toggle expand/collapse
|
||||
* - Expand hint text: "▲ Scan vergrößern" / "▼ Scan verkleinern"
|
||||
*
|
||||
* Mode switcher abbreviates: "Lesen | Bearb."
|
||||
* Scroll-sync: tapping a paragraph briefly highlights the matching
|
||||
* region in the expanded PDF. If PDF is collapsed, it auto-expands
|
||||
* first, then scrolls to the annotation.
|
||||
*
|
||||
* Same status bar at the bottom, same flowing prose styling. */</pre>
|
||||
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
|
||||
<tr class="grp"><td colspan="3">PDF strip</td></tr>
|
||||
<tr><td>Collapsed height</td><td>70px</td><td>Shows miniature scan preview</td></tr>
|
||||
<tr><td>Expanded height</td><td>~50vh or max 300px</td><td>Smooth CSS transition (300ms ease)</td></tr>
|
||||
<tr><td>Toggle</td><td>Tap anywhere on strip</td><td>Hint text in bottom-right corner</td></tr>
|
||||
<tr class="grp"><td colspan="3">Text area</td></tr>
|
||||
<tr><td>Font</td><td>Tinos, 15px, line-height 1.9</td><td>Slightly larger than desktop for touch</td></tr>
|
||||
<tr><td>Padding</td><td>16px</td><td>Full-width, no wasted space</td></tr>
|
||||
<tr class="grp"><td colspan="3">Mode switcher</td></tr>
|
||||
<tr><td>Labels</td><td>"Lesen | Bearb."</td><td>Abbreviated to fit mobile topbar</td></tr>
|
||||
<tr><td>Font size</td><td>10px</td><td>Compact but readable</td></tr>
|
||||
<tr class="grp"><td colspan="3">Scroll sync on mobile</td></tr>
|
||||
<tr><td>Tap paragraph</td><td>Expand PDF if collapsed, then highlight</td><td>Auto-expand + scroll + flash</td></tr>
|
||||
<tr><td>Tap annotation</td><td>Collapse PDF, scroll text to paragraph</td><td>Smart collapse after showing match</td></tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||
S5: MODE SWITCHER DETAIL
|
||||
═══════════════════════════════════════════════════════════════════════════ -->
|
||||
<div class="scr" id="s5">
|
||||
<div class="scr-head"><h3>S5 — Mode switcher states</h3><span class="scr-id">S5</span></div>
|
||||
<div class="scr-desc">The mode switcher is a segmented control in the topbar that replaces the previous “Transkribieren” turquoise button. It governs three visual states: <strong>Lesen</strong> (read mode, this spec), <strong>Bearbeiten</strong> (transcribe/edit mode), and the existing <strong>Annotieren</strong> button (yellow comment annotations). The modes are mutually exclusive.</div>
|
||||
<div class="scr-var"><strong>Segmented control</strong> — replacing the turquoise “Transkribieren” button.</div>
|
||||
|
||||
<div class="previews" style="gap:16px;">
|
||||
<!-- State 1: Lesen active -->
|
||||
<div class="prev-col" style="align-items:center;">
|
||||
<div class="bp-lbl">Lesen active</div>
|
||||
<div style="background:#fff;border:1px solid #e4e2d7;border-radius:var(--radius-md);padding:12px 16px;display:flex;align-items:center;gap:8px;">
|
||||
<div class="mode-sw" style="font-size:8px;"><span class="active" style="padding:4px 12px;">Lesen</span><span style="padding:4px 12px;">Bearbeiten</span></div>
|
||||
<div style="width:1px;height:20px;background:#e4e2d7;"></div>
|
||||
<div class="fa-topbar-btn ghost" style="font-size:8px;padding:4px 10px;">✎ Annotieren</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- State 2: Bearbeiten active -->
|
||||
<div class="prev-col" style="align-items:center;">
|
||||
<div class="bp-lbl">Bearbeiten active</div>
|
||||
<div style="background:#fff;border:1px solid #e4e2d7;border-radius:var(--radius-md);padding:12px 16px;display:flex;align-items:center;gap:8px;">
|
||||
<div class="mode-sw" style="font-size:8px;"><span style="padding:4px 12px;">Lesen</span><span class="active" style="padding:4px 12px;">Bearbeiten</span></div>
|
||||
<div style="width:1px;height:20px;background:#e4e2d7;"></div>
|
||||
<div class="fa-topbar-btn ghost" style="font-size:8px;padding:4px 10px;">✎ Annotieren</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- State 3: Annotieren active -->
|
||||
<div class="prev-col" style="align-items:center;">
|
||||
<div class="bp-lbl">Annotieren active (separate button)</div>
|
||||
<div style="background:#fff;border:1px solid #e4e2d7;border-radius:var(--radius-md);padding:12px 16px;display:flex;align-items:center;gap:8px;">
|
||||
<div class="mode-sw" style="font-size:8px;"><span style="padding:4px 12px;opacity:.5;">Lesen</span><span style="padding:4px 12px;opacity:.5;">Bearbeiten</span></div>
|
||||
<div style="width:1px;height:20px;background:#e4e2d7;"></div>
|
||||
<div class="fa-topbar-btn" style="font-size:8px;padding:4px 10px;background:var(--navy);color:#fff;border-color:var(--navy);">✎ Annotieren</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- State 4: Lesen disabled (no blocks) -->
|
||||
<div class="prev-col" style="align-items:center;">
|
||||
<div class="bp-lbl">No transcription blocks</div>
|
||||
<div style="background:#fff;border:1px solid #e4e2d7;border-radius:var(--radius-md);padding:12px 16px;display:flex;align-items:center;gap:8px;">
|
||||
<div class="mode-sw" style="font-size:8px;"><span style="padding:4px 12px;opacity:.35;cursor:not-allowed;">Lesen</span><span class="active" style="padding:4px 12px;">Bearbeiten</span></div>
|
||||
<div style="width:1px;height:20px;background:#e4e2d7;"></div>
|
||||
<div class="fa-topbar-btn ghost" style="font-size:8px;padding:4px 10px;">✎ Annotieren</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent">
|
||||
<h4>S5 · Mode switcher states</h4>
|
||||
<pre>/* Three mutually exclusive modes:
|
||||
*
|
||||
* 1. Lesen (read) — this spec. Flowing prose, dimmed annotations, no editing.
|
||||
* 2. Bearbeiten (edit) — annotation-transcription-final-spec. Block cards, contenteditable.
|
||||
* 3. Annotieren — yellow comment annotations on PDF. Separate button, not in segmented control.
|
||||
*
|
||||
* The segmented control only contains Lesen + Bearbeiten.
|
||||
* Annotieren is a separate button that, when active, deselects both Lesen and Bearbeiten
|
||||
* (both appear deselected/dimmed in the segmented control).
|
||||
*
|
||||
* When the user clicks Annotieren while in read/transcribe mode:
|
||||
* → Enter annotate mode, both segmented items dim
|
||||
* When the user clicks a segmented item while in annotate mode:
|
||||
* → Exit annotate mode, enter the selected mode
|
||||
*
|
||||
* State: let mode: 'read' | 'transcribe' | 'annotate' = $state(...)
|
||||
* Default: 'read' if blocks.length > 0, else 'transcribe'
|
||||
* The "Annotieren" button is hidden if !canAnnotate || !isPdf */</pre>
|
||||
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
|
||||
<tr class="grp"><td colspan="3">Segmented control</td></tr>
|
||||
<tr><td>Items</td><td>"Lesen" | "Bearbeiten"</td><td>Mobile: "Lesen" | "Bearb."</td></tr>
|
||||
<tr><td>Active style</td><td>bg:navy, color:#fff</td><td>Rounded within the pill</td></tr>
|
||||
<tr><td>Inactive style</td><td>bg:transparent, color:muted</td><td>Hover: bg:sand</td></tr>
|
||||
<tr><td>Dimmed style</td><td>Both items at opacity .5</td><td>Only when annotate mode is active</td></tr>
|
||||
<tr><td>Disabled (Lesen)</td><td>opacity .35, cursor not-allowed</td><td>When no transcription blocks exist</td></tr>
|
||||
<tr class="grp"><td colspan="3">Annotieren button</td></tr>
|
||||
<tr><td>Default</td><td>Ghost style (border:muted)</td><td>Same as current topbar button</td></tr>
|
||||
<tr><td>Active</td><td>bg:navy, color:#fff</td><td>Filled state when annotate mode on</td></tr>
|
||||
<tr><td>Visibility</td><td>canAnnotate && isPdf</td><td>Hidden for non-PDF documents</td></tr>
|
||||
<tr class="grp"><td colspan="3">Accessibility</td></tr>
|
||||
<tr><td>Segmented</td><td>role="tablist", children role="tab"</td><td>aria-selected on active tab</td></tr>
|
||||
<tr><td>Annotieren</td><td>aria-pressed={annotateMode}</td><td>Toggle button semantics</td></tr>
|
||||
<tr><td>Disabled tab</td><td>aria-disabled="true", tabindex="-1"</td><td>Not focusable when no blocks</td></tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ═══ LLM IMPLEMENTATION GUIDE ═══ -->
|
||||
<div class="llm">
|
||||
<h2>Implementation Guide — Transcription Read Mode (Clean Split)</h2>
|
||||
|
||||
<h3>1. Overview</h3>
|
||||
<p>Read mode is the default view for documents that have transcription blocks. It reuses the same side-by-side split layout as transcribe mode but replaces the editable block cards with flowing serif prose. The goal is a distraction-free reading experience that still lets users compare handwriting with typed text.</p>
|
||||
|
||||
<h3>2. Mode State Management</h3>
|
||||
<p>The document detail page manages a single <code>mode</code> state that governs the entire view:</p>
|
||||
<pre>let mode: 'read' | 'transcribe' | 'annotate' = $state(
|
||||
blocks.length > 0 ? 'read' : 'transcribe'
|
||||
);</pre>
|
||||
<ul>
|
||||
<li><code>mode === 'read'</code> → this spec (flowing prose, dimmed annotations, no editing)</li>
|
||||
<li><code>mode === 'transcribe'</code> → annotation-transcription-final-spec (block cards, contenteditable)</li>
|
||||
<li><code>mode === 'annotate'</code> → yellow comment annotations on PDF</li>
|
||||
<li>The segmented control in the topbar toggles between <code>'read'</code> and <code>'transcribe'</code>.</li>
|
||||
<li>The “Annotieren” button toggles <code>'annotate'</code> on/off. When entering annotate mode, the previous mode (read or transcribe) is stored so the user returns to it when exiting.</li>
|
||||
</ul>
|
||||
|
||||
<h3>3. Component Architecture</h3>
|
||||
<h4>3a. New components</h4>
|
||||
<table>
|
||||
<thead><tr><th>Component</th><th>Purpose</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>TranscriptionReadView.svelte</code></td><td>Right panel content in read mode. Renders transcription blocks as flowing prose (<code><article></code> with <code><p></code> per block). Handles scroll-sync click handlers.</td></tr>
|
||||
<tr><td><code>ModeSwitcher.svelte</code></td><td>Segmented control (<code>Lesen | Bearbeiten</code>). Props: <code>mode</code> (bindable), <code>hasBlocks</code> (disables Lesen when false). Emits mode changes.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h4>3b. Modified components</h4>
|
||||
<table>
|
||||
<thead><tr><th>Component</th><th>Change</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>DocumentTopBar.svelte</code></td><td>Replace the <code>Transkribieren</code> button with <code>ModeSwitcher</code>. Keep the <code>Annotieren</code> button separate. Add <code>mode</code> bindable prop.</td></tr>
|
||||
<tr><td><code>[id]/+page.svelte</code></td><td>Add <code>mode</code> state. Conditionally render <code>TranscriptionReadView</code> vs <code>TranscriptionEditView</code> in the right panel based on <code>mode</code>.</td></tr>
|
||||
<tr><td><code>PdfAnnotationLayer.svelte</code></td><td>Accept <code>dimmed</code> prop. When true: annotation rects get opacity 0.3, no numbered badges, but remain clickable for scroll-sync.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>4. Read View Rendering</h3>
|
||||
<h4>4a. Text rendering</h4>
|
||||
<ul>
|
||||
<li>Fetch transcription blocks from <code>GET /api/documents/{id}/transcription-blocks</code> (same endpoint as transcribe mode).</li>
|
||||
<li>Render each block as a <code><p data-block-id="{block.annotation_id}"></code> inside an <code><article></code> element.</li>
|
||||
<li>Typography: <code>font-family: Tinos, Georgia, serif; font-size: 16px; line-height: 1.85</code>.</li>
|
||||
<li>Padding: <code>24px 32px</code> for comfortable reading margins.</li>
|
||||
<li><code>[unleserlich]</code> markers: detect via regex <code>/\[unleserlich\]/g</code> and wrap in <code><em class="text-ink-2 italic text-[0.9em]"></code>.</li>
|
||||
<li>Text is <strong>not</strong> contenteditable. No cursor, no selection highlights, no editing.</li>
|
||||
</ul>
|
||||
|
||||
<h4>4b. Scroll sync</h4>
|
||||
<ul>
|
||||
<li>Each paragraph has a click handler that dispatches a <code>highlight-annotation</code> event with the <code>annotation_id</code>.</li>
|
||||
<li>The PDF viewer listens for this event, scrolls to the annotation, and applies a CSS animation (<code>flash-fade</code>, 1.5s ease-out).</li>
|
||||
<li>Reverse direction: clicking a dimmed annotation on the PDF dispatches <code>highlight-paragraph</code> with the <code>annotation_id</code>. The text panel scrolls the matching paragraph into view and applies a background highlight that fades.</li>
|
||||
<li>Use <code>scrollIntoView({ behavior: 'smooth', block: 'center' })</code> for both directions.</li>
|
||||
<li>The highlight CSS animation: <code>background rgba(0,199,177,.10) → transparent</code> over 1.5s.</li>
|
||||
</ul>
|
||||
|
||||
<h3>5. PDF Annotations in Read Mode</h3>
|
||||
<ul>
|
||||
<li>Turquoise annotation rectangles are rendered but <strong>dimmed</strong>: border opacity 0.3, background opacity 0.04.</li>
|
||||
<li>No numbered badges (the <code>.ann-num</code> elements are hidden via <code>display: none</code>).</li>
|
||||
<li>Annotations remain clickable — they trigger scroll-sync to the matching paragraph.</li>
|
||||
<li>When an annotation is flash-highlighted (via scroll-sync), it briefly returns to full opacity before fading back to dimmed.</li>
|
||||
<li>Yellow comment annotations are not shown in read mode (they belong to annotate mode only).</li>
|
||||
</ul>
|
||||
|
||||
<h3>6. Status Bar</h3>
|
||||
<ul>
|
||||
<li>Positioned at the bottom of the text panel (not the full viewport).</li>
|
||||
<li>Content: <code>"{n} Abschnitte · Zuletzt bearbeitet: {userName}, {HH:mm}"</code></li>
|
||||
<li>The “Zuletzt bearbeitet” timestamp is the most recent <code>updated_at</code> across all transcription blocks for this document.</li>
|
||||
<li>The user name comes from the <code>updated_by</code> field of that most recently updated block.</li>
|
||||
<li>i18n keys: <code>transcription_status_sections</code>, <code>transcription_status_last_edited</code>.</li>
|
||||
</ul>
|
||||
|
||||
<h3>7. Mobile Layout (viewport < 768px)</h3>
|
||||
<ul>
|
||||
<li>The side-by-side split becomes vertical: PDF strip at top, text below.</li>
|
||||
<li>PDF strip collapsed height: <code>70px</code>. Shows a miniature scan preview.</li>
|
||||
<li>Tap strip to expand (~50vh, max 300px). Tap again to collapse. Smooth CSS transition (300ms ease).</li>
|
||||
<li>Expand/collapse hint text in bottom-right corner of the strip.</li>
|
||||
<li>Mode switcher abbreviation: “Lesen | Bearb.” (i18n key: <code>mode_edit_short</code>).</li>
|
||||
<li>Scroll-sync on paragraph tap: if PDF is collapsed, auto-expand first, then scroll to annotation.</li>
|
||||
<li>Text typography: <code>15px</code> (slightly larger than desktop) with <code>line-height: 1.9</code>.</li>
|
||||
</ul>
|
||||
|
||||
<h3>8. Empty State</h3>
|
||||
<ul>
|
||||
<li>When <code>transcription_blocks</code> count is 0, “Lesen” tab is disabled (<code>opacity: 0.35</code>, <code>cursor: not-allowed</code>, <code>aria-disabled="true"</code>).</li>
|
||||
<li>Mode defaults to <code>'transcribe'</code>.</li>
|
||||
<li>Right panel shows empty state: pencil icon in 48px sand circle, title (“Noch keine Transkription”), description (“Zeichne Bereiche auf dem Scan…”).</li>
|
||||
<li>As soon as the first block is saved, “Lesen” becomes clickable. Mode does not auto-switch.</li>
|
||||
</ul>
|
||||
|
||||
<h3>9. i18n Keys</h3>
|
||||
<table>
|
||||
<thead><tr><th>Key</th><th>de</th><th>en</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>mode_read</code></td><td>Lesen</td><td>Read</td></tr>
|
||||
<tr><td><code>mode_edit</code></td><td>Bearbeiten</td><td>Edit</td></tr>
|
||||
<tr><td><code>mode_edit_short</code></td><td>Bearb.</td><td>Edit</td></tr>
|
||||
<tr><td><code>transcription_status_sections</code></td><td>{n} Abschnitte</td><td>{n} sections</td></tr>
|
||||
<tr><td><code>transcription_status_last_edited</code></td><td>Zuletzt bearbeitet: {name}, {time}</td><td>Last edited: {name}, {time}</td></tr>
|
||||
<tr><td><code>transcription_empty_title</code></td><td>Noch keine Transkription</td><td>No transcription yet</td></tr>
|
||||
<tr><td><code>transcription_empty_desc</code></td><td>Zeichne Bereiche auf dem Scan und tippe den Text ab, um eine Transkription zu erstellen.</td><td>Draw regions on the scan and type the text to create a transcription.</td></tr>
|
||||
<tr><td><code>scan_expand</code></td><td>Scan vergrößern</td><td>Expand scan</td></tr>
|
||||
<tr><td><code>scan_collapse</code></td><td>Scan verkleinern</td><td>Collapse scan</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>10. Accessibility</h3>
|
||||
<ul>
|
||||
<li>Mode switcher: <code>role="tablist"</code> with <code>role="tab"</code> children, <code>aria-selected</code> on active tab.</li>
|
||||
<li>Disabled “Lesen” tab: <code>aria-disabled="true"</code>, <code>tabindex="-1"</code>.</li>
|
||||
<li>Read view text: semantic HTML — <code><article></code> wrapping <code><p></code> elements. No <code>contenteditable</code>.</li>
|
||||
<li>Paragraphs are clickable: <code>role="button"</code>, <code>tabindex="0"</code>, <code>aria-label="Abschnitt N — klicken um Scan-Position anzuzeigen"</code>.</li>
|
||||
<li>PDF strip toggle on mobile: <code>role="button"</code>, <code>aria-expanded="{expanded}"</code>, <code>aria-label="Scan {expanded ? 'verkleinern' : 'vergrößern'}"</code>.</li>
|
||||
<li>Scroll-sync animations respect <code>prefers-reduced-motion</code>: skip the 1.5s fade, apply instant highlight that disappears after 200ms.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
3
frontend/.gitignore
vendored
3
frontend/.gitignore
vendored
@@ -31,3 +31,6 @@ src/lib/paraglide
|
||||
# src/lib/generated/api.ts
|
||||
src/lib/paraglide_bak*
|
||||
/coverage
|
||||
|
||||
# Playwright auth state — regenerated at the start of each CI run via auth.setup.ts
|
||||
e2e/.auth/
|
||||
|
||||
@@ -37,6 +37,57 @@ test.describe('Accessibility — authenticated pages', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Accessibility — dark mode (system preference)', () => {
|
||||
for (const { name, path } of AUTHENTICATED_PAGES) {
|
||||
test(`${name} page has no wcag2a/wcag2aa violations in prefers-color-scheme: dark`, async ({
|
||||
browser
|
||||
}) => {
|
||||
const context = await browser.newContext({
|
||||
colorScheme: 'dark',
|
||||
storageState: 'e2e/.auth/user.json'
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto(path);
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
const results = await buildAxe(page).analyze();
|
||||
|
||||
if (results.violations.length > 0) {
|
||||
const summary = results.violations
|
||||
.map((v) => `[${v.impact}] ${v.id}: ${v.description} (${v.nodes.length} node(s))`)
|
||||
.join('\n');
|
||||
console.log(`\nAccessibility violations on ${name} (dark/media):\n${summary}`);
|
||||
}
|
||||
|
||||
await context.close();
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Accessibility — dark mode (manual toggle)', () => {
|
||||
for (const { name, path } of AUTHENTICATED_PAGES) {
|
||||
test(`${name} page has no wcag2a/wcag2aa violations with data-theme='dark'`, async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(path);
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
|
||||
|
||||
const results = await buildAxe(page).analyze();
|
||||
|
||||
if (results.violations.length > 0) {
|
||||
const summary = results.violations
|
||||
.map((v) => `[${v.impact}] ${v.id}: ${v.description} (${v.nodes.length} node(s))`)
|
||||
.join('\n');
|
||||
console.log(`\nAccessibility violations on ${name} (dark/manual):\n${summary}`);
|
||||
}
|
||||
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Accessibility — login page', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
|
||||
29
frontend/e2e/dashboard-classic-split.spec.ts
Normal file
29
frontend/e2e/dashboard-classic-split.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { login } from './helpers/auth';
|
||||
|
||||
/**
|
||||
* Classic Split layout — verifies the right column visibility guard.
|
||||
*
|
||||
* The right column (DropZone + NeedsMetadata queue) is only rendered when
|
||||
* `canWrite === true` or there are incomplete docs. A read-only user with a
|
||||
* complete archive must never see an empty 300px ghost column.
|
||||
*/
|
||||
|
||||
test.describe('Dashboard Classic Split — write user', () => {
|
||||
test('right column is visible for admin user', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByTestId('dashboard-right-column')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Dashboard Classic Split — read-only user', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page, 'reader', 'reader123');
|
||||
});
|
||||
|
||||
test('right column is absent for read-only user with no incomplete docs', async ({ page }) => {
|
||||
await expect(page.getByTestId('dashboard-right-column')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
88
frontend/e2e/focus-rings.spec.ts
Normal file
88
frontend/e2e/focus-rings.spec.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Expected focus ring resolved colors
|
||||
// Light: --c-focus-ring: #012851 (brand-navy)
|
||||
const FOCUS_RING_LIGHT = 'rgb(1, 40, 81)';
|
||||
// Dark: --c-focus-ring: #a1dcd8 (brand-mint)
|
||||
const FOCUS_RING_DARK = 'rgb(161, 220, 216)';
|
||||
|
||||
test.describe('Focus ring token — CSS custom property', () => {
|
||||
test('--c-focus-ring is defined in light mode', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
const value = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--c-focus-ring').trim()
|
||||
);
|
||||
expect(value).toBe('#012851');
|
||||
});
|
||||
|
||||
test('--c-focus-ring is defined in dark mode', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
|
||||
|
||||
const value = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--c-focus-ring').trim()
|
||||
);
|
||||
expect(value).toBe('#a1dcd8');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Focus ring — header interactive elements', () => {
|
||||
test('ThemeToggle has brand-navy ring in light mode', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
await page.getByRole('button', { name: /dark mode|dunkelmodus/i }).focus();
|
||||
const boxShadow = await page.evaluate(
|
||||
() => getComputedStyle(document.activeElement as HTMLElement).boxShadow
|
||||
);
|
||||
expect(boxShadow).toContain(FOCUS_RING_LIGHT);
|
||||
});
|
||||
|
||||
test('AppNav link has brand-mint ring in dark mode', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
|
||||
|
||||
// Focus first desktop nav link
|
||||
await page.locator('header nav').getByRole('link').first().focus();
|
||||
const boxShadow = await page.evaluate(
|
||||
() => getComputedStyle(document.activeElement as HTMLElement).boxShadow
|
||||
);
|
||||
expect(boxShadow).toContain(FOCUS_RING_DARK);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Focus ring — form inputs', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('login username input has brand-mint ring in dark mode', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
|
||||
|
||||
await page.locator('#username').focus();
|
||||
const boxShadow = await page.evaluate(
|
||||
() => getComputedStyle(document.activeElement as HTMLElement).boxShadow
|
||||
);
|
||||
expect(boxShadow).toContain(FOCUS_RING_DARK);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Focus ring — PersonTypeahead', () => {
|
||||
test('PersonTypeahead input has brand-navy ring in light mode', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
// Open advanced filter panel to expose the sender PersonTypeahead
|
||||
await page.getByRole('button', { name: /filter/i }).click();
|
||||
await page.waitForSelector('#senderId-search');
|
||||
|
||||
await page.locator('#senderId-search').focus();
|
||||
const boxShadow = await page.evaluate(
|
||||
() => getComputedStyle(document.activeElement as HTMLElement).boxShadow
|
||||
);
|
||||
expect(boxShadow).toContain(FOCUS_RING_LIGHT);
|
||||
});
|
||||
});
|
||||
118
frontend/e2e/header.spec.ts
Normal file
118
frontend/e2e/header.spec.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
|
||||
// #012851 — brand-navy, set as --c-header in layout.css (both light and dark mode)
|
||||
const BRAND_NAVY = 'rgb(1, 40, 81)';
|
||||
|
||||
test.describe('Header — brand-navy background', () => {
|
||||
test('header background is brand-navy in light mode', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('navigation')).toBeVisible();
|
||||
|
||||
const bg = await page.locator('header').evaluate((el) => getComputedStyle(el).backgroundColor);
|
||||
expect(bg).toBe(BRAND_NAVY);
|
||||
});
|
||||
|
||||
test('header passes accessibility audit in light mode', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('navigation')).toBeVisible();
|
||||
|
||||
const results = await new AxeBuilder({ page }).include('header').analyze();
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
|
||||
test('header background stays brand-navy after switching to dark mode', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('navigation')).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('banner')
|
||||
.getByRole('button', { name: /dark mode/i })
|
||||
.click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||
|
||||
const bg = await page.locator('header').evaluate((el) => getComputedStyle(el).backgroundColor);
|
||||
expect(bg).toBe(BRAND_NAVY);
|
||||
});
|
||||
|
||||
test('header passes accessibility audit in dark mode', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('navigation')).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('banner')
|
||||
.getByRole('button', { name: /dark mode/i })
|
||||
.click();
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||
|
||||
const results = await new AxeBuilder({ page }).include('header').analyze();
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
|
||||
test('logo text is visible at 375px viewport', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 375, height: 812 });
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByRole('banner').getByText('Familienarchiv')).toBeVisible();
|
||||
});
|
||||
|
||||
test('hamburger menu opens on tablet viewport (768px)', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 768, height: 1024 });
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('navigation')).toBeVisible();
|
||||
|
||||
const hamburger = page.getByRole('button', { name: /menü öffnen/i });
|
||||
await expect(hamburger).toBeVisible();
|
||||
await hamburger.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('navigation', { name: /mobile/i }).or(page.locator('#mobile-nav'))
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Login page — AuthHeader', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('login page has brand-navy header with language switcher', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
|
||||
const header = page.locator('header');
|
||||
await expect(header).toBeVisible();
|
||||
|
||||
const bg = await header.evaluate((el) => getComputedStyle(el).backgroundColor);
|
||||
expect(bg).toBe(BRAND_NAVY);
|
||||
|
||||
await expect(header.getByRole('button', { name: 'DE' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('login page header passes accessibility audit', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await expect(page.locator('header')).toBeVisible();
|
||||
|
||||
const results = await new AxeBuilder({ page }).include('header').analyze();
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Forgot-password page — AuthHeader', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('forgot-password page has brand-navy header', async ({ page }) => {
|
||||
await page.goto('/forgot-password');
|
||||
|
||||
const header = page.locator('header');
|
||||
await expect(header).toBeVisible();
|
||||
|
||||
const bg = await header.evaluate((el) => getComputedStyle(el).backgroundColor);
|
||||
expect(bg).toBe(BRAND_NAVY);
|
||||
});
|
||||
|
||||
test('forgot-password page header passes accessibility audit', async ({ page }) => {
|
||||
await page.goto('/forgot-password');
|
||||
await expect(page.locator('header')).toBeVisible();
|
||||
|
||||
const results = await new AxeBuilder({ page }).include('header').analyze();
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -80,8 +80,7 @@ test.describe('Password reset', () => {
|
||||
await page.locator('input[name="currentPassword"]').fill(newPassword);
|
||||
await page.locator('input[name="newPassword"]').fill(originalPassword);
|
||||
await page.locator('input[name="confirmPassword"]').fill(originalPassword);
|
||||
// Profile page has two "Speichern" buttons — the password form is the last one
|
||||
await page.locator('button[type="submit"]').last().click();
|
||||
await page.getByTestId('submit-password').click();
|
||||
// After changing password, auth_token is stale → redirect to login
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
|
||||
|
||||
@@ -60,6 +60,48 @@ test.describe('Theme toggle', () => {
|
||||
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
|
||||
});
|
||||
|
||||
test('header uses --c-header token background in dark mode', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
|
||||
|
||||
const headerBg = await page.evaluate(() => {
|
||||
const header = document.querySelector('header');
|
||||
return header ? getComputedStyle(header).backgroundColor : null;
|
||||
});
|
||||
// --c-header in dark mode = #012851 (brand navy) → rgb(1, 40, 81)
|
||||
expect(headerBg).toBe('rgb(1, 40, 81)');
|
||||
});
|
||||
|
||||
test('color-scheme is dark when data-theme=dark is set', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
|
||||
|
||||
const colorScheme = await page.evaluate(
|
||||
() => getComputedStyle(document.documentElement).colorScheme
|
||||
);
|
||||
expect(colorScheme).toBe('dark');
|
||||
});
|
||||
|
||||
test('color-scheme is dark in prefers-color-scheme: dark media', async ({ browser }) => {
|
||||
const context = await browser.newContext({
|
||||
colorScheme: 'dark',
|
||||
storageState: 'e2e/.auth/user.json'
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
const colorScheme = await page.evaluate(
|
||||
() => getComputedStyle(document.documentElement).colorScheme
|
||||
);
|
||||
await context.close();
|
||||
expect(colorScheme).toBe('dark');
|
||||
});
|
||||
|
||||
test('saved theme is applied before first paint (no flash)', async ({ page }) => {
|
||||
// Set dark theme in localStorage before navigating
|
||||
await page.goto('/');
|
||||
|
||||
@@ -223,6 +223,12 @@
|
||||
"admin_label_initial_password": "Passwort",
|
||||
"doc_file_error_preview": "Vorschau konnte nicht geladen werden.",
|
||||
"doc_download_title": "Herunterladen",
|
||||
"topbar_back_label": "Zurück zur Dokumentenliste",
|
||||
"topbar_more_actions": "Weitere Aktionen",
|
||||
"topbar_overflow_more": "+{count} weitere",
|
||||
"topbar_overflow_suffix": "weitere",
|
||||
"topbar_overflow_heading": "Weitere Empfänger",
|
||||
"topbar_overflow_show": "{count} weitere Empfänger anzeigen",
|
||||
"doc_tag_filter_title": "Nach {name} filtern",
|
||||
"doc_conversation_title": "Konversation anzeigen",
|
||||
"doc_preview_iframe_title": "Dokumentvorschau",
|
||||
@@ -321,6 +327,7 @@
|
||||
"doc_panel_tab_history": "Verlauf",
|
||||
"doc_panel_annotate": "Annotieren",
|
||||
"doc_panel_annotate_stop": "Fertig",
|
||||
"doc_panel_annotate_hint": "Klicken und ziehen Sie, um einen Bereich zu markieren",
|
||||
"doc_panel_annotation_thread_title": "Annotation",
|
||||
"doc_panel_discussion_annotation_tab": "Annotation · Seite {page}",
|
||||
"pdf_annotations_show": "Annotierungen anzeigen",
|
||||
@@ -375,6 +382,8 @@
|
||||
"dashboard_needs_metadata_heading": "Metadaten fehlen",
|
||||
"dashboard_needs_metadata_show_all": "Alle anzeigen",
|
||||
"dashboard_recent_heading": "Zuletzt aktiv",
|
||||
"dashboard_stats_documents": "Dokumente",
|
||||
"dashboard_stats_persons": "Personen",
|
||||
"dashboard_resume_label": "Zuletzt geöffnet:",
|
||||
"dashboard_resume_fallback": "Unbekanntes Dokument",
|
||||
"doc_status_placeholder": "Platzhalter",
|
||||
|
||||
@@ -223,6 +223,12 @@
|
||||
"admin_label_initial_password": "Password",
|
||||
"doc_file_error_preview": "Could not load preview.",
|
||||
"doc_download_title": "Download",
|
||||
"topbar_back_label": "Back to document list",
|
||||
"topbar_more_actions": "More actions",
|
||||
"topbar_overflow_more": "+{count} more",
|
||||
"topbar_overflow_suffix": "more",
|
||||
"topbar_overflow_heading": "More receivers",
|
||||
"topbar_overflow_show": "Show {count} more receivers",
|
||||
"doc_tag_filter_title": "Filter by {name}",
|
||||
"doc_conversation_title": "Show conversation",
|
||||
"doc_preview_iframe_title": "Document Preview",
|
||||
@@ -321,6 +327,7 @@
|
||||
"doc_panel_tab_history": "History",
|
||||
"doc_panel_annotate": "Annotate",
|
||||
"doc_panel_annotate_stop": "Done",
|
||||
"doc_panel_annotate_hint": "Click and drag to mark an area",
|
||||
"doc_panel_annotation_thread_title": "Annotation",
|
||||
"doc_panel_discussion_annotation_tab": "Annotation · Page {page}",
|
||||
"pdf_annotations_show": "Show annotations",
|
||||
@@ -375,6 +382,8 @@
|
||||
"dashboard_needs_metadata_heading": "Missing Metadata",
|
||||
"dashboard_needs_metadata_show_all": "Show all",
|
||||
"dashboard_recent_heading": "Recent Activity",
|
||||
"dashboard_stats_documents": "Documents",
|
||||
"dashboard_stats_persons": "Persons",
|
||||
"dashboard_resume_label": "Last opened:",
|
||||
"dashboard_resume_fallback": "Unknown document",
|
||||
"doc_status_placeholder": "Placeholder",
|
||||
|
||||
@@ -223,6 +223,12 @@
|
||||
"admin_label_initial_password": "Contraseña",
|
||||
"doc_file_error_preview": "No se pudo cargar la vista previa.",
|
||||
"doc_download_title": "Descargar",
|
||||
"topbar_back_label": "Volver a la lista de documentos",
|
||||
"topbar_more_actions": "Más acciones",
|
||||
"topbar_overflow_more": "+{count} más",
|
||||
"topbar_overflow_suffix": "más",
|
||||
"topbar_overflow_heading": "Más destinatarios",
|
||||
"topbar_overflow_show": "Mostrar {count} destinatarios más",
|
||||
"doc_tag_filter_title": "Filtrar por {name}",
|
||||
"doc_conversation_title": "Ver conversación",
|
||||
"doc_preview_iframe_title": "Vista previa del documento",
|
||||
@@ -321,6 +327,7 @@
|
||||
"doc_panel_tab_history": "Historial",
|
||||
"doc_panel_annotate": "Anotar",
|
||||
"doc_panel_annotate_stop": "Listo",
|
||||
"doc_panel_annotate_hint": "Haga clic y arrastre para marcar un área",
|
||||
"doc_panel_annotation_thread_title": "Anotación",
|
||||
"doc_panel_discussion_annotation_tab": "Anotación · Página {page}",
|
||||
"pdf_annotations_show": "Mostrar anotaciones",
|
||||
@@ -375,6 +382,8 @@
|
||||
"dashboard_needs_metadata_heading": "Metadatos incompletos",
|
||||
"dashboard_needs_metadata_show_all": "Ver todos",
|
||||
"dashboard_recent_heading": "Actividad reciente",
|
||||
"dashboard_stats_documents": "Documentos",
|
||||
"dashboard_stats_persons": "Personas",
|
||||
"dashboard_resume_label": "Último abierto:",
|
||||
"dashboard_resume_fallback": "Documento desconocido",
|
||||
"doc_status_placeholder": "Marcador",
|
||||
|
||||
64
frontend/src/lib/actions/clickOutside.svelte.spec.ts
Normal file
64
frontend/src/lib/actions/clickOutside.svelte.spec.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
|
||||
const { clickOutside } = await import('./clickOutside');
|
||||
|
||||
describe('clickOutside action', () => {
|
||||
const nodes: HTMLElement[] = [];
|
||||
|
||||
function makeNode(): HTMLElement {
|
||||
const node = document.createElement('div');
|
||||
document.body.appendChild(node);
|
||||
nodes.push(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
nodes.forEach((n) => n.remove());
|
||||
nodes.length = 0;
|
||||
});
|
||||
|
||||
it('registers a capture-phase click listener on mount', () => {
|
||||
const node = makeNode();
|
||||
const original = document.addEventListener.bind(document);
|
||||
let registered = false;
|
||||
document.addEventListener = (type: string, _fn: unknown, opts: unknown) => {
|
||||
if (type === 'click' && opts === true) registered = true;
|
||||
original(type as string, _fn as EventListener, opts as boolean);
|
||||
};
|
||||
clickOutside(node);
|
||||
expect(registered).toBe(true);
|
||||
document.addEventListener = original;
|
||||
});
|
||||
|
||||
it('dispatches clickoutside when clicking outside the node', () => {
|
||||
const node = makeNode();
|
||||
const outside = makeNode();
|
||||
let fired = false;
|
||||
node.addEventListener('clickoutside', () => (fired = true));
|
||||
clickOutside(node);
|
||||
outside.click();
|
||||
expect(fired).toBe(true);
|
||||
});
|
||||
|
||||
it('does not dispatch clickoutside when clicking inside the node', () => {
|
||||
const node = makeNode();
|
||||
const child = document.createElement('span');
|
||||
node.appendChild(child);
|
||||
let fired = false;
|
||||
node.addEventListener('clickoutside', () => (fired = true));
|
||||
clickOutside(node);
|
||||
child.click();
|
||||
expect(fired).toBe(false);
|
||||
});
|
||||
|
||||
it('removes the listener on destroy', () => {
|
||||
const node = makeNode();
|
||||
const outside = makeNode();
|
||||
let count = 0;
|
||||
node.addEventListener('clickoutside', () => count++);
|
||||
const { destroy } = clickOutside(node);
|
||||
destroy();
|
||||
outside.click();
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
});
|
||||
15
frontend/src/lib/actions/clickOutside.ts
Normal file
15
frontend/src/lib/actions/clickOutside.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export function clickOutside(node: HTMLElement): { destroy: () => void } {
|
||||
function handleClick(event: MouseEvent) {
|
||||
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
|
||||
node.dispatchEvent(new CustomEvent('clickoutside'));
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', handleClick, true);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
}
|
||||
};
|
||||
}
|
||||
22
frontend/src/lib/components/AnnotateHintStrip.svelte
Normal file
22
frontend/src/lib/components/AnnotateHintStrip.svelte
Normal file
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
type Props = {
|
||||
annotateMode: boolean;
|
||||
};
|
||||
|
||||
let { annotateMode }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if annotateMode}
|
||||
<div
|
||||
data-testid="annotate-hint-strip"
|
||||
class="hidden h-[29px] items-center gap-2 border-t border-dashed px-3.5 md:flex"
|
||||
style="background: rgba(1,40,81,0.05); border-color: rgba(1,40,81,0.20)"
|
||||
>
|
||||
<span class="text-[16px] font-bold tracking-wide text-primary uppercase"
|
||||
>{m.doc_panel_annotate()}</span
|
||||
>
|
||||
<span class="text-[16px] text-ink-2">{m.doc_panel_annotate_hint()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
27
frontend/src/lib/components/AnnotateHintStrip.svelte.spec.ts
Normal file
27
frontend/src/lib/components/AnnotateHintStrip.svelte.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import AnnotateHintStrip from './AnnotateHintStrip.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('AnnotateHintStrip', () => {
|
||||
it('is absent from the DOM when annotateMode is false', async () => {
|
||||
render(AnnotateHintStrip, { annotateMode: false });
|
||||
const strip = page.getByTestId('annotate-hint-strip');
|
||||
await expect.element(strip).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is present in the DOM when annotateMode is true', async () => {
|
||||
render(AnnotateHintStrip, { annotateMode: true });
|
||||
const strip = page.getByTestId('annotate-hint-strip');
|
||||
await expect.element(strip).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has hidden md:flex class to hide below 768px', async () => {
|
||||
render(AnnotateHintStrip, { annotateMode: true });
|
||||
const strip = page.getByTestId('annotate-hint-strip');
|
||||
await expect.element(strip).toHaveClass('hidden');
|
||||
await expect.element(strip).toHaveClass('md:flex');
|
||||
});
|
||||
});
|
||||
@@ -312,7 +312,7 @@ onMount(async () => {
|
||||
<div
|
||||
data-comment-id={thread.id}
|
||||
class={highlightedCommentId === thread.id
|
||||
? 'rounded ring-2 ring-accent ring-offset-1 transition-shadow'
|
||||
? 'rounded outline-2 outline-offset-1 outline-accent transition-shadow outline-dotted'
|
||||
: ''}
|
||||
>
|
||||
{@render commentEntry(thread, thread.id, thread.replies.length === 0)}
|
||||
@@ -323,7 +323,7 @@ onMount(async () => {
|
||||
<div
|
||||
data-comment-id={reply.id}
|
||||
class="mt-3 ml-6 border-l-2 border-line pl-4 {highlightedCommentId === reply.id
|
||||
? 'rounded ring-2 ring-accent ring-offset-1 transition-shadow'
|
||||
? 'rounded outline-2 outline-offset-1 outline-accent transition-shadow outline-dotted'
|
||||
: ''}"
|
||||
>
|
||||
{@render commentEntry(reply, thread.id, ri === thread.replies.length - 1)}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
type NotificationDTO = {
|
||||
id: string;
|
||||
type: 'REPLY' | 'MENTION';
|
||||
documentId?: string;
|
||||
referenceId?: string;
|
||||
annotationId?: string;
|
||||
read: boolean;
|
||||
createdAt: string;
|
||||
actorName?: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
mentions: NotificationDTO[];
|
||||
}
|
||||
|
||||
let { mentions }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if mentions.length > 0}
|
||||
<div data-testid="dashboard-mentions" class="rounded-sm border border-line bg-surface p-6">
|
||||
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||
{m.dashboard_notifications_heading()}
|
||||
</h2>
|
||||
<div>
|
||||
{#each mentions as mention (mention.id)}
|
||||
<div class="flex items-center gap-3 border-b border-line py-2 last:border-0">
|
||||
{#if mention.documentId}
|
||||
<a
|
||||
href={mention.annotationId
|
||||
? `/documents/${mention.documentId}?commentId=${mention.referenceId}&annotationId=${mention.annotationId}`
|
||||
: `/documents/${mention.documentId}?commentId=${mention.referenceId}`}
|
||||
class="font-serif text-lg text-ink hover:text-ink-2 hover:underline"
|
||||
>{mention.actorName ?? ''}</a
|
||||
>
|
||||
<span class="font-sans text-xs text-gray-400">
|
||||
{mention.type === 'MENTION'
|
||||
? m.dashboard_notification_mentioned()
|
||||
: m.dashboard_notification_replied()}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="font-serif text-lg text-ink">{mention.actorName ?? ''}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="mt-4 border-t border-line pt-4">
|
||||
<a
|
||||
href="/notifications"
|
||||
class="text-sm font-medium text-ink-2 transition-colors hover:text-ink"
|
||||
>{m.notification_history_view_link()}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
import DashboardMentions from './DashboardMentions.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
type NotificationDTO = {
|
||||
id: string;
|
||||
type: 'REPLY' | 'MENTION';
|
||||
documentId?: string;
|
||||
referenceId?: string;
|
||||
annotationId?: string;
|
||||
read: boolean;
|
||||
createdAt: string;
|
||||
actorName?: string;
|
||||
};
|
||||
|
||||
function makeMention(overrides: Partial<NotificationDTO> = {}): NotificationDTO {
|
||||
return {
|
||||
id: 'notif-1',
|
||||
type: 'MENTION',
|
||||
documentId: 'doc-abc',
|
||||
referenceId: 'comment-xyz',
|
||||
read: false,
|
||||
createdAt: '2026-01-15T10:00:00Z',
|
||||
actorName: 'Anna Schmidt',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('DashboardMentions', () => {
|
||||
it('renders nothing when mentions list is empty', async () => {
|
||||
render(DashboardMentions, { mentions: [] });
|
||||
const widget = page.getByTestId('dashboard-mentions');
|
||||
await expect.element(widget).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a heading when mentions are present', async () => {
|
||||
render(DashboardMentions, { mentions: [makeMention()] });
|
||||
const widget = page.getByTestId('dashboard-mentions');
|
||||
await expect.element(widget).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('builds link with commentId param when no annotationId', async () => {
|
||||
render(DashboardMentions, {
|
||||
mentions: [makeMention({ documentId: 'doc-1', referenceId: 'cmt-1' })]
|
||||
});
|
||||
const link = page.getByRole('link');
|
||||
await expect.element(link).toHaveAttribute('href', '/documents/doc-1?commentId=cmt-1');
|
||||
});
|
||||
|
||||
it('builds link with commentId and annotationId when annotationId is present', async () => {
|
||||
render(DashboardMentions, {
|
||||
mentions: [makeMention({ documentId: 'doc-2', referenceId: 'cmt-2', annotationId: 'ann-9' })]
|
||||
});
|
||||
const link = page.getByRole('link');
|
||||
await expect
|
||||
.element(link)
|
||||
.toHaveAttribute('href', '/documents/doc-2?commentId=cmt-2&annotationId=ann-9');
|
||||
});
|
||||
|
||||
it('shows actor name in each row', async () => {
|
||||
render(DashboardMentions, { mentions: [makeMention({ actorName: 'Maria Müller' })] });
|
||||
await expect.element(page.getByText('Maria Müller')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "replied" label for REPLY type', async () => {
|
||||
render(DashboardMentions, { mentions: [makeMention({ type: 'REPLY' })] });
|
||||
const widget = page.getByTestId('dashboard-mentions');
|
||||
await expect.element(widget).toBeInTheDocument();
|
||||
const link = page.getByRole('link');
|
||||
await expect.element(link).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a span instead of a link when documentId is absent', async () => {
|
||||
render(DashboardMentions, {
|
||||
mentions: [makeMention({ documentId: undefined, actorName: 'Lena Bauer' })]
|
||||
});
|
||||
await expect.element(page.getByText('Lena Bauer')).toBeInTheDocument();
|
||||
const links = page.getByRole('link');
|
||||
await expect.element(links).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { getLocale } from '$lib/paraglide/runtime.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Document = {
|
||||
id: string;
|
||||
@@ -9,11 +10,14 @@ type Document = {
|
||||
sender?: { id: string; firstName: string; lastName: string };
|
||||
};
|
||||
|
||||
type StatsDTO = components['schemas']['StatsDTO'];
|
||||
|
||||
interface Props {
|
||||
recentDocs: Document[];
|
||||
stats?: StatsDTO | null;
|
||||
}
|
||||
|
||||
let { recentDocs }: Props = $props();
|
||||
let { recentDocs, stats = null }: Props = $props();
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
// updatedAt is a full ISO datetime — no T12:00:00 noon-anchor needed here
|
||||
@@ -31,7 +35,10 @@ function formatDate(dateStr: string): string {
|
||||
{m.dashboard_recent_heading()}
|
||||
</h2>
|
||||
{#each recentDocs as doc (doc.id)}
|
||||
<div class="flex items-center justify-between border-b border-line py-2 last:border-0">
|
||||
<div
|
||||
data-testid="doc-row-{doc.id}"
|
||||
class="flex min-h-[44px] items-center justify-between border-b border-line py-2 last:border-0"
|
||||
>
|
||||
<a
|
||||
href="/documents/{doc.id}"
|
||||
class="font-serif text-lg text-ink hover:text-ink-2 hover:underline"
|
||||
@@ -48,5 +55,12 @@ function formatDate(dateStr: string): string {
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#if stats?.totalDocuments != null}
|
||||
<p data-testid="dashboard-stats-footnote" class="mt-4 font-sans text-sm text-ink-3">
|
||||
{stats.totalDocuments}
|
||||
{m.dashboard_stats_documents()} · {stats.totalPersons}
|
||||
{m.dashboard_stats_persons()}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -55,3 +55,40 @@ describe('DashboardRecentDocuments', () => {
|
||||
await expect.element(dateEl).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardRecentDocuments — stats footnote', () => {
|
||||
it('renders stats footnote when stats.totalDocuments is provided', async () => {
|
||||
render(DashboardRecentDocuments, {
|
||||
recentDocs: [makeDoc('d1', 'Taufschein')],
|
||||
stats: { totalDocuments: 248, totalPersons: 34 }
|
||||
});
|
||||
const footnote = page.getByTestId('dashboard-stats-footnote');
|
||||
await expect.element(footnote).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits stats footnote when stats is null', async () => {
|
||||
render(DashboardRecentDocuments, {
|
||||
recentDocs: [makeDoc('d1', 'Taufschein')],
|
||||
stats: null
|
||||
});
|
||||
const footnote = page.getByTestId('dashboard-stats-footnote');
|
||||
await expect.element(footnote).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "0 Documents" when totalDocuments is 0', async () => {
|
||||
render(DashboardRecentDocuments, {
|
||||
recentDocs: [makeDoc('d1', 'Taufschein')],
|
||||
stats: { totalDocuments: 0, totalPersons: 0 }
|
||||
});
|
||||
const footnote = page.getByTestId('dashboard-stats-footnote');
|
||||
await expect.element(footnote).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardRecentDocuments — touch targets', () => {
|
||||
it('each doc row has min-h-[44px] class for WCAG touch target', async () => {
|
||||
render(DashboardRecentDocuments, { recentDocs: [makeDoc('d1', 'Taufschein')] });
|
||||
const row = page.getByTestId('doc-row-d1');
|
||||
await expect.element(row).toHaveClass('min-h-[44px]');
|
||||
});
|
||||
});
|
||||
|
||||
20
frontend/src/lib/components/DocumentStatusChip.svelte
Normal file
20
frontend/src/lib/components/DocumentStatusChip.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { statusDotClass, statusLabel } from '$lib/utils/personFormat';
|
||||
|
||||
type DocumentStatus = 'PLACEHOLDER' | 'UPLOADED' | 'TRANSCRIBED' | 'REVIEWED' | 'ARCHIVED';
|
||||
|
||||
type Props = {
|
||||
status: DocumentStatus;
|
||||
};
|
||||
|
||||
let { status }: Props = $props();
|
||||
|
||||
const dotClass = $derived(statusDotClass(status));
|
||||
const label = $derived(statusLabel(status));
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="hidden shrink-0 md:block {dotClass} h-4 w-4 rounded-full"
|
||||
title={label}
|
||||
aria-label={label}
|
||||
></span>
|
||||
@@ -1,5 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { formatDate } from '$lib/utils/personFormat';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
import PersonChipRow from './PersonChipRow.svelte';
|
||||
import AnnotateHintStrip from './AnnotateHintStrip.svelte';
|
||||
import OverflowPillButton from './OverflowPillButton.svelte';
|
||||
|
||||
type Person = { id: string; firstName: string; lastName: string };
|
||||
|
||||
@@ -25,128 +30,207 @@ type Props = {
|
||||
let { doc, canWrite, canAnnotate, fileUrl, annotateMode = $bindable() }: Props = $props();
|
||||
|
||||
const isPdf = $derived(!!doc.filePath && doc.contentType?.startsWith('application/pdf'));
|
||||
const receivers = $derived(doc.receivers ?? []);
|
||||
const extraCount = $derived(Math.max(0, receivers.length - 2));
|
||||
const overflowPersons = $derived(receivers.slice(2));
|
||||
|
||||
const receiverDisplay = $derived.by(() => {
|
||||
const receivers = doc.receivers ?? [];
|
||||
if (receivers.length === 0) return null;
|
||||
const shown = receivers.slice(0, 2);
|
||||
const extra = receivers.length - shown.length;
|
||||
const names = shown.map((r) => `${r.firstName} ${r.lastName}`).join(', ');
|
||||
return extra > 0 ? `${names} +${extra}` : names;
|
||||
});
|
||||
const shortDate = $derived(doc.documentDate ? formatDate(doc.documentDate, 'short') : null);
|
||||
const longDate = $derived(doc.documentDate ? formatDate(doc.documentDate, 'long') : null);
|
||||
|
||||
const compactMeta = $derived.by(() => {
|
||||
const parts: string[] = [];
|
||||
if (doc.documentDate) {
|
||||
parts.push(
|
||||
new Intl.DateTimeFormat('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'numeric',
|
||||
year: 'numeric'
|
||||
}).format(new Date(doc.documentDate + 'T12:00:00'))
|
||||
);
|
||||
}
|
||||
if (doc.sender) {
|
||||
const senderName = `${doc.sender.firstName} ${doc.sender.lastName}`;
|
||||
const receiver = receiverDisplay;
|
||||
parts.push(receiver ? `${senderName} → ${receiver}` : senderName);
|
||||
} else if (receiverDisplay) {
|
||||
parts.push(`→ ${receiverDisplay}`);
|
||||
}
|
||||
return parts.join(' · ');
|
||||
});
|
||||
let mobileMenuOpen = $state(false);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="z-20 flex shrink-0 items-center justify-between border-b border-line bg-surface px-3 py-3 shadow-sm sm:px-6"
|
||||
data-topbar
|
||||
>
|
||||
<!-- Left: back + title -->
|
||||
<div class="flex min-w-0 items-center gap-4 overflow-hidden">
|
||||
{#snippet annotateBtn(mobile: boolean)}
|
||||
<button
|
||||
onclick={() => {
|
||||
annotateMode = true;
|
||||
if (mobile) mobileMenuOpen = false;
|
||||
}}
|
||||
aria-label={m.doc_panel_annotate()}
|
||||
aria-pressed={false}
|
||||
class={mobile
|
||||
? 'flex w-full items-center gap-2 rounded px-3 py-2 text-left text-[16px] text-ink transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary'
|
||||
: 'hidden items-center gap-1.5 rounded border border-primary px-3 py-1.5 font-sans text-[16px] font-medium text-ink transition hover:bg-primary hover:text-primary-fg focus-visible:ring-2 focus-visible:ring-primary md:flex'}
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Note/Note-Add-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5 shrink-0"
|
||||
/>
|
||||
{m.doc_panel_annotate()}
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
{#snippet annotateStopBtn(mobile: boolean)}
|
||||
<button
|
||||
onclick={() => {
|
||||
annotateMode = false;
|
||||
if (mobile) mobileMenuOpen = false;
|
||||
}}
|
||||
aria-label={m.doc_panel_annotate_stop()}
|
||||
aria-pressed={true}
|
||||
class={mobile
|
||||
? 'flex w-full items-center gap-2 rounded bg-primary px-3 py-2 text-left text-[16px] text-primary-fg transition focus-visible:ring-2 focus-visible:ring-primary'
|
||||
: 'flex items-center gap-1.5 rounded bg-primary px-3 py-1.5 font-sans text-[16px] font-medium text-primary-fg transition focus-visible:ring-2 focus-visible:ring-primary'}
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Note/Note-Add-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5 shrink-0 invert"
|
||||
/>
|
||||
{m.doc_panel_annotate_stop()}
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
{#snippet downloadLink(mobile: boolean)}
|
||||
<a
|
||||
href={fileUrl}
|
||||
download={doc.originalFilename}
|
||||
onclick={() => {
|
||||
if (mobile) mobileMenuOpen = false;
|
||||
}}
|
||||
class={mobile
|
||||
? 'flex items-center gap-2 rounded px-3 py-2 text-[16px] text-ink transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary'
|
||||
: 'hidden rounded border border-transparent bg-muted p-1.5 text-ink transition hover:bg-accent focus-visible:ring-2 focus-visible:ring-primary md:block'}
|
||||
title={m.doc_download_title()}
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Download-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5 shrink-0"
|
||||
/>
|
||||
{#if mobile}{m.doc_download_title()}{/if}
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
<div data-topbar class="relative z-10 border-b border-line bg-surface shadow-sm">
|
||||
<!-- Main row -->
|
||||
<div class="flex h-[75px] shrink-0 items-center xs:h-[88px]">
|
||||
<!-- Accent bar -->
|
||||
<div class="h-full w-[3px] shrink-0 bg-primary"></div>
|
||||
|
||||
<!-- Back link — 44×44px touch target -->
|
||||
<a
|
||||
href="/"
|
||||
class="group flex shrink-0 items-center gap-2 font-sans text-sm font-medium text-ink-2 transition-colors hover:text-ink"
|
||||
aria-label={m.topbar_back_label()}
|
||||
class="group -ml-0.5 flex h-11 w-11 shrink-0 items-center justify-center rounded-full transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-canvas transition-colors group-hover:bg-accent"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
<span class="hidden sm:inline">{m.btn_back()}</span>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Arrow/Arrow-Left-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<div class="min-w-0 border-l border-line pl-4">
|
||||
<!-- Divider -->
|
||||
<div class="mx-2 h-6 w-px shrink-0 bg-line"></div>
|
||||
|
||||
<!-- Title + meta -->
|
||||
<div class="min-w-0 flex-1 overflow-hidden">
|
||||
<h1
|
||||
class="truncate font-serif text-base leading-tight text-ink"
|
||||
class="truncate font-serif text-[18px] leading-tight text-ink lg:text-[20px]"
|
||||
title={doc.title ?? doc.originalFilename ?? ''}
|
||||
>
|
||||
{doc.title || doc.originalFilename}
|
||||
</h1>
|
||||
{#if compactMeta}
|
||||
<p class="truncate font-sans text-xs text-ink-2" title={compactMeta}>
|
||||
{compactMeta}
|
||||
{#if shortDate}
|
||||
<p class="font-sans text-[16px] text-ink-2">
|
||||
<span class="lg:hidden">{shortDate}</span>
|
||||
<span class="hidden lg:inline">{longDate}</span>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Chip row — desktop only, hidden on small screens to make room for buttons -->
|
||||
<div class="mx-3 hidden min-w-0 shrink-0 md:block">
|
||||
<PersonChipRow sender={doc.sender} receivers={receivers} abbreviated={true} extraCount={0} />
|
||||
</div>
|
||||
|
||||
<!-- Overflow pill button (desktop) + status dot -->
|
||||
{#if extraCount > 0}
|
||||
<OverflowPillButton extraCount={extraCount} persons={overflowPersons} />
|
||||
{/if}
|
||||
|
||||
<!-- Divider between metadata and actions -->
|
||||
<div class="mx-3 hidden h-6 w-px shrink-0 bg-line md:block"></div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex shrink-0 items-center gap-1.5 pr-3 font-sans">
|
||||
{#if canAnnotate && isPdf && !annotateMode}
|
||||
{@render annotateBtn(false)}
|
||||
{/if}
|
||||
|
||||
{#if canAnnotate && isPdf && annotateMode}
|
||||
{@render annotateStopBtn(false)}
|
||||
{/if}
|
||||
|
||||
{#if canWrite && !annotateMode}
|
||||
<a
|
||||
href="/documents/{doc.id}/edit"
|
||||
aria-label={m.btn_edit()}
|
||||
class="flex items-center gap-1.5 rounded border border-primary bg-transparent px-3 py-1.5 text-[16px] font-medium text-ink transition hover:bg-primary hover:text-primary-fg focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
<span class="hidden sm:inline">{m.btn_edit()}</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if doc.filePath && !annotateMode}
|
||||
{@render downloadLink(false)}
|
||||
{/if}
|
||||
|
||||
<!-- Kebab menu — mobile only, contains actions hidden below md -->
|
||||
{#if (canAnnotate && isPdf) || doc.filePath}
|
||||
<div
|
||||
role="group"
|
||||
class="relative md:hidden"
|
||||
use:clickOutside
|
||||
onclickoutside={() => (mobileMenuOpen = false)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
|
||||
aria-label={m.topbar_more_actions()}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={mobileMenuOpen}
|
||||
class="flex h-9 w-9 items-center justify-center rounded border border-line bg-muted transition hover:bg-accent focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/View-More-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{#if mobileMenuOpen}
|
||||
<div
|
||||
role="menu"
|
||||
class="absolute top-full right-0 z-50 mt-1 min-w-[200px] rounded-md border border-line bg-surface p-2 shadow-lg"
|
||||
>
|
||||
{#if canAnnotate && isPdf && !annotateMode}
|
||||
{@render annotateBtn(true)}
|
||||
{/if}
|
||||
|
||||
{#if doc.filePath}
|
||||
{@render downloadLink(true)}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: actions -->
|
||||
<div class="ml-4 flex shrink-0 items-center gap-2 font-sans">
|
||||
{#if canAnnotate && isPdf}
|
||||
<button
|
||||
onclick={() => (annotateMode = !annotateMode)}
|
||||
aria-label={annotateMode ? m.doc_panel_annotate_stop() : m.doc_panel_annotate()}
|
||||
class="flex items-center gap-1.5 rounded px-3 py-1.5 font-sans text-xs font-medium transition {annotateMode
|
||||
? 'bg-primary text-primary-fg'
|
||||
: 'border border-primary text-ink hover:bg-primary hover:text-primary-fg'}"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Note/Note-Add-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 {annotateMode ? 'invert' : ''}"
|
||||
/>
|
||||
<span class="hidden sm:inline"
|
||||
>{annotateMode ? m.doc_panel_annotate_stop() : m.doc_panel_annotate()}</span
|
||||
>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if canWrite}
|
||||
<a
|
||||
href="/documents/{doc.id}/edit"
|
||||
aria-label={m.btn_edit()}
|
||||
class="flex items-center gap-2 rounded border border-primary bg-transparent px-3 py-1.5 text-xs font-medium text-ink transition hover:bg-primary hover:text-primary-fg"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Edit-Content-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<span class="hidden sm:inline">{m.btn_edit()}</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if doc.filePath}
|
||||
<a
|
||||
href={fileUrl}
|
||||
download={doc.originalFilename}
|
||||
class="rounded border border-transparent bg-muted p-1.5 text-ink transition hover:bg-accent"
|
||||
title={m.doc_download_title()}
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Download-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Hint strip — only when annotateMode, only at ≥768px -->
|
||||
<AnnotateHintStrip annotateMode={annotateMode} />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { setLocale, getLocale } from '$lib/paraglide/runtime';
|
||||
|
||||
let { inverted = false }: { inverted?: boolean } = $props();
|
||||
|
||||
const locales = ['DE', 'EN', 'ES'] as const;
|
||||
const localeMap = { DE: 'de', EN: 'en', ES: 'es' } as const;
|
||||
const activeLocale = $derived(getLocale().toUpperCase());
|
||||
@@ -10,8 +12,14 @@ const activeLocale = $derived(getLocale().toUpperCase());
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLocale(localeMap[locale])}
|
||||
class="font-sans tracking-widest transition-colors
|
||||
{activeLocale === locale ? 'font-bold text-ink' : 'font-normal text-ink-3 hover:text-ink'}"
|
||||
class="rounded px-1 font-sans tracking-widest transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring
|
||||
{activeLocale === locale
|
||||
? inverted
|
||||
? 'font-bold text-white'
|
||||
: 'font-bold text-ink'
|
||||
: inverted
|
||||
? 'font-normal text-white/70 hover:text-white'
|
||||
: 'font-normal text-ink-3 hover:text-ink'}"
|
||||
>
|
||||
{locale}
|
||||
</button>
|
||||
|
||||
94
frontend/src/lib/components/LanguageSwitcher.svelte.spec.ts
Normal file
94
frontend/src/lib/components/LanguageSwitcher.svelte.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import LanguageSwitcher from './LanguageSwitcher.svelte';
|
||||
|
||||
const mockSetLocale = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('$lib/paraglide/runtime', () => ({
|
||||
getLocale: vi.fn(() => 'de'),
|
||||
setLocale: mockSetLocale
|
||||
}));
|
||||
|
||||
beforeEach(() => mockSetLocale.mockClear());
|
||||
afterEach(cleanup);
|
||||
|
||||
// ─── inverted=true (dark background) ──────────────────────────────────────
|
||||
|
||||
describe('LanguageSwitcher – inverted=true', () => {
|
||||
it('active locale button has text-white and font-bold', async () => {
|
||||
render(LanguageSwitcher, { inverted: true });
|
||||
|
||||
const el = await page.getByRole('button', { name: 'DE' }).element();
|
||||
|
||||
expect(el.className).toMatch(/\btext-white\b/);
|
||||
expect(el.className).toMatch(/\bfont-bold\b/);
|
||||
});
|
||||
|
||||
it('inactive locale buttons have text-white/70', async () => {
|
||||
render(LanguageSwitcher, { inverted: true });
|
||||
|
||||
const el = await page.getByRole('button', { name: 'EN' }).element();
|
||||
|
||||
expect(el.className).toMatch(/text-white\/70/);
|
||||
});
|
||||
|
||||
it('inactive locale buttons do not have font-bold', async () => {
|
||||
render(LanguageSwitcher, { inverted: true });
|
||||
|
||||
const el = await page.getByRole('button', { name: 'EN' }).element();
|
||||
|
||||
expect(el.className).not.toMatch(/\bfont-bold\b/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── inverted=false (light background) ─────────────────────────────────────
|
||||
|
||||
describe('LanguageSwitcher – inverted=false', () => {
|
||||
it('active locale button has text-ink and font-bold', async () => {
|
||||
render(LanguageSwitcher, { inverted: false });
|
||||
|
||||
const el = await page.getByRole('button', { name: 'DE' }).element();
|
||||
|
||||
expect(el.className).toMatch(/\btext-ink\b/);
|
||||
expect(el.className).toMatch(/\bfont-bold\b/);
|
||||
});
|
||||
|
||||
it('inactive locale buttons have text-ink-3', async () => {
|
||||
render(LanguageSwitcher, { inverted: false });
|
||||
|
||||
const el = await page.getByRole('button', { name: 'EN' }).element();
|
||||
|
||||
expect(el.className).toMatch(/\btext-ink-3\b/);
|
||||
});
|
||||
|
||||
it('inactive locale buttons do not have text-white', async () => {
|
||||
render(LanguageSwitcher, { inverted: false });
|
||||
|
||||
const el = await page.getByRole('button', { name: 'EN' }).element();
|
||||
|
||||
expect(el.className).not.toMatch(/\btext-white\b/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── locale switching ──────────────────────────────────────────────────────
|
||||
|
||||
describe('LanguageSwitcher – locale switching', () => {
|
||||
it('calls setLocale with en when EN button is clicked', async () => {
|
||||
render(LanguageSwitcher, { inverted: false });
|
||||
|
||||
const el = await page.getByRole('button', { name: 'EN' }).element();
|
||||
el.click();
|
||||
|
||||
expect(mockSetLocale).toHaveBeenCalledWith('en');
|
||||
});
|
||||
|
||||
it('calls setLocale with es when ES button is clicked', async () => {
|
||||
render(LanguageSwitcher, { inverted: false });
|
||||
|
||||
const el = await page.getByRole('button', { name: 'ES' }).element();
|
||||
el.click();
|
||||
|
||||
expect(mockSetLocale).toHaveBeenCalledWith('es');
|
||||
});
|
||||
});
|
||||
@@ -187,7 +187,7 @@ const popupOpen = $derived(query !== null);
|
||||
<div class="relative">
|
||||
<textarea
|
||||
{@attach attachTextarea}
|
||||
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:ring-1 focus:ring-accent focus:outline-none"
|
||||
class="w-full resize-none rounded border border-line px-3 py-2 font-serif text-sm text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
rows={rows}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -154,7 +154,7 @@ onDestroy(() => {
|
||||
: m.notification_bell_label()}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="true"
|
||||
class="relative rounded-sm p-2 text-ink-2 transition-colors hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
class="relative rounded-sm p-2 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
<!-- Bell SVG -->
|
||||
<svg
|
||||
|
||||
77
frontend/src/lib/components/OverflowPillButton.svelte
Normal file
77
frontend/src/lib/components/OverflowPillButton.svelte
Normal file
@@ -0,0 +1,77 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
|
||||
type Person = { id: string; firstName: string; lastName: string };
|
||||
|
||||
type Props = {
|
||||
extraCount: number;
|
||||
persons: Person[];
|
||||
};
|
||||
|
||||
let { extraCount, persons }: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let buttonEl: HTMLButtonElement | undefined = $state();
|
||||
|
||||
function toggle() {
|
||||
open = !open;
|
||||
}
|
||||
|
||||
async function close() {
|
||||
open = false;
|
||||
await tick();
|
||||
buttonEl?.focus();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
close();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
role="group"
|
||||
class="relative hidden md:block"
|
||||
use:clickOutside
|
||||
onclickoutside={() => (open = false)}
|
||||
onkeydown={handleKeydown}
|
||||
>
|
||||
<button
|
||||
bind:this={buttonEl}
|
||||
type="button"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
aria-label={m.topbar_overflow_show({ count: extraCount })}
|
||||
onclick={toggle}
|
||||
onkeydown={handleKeydown}
|
||||
class="inline-flex shrink-0 items-center rounded-full border border-line bg-muted px-2 py-0.5 text-[14px] font-bold text-ink-2 hover:bg-surface focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
+{extraCount}<span class="hidden lg:inline"> {m.topbar_overflow_suffix()}</span>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
role="list"
|
||||
class="absolute top-full left-0 z-50 mt-1 min-w-[160px] rounded-md border border-line bg-surface p-3 shadow-lg"
|
||||
>
|
||||
<p class="mb-2 text-[14px] font-bold tracking-wide text-ink-2 uppercase">
|
||||
{m.topbar_overflow_heading()}
|
||||
</p>
|
||||
{#each persons as person (person.id)}
|
||||
<div role="listitem">
|
||||
<a
|
||||
href="/persons/{person.id}"
|
||||
class="block py-0.5 text-[18px] text-ink hover:text-primary focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
{person.firstName}
|
||||
{person.lastName}
|
||||
</a>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import OverflowPillButton from './OverflowPillButton.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const persons = [
|
||||
{ id: 'p1', firstName: 'Anna', lastName: 'Müller' },
|
||||
{ id: 'p2', firstName: 'Hans', lastName: 'Schmidt' }
|
||||
];
|
||||
|
||||
describe('OverflowPillButton', () => {
|
||||
it('renders button with correct aria-haspopup and collapsed aria-expanded', async () => {
|
||||
render(OverflowPillButton, { extraCount: 2, persons });
|
||||
const btn = page.getByRole('button');
|
||||
await expect.element(btn).toHaveAttribute('aria-haspopup', 'true');
|
||||
await expect.element(btn).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('shows tooltip on click and sets aria-expanded true', async () => {
|
||||
render(OverflowPillButton, { extraCount: 2, persons });
|
||||
const btn = page.getByRole('button');
|
||||
await userEvent.click(btn);
|
||||
const tooltip = page.getByRole('list');
|
||||
await expect.element(tooltip).toBeInTheDocument();
|
||||
await expect.element(btn).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
it('closes tooltip on Escape and returns focus to button', async () => {
|
||||
render(OverflowPillButton, { extraCount: 2, persons });
|
||||
const btn = page.getByRole('button');
|
||||
await userEvent.click(btn);
|
||||
await expect.element(page.getByRole('list')).toBeInTheDocument();
|
||||
await userEvent.keyboard('{Escape}');
|
||||
await expect.element(page.getByRole('list')).not.toBeInTheDocument();
|
||||
await expect.element(btn).toHaveFocus();
|
||||
});
|
||||
|
||||
it('renders person links inside tooltip', async () => {
|
||||
render(OverflowPillButton, { extraCount: 2, persons });
|
||||
await userEvent.click(page.getByRole('button'));
|
||||
const links = page.getByRole('link');
|
||||
await expect.element(links.nth(0)).toHaveAttribute('href', '/persons/p1');
|
||||
await expect.element(links.nth(1)).toHaveAttribute('href', '/persons/p2');
|
||||
});
|
||||
});
|
||||
14
frontend/src/lib/components/OverflowPillDisplay.svelte
Normal file
14
frontend/src/lib/components/OverflowPillDisplay.svelte
Normal file
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
type Props = {
|
||||
extraCount: number;
|
||||
};
|
||||
|
||||
let { extraCount }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="inline-flex shrink-0 items-center rounded-full border border-line bg-muted px-2 py-0.5 text-[14px] font-bold text-ink-2"
|
||||
>
|
||||
+{extraCount}
|
||||
</span>
|
||||
@@ -302,7 +302,7 @@ $effect(() => {
|
||||
<select
|
||||
id="compare-a"
|
||||
bind:value={compareA}
|
||||
class="w-full rounded border border-line bg-surface px-2 py-1 font-sans text-xs text-ink focus:ring-1 focus:ring-accent focus:outline-none"
|
||||
class="w-full rounded border border-line bg-surface px-2 py-1 font-sans text-xs text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{#each versions as v, i (v.id)}
|
||||
@@ -317,7 +317,7 @@ $effect(() => {
|
||||
<select
|
||||
id="compare-b"
|
||||
bind:value={compareB}
|
||||
class="w-full rounded border border-line bg-surface px-2 py-1 font-sans text-xs text-ink focus:ring-1 focus:ring-accent focus:outline-none"
|
||||
class="w-full rounded border border-line bg-surface px-2 py-1 font-sans text-xs text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{#each versions as v, i (v.id)}
|
||||
|
||||
34
frontend/src/lib/components/PersonChip.svelte
Normal file
34
frontend/src/lib/components/PersonChip.svelte
Normal file
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { abbreviateName, personAvatarColor } from '$lib/utils/personFormat';
|
||||
|
||||
type Person = { id: string; firstName: string; lastName: string };
|
||||
|
||||
type Props = {
|
||||
person: Person;
|
||||
abbreviated: boolean;
|
||||
};
|
||||
|
||||
let { person, abbreviated }: Props = $props();
|
||||
|
||||
const displayName = $derived(
|
||||
abbreviated ? abbreviateName(person) : `${person.firstName} ${person.lastName}`
|
||||
);
|
||||
const avatarColor = $derived(personAvatarColor(person.id));
|
||||
const initials = $derived(
|
||||
`${person.firstName.charAt(0)}${person.lastName.charAt(0)}`.toUpperCase()
|
||||
);
|
||||
</script>
|
||||
|
||||
<a
|
||||
href="/persons/{person.id}"
|
||||
class="inline-flex shrink-0 items-center gap-1.5 rounded-full border border-line bg-muted px-2 py-0.5 hover:border-primary hover:bg-surface focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<span
|
||||
class="flex h-[25px] w-[25px] shrink-0 items-center justify-center rounded-full text-[13px] font-bold text-white"
|
||||
style="background-color: {avatarColor}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
<span class="text-[14px] font-semibold text-ink">{displayName}</span>
|
||||
</a>
|
||||
42
frontend/src/lib/components/PersonChipRow.svelte
Normal file
42
frontend/src/lib/components/PersonChipRow.svelte
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import PersonChip from './PersonChip.svelte';
|
||||
import OverflowPillDisplay from './OverflowPillDisplay.svelte';
|
||||
|
||||
type Person = { id: string; firstName: string; lastName: string };
|
||||
|
||||
type Props = {
|
||||
sender: Person | null | undefined;
|
||||
receivers: Person[];
|
||||
abbreviated: boolean;
|
||||
extraCount: number;
|
||||
};
|
||||
|
||||
let { sender, receivers, abbreviated, extraCount }: Props = $props();
|
||||
|
||||
const visibleReceivers = $derived(receivers.slice(0, 2));
|
||||
</script>
|
||||
|
||||
<div class="hidden min-w-0 items-center gap-1.5 overflow-hidden xs:flex">
|
||||
{#if sender}
|
||||
<PersonChip person={sender} abbreviated={abbreviated} />
|
||||
{/if}
|
||||
|
||||
{#if sender && receivers.length > 0}
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Long-Arrow/Long-Arrow-Right-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-6 w-6 shrink-0 opacity-40"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#each visibleReceivers as receiver, i (receiver.id)}
|
||||
<span class={i === 1 ? 'hidden md:contents' : ''}>
|
||||
<PersonChip person={receiver} abbreviated={abbreviated} />
|
||||
</span>
|
||||
{/each}
|
||||
|
||||
{#if extraCount > 0}
|
||||
<OverflowPillDisplay extraCount={extraCount} />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
type Person = components['schemas']['Person'];
|
||||
|
||||
interface Props {
|
||||
@@ -56,20 +57,6 @@ function selectPerson(person: Person) {
|
||||
function removePerson(id: string | undefined) {
|
||||
selectedPersons = selectedPersons.filter((p) => p.id !== id);
|
||||
}
|
||||
|
||||
function clickOutside(node: HTMLElement) {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
|
||||
showDropdown = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClick, true);
|
||||
return {
|
||||
destroy() {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onscroll={updateDropdownPosition} onresize={updateDropdownPosition} />
|
||||
@@ -78,7 +65,7 @@ function clickOutside(node: HTMLElement) {
|
||||
<input type="hidden" name="receiverIds" value={person.id} />
|
||||
{/each}
|
||||
|
||||
<div class="relative" use:clickOutside>
|
||||
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
|
||||
<div
|
||||
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-line bg-surface p-2 focus-within:border-ink focus-within:ring-1 focus-within:ring-ink"
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { untrack } from 'svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
type Person = components['schemas']['Person'];
|
||||
|
||||
interface Props {
|
||||
@@ -118,23 +119,9 @@ function selectPerson(person: Person) {
|
||||
showDropdown = false;
|
||||
onchange?.(person.id!);
|
||||
}
|
||||
|
||||
function clickOutside(node: HTMLElement) {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
|
||||
showDropdown = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClick, true);
|
||||
return {
|
||||
destroy() {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative" use:clickOutside>
|
||||
<div class="relative" use:clickOutside onclickoutside={() => (showDropdown = false)}>
|
||||
<label
|
||||
for={name}
|
||||
class={compact
|
||||
@@ -154,8 +141,8 @@ function clickOutside(node: HTMLElement) {
|
||||
onfocus={handleFocus}
|
||||
placeholder={placeholder ?? m.comp_typeahead_placeholder()}
|
||||
class={compact
|
||||
? 'mt-1 block h-9 w-full rounded border border-line bg-surface px-2 text-sm text-ink placeholder:text-ink-3 focus:border-primary focus:outline-none'
|
||||
: 'mt-1 block w-full rounded-md border border-line bg-surface p-2 text-ink shadow-sm placeholder:text-ink-3 focus:border-accent focus:ring-accent'}
|
||||
? 'mt-1 block h-9 w-full rounded border border-line bg-surface px-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'
|
||||
: 'mt-1 block w-full rounded-md border border-line bg-surface p-2 text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}
|
||||
/>
|
||||
|
||||
{#if showDropdown && (results.length > 0 || loading)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
|
||||
interface Props {
|
||||
tags?: string[];
|
||||
@@ -66,23 +67,9 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
activeIndex = (activeIndex - 1 + suggestions.length) % suggestions.length;
|
||||
}
|
||||
}
|
||||
|
||||
function clickOutside(node: HTMLElement) {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (node && !node.contains(e.target as Node) && !e.defaultPrevented) {
|
||||
showSuggestions = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClick, true);
|
||||
return {
|
||||
destroy() {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="w-full" use:clickOutside>
|
||||
<div class="w-full" use:clickOutside onclickoutside={() => (showSuggestions = false)}>
|
||||
<!-- Tag Container -->
|
||||
<div
|
||||
class="flex min-h-[42px] flex-wrap gap-2 rounded border border-line bg-surface p-2 focus-within:border-ink focus-within:ring-1 focus-within:ring-ink"
|
||||
|
||||
@@ -31,12 +31,12 @@ function toggle() {
|
||||
onclick={toggle}
|
||||
aria-label={theme === 'dark' ? 'light mode' : 'dark mode'}
|
||||
title={theme === 'dark' ? 'light mode' : 'dark mode'}
|
||||
class="rounded p-1.5 text-ink-2 transition-colors hover:bg-muted hover:text-ink"
|
||||
class="rounded p-1.5 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
{#if theme === 'dark'}
|
||||
<!-- Sun icon — click to go light -->
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -52,7 +52,7 @@ function toggle() {
|
||||
{:else}
|
||||
<!-- Moon icon — click to go dark -->
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
|
||||
@@ -49,7 +49,7 @@ let titleValue = $derived(titleDirty ? titleOverride : suggestedTitle || titleOv
|
||||
titleDirty = true;
|
||||
}}
|
||||
required={titleRequired}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -65,7 +65,7 @@ let titleValue = $derived(titleDirty ? titleOverride : suggestedTitle || titleOv
|
||||
name="documentLocation"
|
||||
value={initialDocumentLocation}
|
||||
placeholder={m.form_placeholder_archive_location()}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-ink-3">{m.form_helper_archive_location()}</p>
|
||||
</div>
|
||||
@@ -87,7 +87,7 @@ let titleValue = $derived(titleDirty ? titleOverride : suggestedTitle || titleOv
|
||||
name="summary"
|
||||
rows="5"
|
||||
placeholder={m.form_placeholder_content()}
|
||||
class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||
class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>{initialSummary}</textarea
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ let { initialTranscription = '' }: { initialTranscription?: string } = $props();
|
||||
name="transcription"
|
||||
rows="12"
|
||||
placeholder={m.form_placeholder_transcription()}
|
||||
class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||
class="block w-full rounded border border-line p-2 font-serif text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>{initialTranscription}</textarea
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -71,7 +71,7 @@ $effect(() => {
|
||||
placeholder={m.form_placeholder_date()}
|
||||
maxlength="10"
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm
|
||||
{dateInvalid ? 'border-red-400 focus:border-red-500 focus:ring-red-500' : 'focus:border-ink focus:ring-ink'}"
|
||||
{dateInvalid ? 'border-red-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500' : 'focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}"
|
||||
aria-describedby={dateInvalid ? 'date-error' : undefined}
|
||||
/>
|
||||
<input type="hidden" name="documentDate" value={dateIso} />
|
||||
@@ -91,7 +91,7 @@ $effect(() => {
|
||||
name="location"
|
||||
value={initialLocation}
|
||||
placeholder={m.form_placeholder_location()}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ let selected = $derived([...selectedGroupIds]);
|
||||
name="groupIds"
|
||||
value={group.id}
|
||||
bind:group={selected}
|
||||
class="rounded border-line text-ink focus:ring-accent"
|
||||
class="rounded border-line text-ink focus:ring-focus-ring"
|
||||
/>
|
||||
{group.name}
|
||||
</label>
|
||||
|
||||
@@ -13,7 +13,7 @@ let { required = false }: { required?: boolean } = $props();
|
||||
type="password"
|
||||
name="newPassword"
|
||||
required={required}
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -25,7 +25,7 @@ let { required = false }: { required?: boolean } = $props();
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
required={required}
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@ function handleBirthDateInput(e: Event) {
|
||||
type="text"
|
||||
name="firstName"
|
||||
value={firstName}
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -49,7 +49,7 @@ function handleBirthDateInput(e: Event) {
|
||||
type="text"
|
||||
name="lastName"
|
||||
value={lastName}
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -63,7 +63,7 @@ function handleBirthDateInput(e: Event) {
|
||||
placeholder="TT.MM.JJJJ"
|
||||
value={birthDateDisplay}
|
||||
oninput={handleBirthDateInput}
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
<input type="hidden" name="birthDate" value={birthDateIso} />
|
||||
</label>
|
||||
@@ -76,7 +76,7 @@ function handleBirthDateInput(e: Event) {
|
||||
type="email"
|
||||
name="email"
|
||||
value={email}
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -88,7 +88,7 @@ function handleBirthDateInput(e: Event) {
|
||||
name="contact"
|
||||
rows="3"
|
||||
placeholder={m.profile_contact_placeholder()}
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>{contact}</textarea
|
||||
>
|
||||
</label>
|
||||
|
||||
@@ -31,6 +31,63 @@ export function germanToIso(german: string): string {
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a raw date string into German DD.MM.YYYY format.
|
||||
*
|
||||
* Handles two modes:
|
||||
* - Pure digit stream (no dots): auto-inserts dots after position 2 and 4
|
||||
* - Manual dot entry: preserves user-typed dots, pads single-digit day/month,
|
||||
* and overflows extra digits from day→month and month→year
|
||||
*/
|
||||
export function formatGermanDateInput(raw: string): string {
|
||||
if (!raw.includes('.')) {
|
||||
const digits = raw.replace(/\D/g, '').slice(0, 8);
|
||||
if (digits.length <= 2) return digits;
|
||||
if (digits.length <= 4) return `${digits.slice(0, 2)}.${digits.slice(2)}`;
|
||||
return `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
|
||||
}
|
||||
|
||||
const trailingDot = raw.endsWith('.');
|
||||
const parts = raw.split('.').map((p) => p.replace(/\D/g, ''));
|
||||
|
||||
let day = parts[0] ?? '';
|
||||
let month = parts[1] ?? '';
|
||||
let year = parts[2] ?? '';
|
||||
|
||||
let dayOverflowed = false;
|
||||
if (day.length > 2) {
|
||||
month = day.slice(2) + month;
|
||||
day = day.slice(0, 2);
|
||||
dayOverflowed = true;
|
||||
}
|
||||
|
||||
let monthOverflowed = false;
|
||||
if (month.length > 2) {
|
||||
year = month.slice(2) + year;
|
||||
month = month.slice(0, 2);
|
||||
monthOverflowed = true;
|
||||
}
|
||||
|
||||
year = year.slice(0, 4);
|
||||
|
||||
const afterDay = !dayOverflowed && parts.length >= 2;
|
||||
|
||||
if (day.length === 1 && (month || (trailingDot && !dayOverflowed))) {
|
||||
day = '0' + day;
|
||||
}
|
||||
if (month.length === 1 && (year || (trailingDot && afterDay && !monthOverflowed))) {
|
||||
month = '0' + month;
|
||||
}
|
||||
|
||||
if (year) return `${day}.${month}.${year}`;
|
||||
if (month) {
|
||||
const dot2 = trailingDot && afterDay && !monthOverflowed ? '.' : '';
|
||||
return `${day}.${month}${dot2}`;
|
||||
}
|
||||
const dot1 = trailingDot && !dayOverflowed ? '.' : '';
|
||||
return `${day}${dot1}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a date input event for German-format date fields (DD.MM.YYYY).
|
||||
* Strips non-digits, formats with dots, mutates the input's displayed value,
|
||||
@@ -38,15 +95,7 @@ export function germanToIso(german: string): string {
|
||||
*/
|
||||
export function handleGermanDateInput(e: Event): { display: string; iso: string } {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const digits = input.value.replace(/\D/g, '').slice(0, 8);
|
||||
let display: string;
|
||||
if (digits.length <= 2) {
|
||||
display = digits;
|
||||
} else if (digits.length <= 4) {
|
||||
display = `${digits.slice(0, 2)}.${digits.slice(2)}`;
|
||||
} else {
|
||||
display = `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
|
||||
}
|
||||
const display = formatGermanDateInput(input.value);
|
||||
input.value = display;
|
||||
return { display, iso: germanToIso(display) };
|
||||
}
|
||||
|
||||
171
frontend/src/lib/utils/personFormat.spec.ts
Normal file
171
frontend/src/lib/utils/personFormat.spec.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
abbreviateName,
|
||||
formatXsMeta,
|
||||
personAvatarColor,
|
||||
formatDate,
|
||||
statusDotClass,
|
||||
statusLabel
|
||||
} from './personFormat';
|
||||
|
||||
// ─── abbreviateName ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('abbreviateName', () => {
|
||||
it('abbreviates first name to initial + last name', () => {
|
||||
expect(abbreviateName({ firstName: 'Karl', lastName: 'Raddatz' })).toBe('K. Raddatz');
|
||||
});
|
||||
|
||||
it('returns single name as-is when no last name', () => {
|
||||
expect(abbreviateName({ firstName: 'Elfriede', lastName: '' })).toBe('Elfriede');
|
||||
});
|
||||
|
||||
it('preserves hyphenated last name', () => {
|
||||
expect(abbreviateName({ firstName: 'Karl', lastName: 'Müller-Schmidt' })).toBe(
|
||||
'K. Müller-Schmidt'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles leading/trailing whitespace in names', () => {
|
||||
expect(abbreviateName({ firstName: ' Karl ', lastName: ' Raddatz ' })).toBe('K. Raddatz');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── formatXsMeta ────────────────────────────────────────────────────────────
|
||||
|
||||
type Doc = {
|
||||
sender?: { firstName: string; lastName: string } | null;
|
||||
receivers?: { firstName: string; lastName: string }[];
|
||||
documentDate?: string | null;
|
||||
};
|
||||
|
||||
describe('formatXsMeta', () => {
|
||||
const sender = { firstName: 'Karl', lastName: 'Raddatz' };
|
||||
const receiver1 = { firstName: 'Elfriede', lastName: 'Raddatz' };
|
||||
const receiver2 = { firstName: 'Anna', lastName: 'Müller' };
|
||||
const receiver3 = { firstName: 'Hans', lastName: 'Schmidt' };
|
||||
|
||||
it('formats sender with no receivers and date', () => {
|
||||
const doc: Doc = { sender, receivers: [], documentDate: '1943-12-24' };
|
||||
expect(formatXsMeta(doc)).toBe('K.Raddatz · 24.12.1943');
|
||||
});
|
||||
|
||||
it('formats sender with one receiver and date', () => {
|
||||
const doc: Doc = { sender, receivers: [receiver1], documentDate: '1943-12-24' };
|
||||
expect(formatXsMeta(doc)).toBe('K.Raddatz → E.Raddatz · 24.12.1943');
|
||||
});
|
||||
|
||||
it('formats sender with three receivers showing +2', () => {
|
||||
const doc: Doc = {
|
||||
sender,
|
||||
receivers: [receiver1, receiver2, receiver3],
|
||||
documentDate: '1943-12-24'
|
||||
};
|
||||
expect(formatXsMeta(doc)).toBe('K.Raddatz → E.Raddatz +2 · 24.12.1943');
|
||||
});
|
||||
|
||||
it('formats without sender', () => {
|
||||
const doc: Doc = { sender: null, receivers: [receiver1], documentDate: '1943-12-24' };
|
||||
expect(formatXsMeta(doc)).toBe('E.Raddatz · 24.12.1943');
|
||||
});
|
||||
|
||||
it('formats without date', () => {
|
||||
const doc: Doc = { sender, receivers: [], documentDate: null };
|
||||
expect(formatXsMeta(doc)).toBe('K.Raddatz');
|
||||
});
|
||||
|
||||
it('formats with no sender and no date', () => {
|
||||
const doc: Doc = { sender: null, receivers: [receiver1], documentDate: null };
|
||||
expect(formatXsMeta(doc)).toBe('E.Raddatz');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── personAvatarColor ───────────────────────────────────────────────────────
|
||||
|
||||
const PALETTE = ['#012851', '#5A3080', '#007596', '#2A6040', '#803020'];
|
||||
|
||||
describe('personAvatarColor', () => {
|
||||
it('returns a value from the palette', () => {
|
||||
expect(PALETTE).toContain(personAvatarColor('abc'));
|
||||
});
|
||||
|
||||
it('is deterministic — same id always returns same color', () => {
|
||||
const id = '550e8400-e29b-41d4-a716-446655440000';
|
||||
expect(personAvatarColor(id)).toBe(personAvatarColor(id));
|
||||
});
|
||||
|
||||
it('all 5 palette entries are reachable across 1000 random UUIDs', () => {
|
||||
const seen = new Set<string>();
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
seen.add(personAvatarColor(crypto.randomUUID()));
|
||||
}
|
||||
expect(seen.size).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── formatDate ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('formats short date as dd.mm.yyyy', () => {
|
||||
expect(formatDate('1943-12-24', 'short')).toBe('24.12.1943');
|
||||
});
|
||||
|
||||
it('formats long date with German month name', () => {
|
||||
expect(formatDate('1943-12-24', 'long')).toBe('24. Dezember 1943');
|
||||
});
|
||||
|
||||
it('does not shift Dec 31 to Jan 1 (UTC off-by-one guard)', () => {
|
||||
expect(formatDate('1943-12-31', 'short')).toBe('31.12.1943');
|
||||
});
|
||||
|
||||
it('does not shift Jan 1 to Dec 31 (UTC off-by-one guard)', () => {
|
||||
expect(formatDate('1944-01-01', 'short')).toBe('01.01.1944');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── statusDotClass ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('statusDotClass', () => {
|
||||
it('PLACEHOLDER → bg-gray-400', () => {
|
||||
expect(statusDotClass('PLACEHOLDER')).toBe('bg-gray-400');
|
||||
});
|
||||
|
||||
it('UPLOADED → bg-emerald-500', () => {
|
||||
expect(statusDotClass('UPLOADED')).toBe('bg-emerald-500');
|
||||
});
|
||||
|
||||
it('TRANSCRIBED → bg-blue-400', () => {
|
||||
expect(statusDotClass('TRANSCRIBED')).toBe('bg-blue-400');
|
||||
});
|
||||
|
||||
it('REVIEWED → bg-amber-400', () => {
|
||||
expect(statusDotClass('REVIEWED')).toBe('bg-amber-400');
|
||||
});
|
||||
|
||||
it('ARCHIVED → bg-emerald-600', () => {
|
||||
expect(statusDotClass('ARCHIVED')).toBe('bg-emerald-600');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── statusLabel ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('statusLabel', () => {
|
||||
it('PLACEHOLDER → "Platzhalter"', () => {
|
||||
expect(statusLabel('PLACEHOLDER')).toBe('Platzhalter');
|
||||
});
|
||||
|
||||
it('UPLOADED → "Hochgeladen"', () => {
|
||||
expect(statusLabel('UPLOADED')).toBe('Hochgeladen');
|
||||
});
|
||||
|
||||
it('TRANSCRIBED → "Transkribiert"', () => {
|
||||
expect(statusLabel('TRANSCRIBED')).toBe('Transkribiert');
|
||||
});
|
||||
|
||||
it('REVIEWED → "Geprüft"', () => {
|
||||
expect(statusLabel('REVIEWED')).toBe('Geprüft');
|
||||
});
|
||||
|
||||
it('ARCHIVED → "Archiviert"', () => {
|
||||
expect(statusLabel('ARCHIVED')).toBe('Archiviert');
|
||||
});
|
||||
});
|
||||
102
frontend/src/lib/utils/personFormat.ts
Normal file
102
frontend/src/lib/utils/personFormat.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { formatDocumentStatus } from './documentStatusLabel';
|
||||
|
||||
type Person = { firstName: string; lastName: string };
|
||||
type DocumentStatus = 'PLACEHOLDER' | 'UPLOADED' | 'TRANSCRIBED' | 'REVIEWED' | 'ARCHIVED';
|
||||
type DocForMeta = {
|
||||
sender?: Person | null;
|
||||
receivers?: Person[];
|
||||
documentDate?: string | null;
|
||||
};
|
||||
|
||||
const AVATAR_PALETTE = ['#012851', '#5A3080', '#007596', '#2A6040', '#803020'] as const;
|
||||
|
||||
function djb2(str: string): number {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = (hash * 33) ^ str.charCodeAt(i);
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
export function abbreviateName(person: Person): string {
|
||||
const first = person.firstName.trim();
|
||||
const last = person.lastName.trim();
|
||||
if (!last) return first;
|
||||
return `${first.charAt(0)}. ${last}`;
|
||||
}
|
||||
|
||||
function abbreviateCompact(person: Person): string {
|
||||
const first = person.firstName.trim();
|
||||
const last = person.lastName.trim();
|
||||
if (!last) return first;
|
||||
return `${first.charAt(0)}.${last}`;
|
||||
}
|
||||
|
||||
export function formatXsMeta(doc: DocForMeta): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
const receivers = doc.receivers ?? [];
|
||||
if (doc.sender) {
|
||||
const senderAbbr = abbreviateCompact(doc.sender);
|
||||
if (receivers.length === 0) {
|
||||
parts.push(senderAbbr);
|
||||
} else {
|
||||
const extra = receivers.length - 1;
|
||||
const firstReceiver = abbreviateCompact(receivers[0]);
|
||||
parts.push(
|
||||
extra > 0
|
||||
? `${senderAbbr} → ${firstReceiver} +${extra}`
|
||||
: `${senderAbbr} → ${firstReceiver}`
|
||||
);
|
||||
}
|
||||
} else if (receivers.length > 0) {
|
||||
const extra = receivers.length - 1;
|
||||
const firstReceiver = abbreviateCompact(receivers[0]);
|
||||
parts.push(extra > 0 ? `${firstReceiver} +${extra}` : firstReceiver);
|
||||
}
|
||||
|
||||
if (doc.documentDate) {
|
||||
parts.push(formatDate(doc.documentDate, 'short'));
|
||||
}
|
||||
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
export function personAvatarColor(personId: string): string {
|
||||
return AVATAR_PALETTE[djb2(personId) % AVATAR_PALETTE.length];
|
||||
}
|
||||
|
||||
export function formatDate(isoDate: string, format: 'short' | 'long'): string {
|
||||
const date = new Date(isoDate + 'T12:00:00');
|
||||
if (format === 'short') {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
}).format(date);
|
||||
}
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function statusDotClass(status: DocumentStatus): string {
|
||||
switch (status) {
|
||||
case 'PLACEHOLDER':
|
||||
return 'bg-gray-400';
|
||||
case 'UPLOADED':
|
||||
return 'bg-emerald-500';
|
||||
case 'TRANSCRIBED':
|
||||
return 'bg-blue-400';
|
||||
case 'REVIEWED':
|
||||
return 'bg-amber-400';
|
||||
case 'ARCHIVED':
|
||||
return 'bg-emerald-600';
|
||||
}
|
||||
}
|
||||
|
||||
export function statusLabel(status: string): string {
|
||||
return formatDocumentStatus(status);
|
||||
}
|
||||
@@ -35,7 +35,8 @@ const userInitials = $derived.by(() => {
|
||||
|
||||
<div class="min-h-screen bg-canvas" data-hydrated={hydrated || undefined}>
|
||||
{#if !isAuthPage}
|
||||
<header class="sticky top-0 z-50 border-b border-line-2 bg-surface">
|
||||
<header class="sticky top-0 z-50 bg-header">
|
||||
<div class="h-1 bg-accent"></div>
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex h-16 justify-between">
|
||||
<!-- Logo & Nav -->
|
||||
@@ -45,9 +46,9 @@ const userInitials = $derived.by(() => {
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Language selector (desktop only — mobile lives in nav drawer) -->
|
||||
<div
|
||||
class="hidden items-center gap-1 border-r border-line pr-3 sm:flex [&_button]:px-1.5 [&_button]:py-1 [&_button]:text-xs"
|
||||
class="hidden items-center gap-1 pr-3 lg:flex [&_button]:px-1.5 [&_button]:py-1 [&_button]:text-xs"
|
||||
>
|
||||
<LanguageSwitcher />
|
||||
<LanguageSwitcher inverted />
|
||||
</div>
|
||||
|
||||
<!-- Theme toggle -->
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createApiClient } from '$lib/api.server';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type IncompleteDocumentDTO = components['schemas']['IncompleteDocumentDTO'];
|
||||
type NotificationDTO = components['schemas']['NotificationDTO'];
|
||||
type StatsDTO = components['schemas']['StatsDTO'];
|
||||
type Document = components['schemas']['Document'];
|
||||
|
||||
export async function load({ url, fetch }) {
|
||||
@@ -55,19 +55,19 @@ export async function load({ url, fetch }) {
|
||||
const receiverObj = allPersons.find((p) => p.id === receiverId);
|
||||
|
||||
// Dashboard widgets — failures are isolated and don't crash the page
|
||||
let mentions: NotificationDTO[] = [];
|
||||
let stats: StatsDTO | null = null;
|
||||
let incompleteDocs: IncompleteDocumentDTO[] = [];
|
||||
let recentDocs: Document[] = [];
|
||||
|
||||
if (isDashboard) {
|
||||
const [mentionsResult, incompleteResult, recentResult] = await Promise.allSettled([
|
||||
api.GET('/api/notifications', { params: { query: { size: 5 } } }),
|
||||
api.GET('/api/documents/incomplete', { params: { query: { size: 5 } } }),
|
||||
const [statsResult, incompleteResult, recentResult] = await Promise.allSettled([
|
||||
api.GET('/api/stats'),
|
||||
api.GET('/api/documents/incomplete', { params: { query: { size: 3 } } }),
|
||||
api.GET('/api/documents/recent-activity', { params: { query: { size: 5 } } })
|
||||
]);
|
||||
|
||||
if (mentionsResult.status === 'fulfilled' && mentionsResult.value.response.ok) {
|
||||
mentions = mentionsResult.value.data?.content ?? [];
|
||||
if (statsResult.status === 'fulfilled' && statsResult.value.response.ok) {
|
||||
stats = statsResult.value.data ?? null;
|
||||
}
|
||||
if (incompleteResult.status === 'fulfilled' && incompleteResult.value.response.ok) {
|
||||
incompleteDocs = incompleteResult.value.data ?? [];
|
||||
@@ -80,7 +80,7 @@ export async function load({ url, fetch }) {
|
||||
return {
|
||||
isDashboard,
|
||||
documents,
|
||||
mentions,
|
||||
stats,
|
||||
incompleteDocs,
|
||||
recentDocs,
|
||||
initialValues: {
|
||||
@@ -96,7 +96,7 @@ export async function load({ url, fetch }) {
|
||||
return {
|
||||
isDashboard,
|
||||
documents: [],
|
||||
mentions: [],
|
||||
stats: null,
|
||||
incompleteDocs: [],
|
||||
recentDocs: [],
|
||||
initialValues: { senderName: '', receiverName: '' },
|
||||
|
||||
@@ -6,7 +6,6 @@ import SearchFilterBar from './SearchFilterBar.svelte';
|
||||
import DropZone from './DropZone.svelte';
|
||||
import DocumentList from './DocumentList.svelte';
|
||||
import DashboardResumeStrip from '$lib/components/DashboardResumeStrip.svelte';
|
||||
import DashboardMentions from '$lib/components/DashboardMentions.svelte';
|
||||
import DashboardNeedsMetadata from '$lib/components/DashboardNeedsMetadata.svelte';
|
||||
import DashboardRecentDocuments from '$lib/components/DashboardRecentDocuments.svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
@@ -69,6 +68,11 @@ $effect(() => {
|
||||
tagNames = data.filters?.tags || [];
|
||||
if (hasAdvancedFilters(data.filters)) showAdvanced = true;
|
||||
});
|
||||
|
||||
// Right column is only rendered when there is something to show.
|
||||
// Omitting it prevents an empty 300px ghost column for read-only users
|
||||
// with a complete archive.
|
||||
const showRightColumn = $derived(data.canWrite || (data.incompleteDocs?.length ?? 0) > 0);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -94,20 +98,25 @@ $effect(() => {
|
||||
{#if data.isDashboard}
|
||||
<DashboardResumeStrip />
|
||||
|
||||
{#if data.canWrite}
|
||||
<div class="mt-4">
|
||||
<DropZone />
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Classic Split: right column first in DOM so it appears above recent docs on mobile.
|
||||
lg:order-last moves it back to the visual right on desktop. -->
|
||||
<!-- No items-start — CSS Grid stretch default makes both columns equal height -->
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 {showRightColumn ? 'lg:grid-cols-[1fr_300px]' : ''}">
|
||||
{#if showRightColumn}
|
||||
<div data-testid="dashboard-right-column" class="flex h-full flex-col gap-4 lg:order-last">
|
||||
{#if data.canWrite}
|
||||
<DropZone />
|
||||
{/if}
|
||||
<!-- flex-1 + min-h-0 fills remaining height after DropZone.
|
||||
min-h-0 overrides the default min-height:auto that prevents flex
|
||||
children from shrinking below their content size. -->
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<DashboardNeedsMetadata incompleteDocs={data.incompleteDocs ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="mt-6 grid gap-4 {(data.mentions?.length ?? 0) > 0 && (data.incompleteDocs?.length ?? 0) > 0 ? 'lg:grid-cols-2' : ''}"
|
||||
>
|
||||
<DashboardMentions mentions={data.mentions ?? []} />
|
||||
<DashboardNeedsMetadata incompleteDocs={data.incompleteDocs ?? []} />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<DashboardRecentDocuments recentDocs={data.recentDocs ?? []} />
|
||||
<DashboardRecentDocuments recentDocs={data.recentDocs ?? []} stats={data.stats} />
|
||||
</div>
|
||||
{:else}
|
||||
<DocumentList documents={data.documents ?? []} canWrite={data.canWrite} error={data.error} />
|
||||
|
||||
@@ -28,53 +28,53 @@ function handleOverlayKeydown(event: KeyboardEvent) {
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="mr-10 hidden flex-shrink-0 items-center md:flex">
|
||||
<div class="flex items-stretch">
|
||||
<div class="mr-10 flex flex-shrink-0 items-center">
|
||||
<a href="/" class="flex items-center" aria-label="Familienarchiv">
|
||||
<span class="font-sans text-xl font-bold tracking-widest text-ink uppercase"
|
||||
<span class="font-sans text-xl font-bold tracking-widest text-white uppercase"
|
||||
>Familienarchiv</span
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Desktop nav -->
|
||||
<nav class="hidden items-center sm:flex sm:space-x-1">
|
||||
<nav class="hidden items-stretch lg:flex lg:space-x-1">
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
||||
class="my-2 inline-flex items-center px-3 font-sans text-xs font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-focus-ring
|
||||
{page.url.pathname === '/' || page.url.pathname.startsWith('/documents')
|
||||
? 'rounded bg-nav-active text-ink'
|
||||
: 'rounded text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
? 'border-b-2 border-accent text-white'
|
||||
: 'text-white/70 hover:text-white'}"
|
||||
>
|
||||
{m.nav_documents()}
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/persons"
|
||||
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
||||
class="my-2 inline-flex items-center px-3 font-sans text-xs font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-focus-ring
|
||||
{page.url.pathname.startsWith('/persons')
|
||||
? 'rounded bg-nav-active text-ink'
|
||||
: 'rounded text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
? 'border-b-2 border-accent text-white'
|
||||
: 'text-white/70 hover:text-white'}"
|
||||
>
|
||||
{m.nav_persons()}
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/korrespondenz"
|
||||
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
||||
class="my-2 inline-flex items-center px-3 font-sans text-xs font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-focus-ring
|
||||
{page.url.pathname.startsWith('/korrespondenz')
|
||||
? 'rounded bg-nav-active text-ink'
|
||||
: 'rounded text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
? 'border-b-2 border-accent text-white'
|
||||
: 'text-white/70 hover:text-white'}"
|
||||
>
|
||||
{m.nav_conversations()}
|
||||
</a>
|
||||
{#if isAdmin}
|
||||
<a
|
||||
href="/admin"
|
||||
class="inline-flex items-center px-3 py-1.5 font-sans text-xs font-bold tracking-widest uppercase transition-colors
|
||||
class="my-2 inline-flex items-center px-3 font-sans text-xs font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-focus-ring
|
||||
{page.url.pathname.startsWith('/admin')
|
||||
? 'rounded bg-nav-active text-ink'
|
||||
: 'rounded text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
? 'border-b-2 border-accent text-white'
|
||||
: 'text-white/70 hover:text-white'}"
|
||||
>
|
||||
{m.nav_admin()}
|
||||
</a>
|
||||
@@ -83,7 +83,7 @@ function handleOverlayKeydown(event: KeyboardEvent) {
|
||||
|
||||
<!-- Hamburger toggle (mobile only) -->
|
||||
<button
|
||||
class="ml-auto flex h-11 w-11 items-center justify-center rounded text-ink-2 transition-colors hover:bg-muted hover:text-ink sm:hidden"
|
||||
class="ml-auto flex h-11 w-11 items-center justify-center self-center rounded text-white/70 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring lg:hidden"
|
||||
aria-label={mobileNavOpen ? 'Menü schließen' : 'Menü öffnen'}
|
||||
aria-expanded={mobileNavOpen}
|
||||
aria-controls="mobile-nav"
|
||||
@@ -131,41 +131,41 @@ function handleOverlayKeydown(event: KeyboardEvent) {
|
||||
<!-- Mobile nav overlay -->
|
||||
{#if mobileNavOpen}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="fixed inset-0 top-[68px] z-40 sm:hidden" onkeydown={handleOverlayKeydown}>
|
||||
<div class="fixed inset-0 top-[68px] z-40 lg:hidden" onkeydown={handleOverlayKeydown}>
|
||||
<!-- Backdrop -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="absolute inset-0 bg-black/20" onclick={closeMobileNav}></div>
|
||||
|
||||
<!-- Panel -->
|
||||
<!-- Panel — white background with navy text (reverses the dark header) -->
|
||||
<div class="relative border-b border-line bg-surface shadow-md">
|
||||
<nav id="mobile-nav">
|
||||
<a
|
||||
href="/"
|
||||
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors
|
||||
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset
|
||||
{page.url.pathname === '/' || page.url.pathname.startsWith('/documents')
|
||||
? 'bg-nav-active text-ink'
|
||||
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
? 'bg-accent-bg text-ink'
|
||||
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
>
|
||||
{m.nav_documents()}
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/persons"
|
||||
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors
|
||||
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset
|
||||
{page.url.pathname.startsWith('/persons')
|
||||
? 'bg-nav-active text-ink'
|
||||
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
? 'bg-accent-bg text-ink'
|
||||
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
>
|
||||
{m.nav_persons()}
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/korrespondenz"
|
||||
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors
|
||||
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset
|
||||
{page.url.pathname.startsWith('/korrespondenz')
|
||||
? 'bg-nav-active text-ink'
|
||||
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
? 'bg-accent-bg text-ink'
|
||||
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
>
|
||||
{m.nav_conversations()}
|
||||
</a>
|
||||
@@ -173,10 +173,10 @@ function handleOverlayKeydown(event: KeyboardEvent) {
|
||||
{#if isAdmin}
|
||||
<a
|
||||
href="/admin"
|
||||
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors
|
||||
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset
|
||||
{page.url.pathname.startsWith('/admin')
|
||||
? 'bg-nav-active text-ink'
|
||||
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
? 'bg-accent-bg text-ink'
|
||||
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||
>
|
||||
{m.nav_admin()}
|
||||
</a>
|
||||
|
||||
19
frontend/src/routes/AuthHeader.svelte
Normal file
19
frontend/src/routes/AuthHeader.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
|
||||
</script>
|
||||
|
||||
<header class="bg-header">
|
||||
<div class="h-1 bg-accent"></div>
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex h-12 items-center justify-between">
|
||||
<a href="/" class="flex items-center" aria-label="Familienarchiv">
|
||||
<span class="font-sans text-sm font-bold tracking-widest text-white uppercase"
|
||||
>Familienarchiv</span
|
||||
>
|
||||
</a>
|
||||
<div class="flex items-center gap-1">
|
||||
<LanguageSwitcher inverted />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -46,7 +46,7 @@ let {
|
||||
onblur={onblur}
|
||||
aria-label={m.docs_search_placeholder()}
|
||||
placeholder={m.docs_search_placeholder()}
|
||||
class="block w-full border-line py-2.5 pr-10 pl-3 placeholder-ink-3 shadow-sm focus:border-ink focus:ring-ink"
|
||||
class="block w-full border-line py-2.5 pr-10 pl-3 placeholder-ink-3 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<img
|
||||
|
||||
@@ -1,27 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
|
||||
let { userInitials }: { userInitials: string | null } = $props();
|
||||
|
||||
let userMenuOpen = $state(false);
|
||||
|
||||
function clickOutside(node: HTMLElement) {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
|
||||
userMenuOpen = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClick, true);
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative"
|
||||
{@attach clickOutside}
|
||||
use:clickOutside
|
||||
onclickoutside={() => (userMenuOpen = false)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape') userMenuOpen = false;
|
||||
}}
|
||||
@@ -33,7 +23,7 @@ function clickOutside(node: HTMLElement) {
|
||||
aria-expanded={userMenuOpen}
|
||||
aria-haspopup="true"
|
||||
onclick={() => (userMenuOpen = !userMenuOpen)}
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary font-sans text-xs font-bold text-primary-fg transition-opacity hover:opacity-80"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-white font-sans text-xs font-bold text-brand-navy transition-opacity hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
{userInitials}
|
||||
</button>
|
||||
@@ -44,13 +34,13 @@ function clickOutside(node: HTMLElement) {
|
||||
aria-expanded={userMenuOpen}
|
||||
aria-haspopup="true"
|
||||
onclick={() => (userMenuOpen = !userMenuOpen)}
|
||||
class="inline-flex items-center gap-1.5 px-3 py-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase transition-colors hover:text-ink"
|
||||
class="group rounded-sm p-2 transition-colors hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Small-16px/SVG/Action/Account-SM.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-4 w-4 opacity-50"
|
||||
class="h-5 w-5 opacity-65 brightness-0 invert transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -63,7 +63,7 @@ function handleKeydown(event: KeyboardEvent) {
|
||||
>
|
||||
<!-- Desktop-only heading -->
|
||||
<div
|
||||
class="hidden px-3 pt-3 pb-1 text-[9px] font-extrabold tracking-widest text-white/30 uppercase lg:block"
|
||||
class="hidden px-3 pt-3 pb-1 text-[9px] font-extrabold tracking-widest text-white/50 uppercase lg:block"
|
||||
>
|
||||
{m.admin_heading()}
|
||||
</div>
|
||||
@@ -123,7 +123,7 @@ function handleKeydown(event: KeyboardEvent) {
|
||||
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-[13px] font-black {isActive('users') ? 'text-white/65' : 'text-white/20'}">
|
||||
<span class="text-[13px] font-black {isActive('users') ? 'text-white/65' : 'text-white/50'}">
|
||||
{userCount}
|
||||
</span>
|
||||
<span
|
||||
@@ -190,7 +190,7 @@ function handleKeydown(event: KeyboardEvent) {
|
||||
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-[13px] font-black {isActive('groups') ? 'text-white/65' : 'text-white/20'}">
|
||||
<span class="text-[13px] font-black {isActive('groups') ? 'text-white/65' : 'text-white/50'}">
|
||||
{groupCount}
|
||||
</span>
|
||||
<span
|
||||
@@ -259,7 +259,7 @@ function handleKeydown(event: KeyboardEvent) {
|
||||
/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z" />
|
||||
</svg>
|
||||
<span class="text-[13px] font-black {isActive('tags') ? 'text-white/65' : 'text-white/20'}">
|
||||
<span class="text-[13px] font-black {isActive('tags') ? 'text-white/65' : 'text-white/50'}">
|
||||
{tagCount}
|
||||
</span>
|
||||
<span
|
||||
@@ -355,7 +355,7 @@ function handleKeydown(event: KeyboardEvent) {
|
||||
transition:fly={{ x: -160, duration: 180 }}
|
||||
>
|
||||
<!-- Heading -->
|
||||
<div class="px-3 pt-3 pb-1 text-[9px] font-extrabold tracking-widest text-white/30 uppercase">
|
||||
<div class="px-3 pt-3 pb-1 text-[9px] font-extrabold tracking-widest text-white/50 uppercase">
|
||||
{m.admin_heading()}
|
||||
</div>
|
||||
|
||||
@@ -384,7 +384,7 @@ function handleKeydown(event: KeyboardEvent) {
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="text-[13px] font-black {isActive('users') ? 'text-white/65' : 'text-white/20'}"
|
||||
class="text-[13px] font-black {isActive('users') ? 'text-white/65' : 'text-white/50'}"
|
||||
>
|
||||
{userCount}
|
||||
</span>
|
||||
@@ -422,7 +422,7 @@ function handleKeydown(event: KeyboardEvent) {
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="text-[13px] font-black {isActive('groups') ? 'text-white/65' : 'text-white/20'}"
|
||||
class="text-[13px] font-black {isActive('groups') ? 'text-white/65' : 'text-white/50'}"
|
||||
>
|
||||
{groupCount}
|
||||
</span>
|
||||
@@ -460,7 +460,7 @@ function handleKeydown(event: KeyboardEvent) {
|
||||
/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z" />
|
||||
</svg>
|
||||
<span class="text-[13px] font-black {isActive('tags') ? 'text-white/65' : 'text-white/20'}">
|
||||
<span class="text-[13px] font-black {isActive('tags') ? 'text-white/65' : 'text-white/50'}">
|
||||
{tagCount}
|
||||
</span>
|
||||
<span
|
||||
|
||||
@@ -133,7 +133,7 @@ const ADMIN_PERMISSIONS = $derived([
|
||||
name="name"
|
||||
value={data.group.name}
|
||||
required
|
||||
class="bg-background w-full rounded-sm border border-line px-3 py-2 font-sans text-sm text-ink placeholder:text-ink-3 focus:border-primary focus:ring-1 focus:ring-primary focus:outline-none"
|
||||
class="bg-background w-full rounded-sm border border-line px-3 py-2 font-sans text-sm text-ink placeholder:text-ink-3 focus:border-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -150,7 +150,7 @@ const ADMIN_PERMISSIONS = $derived([
|
||||
name="permissions"
|
||||
value={perm.value}
|
||||
checked={data.group.permissions.includes(perm.value)}
|
||||
class="h-4 w-4 rounded border-line text-primary focus:ring-primary"
|
||||
class="h-4 w-4 rounded border-line text-primary focus:ring-focus-ring"
|
||||
/>
|
||||
{perm.label}
|
||||
</label>
|
||||
|
||||
@@ -109,10 +109,12 @@ describe('GroupsListPanel — collapse toggle', () => {
|
||||
});
|
||||
|
||||
it('persists collapse state using the groups-specific localStorage key', async () => {
|
||||
const setSpy = vi.spyOn(Storage.prototype, 'setItem');
|
||||
render(GroupsListPanel, { groups });
|
||||
const setSpy = vi.spyOn(Storage.prototype, 'setItem');
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="Liste einklappen"]')!.click();
|
||||
expect(setSpy).toHaveBeenCalledWith('admin_groups_list_collapsed', 'true');
|
||||
await vi.waitFor(() =>
|
||||
expect(setSpy).toHaveBeenCalledWith('admin_groups_list_collapsed', 'true')
|
||||
);
|
||||
setSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,7 +101,7 @@ beforeNavigate(({ cancel, to }) => {
|
||||
name="name"
|
||||
placeholder={m.admin_group_name_placeholder()}
|
||||
required
|
||||
class="w-full rounded-sm border border-line bg-surface px-3 py-2 text-sm text-ink placeholder:text-ink-3 focus:ring-1 focus:ring-primary focus:outline-none"
|
||||
class="w-full rounded-sm border border-line bg-surface px-3 py-2 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -117,7 +117,7 @@ beforeNavigate(({ cancel, to }) => {
|
||||
type="checkbox"
|
||||
name="permissions"
|
||||
value={perm.value}
|
||||
class="rounded border-line text-primary focus:ring-primary"
|
||||
class="rounded border-line text-primary focus:ring-focus-ring"
|
||||
/>
|
||||
<span class="font-mono text-xs font-bold uppercase">{perm.value}</span>
|
||||
<span class="text-ink-3">— {perm.label}</span>
|
||||
@@ -146,7 +146,7 @@ beforeNavigate(({ cancel, to }) => {
|
||||
type="checkbox"
|
||||
name="permissions"
|
||||
value={perm.value}
|
||||
class="rounded border-line text-primary focus:ring-primary"
|
||||
class="rounded border-line text-primary focus:ring-focus-ring"
|
||||
/>
|
||||
<span class="font-mono text-xs font-bold uppercase">{perm.value}</span>
|
||||
<span class="font-normal text-ink-3">— {perm.label}</span>
|
||||
|
||||
@@ -104,7 +104,7 @@ $effect(() => {
|
||||
name="name"
|
||||
value={data.tag.name}
|
||||
required
|
||||
class="w-full rounded-sm border border-line bg-surface px-3 py-2 text-sm text-ink focus:ring-1 focus:ring-primary focus:outline-none"
|
||||
class="w-full rounded-sm border border-line bg-surface px-3 py-2 text-sm text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -88,10 +88,12 @@ describe('TagsListPanel — collapse toggle', () => {
|
||||
});
|
||||
|
||||
it('persists collapse state using the tags-specific localStorage key', async () => {
|
||||
const setSpy = vi.spyOn(Storage.prototype, 'setItem');
|
||||
render(TagsListPanel, { tags });
|
||||
const setSpy = vi.spyOn(Storage.prototype, 'setItem');
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="Liste einklappen"]')!.click();
|
||||
expect(setSpy).toHaveBeenCalledWith('admin_tags_list_collapsed', 'true');
|
||||
await vi.waitFor(() =>
|
||||
expect(setSpy).toHaveBeenCalledWith('admin_tags_list_collapsed', 'true')
|
||||
);
|
||||
setSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,7 +106,7 @@ const filtered = $derived(
|
||||
type="search"
|
||||
bind:value={searchQuery}
|
||||
placeholder={m.admin_users_search_placeholder()}
|
||||
class="w-full rounded-sm border border-line bg-surface px-2 py-1.5 text-sm text-ink placeholder:text-ink-3 focus:ring-1 focus:ring-primary focus:outline-none"
|
||||
class="w-full rounded-sm border border-line bg-surface px-2 py-1.5 text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -131,10 +131,12 @@ describe('UsersListPanel — collapse toggle', () => {
|
||||
});
|
||||
|
||||
it('persists collapse state using the users-specific localStorage key', async () => {
|
||||
const setSpy = vi.spyOn(Storage.prototype, 'setItem');
|
||||
render(UsersListPanel, { users });
|
||||
const setSpy = vi.spyOn(Storage.prototype, 'setItem');
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="Liste einklappen"]')!.click();
|
||||
expect(setSpy).toHaveBeenCalledWith('admin_users_list_collapsed', 'true');
|
||||
await vi.waitFor(() =>
|
||||
expect(setSpy).toHaveBeenCalledWith('admin_users_list_collapsed', 'true')
|
||||
);
|
||||
setSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ import { m } from '$lib/paraglide/messages.js';
|
||||
type="text"
|
||||
name="username"
|
||||
required
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -26,6 +26,6 @@ import { m } from '$lib/paraglide/messages.js';
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -31,7 +31,7 @@ let {
|
||||
<div class="mb-6 grid grid-cols-1 items-end gap-4 md:grid-cols-[1fr_auto_1fr] md:gap-6">
|
||||
<!-- Sender -->
|
||||
<div
|
||||
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_input]:focus:border-ink [&_input]:focus:ring-ink [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
|
||||
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
|
||||
>
|
||||
<PersonTypeahead
|
||||
name="senderId"
|
||||
@@ -73,7 +73,7 @@ let {
|
||||
|
||||
<!-- Receiver -->
|
||||
<div
|
||||
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_input]:focus:border-ink [&_input]:focus:ring-ink [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
|
||||
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
|
||||
>
|
||||
<PersonTypeahead
|
||||
name="receiverId"
|
||||
@@ -99,7 +99,7 @@ let {
|
||||
type="date"
|
||||
bind:value={fromDate}
|
||||
onchange={() => onapplyFilters()}
|
||||
class="block w-full border-line py-2.5 text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||
class="block w-full border-line py-2.5 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -113,7 +113,7 @@ let {
|
||||
type="date"
|
||||
bind:value={toDate}
|
||||
onchange={() => onapplyFilters()}
|
||||
class="block w-full border-line py-2.5 text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||
class="block w-full border-line py-2.5 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -48,9 +48,9 @@ const withDocs = {
|
||||
// ─── Empty state ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Conversations page – empty state', () => {
|
||||
it('shows the "select two persons" prompt when no persons are selected', async () => {
|
||||
it('shows the empty-state heading when no persons are selected', async () => {
|
||||
render(Page, { data: baseData });
|
||||
await expect.element(page.getByText(/Wählen Sie zwei Personen aus/i)).toBeInTheDocument();
|
||||
await expect.element(page.getByText(/Korrespondenz durchsuchen/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the swap button when no persons are selected', async () => {
|
||||
|
||||
@@ -90,7 +90,7 @@ $effect(() => {
|
||||
titleOverride = (e.target as HTMLInputElement).value;
|
||||
titleDirty = true;
|
||||
}}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
placeholder="Titel eingeben…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import AuthHeader from '../AuthHeader.svelte';
|
||||
|
||||
let { form }: { form?: { error?: string; success?: boolean } } = $props();
|
||||
</script>
|
||||
|
||||
<div class="relative flex min-h-screen flex-col bg-surface">
|
||||
<div class="flex min-h-screen flex-col bg-canvas">
|
||||
<AuthHeader />
|
||||
<div class="flex flex-1 items-center justify-center px-4">
|
||||
<div class="w-full max-w-sm">
|
||||
<!-- Logo -->
|
||||
@@ -44,7 +46,7 @@ let { form }: { form?: { error?: string; success?: boolean } } = $props();
|
||||
id="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:border-ink focus:ring-1 focus:ring-ink focus:outline-none"
|
||||
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ let {
|
||||
<div class="mb-6 grid grid-cols-1 items-end gap-4 md:grid-cols-[1fr_auto_1fr] md:gap-6">
|
||||
<!-- Sender -->
|
||||
<div
|
||||
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_input]:focus:border-ink [&_input]:focus:ring-ink [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
|
||||
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
|
||||
>
|
||||
<PersonTypeahead
|
||||
name="senderId"
|
||||
@@ -73,7 +73,7 @@ let {
|
||||
|
||||
<!-- Receiver -->
|
||||
<div
|
||||
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_input]:focus:border-ink [&_input]:focus:ring-ink [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
|
||||
class="relative z-30 [&_input]:border-line [&_input]:py-2.5 [&_label]:mb-2 [&_label]:text-xs [&_label]:font-bold [&_label]:tracking-widest [&_label]:text-ink-2 [&_label]:uppercase"
|
||||
>
|
||||
<PersonTypeahead
|
||||
name="receiverId"
|
||||
@@ -99,7 +99,7 @@ let {
|
||||
type="date"
|
||||
bind:value={fromDate}
|
||||
onchange={() => onapplyFilters()}
|
||||
class="block w-full border-line py-2.5 text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||
class="block w-full border-line py-2.5 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -113,7 +113,7 @@ let {
|
||||
type="date"
|
||||
bind:value={toDate}
|
||||
onchange={() => onapplyFilters()}
|
||||
class="block w-full border-line py-2.5 text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||
class="block w-full border-line py-2.5 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
|
||||
interface Correspondent {
|
||||
id: string;
|
||||
@@ -17,20 +18,6 @@ interface Props {
|
||||
|
||||
let { correspondents, loading, senderName, onselect, onclose }: Props = $props();
|
||||
|
||||
function clickOutside(node: HTMLElement) {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (node && !node.contains(event.target as Node) && !event.defaultPrevented) {
|
||||
onclose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClick, true);
|
||||
return {
|
||||
destroy() {
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getOptionElements(container: HTMLElement): HTMLElement[] {
|
||||
return Array.from(container.querySelectorAll<HTMLElement>('[role="option"]'));
|
||||
}
|
||||
@@ -60,6 +47,7 @@ function getInitials(person: Correspondent): string {
|
||||
|
||||
<div
|
||||
use:clickOutside
|
||||
onclickoutside={onclose}
|
||||
role="listbox"
|
||||
tabindex="-1"
|
||||
aria-label={m.conv_suggestions_heading()}
|
||||
@@ -78,7 +66,7 @@ function getInitials(person: Correspondent): string {
|
||||
role="option"
|
||||
aria-selected="false"
|
||||
tabindex="0"
|
||||
class="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm text-ink hover:bg-muted focus:bg-muted focus:outline-none"
|
||||
class="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm text-ink hover:bg-muted focus:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset"
|
||||
onclick={() => onselect(person.id)}
|
||||
onkeydown={(e) => e.key === 'Enter' && onselect(person.id)}
|
||||
>
|
||||
@@ -103,7 +91,7 @@ function getInitials(person: Correspondent): string {
|
||||
role="option"
|
||||
aria-selected="false"
|
||||
tabindex="0"
|
||||
class="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm text-ink hover:bg-muted focus:bg-muted focus:outline-none"
|
||||
class="flex cursor-pointer items-center gap-2 px-3 py-2 text-sm text-ink hover:bg-muted focus:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset"
|
||||
onclick={() => onselect('')}
|
||||
onkeydown={(e) => e.key === 'Enter' && onselect('')}
|
||||
>
|
||||
|
||||
@@ -42,7 +42,7 @@ let isActive = $derived(!!(fromDate || toDate || sortDir !== 'DESC'));
|
||||
bind:value={fromDate}
|
||||
onchange={() => onapplyFilters()}
|
||||
placeholder={m.conv_strip_from_placeholder()}
|
||||
class="h-8 w-[100px] rounded border bg-surface px-2 text-xs text-ink focus:outline-none {fromDate ? 'border-primary' : 'border-line'}"
|
||||
class="h-8 w-[100px] rounded border bg-surface px-2 text-xs text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring {fromDate ? 'border-primary' : 'border-line'}"
|
||||
/>
|
||||
|
||||
<span class="text-xs text-ink-3">–</span>
|
||||
@@ -52,7 +52,7 @@ let isActive = $derived(!!(fromDate || toDate || sortDir !== 'DESC'));
|
||||
bind:value={toDate}
|
||||
onchange={() => onapplyFilters()}
|
||||
placeholder={m.conv_strip_to_placeholder()}
|
||||
class="h-8 w-[100px] rounded border bg-surface px-2 text-xs text-ink focus:outline-none {toDate ? 'border-primary' : 'border-line'}"
|
||||
class="h-8 w-[100px] rounded border bg-surface px-2 text-xs text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring {toDate ? 'border-primary' : 'border-line'}"
|
||||
/>
|
||||
|
||||
<!-- Document count -->
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
|
||||
/* ─── 2. Raw palette — never used directly in components ──────────────────── */
|
||||
@theme {
|
||||
/* Breakpoints */
|
||||
--breakpoint-xs: 375px;
|
||||
|
||||
/* Brand palette constants */
|
||||
--palette-navy: #012851;
|
||||
--palette-mint: #a1dcd8;
|
||||
@@ -48,14 +51,17 @@
|
||||
--color-primary: var(--c-primary);
|
||||
--color-primary-fg: var(--c-primary-fg);
|
||||
|
||||
/* Nav active state */
|
||||
--color-nav-active: var(--c-nav-active);
|
||||
|
||||
/* PDF viewer */
|
||||
--color-pdf-bg: var(--c-pdf-bg);
|
||||
--color-pdf-ctrl: var(--c-pdf-ctrl);
|
||||
--color-pdf-text: var(--c-pdf-text);
|
||||
|
||||
/* Header surface — independent from canvas/surface for per-mode control */
|
||||
--color-header: var(--c-header);
|
||||
|
||||
/* Focus ring — keyboard focus indicator, mode-aware (navy in light, mint in dark) */
|
||||
--color-focus-ring: var(--c-focus-ring);
|
||||
|
||||
/* Static brand tokens (not themed) */
|
||||
--color-brand-navy: var(--palette-navy);
|
||||
--color-brand-mint: var(--palette-mint);
|
||||
@@ -84,7 +90,11 @@
|
||||
--c-primary: #012851;
|
||||
--c-primary-fg: #ffffff;
|
||||
|
||||
--c-nav-active: rgba(180, 185, 255, 0.15);
|
||||
/* Header is brand-navy in light mode; same in dark mode for contrast compliance */
|
||||
--c-header: #012851;
|
||||
|
||||
/* Focus ring: brand-navy in light mode — 14:1 on white, ~11:1 on sand */
|
||||
--c-focus-ring: #012851;
|
||||
|
||||
--c-pdf-bg: #ebebeb;
|
||||
--c-pdf-ctrl: #d8d8d8;
|
||||
@@ -92,19 +102,26 @@
|
||||
}
|
||||
|
||||
/* ─── 5. Dark mode ─────────────────────────────────────────────────────────── */
|
||||
/*
|
||||
Navy-tinted dark palette derived from brand-navy (#012851).
|
||||
KEEP THESE TWO BLOCKS IN SYNC — they cover the same design intent via
|
||||
different activation paths (system preference vs. manual toggle).
|
||||
*/
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme='light']) {
|
||||
--c-canvas: #0d0d0d;
|
||||
--c-surface: #1a1a1a;
|
||||
--c-overlay: #242424;
|
||||
--c-muted: #252525;
|
||||
color-scheme: dark;
|
||||
|
||||
--c-line: #3d3d3d;
|
||||
--c-line-2: #2e2e2e;
|
||||
--c-canvas: #010e1e;
|
||||
--c-surface: #011526;
|
||||
--c-overlay: #011e38;
|
||||
--c-muted: #011a30;
|
||||
|
||||
--c-line: #0d3358;
|
||||
--c-line-2: #092843;
|
||||
|
||||
--c-ink: #f0efe9;
|
||||
--c-ink-2: #9ca3af; /* gray-400 — 7.5:1 on dark surface — WCAG AAA ✓ */
|
||||
--c-ink-3: #8b97a5; /* gray-450 — 6.5:1 on dark surface — WCAG AA ✓ */
|
||||
--c-ink-2: #9ca3af; /* 7.5:1 on #011526 — WCAG AAA ✓ */
|
||||
--c-ink-3: #8b97a5; /* 7.1:1 on #011526 — WCAG AAA ✓ */
|
||||
|
||||
--c-accent: #00c7b1;
|
||||
--c-accent-bg: rgba(0, 199, 177, 0.12);
|
||||
@@ -112,27 +129,34 @@
|
||||
--c-primary: #a1dcd8;
|
||||
--c-primary-fg: #012851;
|
||||
|
||||
--c-nav-active: rgba(180, 185, 255, 0.12);
|
||||
/* Header at brand-navy: 4.99:1 with ink-3 (WCAG AA ✓), visually above canvas */
|
||||
--c-header: #012851;
|
||||
|
||||
--c-pdf-bg: #1e1e1e;
|
||||
--c-pdf-ctrl: #2a2a2a;
|
||||
--c-pdf-text: #d1d1d1;
|
||||
/* Focus ring: brand-mint in dark mode — 9.2:1 on canvas, 7.1:1 on surface */
|
||||
--c-focus-ring: #a1dcd8;
|
||||
|
||||
--c-pdf-bg: #010e1e;
|
||||
--c-pdf-ctrl: #011526;
|
||||
--c-pdf-text: #f0efe9;
|
||||
}
|
||||
}
|
||||
|
||||
/* Manual dark override — takes precedence over media query */
|
||||
/* KEEP IN SYNC with the @media block above */
|
||||
:root[data-theme='dark'] {
|
||||
--c-canvas: #0d0d0d;
|
||||
--c-surface: #1a1a1a;
|
||||
--c-overlay: #242424;
|
||||
--c-muted: #252525;
|
||||
color-scheme: dark;
|
||||
|
||||
--c-line: #3d3d3d;
|
||||
--c-line-2: #2e2e2e;
|
||||
--c-canvas: #010e1e;
|
||||
--c-surface: #011526;
|
||||
--c-overlay: #011e38;
|
||||
--c-muted: #011a30;
|
||||
|
||||
--c-line: #0d3358;
|
||||
--c-line-2: #092843;
|
||||
|
||||
--c-ink: #f0efe9;
|
||||
--c-ink-2: #9ca3af;
|
||||
--c-ink-3: #6b7280;
|
||||
--c-ink-2: #9ca3af; /* 7.5:1 on #011526 — WCAG AAA ✓ */
|
||||
--c-ink-3: #8b97a5; /* 7.1:1 on #011526 — WCAG AAA ✓ */
|
||||
|
||||
--c-accent: #00c7b1;
|
||||
--c-accent-bg: rgba(0, 199, 177, 0.12);
|
||||
@@ -140,11 +164,15 @@
|
||||
--c-primary: #a1dcd8;
|
||||
--c-primary-fg: #012851;
|
||||
|
||||
--c-nav-active: rgba(180, 185, 255, 0.12);
|
||||
/* Header at brand-navy: 4.99:1 with ink-3 (WCAG AA ✓), visually above canvas */
|
||||
--c-header: #012851;
|
||||
|
||||
--c-pdf-bg: #1e1e1e;
|
||||
--c-pdf-ctrl: #2a2a2a;
|
||||
--c-pdf-text: #d1d1d1;
|
||||
/* Focus ring: brand-mint in dark mode — 9.2:1 on canvas, 7.1:1 on surface */
|
||||
--c-focus-ring: #a1dcd8;
|
||||
|
||||
--c-pdf-bg: #010e1e;
|
||||
--c-pdf-ctrl: #011526;
|
||||
--c-pdf-text: #f0efe9;
|
||||
}
|
||||
|
||||
/* ─── 6. Icon inversion — De Gruyter icons are black SVGs loaded as <img> ──── */
|
||||
@@ -219,4 +247,17 @@
|
||||
text-decoration-thickness: 2px;
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
/* Fallback focus ring for any interactive element not styled with ring-focus-ring */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--c-focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure focus rings are visible in Windows High Contrast / forced-colors mode */
|
||||
@media (forced-colors: active) {
|
||||
:focus-visible {
|
||||
outline: 3px solid ButtonText;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { setLocale, getLocale } from '$lib/paraglide/runtime';
|
||||
import AuthHeader from '../AuthHeader.svelte';
|
||||
|
||||
let { form }: { form?: { error?: string; success?: boolean } } = $props();
|
||||
|
||||
const locales = ['DE', 'EN', 'ES'] as const;
|
||||
const localeMap = { DE: 'de', EN: 'en', ES: 'es' } as const;
|
||||
const activeLocale = $derived(getLocale().toUpperCase());
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m.page_title_login()}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="relative flex min-h-screen flex-col bg-canvas">
|
||||
<!-- Language switcher -->
|
||||
<div class="absolute top-4 right-4 flex items-center gap-1">
|
||||
{#each locales as locale (locale)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setLocale(localeMap[locale])}
|
||||
class="px-1.5 py-1 font-sans text-xs tracking-widest transition-colors
|
||||
{activeLocale === locale
|
||||
? 'font-bold text-ink'
|
||||
: 'font-normal text-ink-3 hover:text-ink'}"
|
||||
>
|
||||
{locale}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex min-h-screen flex-col bg-canvas">
|
||||
<AuthHeader />
|
||||
|
||||
<div class="flex flex-1 items-center justify-center px-4">
|
||||
<div class="w-full max-w-sm">
|
||||
@@ -60,7 +42,7 @@ const activeLocale = $derived(getLocale().toUpperCase());
|
||||
id="username"
|
||||
required
|
||||
autocomplete="username"
|
||||
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:border-ink focus:ring-1 focus:ring-ink focus:outline-none"
|
||||
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -76,7 +58,7 @@ const activeLocale = $derived(getLocale().toUpperCase());
|
||||
id="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:border-ink focus:ring-1 focus:ring-ink focus:outline-none"
|
||||
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@ afterEach(cleanup);
|
||||
describe('Login page – rendering', () => {
|
||||
it('renders the page title', async () => {
|
||||
render(LoginPage, {});
|
||||
await expect.element(page.getByRole('link', { name: 'Familienarchiv' })).toBeInTheDocument();
|
||||
await expect
|
||||
.element(page.getByRole('link', { name: 'Familienarchiv' }).first())
|
||||
.toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/login-default.png' });
|
||||
});
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ function typeBadgeLabel(type: NotificationItem['type']): string {
|
||||
aria-checked={activeType === null && activeReadFilter === null}
|
||||
onclick={() => setFilter({ type: null, read: null })}
|
||||
class={[
|
||||
'rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2',
|
||||
'rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2',
|
||||
activeType === null && activeReadFilter === null
|
||||
? 'bg-primary text-primary-fg'
|
||||
: 'bg-muted text-ink'
|
||||
@@ -126,7 +126,7 @@ function typeBadgeLabel(type: NotificationItem['type']): string {
|
||||
aria-checked={activeReadFilter === 'false'}
|
||||
onclick={() => setFilter({ read: 'false', type: null })}
|
||||
class={[
|
||||
'inline-flex items-center gap-1.5 rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2',
|
||||
'inline-flex items-center gap-1.5 rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2',
|
||||
activeReadFilter === 'false'
|
||||
? 'bg-primary text-primary-fg'
|
||||
: 'bg-muted text-ink'
|
||||
@@ -149,7 +149,7 @@ function typeBadgeLabel(type: NotificationItem['type']): string {
|
||||
aria-checked={activeType === 'MENTION'}
|
||||
onclick={() => setFilter({ type: 'MENTION', read: null })}
|
||||
class={[
|
||||
'rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2',
|
||||
'rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2',
|
||||
activeType === 'MENTION'
|
||||
? 'bg-primary text-primary-fg'
|
||||
: 'bg-muted text-ink'
|
||||
@@ -164,7 +164,7 @@ function typeBadgeLabel(type: NotificationItem['type']): string {
|
||||
aria-checked={activeType === 'REPLY'}
|
||||
onclick={() => setFilter({ type: 'REPLY', read: null })}
|
||||
class={[
|
||||
'rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2',
|
||||
'rounded-full px-3 py-2 font-sans text-sm font-medium focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2',
|
||||
activeType === 'REPLY'
|
||||
? 'bg-primary text-primary-fg'
|
||||
: 'bg-muted text-ink'
|
||||
|
||||
@@ -22,14 +22,14 @@ function makeUrl(params: Record<string, string | string[]> = {}) {
|
||||
// ─── dashboard mode (no search filters) ──────────────────────────────────────
|
||||
|
||||
describe('home page load — dashboard mode', () => {
|
||||
it('sets isDashboard true and fetches all three widget APIs', async () => {
|
||||
it('sets isDashboard true and fetches stats, incomplete, and recent APIs', async () => {
|
||||
const mockGet = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons
|
||||
.mockResolvedValueOnce({
|
||||
response: { ok: true },
|
||||
data: { content: [{ id: 'n1' }] }
|
||||
}) // notifications
|
||||
data: { totalDocuments: 42, totalPersons: 7 }
|
||||
}) // stats
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [{ id: 'd1' }] }) // incomplete
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [{ id: 'd2' }] }); // recent
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
@@ -39,17 +39,20 @@ describe('home page load — dashboard mode', () => {
|
||||
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
|
||||
|
||||
expect(result.isDashboard).toBe(true);
|
||||
expect(result.mentions).toHaveLength(1);
|
||||
expect(result.stats).toEqual({ totalDocuments: 42, totalPersons: 7 });
|
||||
expect(result.incompleteDocs).toHaveLength(1);
|
||||
expect(result.recentDocs).toHaveLength(1);
|
||||
expect(result.documents).toEqual([]);
|
||||
});
|
||||
|
||||
it('defaults mentions to [] when notifications API rejects', async () => {
|
||||
it('returns stats with totalDocuments from /api/stats', async () => {
|
||||
const mockGet = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons
|
||||
.mockRejectedValueOnce(new Error('network')) // notifications
|
||||
.mockResolvedValueOnce({
|
||||
response: { ok: true },
|
||||
data: { totalDocuments: 248, totalPersons: 34 }
|
||||
}) // stats
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] }); // recent
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
@@ -58,7 +61,24 @@ describe('home page load — dashboard mode', () => {
|
||||
|
||||
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
|
||||
|
||||
expect(result.mentions).toEqual([]);
|
||||
expect(result.stats?.totalDocuments).toBe(248);
|
||||
expect(result.stats?.totalPersons).toBe(34);
|
||||
});
|
||||
|
||||
it('returns stats: null when /api/stats rejects', async () => {
|
||||
const mockGet = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons
|
||||
.mockRejectedValueOnce(new Error('network')) // stats
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] }); // recent
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
|
||||
|
||||
expect(result.stats).toBeNull();
|
||||
});
|
||||
|
||||
it('defaults incompleteDocs to [] when incomplete API rejects', async () => {
|
||||
@@ -81,7 +101,10 @@ describe('home page load — dashboard mode', () => {
|
||||
const mockGet = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: { content: [] } }) // notifications
|
||||
.mockResolvedValueOnce({
|
||||
response: { ok: true },
|
||||
data: { totalDocuments: 0, totalPersons: 0 }
|
||||
}) // stats
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete
|
||||
.mockRejectedValueOnce(new Error('network')); // recent
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
@@ -113,7 +136,7 @@ describe('home page load — search mode', () => {
|
||||
|
||||
expect(result.isDashboard).toBe(false);
|
||||
expect(result.documents).toHaveLength(1);
|
||||
expect(result.mentions).toEqual([]);
|
||||
expect(result.stats).toBeNull();
|
||||
expect(result.incompleteDocs).toEqual([]);
|
||||
expect(result.recentDocs).toEqual([]);
|
||||
// Only two API calls — no widget calls
|
||||
|
||||
@@ -21,8 +21,12 @@ const emptyData = {
|
||||
user: undefined,
|
||||
canWrite: true,
|
||||
canAnnotate: false,
|
||||
isDashboard: false,
|
||||
filters: { q: '', from: '', to: '', senderId: '', receiverId: '', tags: [] },
|
||||
documents: [],
|
||||
incompleteDocs: [],
|
||||
recentDocs: [],
|
||||
stats: null,
|
||||
incompleteCount: 0,
|
||||
initialValues: { senderName: '', receiverName: '' },
|
||||
error: null
|
||||
@@ -189,6 +193,42 @@ describe('Home page – search input keystroke preservation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Dashboard mode ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('Home page – dashboard mode', () => {
|
||||
const dashboardData = {
|
||||
...emptyData,
|
||||
isDashboard: true,
|
||||
canWrite: false,
|
||||
incompleteDocs: [],
|
||||
recentDocs: []
|
||||
};
|
||||
|
||||
it('hides the right column when canWrite is false and incompleteDocs is empty', async () => {
|
||||
render(Page, { data: dashboardData });
|
||||
const rightCol = page.getByTestId('dashboard-right-column');
|
||||
await expect.element(rightCol).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the right column when canWrite is true', async () => {
|
||||
render(Page, { data: { ...dashboardData, canWrite: true } });
|
||||
const rightCol = page.getByTestId('dashboard-right-column');
|
||||
await expect.element(rightCol).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the right column when incompleteDocs is non-empty', async () => {
|
||||
render(Page, {
|
||||
data: {
|
||||
...dashboardData,
|
||||
canWrite: false,
|
||||
incompleteDocs: [{ id: 'd1', title: 'Taufschein' }]
|
||||
}
|
||||
});
|
||||
const rightCol = page.getByTestId('dashboard-right-column');
|
||||
await expect.element(rightCol).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Error state ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Home page – error state', () => {
|
||||
|
||||
@@ -54,7 +54,7 @@ function handleSearch() {
|
||||
oninput={handleSearch}
|
||||
onfocus={() => (qFocused = true)}
|
||||
onblur={() => (qFocused = false)}
|
||||
class="block w-56 rounded-sm border border-line bg-surface py-2.5 pr-10 pl-4 font-sans text-sm text-ink placeholder-ink-3 shadow-sm focus:border-ink focus:ring-1 focus:ring-ink focus:outline-none"
|
||||
class="block w-56 rounded-sm border border-line bg-surface py-2.5 pr-10 pl-4 font-sans text-sm text-ink placeholder-ink-3 shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-ink-3"
|
||||
|
||||
@@ -26,7 +26,7 @@ let {
|
||||
type="text"
|
||||
required
|
||||
value={person.firstName}
|
||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
|
||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -39,7 +39,7 @@ let {
|
||||
type="text"
|
||||
required
|
||||
value={person.lastName}
|
||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
|
||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
@@ -51,7 +51,7 @@ let {
|
||||
name="alias"
|
||||
type="text"
|
||||
value={person.alias ?? ''}
|
||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
|
||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -66,7 +66,7 @@ let {
|
||||
max="2100"
|
||||
placeholder={m.person_placeholder_year()}
|
||||
value={person.birthYear ?? ''}
|
||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
|
||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -81,7 +81,7 @@ let {
|
||||
max="2100"
|
||||
placeholder={m.person_placeholder_year()}
|
||||
value={person.deathYear ?? ''}
|
||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
|
||||
class="block w-full rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
@@ -93,7 +93,7 @@ let {
|
||||
name="notes"
|
||||
rows="4"
|
||||
placeholder={m.person_placeholder_notes()}
|
||||
class="block w-full resize-y rounded border border-line px-3 py-2 font-serif text-ink focus:border-ink focus:outline-none"
|
||||
class="block w-full resize-y rounded border border-line px-3 py-2 font-serif text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>{person.notes ?? ''}</textarea
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -48,7 +48,7 @@ let { form } = $props();
|
||||
name="firstName"
|
||||
type="text"
|
||||
required
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -61,7 +61,7 @@ let { form } = $props();
|
||||
name="lastName"
|
||||
type="text"
|
||||
required
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -74,7 +74,7 @@ let { form } = $props();
|
||||
name="alias"
|
||||
type="text"
|
||||
placeholder={m.form_placeholder_alias()}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -89,7 +89,7 @@ let { form } = $props();
|
||||
min="1"
|
||||
max="2100"
|
||||
placeholder={m.person_placeholder_year()}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -104,7 +104,7 @@ let { form } = $props();
|
||||
min="1"
|
||||
max="2100"
|
||||
placeholder={m.person_placeholder_year()}
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||
class="block w-full rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -117,7 +117,7 @@ let { form } = $props();
|
||||
name="notes"
|
||||
rows="4"
|
||||
placeholder={m.person_placeholder_notes()}
|
||||
class="block w-full resize-y rounded border border-line p-2 text-sm shadow-sm focus:border-ink focus:ring-ink"
|
||||
class="block w-full resize-y rounded border border-line p-2 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('Persons page – rendering', () => {
|
||||
|
||||
it('shows alias in italic when provided', async () => {
|
||||
render(Page, { data: { ...emptyData, persons: [makePerson({ alias: 'Maxi' })] } });
|
||||
await expect.element(page.getByText('"Maxi"')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('„Maxi"')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows life date range when birthYear is provided', async () => {
|
||||
|
||||
@@ -39,7 +39,7 @@ let {
|
||||
type="password"
|
||||
name="currentPassword"
|
||||
required
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -51,7 +51,7 @@ let {
|
||||
type="password"
|
||||
name="newPassword"
|
||||
required
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -63,13 +63,14 @@ let {
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
required
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="submit-password"
|
||||
class="mt-5 rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
|
||||
>
|
||||
{m.btn_save()}
|
||||
|
||||
@@ -57,7 +57,7 @@ function handleBirthDateInput(e: Event) {
|
||||
type="text"
|
||||
name="firstName"
|
||||
value={user?.firstName ?? ''}
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -69,7 +69,7 @@ function handleBirthDateInput(e: Event) {
|
||||
type="text"
|
||||
name="lastName"
|
||||
value={user?.lastName ?? ''}
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -82,7 +82,7 @@ function handleBirthDateInput(e: Event) {
|
||||
placeholder="TT.MM.JJJJ"
|
||||
value={birthDateDisplay}
|
||||
oninput={handleBirthDateInput}
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
<input type="hidden" name="birthDate" value={birthDateIso} />
|
||||
</label>
|
||||
@@ -95,7 +95,7 @@ function handleBirthDateInput(e: Event) {
|
||||
type="email"
|
||||
name="email"
|
||||
value={user?.email ?? ''}
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -107,7 +107,7 @@ function handleBirthDateInput(e: Event) {
|
||||
name="contact"
|
||||
rows="3"
|
||||
placeholder={m.profile_contact_placeholder()}
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:border-ink focus:outline-none"
|
||||
class="w-full rounded-sm border border-line px-3 py-2 font-serif text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>{user?.contact ?? ''}</textarea
|
||||
>
|
||||
</label>
|
||||
|
||||
@@ -53,7 +53,7 @@ let {
|
||||
id="newPassword"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:border-ink focus:ring-1 focus:ring-ink focus:outline-none"
|
||||
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -69,7 +69,7 @@ let {
|
||||
id="confirmPassword"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:border-ink focus:ring-1 focus:ring-ink focus:outline-none"
|
||||
class="block w-full border border-line px-3 py-2.5 font-serif text-sm text-ink placeholder-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user