**Status:** READY FOR REVIEW — m gates inventor → coder transition.
---
## 0. Premises verified live (before designing)
Before anchoring the design, I checked the live state — CLAUDE.md / memory / issue body can drift, the live system can't.
- **SmartTimeline data substrate is shipped through Slice 4.** `internal/services/projection_service.go:287 (For)` returns `([]TimelineEvent, ProjectionMeta, error)`. The wire envelope (`ResponseEnvelope`) is `{events: TimelineEvent[], lanes: LaneInfo[]}` — `Lanes` is the load-bearing primitive for parent-node aggregation (one column per direct child case / patent / litigation). `LevelPolicy` already differentiates `self_plus_ccr` (Case) / `child_case` (Patent) / `child_patent` (Litigation) / `child_litigation` (Client). Recent commits 7da8802, 7e57507, 7930ee0 confirm — design merge is on `main` (b4f4b3 baseline as of this branch).
- **Frontend renderer for the SmartTimeline is `frontend/src/client/views/shape-timeline.ts` (960 LoC, hand-rolled DOM via `document.createElement`).** It already implements: vertical flow, parallel-track CSS-grid for CCR (`renderParallelTracks`), lane-strip CSS-grid for parent-node aggregation (`renderLaneStrip`), click-to-anchor inline editor, `[Track ▼]` chip, lane-filter chip multiselect, lookahead toggle. The "horizontal Gantt" mode m's brief asks about does **not** exist.
- **No chart library is in the repo.** `package.json` has only `@types/bun`. No D3, no Chart.js, no Apache ECharts, no plotly, no chartjs-node-canvas. Frontend is hand-rolled DOM/SVG via the custom TSX renderer described in `.claude/CLAUDE.md`. Adding a runtime dep would need m's explicit approval (per global rules).
- **No PDF / image-export pipeline exists either.** `internal/services/caldav_ical.go` generates VCALENDAR strings (BEGIN:VCALENDAR / BEGIN:VEVENT) for CalDAV PUT bodies, but there is no public iCal-feed download endpoint, no headless-browser dep (`chromedp` not in `go.sum`), no Go PDF lib. The only existing `Content-Disposition: attachment` header is in `internal/handlers/files.go` for the Gitea Downloads proxy.
- **Custom Views render shapes are list / cards / calendar.** `internal/services/render_spec.go` declares `RenderShape` = `ShapeList | ShapeCards | ShapeCalendar`. **There is no `ShapeTimeline` registered yet** — t-paliad-169 §8.6 reserved the slot but didn't claim it. A new chart shape would extend this enum and grow `frontend/src/views.tsx` host accordingly.
- **Mobile breakpoints in use today are 640px / 720px / 768px / 1023px** (`frontend/src/styles/global.css`). Lime green primary token is `--color-accent: var(--hlc-lime)` with light/dark variants and a `--color-accent-fg` foreground token. There is `@media print` already in the stylesheet — printing is on the table.
- **Project hierarchy depth in prod = 4 levels, 11 projects total.** A loaded Patent at the upper end has 5 child cases; a hypothetical Client could have 100+ matters. Any chart layout must answer "how does this look on a page with 5 cases × 30 events" and "with 100+ matters" — see §10.
If the live state above contradicts a memory or issue note, the live state wins.
---
## 1. Vision + scope
m's brief (verbatim 2026-05-09 18:32):
> One could chose to show the timeline in one or in separate columns and with different colors even... bigger feature development but ... a project timeline / chart would be nice in general. So we need to make some considerations on how to design one. Another aspect to this is vertical or horizontal... and an export functionality would also be great.
The **Project Timeline / Chart** is the *visualisation layer* above the SmartTimeline data substrate. Where SmartTimeline answers "what is the data", the Chart answers "how does the lawyer want to see it today, on what surface, in what shape, exported to whom".
- **`projection_service.go` is the only data source.** No new query path. The chart is a presentation-level concern; data composition is solved.
- **`shape-timeline.ts` (vertical DOM renderer) stays** as the embed default for the Verlauf tab. We add modes alongside it; we don't tear it out.
- **`paliad.deadlines`, `paliad.appointments`, `paliad.project_events`, `paliad.deadline_rules`** schemas — unchanged. Zero migrations in this design.
- **Color tokens (`--color-accent`, `--color-bg-lime-tint`, …)** — anchor every chart palette, light/dark mode + WCAG follow for free.
### Out of scope (v1 of this feature)
- **Cross-matter chart on `/projects` list page** — bundled under the Custom-Views path (§8.3) once `RenderShape="timeline"` lands. Not v1.
- **Live collaborative cursors / annotation pins** — presentation features for a later phase, not for shipping the chart itself.
- **Rich-text editing of chart entries from inside the chart canvas** — clicks deep-link to existing detail pages. Edit-in-place is the SmartTimeline's anchor affordance and stays there.
- **Server-side PDF rendering via headless browser** — adding `chromedp` introduces a Chromium runtime dependency on the Dokploy compose host. Recommend client-side `window.print()` for v1; revisit only if user feedback says "PDFs differ across employees' browsers". See §7.3 for the trade-off in full.
- **Theming UI for end users to pick palettes** — v1 gives a small fixed palette set; a colour-picker is v2 nice-to-have only if real users ask for it.
---
## 2. Renderer choice — SVG for the Gantt mode, DOM for the flow mode
This is the load-bearing call. Five candidates surveyed:
| Renderer | Pros | Cons | Fit |
|---|---|---|---|
| **DOM/CSS grid** (existing) | Accessible by default; themable via CSS vars; free dark-mode + i18n; exportable via `window.print()` | Hard to do continuous date-axis math (Gantt scaling); heavy reflow on resize; html-to-PNG via foreignObject is browser-quirky | Best for vertical flow ✓ |
| **SVG hand-rolled** | Vector by construction → free SVG / PNG export via canvas drawImage; precise positioning math; one paint call; printable | Manual ARIA scaffolding; no automatic text-wrapping; need a layout pass | Best for horizontal Gantt ✓ |
| **`<canvas>`** | Top performance for 1000+ nodes | Zero accessibility; manual hit-testing for clicks; export needs separate path | Overkill for our scale (≤150 nodes typical) ✗ |
| **D3.js** | Battle-tested abstractions for axes / scales | ~250 KB minified, runtime data-driven DOM mutation conflicts with our IIFE-bundle pattern, would need m's package approval | Overkill, runtime cost ✗ |
| **SVG + foreignObject for text** | Vector with native HTML text wrapping | Spotty PDF and Safari support; defeats the export-for-free pitch | Avoid ✗ |
### 2.1 Recommendation
**Two renderers coexist.** Same data, different DOM:
- **`shape-timeline-chart.ts`** (new SVG) powers the standalone `/projects/{id}/chart` page in horizontal Gantt mode. Hand-rolled, no library, ~500 LoC for v1.
The horizontal Gantt page is also where the export buttons live (§7) — exporting a vertical DOM list is "open browser print and cmd-P" already, no new code needed; the Gantt is the genuinely new surface and brings PDF/SVG/PNG with it.
### 2.2 Why hand-rolled SVG over D3
We have ≤150 nodes per project, two axes (date + lane), three primitives (bar, dot, label) and one expanding need (zoom + pan, eventually). D3 ships ~250 KB to give us scales + axis generators + zoom. Our scale is `(date - earliestDate) / dayWidthPx`, a one-liner; our axis is a year/quarter tick generator, ~30 LoC; pan + zoom is `addEventListener("wheel"|"pointermove")`, ~50 LoC. The lift to write it ourselves is real but small, the runtime cost saving is real, and we keep the single-file IIFE bundle pattern intact.
If we ever hit "the layout math is too painful to maintain", D3-only-the-axis-helper or an `axes.ts` module is a refactor we can do then. v1 ships without.
Embedded on `/projects/{id}` Verlauf tab. Today's `shape-timeline.ts` flow with date column / event card right column, "Heute →" rule, parallel tracks for CCR, lane-strip for parent-node aggregation. Nothing changes in this design — I'm explicit about that so the implementer doesn't accidentally rewrite working code.
### 3.2 Horizontal Gantt-strip (SVG, new)
The `/projects/{id}/chart` page. Time on the X axis, lanes on the Y axis. Each lane is a horizontal row; events plot as either a dot (point-in-time: deadline due-date, milestone, appointment) or a bar (range: future-projected sequence between two anchors, or appointment with end_at). Today's rule = vertical line.
```
←──────── 2026 ────────→ 2027 ─────→
┌────────────────────────────────────────┐
Self │ ✓ ●─────●────────────● ░──░──░──░ │
Hauptverf. │ Klage Antw. HV R29a R29c │
│ ↑Heute │
├────────────────────────────────────────┤
Widerklage │ ⊕──────░───░──░ │
(CCR) │ Filed R29d R32 │
│ │
└────────────────────────────────────────┘
Date axis: Q1 Q2 Q3 Q4 Q1 Q2 Q3
│ │
└ year border └ Today rule (lime)
```
### 3.3 Layout invariants (both modes)
These rules must hold across both renderers — they're the contract that lets us swap modes without surprising the user:
1.**Past = left/below; Future = right/above; Today = lime separator.** Vertical: future at top per existing convention. Horizontal: future on right per Gantt convention. The convention flip is fine because the "today" lime separator orients the user instantly.
2.**One row = one event** in vertical; **one bar/dot = one event** in horizontal. We never group two events into one mark. Lane (column in horizontal, parallel-track-column in vertical) is the only grouping primitive.
3.**`Kind` drives shape / glyph; `Status` drives color saturation; `Track` drives column placement.** This composes orthogonally — see §5.
### 3.4 Hybrid not in v1
A "compact horizontal-strip-on-top + vertical-detail-below" hybrid (think Gmail conversation view but for matters) is a tempting third mode. **Not in v1** — adds a third renderer with no clear user request behind it. Revisit if a partner asks "I want both at once".
### 3.5 Single-column vs multi-column on horizontal
Multi-column = lanes, identical to the substrate's `LaneInfo` already. The horizontal Gantt **always multi-lanes** when there's more than one lane; collapsing all events into one row just to give a "single-column" version produces visual chaos with overlapping bars on the same date. The `[Track ▼]` filter (existing) lets the user collapse to a single track if they want a single-row view. So:
- **Substrate has 1 lane** (Case-level, no CCR): single horizontal row.
- **Substrate has 2+ lanes** (Case + CCR sub-project, OR Patent / Litigation / Client level): horizontal multi-lane Gantt with one row per lane.
This mirrors the lane-mode the vertical renderer already uses (`renderLaneStrip`) — same data shape, different rendering.
---
## 4. Column model — extend `LaneInfo`, no new substrate concept
The substrate already discriminates lanes via `levelPolicy(projectType)` returning `LaneAxis`. The chart inherits that vocabulary for free.
### 4.1 What the chart adds
Two read-only filters at chart mount time, both client-side (no backend changes):
```ts
interfaceChartViewState{
layout:"vertical"|"horizontal";// default "horizontal" on /chart, "vertical" on Verlauf
columns:"auto"|"single"|"lanes";// "auto" reads lanes.length from substrate
range?:{from:string;to: string};// ISO; defaults to substrate's earliest..latest+30d
}
```
`columns="auto"` is the default — the substrate decides. `columns="single"` collapses everything into one row (useful when comparing dates across CCR + parent on horizontal). `columns="lanes"` forces lane mode even when only one lane exists (useful for screenshot consistency).
### 4.2 What the chart does not add to the substrate
**No new lane axis.** If the brief later wants "lanes per party" (claimant vs defendant) or "lanes per court country", that becomes a new `LaneAxis` value in `levelPolicy` — substrate work, not chart work. The chart is a render of whatever lanes the substrate produced.
This boundary is important: the chart can be improved / re-skinned / re-renderered without touching the data layer, and substrate improvements (new lane axes, new event kinds) automatically reach both renderers.
---
## 5. Color schemes
The brief asks for *"different colors even"*. Three palette dimensions are useful — and they're orthogonal, so a user picks one at a time.
### 5.1 Palette presets (built-in, fixed)
| Preset | What's color-coded by | Use case |
|---|---|---|
| **`default`** | Lane (`--color-accent` for parent, neutral grey for CCR/parent_context) | Embed in Verlauf, partner glance |
| **`kind-coded`** | Event kind (deadline = blue, appointment = amber, milestone = lime, projected = soft-grey) | "Show me what's a hearing vs a deadline at a glance" |
| **`track-coded`** | Track tag (parent / counterclaim / parent_context — three distinct hues) | CCR-heavy projects where the track is the most important axis |
| **`high-contrast`** | Status only (done = green ✓; open = amber; overdue = red; predicted = light-grey) | Print-friendly, accessibility-first, screenshot for client |
| **`print`** | Black / white / one-stripe-pattern (no color at all) | Faxable, b&w-printable, redactable |
All five palettes are CSS custom-property *swaps* on the chart root — the renderer reads `var(--chart-bar-deadline)`, the palette CSS file defines what each is. No JS branching in the renderer.
A per-user palette picker is a feature with a long tail (storage in user prefs, defaults vs overrides, migration when palette tokens change names, theme conflicts with light/dark). The fixed-preset surface answers 90 % of "I want different colors" with 10 % of the cost. If real users say "I want my-firm-blue", we add a v2 admin-level palette override (`paliad.firm_palette` row keyed by `FIRM_NAME`).
### 5.4 Light / dark / print
Existing dark-mode flip works automatically — the chart palette tokens *reference*`--color-*` family which is already dark-mode-aware. No extra surface. `@media print` overrides force the `print` palette regardless of the user-selected one — a print-out is always b&w-friendly.
---
## 6. Density + visual variants
### 6.1 Density modes
```ts
typeDensity="compact"|"standard"|"spacious";
```
-`compact`: lane height 24px, bar height 12px, label inline-only (no description). Use for "1000-row birds-eye" lane mode.
-`standard` (default): lane height 40px, bar height 20px, label + status pill.
-`spacious`: lane height 64px, bar height 28px, label + pill + description below.
CSS-driven via `[data-density="…"]` on the chart root. The bar & dot SVG geometry is computed from a single `--lane-height` var; switching density is a re-layout pass, not a re-render.
### 6.2 Status / kind / shape variants
The visual encoding stays consistent with `shape-timeline.ts`:
| Kind | Vertical glyph | Horizontal mark |
|---|---|---|
| `deadline` | `…` / `!` (open / overdue) | Filled circle on due date; ring around it for "open" |
| `appointment` | `▢` | Bar from `start_at` to `end_at` (or fixed-width if same-day) |
Colour saturation drives `Status` independently: done = full color; open = lighter; predicted = 50% opacity; overdue = red overlay.
The CSS for the vertical mode already has these variants — the SVG mode replicates them via `<circle>` / `<rect>` + `fill` / `stroke-dasharray` attributes. Same visual language across modes is a non-negotiable.
---
## 7. Export pipeline
This is the most-requested part of the brief. Five formats; client-side only (no Go PDF dep, no headless browser).
| **PNG** | Raster chart at 2× device pixel ratio | Browser: SVG → `<img>` → `<canvas>.drawImage` → `canvas.toBlob()` | One stdlib API call chain. |
| **PDF** | Print-formatted page | `window.print()` with `@media print` stylesheet; user picks "Save as PDF" | Reuses browser's hardened PDF engine — no Go PDF dep, no Chromium pinned to Dokploy. |
| **CSV** | Tabular data, flat | Server: `GET /api/projects/{id}/timeline.csv` → text/csv | Cleanest for "Excel this" use case. |
| **JSON** | Data-as-stored | Server: `GET /api/projects/{id}/timeline?format=json` (existing endpoint, alt content type) | Zero new code beyond a `Content-Disposition: attachment`. |
| **iCal** | Deadlines + appointments as VEVENT | Server: `GET /api/projects/{id}/timeline.ics` reusing `caldav_ical.go` formatter | Lawyers can subscribe in Outlook / Apple Calendar. |
### 7.2 Why client-side for SVG/PNG/PDF, server-side for CSV/JSON/iCal
- **SVG/PNG/PDF need the rendered pixel layout.** Client has it, server doesn't (without a headless browser). Doing it on the client is a 30 LoC flow per format using stdlib browser APIs.
- **CSV/JSON/iCal are pure data.** Server-side they hit the existing `ProjectionService` and stream straight to the client. CSV is `encoding/csv`; JSON is `json.Marshal`; iCal reuses the existing string-builder. Three new handlers, ~120 LoC total.
### 7.3 Why NOT server-side PDF
The clean alternative is "spin up `chromedp` on the Dokploy compose host, render the chart page, return PDF". Trade-off:
- Pro: one canonical PDF render, works the same regardless of user's browser.
- Con: adds a Chromium runtime dep to the paliad Docker image (~150 MB), spins up a child process per export, opens an attack surface (someone exports a hostile SVG → Chromium handles it → CVE), and needs a queue (PDF render is 1-3s; a clicky user can DoS the box).
Browser print, by contrast, is in-process, free, sandboxed, and produces fine-looking PDFs. It loses pixel-perfect cross-browser parity, but lawyers care about content, not subpixel kerning.
**Recommend client-side print for v1.** Revisit if lawyers complain about cross-browser PDF differences. Adding `chromedp` later is a one-PR move; designing it into v1 risks shipping infra weight we may never need.
### 7.4 Print-mode CSS
The PDF path needs a robust `@media print`:
- Fix the chart to fit on landscape A4 (1100 × 760 px viewport).
- Force `palette="print"`.
- Hide chrome (sidebar, footer, header → `.print-hide` class on existing layout).
- Show project metadata (title, parties, court, proceeding type) as a printed header.
- Page-break logic: each lane group fits on one page; if a lane has too many events, split horizontally by year.
This print stylesheet can be extracted as `frontend/src/styles/chart-print.css` so it's auditable separately from the screen styles.
### 7.5 Export menu UI
Single button on the chart page header opens a menu:
```
[ ⤓ Export ▼ ]
├─ SVG (Vektorgrafik)
├─ PNG (Bild, 2× HiDPI)
├─ PDF (Drucken)
├─ ───
├─ CSV (Excel-Tabelle)
├─ JSON (Rohdaten)
└─ iCal (.ics — Outlook / Apple)
```
Translated via existing i18n (`projects.detail.chart.export.*`). One menu, one keyboard shortcut (`Cmd+E` / `Ctrl+E`) opens it.
Columns mirror the wire `TimelineEvent` struct. UTF-8 with BOM (Excel-DE compat). Date format ISO-8601.
### 7.7 What's exported in JSON
The wire `ResponseEnvelope` directly: `{events: TimelineEvent[], lanes: LaneInfo[], meta: ProjectionMeta, exported_at, exported_by, project_id}`. Stable JSON schema; `meta` lets a future re-importer reconstruct the projection state exactly.
### 7.8 What's exported in iCal
Only `kind IN ("deadline", "appointment")` (projected rows are not stable enough to commit to a calendar). VEVENT block per row reuses `caldav_ical.go` formatter; UID is `paliad-deadline-<id>@paliad.de` so re-export overwrites prior subscription. Future projected rows omitted by design — they would clutter every lawyer's Outlook with rule_code-derived events that may or may not fire on the predicted date.
Vertical DOM mode only (existing `shape-timeline.ts`). Density `standard`. Palette `default`. Lane count obeys substrate. **No changes** in this design — the embed stays exactly as it is. The chart-mode opt-in lives below the tab.
A new "**Als Chart anzeigen ↗**" link in the SmartTimeline header opens `/projects/{id}/chart` in a new tab. Optionally (Q3 below) we could host a chart inline with a `[Layout: ▽ Vertikal | ▷ Horizontal]` toggle.
### 8.2 Standalone `/projects/{id}/chart` (new)
Full-page surface optimized for the horizontal SVG renderer. Layout:
URL convention: `/projects/{id}/chart?layout=horizontal&palette=default&density=standard&zoom=4`. State persists in URL so the link is shareable and copy-pasteable. localStorage caches the last chosen state per user as the default.
### 8.3 Custom Views shape (`shape="timeline"`)
Registers `ShapeTimeline RenderShape = "timeline"` in `internal/services/render_spec.go` and adds a corresponding `frontend/src/client/views/shape-timeline-chart.ts` view-host wrapper that adapts a `ViewRow[]` → `TimelineEvent[]` array. This unlocks **cross-project timelines** as a Custom View — "all my UPC matters" or "everything where I'm in the team" rendered as one chart.
ViewRow → TimelineEvent is a lossy shim: `kind` and `track` map directly; `date` reuses `event_date`; cross-project lanes are auto-derived from `project_id`. Projected rows are not surfaced from `ViewService` (it doesn't run the calculator) — Custom Views show actuals only. We document that limitation, ship the shape, and revisit later if needed.
This is **§8.3's gating**: the standalone page (§8.2) and embed (§8.1) ship before the Custom Views shape. The shape is Slice 4 — last, optional, lower-priority.
We'resittinginthe**comfortable band for both**foranyplausiblePaliadproject.Numbersabove1000happenonlyinpathological"showallmyClient'smatters"scenarios—andthoseareboundbylevelPolicyaggregationalready(Client-levelCustomViews).
### 10.3 Mitigations if a real project exceeds the comfort zone
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.