Audit of the end-to-end docforge journey (project Schriftsätze tab, global /submissions/new picker, draft editor, /admin/templates authoring) + a prioritized UX plan. Captures m's grill answers + 4 option-picker decisions: - base preview = truthful (LibreOffice .docx→image, on-demand+cached), via a 'Vorschau' button → modal with a base-switcher - catalog rows = one primary 'Entwurf öffnen' + ⋯ kebab (kills the invisible Bearbeiten-vs-Generieren split) - editor meta → header toolbar (de-densifies the sidebar) - free-start stays first-class; entry points kept distinct-but-consistent - live editing preview stays structural (only the base preview is pixel-true) Resolved the 2-vs-3-panel discrepancy: the Abschnitte panel is display:none when a draft has no seeded sections, so m correctly sees 2 panels. Design only — no engine redesign, no code. Tracer-bullet slice train S1..S5.
24 KiB
PRD — docforge UX: truthful base preview + a coherent submission-drafting flow
Task: t-paliad-369 (m 2026-06-01 17:16) · Author: leibniz (inventor) · Date: 2026-06-01
Status: DESIGN — awaiting head's go/no-go on the coder shift. No code in this task.
Scope: the UX/flow around the generation engine. The engine itself (Rubrum styling,
caption, letterhead from the HLC .dotm) was made correct today — t-paliad-364 / -365 / -367 —
and is explicitly out of scope to redesign.
Reading order for the reviewer: §0 (m's decisions) → §1 (journey map + hacky inventory) → §2 (the base-preview feature, with wireframes) → §3 (prioritized plan) → §4 (out of scope).
§0 m's decisions (2026-06-01)
m was grilled in prose on the UX vision, then answered four concrete option-pickers. Both rounds are folded in here; the open-question record is §5.
0.1 Prose-grill answers (UX vision)
| # | Question | m's answer |
|---|---|---|
| G1 | What reads as "hacky"? | (a) blind base dropdown, (b) invisible Bearbeiten-vs-Generieren, (c) preview ≠ real output, (d) dense panels — ALL bad. NOT (e) [KEIN WERT] walls, NOT (f) duplicate catalog. |
| G1′ | Panel count | m sees TWO panels, not three. (Resolved — see §1.4: the section panel is conditional and silently absent for his drafts.) |
| G2 | Akte-first vs free-start | Keep free-start first-class. No Akte gating. Teach project-less drafts to fill what they can from the proceeding (already done, t-paliad-364). |
| G3 | Base-preview fidelity | Truthful. "truthful would be awesome" — a real image of the actual Word page (letterhead logo, fonts, Rubrum table). |
| G4 | Editor live-preview fidelity | Structural suffices ("a, I think") — show where vars/prose land. The live editing preview stays approximate; pixel-fidelity is only required for the base preview. |
| G5 | Entry points | Keep distinct, make consistent. "in a project it is a bit different than the free approach." Don't converge the project tab and the global picker. |
The load-bearing split (G3 vs G4): base preview = truthful (pixel-true .docx render); live editing preview = structural (approximate HTML). The truthful infra built for the base preview could later upgrade the editing preview, but m does not require that now.
0.2 Concrete option-picker answers
| # | Decision | m's pick | Note |
|---|---|---|---|
| Q1 | Base-preview render infra | LibreOffice on-demand + cached | Renders the actual base with the draft's real data (.docx→PDF→image), cached. Truest "what you preview is what you generate". Cost: headless LibreOffice in the container + cold-render latency — scoped in §2.3. |
| Q2 | Where the preview surfaces | "Vorschau" button → modal | Not an always-on inline pane. Pairs cleanly with on-demand rendering — we only render when the lawyer asks. |
| Q3 | Bearbeiten vs Generieren | One primary + ⋯ menu | Primary "Entwurf öffnen"; a kebab offers the fast "Direkt exportieren" path. Kills the two-equal-buttons ambiguity, keeps the capability. |
| Q4 | Editor sidebar density | Meta → header toolbar | Draft-meta (name, keyword, base+Vorschau, language) moves to a top header strip; the sidebar keeps only the fill-in work (parties + variables). |
§1 Current journey map + the hacky inventory
Three entry points, one editor, one authoring page. All gated behind auth; knowledge-platform pages are separate. Grounded in the live tree (verified 2026-06-01).
1.1 Entry A — the project "Schriftsätze" tab
frontend/src/client/submissions.ts · per-project, opened from a project detail page.
- Shows the full cross-proceeding catalog grouped by proceeding, with the project's own proceeding pinned at the top (lime border, " (dieses Projekt)" suffix) — m's 2026-05-23 decree.
- Each row carries two buttons:
Bearbeiten(→ editor at/projects/{id}/submissions/{code}/draft) andGenerieren(POST…/generate→ immediate.docxdownload, skipping the editor). - A
universellbadge marks rows without a dedicated per-code template.
Hacky (G1-b): the two buttons read as equals, but their behaviour is wildly different — one
opens an editor, the other silently downloads a file. And the one-click Generieren runs the
merge path (onGenerateClick, submissions.ts:212), which historically produced an unfilled
doc (the t-paliad-363 P3 finding); even after today's fixes, "download a finished doc without ever
seeing it" is a foot-gun next to "open the editor".
1.2 Entry B — the global /submissions/new picker
frontend/src/submissions-new.tsx + client/submissions-new.ts · cross-proceeding catalog.
- Search box + proceeding chips + a grouped, read-only catalog table (just got the filter + group-header contrast fixes, t-paliad-365).
- Each row: "Entwurf starten" → choose "Ohne Projekt" (jump straight to the draft) or "Mit Projekt verknüpfen" (a project-picker modal → project-scoped draft).
Consistent-but-distinct (G5): this serves a different moment than Entry A — browsing the whole catalog and starting fresh vs working inside a known case. m wants these kept distinct. The job is consistency of affordances (same row buttons, same naming, same kebab), not convergence. The duplicate-catalog concern (G1-f) m explicitly waved off.
1.3 Entry C — the draft editor
frontend/src/submission-draft.tsx (shell) + client/submission-draft.ts (2873-line bundle).
Layout today (.submission-draft-grid):
- Sidebar (
aside.submission-draft-sidebar): draft switcher + "+ Neuer Entwurf" → name → keyword (filename) → base picker → language toggle → save status → "Aus Projekt importieren" → party picker → ~20 variable fields. Everything stacked vertically. - Section list (
section.submission-draft-sections-wrap,display:noneby default) — the Composer prose-section editor. Painted only when the draft has seeded section rows. - Preview pane (
section.submission-draft-preview-wrap): a read-only, lossy HTML render (paintPreview,submission-draft.ts:1209;preview_htmlfrom the server).
Hacky (G1-a): the base picker is a bare <select> (#submission-draft-base,
paintBasePicker, submission-draft.ts:1257). It mixes legacy Gitea bases and uploaded templates
in one dropdown (an optgroup "Hochgeladene Vorlagen"), and the lawyer picks blind — no idea
what letterhead/structure each base yields. This is m's headline ask.
Hacky (G1-c): the preview pane is plain HTML — no letterhead logo, no HLpat fonts, no Rubrum table layout. It looks nothing like the exported Word document. "What you preview" ≠ "what you generate."
Hacky (G1-d): the sidebar is a long vertical stack of unlike things — draft management, template choice, language, parties, and every variable field — all competing for the same column.
1.4 The 2-vs-3-panel discrepancy — RESOLVED
m reported seeing two panels; the brief described three. m is right. The middle
"Abschnitte" panel is wrap.style.display = "none" whenever state.view.sections is empty
(paintSectionList, submission-draft.ts:1362-1364). Section rows are seeded only for Composer
drafts (a base_id whose submission_bases.section_spec seeds them) or when the lawyer manually
adds a section. m's drafts (project-less / pre-Composer, base_id IS NULL) have zero sections,
so the panel never appears and he sees sidebar ‖ preview.
This is itself a silent UX inconsistency: the editor is 2 panels for some drafts and 3 for others, with no signpost that a section editor exists. The layout design below is grounded in the 2-panel reality and makes the third panel's presence/absence explicit (§3 Slice 5).
1.5 Entry D — /admin/templates authoring
frontend/src/templates-authoring.tsx + client/templates-authoring.ts (docforge slice 6).
Admin-only. Upload .docx → render run-addressable text → select a span + pick a variable from the
palette → drop a {{slot}} → save as a reusable template. Three columns: palette ‖ preview ‖ slots.
Touch only for consistency: uploaded templates surface in the same editor base picker as the Gitea bases, so the base-preview feature (§2) must cover uploaded templates too — an authored template should be previewable exactly like a Gitea base. No authoring-page redesign in this PRD.
1.6 Inventory summary (what we fix vs what m waved off)
| Smell | m's verdict | Addressed in |
|---|---|---|
| (a) blind base dropdown | fix | §2 base preview + §3 S2/S3/S4 |
| (b) invisible Bearbeiten/Generieren | fix | §3 S1 |
| (c) preview ≠ real output | fix (truthful base preview; live preview stays structural) | §2 + §3 S3/S4 |
| (d) dense panels | fix | §3 S2 |
(e) [KEIN WERT] walls |
not a priority (engine fix t-paliad-364 already softens) | §3 S5 (light polish only) |
| (f) duplicate catalog | keep distinct | §3 S1 (consistency, not convergence) |
§2 The template-base preview (m's headline ask)
"Can we have a preview of the template base we are using?"
Shape: a "Vorschau" button (Q2) opens a modal that renders the selected base as a truthful image of the actual Word page (Q3) — same engine, same bytes that an export would produce — via headless LibreOffice on-demand, cached (Q1). The modal carries a base-switcher so the lawyer can flip bases and compare them truthfully → "what you preview is what you generate."
2.1 Wireframe — the Vorschau modal
┌─ Vorschau — Vorlagenbasis ───────────────────────────────────────────────┐
│ Basis: [ HLC Briefkopf ▾ ] Sprache: (•DE) ( EN ) [ ✕ ] │
│ ├ HLC Briefkopf │
│ ├ Universelles Skelett │
│ ├ LG Düsseldorf │
│ ├ UPC Formblatt │
│ └ ── Hochgeladene Vorlagen ── │
│ └ HLC Patents Style v0.26… │
├───────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ [HLC logo] Hogan Lovells … │ ← real page │
│ │ ───────────────────────────────────────────────── │ image, page │
│ │ Landgericht Düsseldorf │ 1 of N │
│ │ │ (LibreOffice │
│ │ In dem Rechtsstreit │ .docx→PDF→PNG │
│ │ Mustermandant GmbH – Klägerin – │ of the SAME │
│ │ ./. │ bytes export │
│ │ Musterbeklagte AG – Beklagte – │ would emit) │
│ │ wegen Patentverletzung │ │
│ │ Az. 4c O 12/23 │ │
│ │ … │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ ‹ Seite 1 / 3 › Daten: (• meine Daten) ( Beispiel ) │
│ [ Diese Basis verwenden ] │
└───────────────────────────────────────────────────────────────────────────┘
- Base-switcher (top): flips the previewed base. "Diese Basis verwenden" commits it to the draft
(the existing
base_id/template_version_idPATCH). So the modal is both a preview and the base-chooser — replacing the blind<select>as the primary base-selection surface. - Daten toggle: meine Daten renders the draft's resolved bag (truthful to the export);
Beispiel substitutes canned sample data (Mustermandant ./. Musterbeklagte, Az. 4c O 12/23) so a
fresh/project-less draft previews a full page instead of
[KEIN WERT]markers. Default: meine Daten when the draft has resolved values, Beispiel when it is essentially empty. - Paging: multi-page docs render page-by-page (
‹ Seite n / N ›).
2.2 Where the button lives
Per Q4, the base picker moves into the editor header toolbar, and the "Vorschau" button sits right next to it (§3 S2 wireframe). The same modal is also reachable from a catalog-row kebab ("Vorschau Vorlagenbasis", §3 S1) so a lawyer can eyeball a base before even opening the editor.
2.3 The truthful-render infra (Q1 = LibreOffice on-demand + cached)
Pipeline (server-side, new GET preview endpoint):
- Resolve the request:
(draft_id | base identity, lang, data-mode). - Run the existing export pipeline to produce
.docxbytes — the same compose/merge code an export uses, so the preview is byte-faithful by construction. (Project-less / sample-data mode feeds a canned bag; "meine Daten" feeds the draft's resolved bag.) soffice --headless --convert-to pdf(headless LibreOffice) → PDF.- PDF → PNG per page (poppler
pdftoppm, or equivalent) at a sensible DPI. - Return the PNG(s); the modal renders them.
Caching (this is what makes "on-demand" affordable):
- Key on
(template identity, lang, data-mode, hash(resolved-bag)). Identical inputs → cached PNG, no re-render. The bag-hash means editing a variable invalidates only that draft's preview. - Sample-data mode caches per
(base, lang)only (data is constant) — so base browsing/compare is effectively free after the first render of each base. - Cache store: on-disk under the response/temp dir, or a small table — coder's call. No binary is retained as a document (the §0.5.7 no-retention invariant is about exported documents; preview PNGs are a regenerable cache, not a stored artifact — flag for the coder to confirm framing).
Cost / risk to flag for m + coder:
- Container dependency: headless LibreOffice (~hundreds of MB) must be added to the Dokploy image (or a sidecar). This is the single biggest cost of the truthful path. Recommend a sidecar/ separate stage so the main Go image stays lean — coder evaluates.
- Cold-render latency: first render of a given
(base, lang, data)is seconds (LibreOffice spin-up + convert). The modal shows a spinner ("⟳ wird gerendert…"); the cache makes repeats instant. A warm-cache pass over the 4–5 known bases in sample-data mode can pre-render the common cases at deploy time. - Concurrency: LibreOffice headless is single-instance-touchy; serialise conversions through a
small worker/queue (one
sofficeat a time, or a pool). Flag for the coder.
Tracer-bullet sequencing (important): the modal UX ships before the LibreOffice infra — see §3 S3 (modal scaffold rendering the existing structural HTML first) → S4 (swap the modal body to the real PNG once LibreOffice lands). This de-risks the heavy infra: the base-compare UX is usable and reviewable immediately, and the truthful render drops in behind the same modal.
§3 Prioritized UX-improvement plan (tracer-bullet first)
Cheap → meaty. Each slice independently shippable and independently reviewable. S1/S2 are the quick wins that fix three of m's four "hacky" complaints with no new infra.
Slice 1 — Catalog row: one primary + ⋯ menu, consistent across both entry points (quick win — kills G1-b, delivers G5-consistency)
TS/CSS only, no backend.
- Replace the two equal buttons with a primary
Entwurf öffnen+ a⋯kebab:Direkt exportieren (.docx)andVorschau Vorlagenbasis. - Apply the same row component / vocabulary to the project Schriftsätze tab
(
client/submissions.ts) and the global picker (client/submissions-new.ts) so they read consistently — while keeping each surface's distinct context (own-proceeding pin on the tab; search + chips + project-link modal on the global picker). - Drop the bare
universelljargon badge for a clearer tooltip/label.
Klageerwiderung Beklagte § 277 ZPO [ Entwurf öffnen ] [ ⋯ ]
de.inf.lg.erwidg └─┐
• Direkt exportieren (.docx)
• Vorschau Vorlagenbasis
Slice 2 — Editor header toolbar: meta out of the sidebar (quick win — kills G1-d)
TS/CSS layout, no backend.
- Lift draft-meta (switcher, name, keyword, base picker + 👁 Vorschau button, language) into a header strip above the working area. Sidebar keeps only parties + variables (the fill-in work). Export button stays top-right.
┌─ Klageerwiderung · de.inf.lg.erwidg ───────────────────────────────────────────┐
│ Entwurf:[ Entwurf v2 ▾ ][+ Neu] Name:[__________] Stichwort:[__________] │
│ Vorlagenbasis:[ HLC Briefkopf ▾ ][👁 Vorschau] Sprache:(•DE)(EN) [Als .docx ⤓]│
├──────────────────────────────────┬──────────────────────────────────────────────┤
│ PARTEIEN │ VORSCHAU (Struktur — wo Daten/Text landen) │
│ ☑ Mustermandant (Klägerin) │ [letterhead] │
│ ☑ Musterbeklagte (Beklagte) │ In dem Rechtsstreit … │
│ VARIABLEN │ Az. «project.case_number» │
│ project.case_number [________] │ … │
│ … │ │
└──────────────────────────────────┴──────────────────────────────────────────────┘
(When the draft has sections, the Composer "Abschnitte" panel sits between sidebar and preview — see S5 for making that presence explicit.)
Slice 3 — Vorschau modal scaffold (structural render first) (tracer bullet for the base preview)
- Build the modal shell (§2.1): base-switcher, Sprache toggle, Daten toggle, paging frame, "Diese Basis verwenden", spinner state.
- Wire the
👁 Vorschaubutton (S2 header) and the row kebab (S1) to open it. - Body initially renders the existing structural
preview_htmlfor the selected base — so the whole base-compare + choose UX is live and reviewable before any LibreOffice work.
Slice 4 — Truthful render: LibreOffice on-demand + cached (the meaty infra — delivers G3)
- Add the preview endpoint + the
.docx→PDF→PNG pipeline + cache (§2.3). - Add headless LibreOffice to the deploy (sidecar recommended) + a single-flight conversion worker.
- Swap the modal body from structural HTML (S3) to the real page PNG(s). Same modal, same UX.
- Warm-cache the known bases in sample-data mode at deploy.
- This is the slice that carries the container-dependency + latency cost — gate it on m's explicit OK for the LibreOffice dependency.
Slice 5 — Section-panel discoverability + small honesty polish (polish)
- Make the conditional "Abschnitte" panel explicit: when a draft could have sections but has none, show a slim empty-state ("Dieser Entwurf hat keine Abschnitte — [+ Abschnitt hinzufügen]") instead of silently rendering nothing — so the section editor is discoverable (fixes the §1.4 inconsistency). When sections genuinely don't apply (pure merge-path draft), say so once.
- Light
[KEIN WERT]softening in the live preview (e.g. a muted "‹noch leer: …›" treatment) so honest gaps read as gentle prompts, not errors. (G1-e is low priority; keep it light.)
Ordering rationale: S1+S2 ship the visible "less hacky" wins immediately (rows + editor layout), no infra. S3 lands the base-preview experience on cheap rails. S4 makes it truthful — the one slice with real infra cost, isolated and gated. S5 is discoverability polish.
§4 Out of scope
- Implementation / code / migration SQL. Design only.
- Redesigning the generation engine — Rubrum/caption/letterhead/HLpat styling are correct as of t-paliad-364/-365/-367. This PRD touches only the UX around it.
- Upgrading the LIVE editing preview to pixel-true — m: structural suffices (G4). The truthful infra (post-S4) could later power a "truthful full-document" view in the editor, but that is a future opt-in, not this work.
- Converging the two entry points — m: keep distinct (G5). We make them consistent, not one.
- Akte-first gating — m: free-start stays first-class (G2).
/admin/templatesauthoring redesign — only consistency touch is that uploaded templates must be previewable via §2; no workflow rework.- Multi-user concurrent editing of one draft.
- The
[KEIN WERT]product question (require-Akte vs fill-what-we-can) — already resolved by t-paliad-364 (fill-what-we-can); not reopened here.
§5 Open-question record (historical)
These were the open questions before m ratified them; kept for the record. All are now closed in §0.
- OQ-G1 What specifically feels hacky? → closed (G1: a/b/c/d, not e/f).
- OQ-G2 Akte-first vs free-start? → closed (free-start first-class).
- OQ-G3 Base-preview fidelity? → closed (truthful).
- OQ-G4 Editor live-preview fidelity? → closed (structural suffices).
- OQ-G5 Converge or keep entry points distinct? → closed (distinct + consistent).
- OQ-Q1 Base-preview render infra? → closed (LibreOffice on-demand + cached).
- OQ-Q2 Where does the preview surface? → closed ("Vorschau" button → modal).
- OQ-Q3 Bearbeiten vs Generieren? → closed (one primary + ⋯ menu).
- OQ-Q4 Sidebar density? → closed (meta → header toolbar).
Flags for the eventual coder (resolve in implementation chat)
- LibreOffice deployment shape — sidecar vs. in-image; the single-flight conversion worker.
- Preview cache — on-disk vs. a small table; eviction policy; confirm preview PNGs are framed as a regenerable cache, not a retained "document" (vs the §0.5.7 no-retention invariant).
- DPI / page-image size — fidelity vs. payload weight for the modal.
- Sample-data source — a single canned bag, or per-jurisdiction sample bags for nicer previews.
- Base-picker vocabulary — with the modal as the chooser, whether to keep "Vorlagenbasis" vs a plainer "Vorlage" label, and how to present Gitea bases vs uploaded templates as one list.