diff --git a/cmd/server/main.go b/cmd/server/main.go
index 50805b2..c8bb33a 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -161,6 +161,7 @@ func main() {
Derivation: services.NewDerivationService(pool, projectSvc, partnerUnitSvc),
UserView: services.NewUserViewService(pool),
Broadcast: services.NewBroadcastService(pool, mailSvc, users, teamSvc, emailTemplateSvc),
+ Pin: services.NewPinService(pool, projectSvc),
}
// t-paliad-146 — Paliadin PoC. Always wired when DATABASE_URL
diff --git a/docs/design-projects-page-2026-05-07.md b/docs/design-projects-page-2026-05-07.md
new file mode 100644
index 0000000..159b983
--- /dev/null
+++ b/docs/design-projects-page-2026-05-07.md
@@ -0,0 +1,1250 @@
+# Projects page redesign — tree-first, filter chips, search-primary, view selector
+
+**Status:** READY FOR REVIEW (godel, inventor, 2026-05-07) — 4 surfaced questions answered by m, see §12
+**Issue:** m/paliad#10 (t-paliad-149)
+**Branch:** `mai/godel/inventor-projects-page`
+
+## m's locks (2026-05-07 22:08)
+
+m answered the 4 surfaced questions:
+- **Q1 default landing:** Last-viewed restore (sessionStorage; first-ever visit falls back to Tree + Alle + top-level only)
+- **New-Q20 Cards default content:** Rich (~9 facts)
+- **New-Q21 Cards customisation:** Full drag-rearrange + named layouts (needs new `paliad.user_card_layouts` table — migration 061)
+- **Q13 search shape:** Both simultaneously (in-place tree/cards filter on page + global Cmd-K palette stays as the from-anywhere shortcut)
+
+These 4 are LOCKED. The other 17 recommendations remain READY-FOR-REVIEW;
+m may challenge any in the review pass.
+
+## 0. TL;DR
+
+`/projects` gets **three view modes** (Tree | Flat | Cards), tree-first by default
+(rooted at clients, descendants navigable), behind a chip row (Alle / Nur meine /
+Angepinnt / Status / Typ / Mit aktiven Fristen) with **AND-across-chips,
+OR-within-multi-select** combinatorics, plus a single prominent search input that
+filters the active view (in-place for tree; substring filter for cards / flat).
+Pinning is a per-user dot table (`paliad.user_pinned_projects`, migration 060) with
+a star marker on every row/card + an "Angepinnt" filter chip.
+
+**Cards view** (m's addition 2026-05-07 22:00) is a quick-overview surface:
+each card shows configurable facts about one project — title + type + status,
+deadline counts, **next 3 events**, **last 3 events / Verlauf entries** — all
+hoverable for detail and clickable to drill in. Card content + layout are
+user-customisable: v1 ships **two presets (Kompakt / Geräumig) + a small
+"shown facts" checklist**; full per-user drag-rearrange deferred to v2.
+Per-user prefs live in `localStorage` in v1 (no new table).
+
+The existing `/api/projects/tree` endpoint and `client/project-tree.ts` are
+reused verbatim for the Tree rendering primitive — they already do the right
+shape (visibility-scoped, deadline-count-aggregated, expand/collapse,
+click-to-navigate). This redesign adds **a chip-driven filter overlay**, **a
+search filter**, **pinning**, **a mobile drill-in mode**, and **the Cards
+view** on top.
+
+**Q15 decision (delegated to inventor by m):** option (a) — bespoke `/projects`
+selector with hardcoded system chips. Rationale in §6. Custom Views (t-144)
+stays event-shaped; projects are scope, not events; conflating the two would
+require a new project DataSource + tree RenderShape that nothing else needs.
+
+**4 surfaced questions for m** (via AskUserQuestion this session):
+- Q1 default landing + view mode (Tree | Cards as default?)
+- New-Q20 Cards default content (which facts on a card by default)
+- New-Q21 Cards customisation scope (presets only | drag-customise | none)
+- Q13 search shape (in-place vs Cmd-K modal)
+
+Other 17 issue-body and inventor follow-up questions are answered with
+recommendations + rationale below; m can challenge any of them in the review.
+
+## 1. Problem + premises verified live
+
+m's framing 2026-05-07 21:52:
+
+> "The Projects Page needs to become nicer. … default view to be a view of all clients
+> — and one can filter very nicely. 'All' / 'Only mine' / Pinned / by status. … The tree
+> view is much nicer and more natural than the flat view. The search should be the main
+> thing for 'ad-hoc' view, for favourites / pinned or other views we need to have a selector."
+
+### 1.1 What exists today
+
+- `frontend/src/projects.tsx` (140 LoC) — page shell with three filters
+ (Typ / Status / Ansicht: flach|baum|wurzeln) and a free-text search input.
+ Default view = flat. Tree is one of three options.
+- `frontend/src/client/projects.ts` (238 LoC) — list-page client. Reads
+ `/api/projects` flat list, filters in JS, renders `
`.
+ Tree mode delegates to `client/project-tree.ts`.
+- `frontend/src/client/project-tree.ts` (289 LoC) — tree renderer. Reads
+ `/api/projects/tree` once + caches. Top-2-levels open by default; deeper
+ collapsed. `sessionStorage` persists overrides per-node. Click row → navigate.
+ Type icons (client/litigation/patent/case/project) defined inline. Deadline
+ counts (open + overdue) shown as per-node badges (NOT subtree-aggregated).
+- `internal/handlers/projects.go:245` — `GET /api/projects/tree` returns the
+ full nested tree (`ProjectService.BuildTree`).
+- `internal/services/project_service.go:319` — `BuildTree` does
+ visibility-scoped path-ordered SELECT + per-project deadline-count JOIN +
+ in-memory stitching. Subtree aggregation **not** done in v1; counts are
+ per-node only.
+- `frontend/src/styles/global.css:5563` — `.projekt-tree-*` CSS rules. Chip
+ patterns already exist as `.fristen-forum-chip` and `.fristen-search-chip`
+ (reusable shape; rename for projects-page namespace).
+
+### 1.2 What does NOT exist today
+
+- **No pin / favourite table.** Sidebar has a `paliad-sidebar-pinned`
+ localStorage key but that's the sidebar pin (sticky-open), unrelated to
+ project pinning.
+- **No "mine" semantics endpoint.** Project membership comes from
+ `paliad.project_teams` (direct) ± inherited via t-139's `can_see_project`.
+ No service method returns "projects I'm directly on the team of."
+- **No subtree-aggregated counts.** Existing tree shows per-node deadline
+ counts; aggregating up the tree is a TODO in `BuildTree` per the t-139 lock.
+- **No Custom Views project source.** `internal/services/filter_spec.go`
+ declares `AllSources = {deadline, appointment, project_event,
+ approval_request}`. No `project` source. RenderShape is `{list, cards,
+ calendar}` — no `tree` shape.
+
+### 1.3 Premises that survive into the design
+
+1. The tree primitive is already correct shape. Reuse it.
+2. The flat-list view stays available (issue says so, and a flat list is
+ still the right call for narrow searches: "give me every Verfahren in
+ status=open across all clients"). Just stops being the default.
+3. Visibility (`paliad.can_see_project`) already extends through ancestors
+ + descendants + partner-unit derivation (t-139 shipped). Nothing to add
+ on the visibility front.
+4. Migration tracker is at 059 on main (058 = paliadin_poc t-146, 059 = profession_vs_responsibility t-148, both shipped 2026-05-07). Next available is 060 (PR 1) and 061 (PR 2).
+5. The Custom Views substrate (t-144) is **NOT** the right place for projects
+ — see §6.
+
+## 2. Locked direction (m, 2026-05-07)
+
+- Default view: tree, rooted at clients, with all descendants navigable.
+- Chip row at top: Alle / Nur meine / Angepinnt / nach Status / further axes
+ inventor proposes.
+- Search is the primary ad-hoc tool — prominent, not buried.
+- A "view selector" for favourites / pinned / saved views (Q15: inventor
+ decides whether this is bespoke or piggy-backs on Custom Views).
+- Tree-first; flat-list view stays available but isn't the default.
+
+## 3. Sub-design 1 — Filter axes + chip set + combinatorics
+
+### 3.1 v1 chip set (Q2)
+
+Recommended chips (left to right in the toolbar):
+
+| Chip | Value(s) | Default |
+|---|---|---|
+| **Alle** | (resets all chips) | active on first visit |
+| **Nur meine** | toggle: on / off | off |
+| **Angepinnt** | toggle: on / off | off |
+| **Status** | multi-select panel: Aktiv / Archiviert / Abgeschlossen | nothing selected = no narrowing |
+| **Typ** | multi-select panel: Mandant / Streitsache / Patent / Verfahren / Projekt | nothing selected = no narrowing |
+| **Mit aktiven Fristen** | toggle: on / off | off |
+
+The first 3 are toggles (single click). The next 2 are multi-select dropdowns
+(reuse the `EventTypeMultiSelect` component shape from t-088 — popover with
+checkbox list + "Alle" / "Ohne" specials). The last is a simple toggle.
+
+Deferred to v2 (worth flagging but not in v1):
+- "Bevorstehende Termine" (next 7d) — overlaps "Mit aktiven Fristen" enough
+ for v1.
+- "Mit offenem Approval-Request" — coupling to t-138 not worth a v1 chip.
+- "Recent activity" sort — covered as Q16 below; defer.
+- "Wo ich Lead bin" — finer-grained than "Nur meine"; rename if m wants it.
+
+### 3.2 Combinatorics (Q3)
+
+**AND across chips.** Each chip narrows. Rationale: every chip is intent-
+narrowing language ("only my … pinned … active deadlines …"). OR across chips
+would force the user to think in set-union language, which patent paralegals
+don't.
+
+**OR within multi-select.** A user picks "Status: Aktiv + Abgeschlossen" to
+see both. AND across the multi-select would only work for orthogonal
+dimensions; it doesn't make sense within one axis.
+
+**Empty multi-select = no narrowing.** Closing the Status panel without
+ticking any box means "I don't care about status" — the chip shows base label
+("Status"); ticking ≥1 means "narrow to these" — the chip shows
+"Status: 2 ausgewählt" (or single-label if exactly one).
+
+### 3.3 "Nur meine" semantics (the load-bearing one)
+
+**Recommendation: direct membership only** in v1, NOT inherited.
+
+- Direct = there is a row in `paliad.project_teams` where
+ `(project_id, user_id) = (this, me)`.
+- Inherited / derived would include ancestor-staffed + partner-unit-derived
+ (per t-139). That's the same set as `can_see_project` (visibility), and at
+ that point "Nur meine" = "visible to me", which is just the default tree.
+
+So "Nur meine" must be **strictly narrower than visibility** to be useful.
+Direct-only is the obvious meaning. A user staffed on a `Patent` row sees the
+parent `Litigation` and `Client` in their tree (visibility cascades up) but
+"Nur meine" filters to the `Patent` row only — the ancestors collapse out.
+
+Edge case: if a user has direct membership only on a deep `Case` row, "Nur
+meine" shows that Case as a top-level orphan (no ancestor visible). The
+alternative is to keep the ancestor chain visible-but-greyed (so they
+understand which Mandant the Case sits under). Recommendation: **keep
+ancestors visible-but-greyed** — they're already in the visibility scope,
+and the chain provides indispensable context. Greyed = `opacity: 0.55` on
+the row, no badges, no pin star, click still navigates.
+
+This needs a new endpoint or a `?scope=mine` parameter on `/api/projects/tree`
+that returns:
+- All directly-staffed projects.
+- Their ancestors (greyed; client returns flag `inherited_visibility: true`).
+- No descendants of directly-staffed projects unless they're also directly-
+ staffed.
+
+### 3.4 Empty-state (Q14)
+
+If chips narrow the tree to zero rows:
+> "Keine Projekte für diese Filter. **[Filter zurücksetzen]** **[+ Neues Projekt]**"
+
+Both buttons inline. The first resets all chips (= "Alle" active); the second
+goes to `/projects/new`.
+
+## 4. Sub-design 2 — Tree view + UX + pinning
+
+### 4.1 Default landing (Q1) — LOCKED last-viewed restore (m 2026-05-07)
+
+Storage key: `paliad.projects.lastView` in sessionStorage. Shape:
+
+```ts
+{
+ viewMode: "tree" | "cards" | "flat", // last selected view-mode segment
+ chips: { // last applied chip state
+ scope: "all" | "mine" | "pinned",
+ status: string[],
+ type: string[],
+ hasOpenDeadlines: boolean,
+ },
+ searchQuery: "", // empty = no active search
+ // tree-mode only:
+ treeExpanded: string[], // explicit expand overrides per node
+ // cards-mode only:
+ cardLayoutId: "uuid", // active named layout (server is source of truth)
+ // flat-mode only:
+ flatColumns: string[], // future-proof; v1 unused
+}
+```
+
+URL params (`?view=…`, `?chips=…`, `?q=…`, `?focus=…`, `?expand=…`) override
+the sessionStorage state on load. So:
+- Bookmarks / share-links → URL takes precedence.
+- Plain navigation back to `/projects` → sessionStorage restores the last view.
+
+**First-ever visit fallback** (no sessionStorage entry, no URL params): Tree
+view, "Alle" chip, top-level only expanded (Mandanten visible, descendants
+collapsed). This is option (a) from the surfaced question — the predictable
+fallback for first-time users.
+
+When sessionStorage is cleared (private mode, browser cleanup), behaviour
+falls back to first-ever-visit defaults.
+
+Edge case: if the user's last view was Cards but the active layout has been
+deleted from another device, fall back to the user's current default layout.
+
+### 4.2 Tree state persistence (Q4)
+
+Recommendation: **session-only via `sessionStorage` (current behaviour) +
+`?expand=` URL param for share-links + `?focus=` for deep-link**.
+
+Rationale: a per-user persistent expand state is one more table-row per
+project (`paliad.user_project_expanded`) for very low return — the user has
+to learn that expand state survives across sessions, which most users won't
+even notice. SessionStorage already fits this need (open Mandant, navigate
+into Patent, come back, Mandant is still open). Persistent state can land
+in v2 if anyone asks.
+
+The URL param overlay lets share-links and palette-deep-links land at the
+right node:
+- `/projects?focus=` — opens with ancestors expanded + node scrolled
+ into view + briefly highlighted (lime accent flash, 600ms).
+- `/projects?expand=uuid1,uuid2,uuid3` — explicit subset of expanded nodes.
+ Useful for "show this exact tree shape to someone."
+
+### 4.3 Deep-link to a node (Q5)
+
+`/projects?focus=` — see above. Also exposed as a programmatic helper
+for sidebar links + breadcrumb navigation: anywhere we link to "see this
+project's parent in the tree" we use `?focus=`.
+
+### 4.4 Lazy-loading (Q6)
+
+Recommendation: **no lazy-loading in v1.** The current `/api/projects/tree`
+returns the full visible tree in one round-trip (`BuildTree`). Live data:
+even global_admins see ≤ 200 projects today; the response is < 100 KB.
+The tree renders instantly on m's local stack.
+
+If/when paliad scales past ~1000 projects, the cleanest path is:
+- Server still returns the tree skeleton (id + parent_id + title + path) in
+ one shot.
+- Per-row counts (open_deadlines, overdue_deadlines, subtree-aggregated)
+ become separate `/api/projects/tree/counts?ids=…` requests, lazy-fetched
+ per visible viewport batch.
+
+Not v1.
+
+### 4.5 Inline counts per node (Q7)
+
+Recommendation: **subtree-aggregated counts by default**, with a "nur direkt"
+toggle (parallel to t-139's pattern on /projects/{id}).
+
+A Mandant row's badge "12 offene Fristen" reflects the union of its own +
+all descendants', not just its own (Mandanten typically have 0 direct
+deadlines — they're scaffolding rows). Without aggregation, every Mandant
+row reads "0 / 0" which is misleading.
+
+Aggregation pattern from t-139 already exists in `BuildTree`:
+- `open_deadlines` and `overdue_deadlines` are per-node today.
+- New columns `open_deadlines_subtree` and `overdue_deadlines_subtree`
+ aggregated in the same query (sum over `path <@ p.path`).
+- Display: by default, badge shows subtree count. Hovering the badge tooltip:
+ "12 (eigene: 0 / aus 4 Unterprojekten: 12)".
+- Toggle in toolbar: "[ ] Nur direkte Fristen zählen" — switches both badges
+ to per-node only.
+
+Same treatment for `Termine` if we add a Termine badge (recommendation: not
+in v1; deadline badges are enough; Termine count would clutter rows).
+
+### 4.6 Action affordances per row (Q8)
+
+Recommendation: **always-visible pin star on every row + click-row-to-open
+detail + no right-click context menu in v1**.
+
+- **Pin star (⋆ filled / ☆ empty):** small button, far-right of the row, before
+ the status chip. Click toggles pin. Always visible (not hover-revealed) —
+ m is on a touch surface ~30% of the time per the PWA strategy, hover is
+ unreliable.
+- **Row click:** navigates to `/projects/{id}` (already implemented).
+- **Toggle chevron click:** expands / collapses (already implemented).
+- **No right-click menu in v1.** Bulk operations (close, archive, move) are
+ out of scope per the issue. If a v2 wants per-row actions, an inline `⋯`
+ button on the right wins over right-click (works on touch, more
+ discoverable).
+
+Mobile: at ≤ 768px, switch to drill-in mode (Q17). Pin star is still on the
+row but the row is full-width and tappable.
+
+### 4.7 Pinning storage (Q9, Q11)
+
+New table `paliad.user_pinned_projects`. Migration 060 (next available).
+
+```sql
+CREATE TABLE paliad.user_pinned_projects (
+ user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
+ project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
+ pinned_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (user_id, project_id)
+);
+
+CREATE INDEX user_pinned_projects_user_idx
+ ON paliad.user_pinned_projects (user_id, pinned_at DESC);
+
+ALTER TABLE paliad.user_pinned_projects ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY user_pinned_projects_owner_all
+ ON paliad.user_pinned_projects FOR ALL
+ USING (auth.uid() = user_id)
+ WITH CHECK (auth.uid() = user_id);
+```
+
+- Per-user (Q11 confirmed). Pinning is personal working state, not project-
+ team metadata. RLS is symmetric: only the owner reads or writes.
+- ON DELETE CASCADE on both sides. Project deletion removes pin rows;
+ user deletion removes their pins.
+- No `display_order` column — pins are sorted by `pinned_at DESC` (most
+ recent first). User-defined ordering can land in v2 if needed.
+
+API:
+- `POST /api/projects/{id}/pin` → 201, idempotent. Body: empty.
+- `DELETE /api/projects/{id}/pin` → 204, idempotent.
+- Pin state surfaces inline in `/api/projects/tree` (new field `pinned: bool`).
+- `GET /api/user-pinned-projects` returns the flat list of pinned IDs (used
+ by the sidebar widget if we want one — defer to v2).
+
+### 4.8 Pin display (Q10) — surfaced to m
+
+Two reasonable shapes:
+
+- **(a) Star marker only.** Pinned projects get a filled-star marker in the
+ row; "Angepinnt" filter chip filters the tree to pinned-only (other rows
+ hidden). Tree shape preserved.
+- **(b) Pinned subtree pulled to top.** Above all client subtrees, a pseudo-
+ group `⋆ Angepinnt (4)` containing flat references to pinned projects (with
+ a small parent-chain breadcrumb so you know which Mandant). Real client
+ subtrees follow.
+- **(c) Both: star marker on real rows AND a "⋆ Angepinnt" pseudo-group at
+ top, always visible.**
+
+Recommendation **(a)** — keeps the tree simple, the chip handles the "pinned
+only" view. (b)/(c) double-render some projects which is confusing.
+
+But m should pick. Surfacing.
+
+### 4.9 Mobile (Q17)
+
+Recommendation: **drill-in mode at ≤ 768px**.
+
+- Tap a row → push the row's children as a new "page" (animated slide-in).
+- Header replaces with a back-arrow + breadcrumb chip ("Siemens AG ›").
+- Tap back → pop one level.
+- Search and chips stay at the top of the current level.
+
+Indented-list mode (the desktop tree shoehorned onto mobile) is awkward —
+deeply nested children get illegible at <600px. Drill-in is what every
+mobile file manager / mail-folder app does.
+
+Implementation: client-side only. Server still returns the full tree.
+At ≤ 768px, the renderer keeps a `currentNode` pointer; rendering slices
+the tree starting at that pointer; back-arrow walks up via `parent_id`.
+
+## 5. Sub-design 3 — Search shape + integration
+
+### 5.1 Search shape (Q13) — LOCKED both simultaneously (m 2026-05-07)
+
+The /projects page input does **in-place filter on the active view**:
+
+- **Tree mode:** non-matching rows hide; ancestors of matches stay visible
+ (greyed `opacity: 0.55`) so context is preserved; matching substring
+ highlights in lime on the title.
+- **Cards mode:** non-matching cards hide; substring highlights in lime on
+ the title and parent path.
+- **Flat mode:** standard table-row substring filter (existing behaviour).
+
+The global Cmd-K palette stays unchanged — `Cmd-K` / `Ctrl-K` still opens
+the universal search-overlay from anywhere in the app, including from
+`/projects`. Users get both: the page input narrows the current view; Cmd-K
+jumps to anywhere across courts / glossary / projects / tools.
+
+Implementation note: the page input does NOT also open Cmd-K when focused.
+Tabbing into the input doesn't trigger the overlay. The two surfaces stay
+visually and behaviourally distinct (page input is inline; Cmd-K is overlay).
+
+### 5.2 Search scope (Q12)
+
+Recommendation: **title + parent path + reference + clientmatter**.
+
+- Title is the obvious one.
+- Parent path: type "Müller" → match the Case "14-vs-Müller" (already in
+ title, ✓), AND match every project under a Mandant titled "Müller AG"
+ (descendant-by-ancestor-title). This is what users mean by "give me Müller
+ stuff."
+- Reference: matches the per-project `reference` field.
+- ClientMatter: `client_number.matter_number`.
+
+Backend implementation: server-side, postgres `pg_trgm` similarity query +
+ancestor-title boost. Endpoint:
+
+`GET /api/projects/tree?q=` — returns the tree filtered to nodes that
+match OR have a matching ancestor OR have a matching descendant.
+
+Client-side echo: the input also does a local substring filter on the cached
+tree for instant feedback (debounced 200ms before hitting the server). The
+server response trumps the local echo when it arrives.
+
+### 5.3 Empty-state (Q14)
+
+Same as §3.4. "Keine Projekte für diese Filter. [Filter zurücksetzen]
+[+ Neues Projekt]." Plus, if the user has typed a search query, an extra
+"Suche im Glossar / Gerichten / Glossar?" link to the Cmd-K palette.
+
+### 5.4 Performance (Q19)
+
+Debounce 200ms (standard). Cap server results at 200 nodes for any query;
+beyond that, show "200+ Treffer — bitte Suche präzisieren" footer.
+
+## 5b. Sub-design 5 — Project Cards view (m's addition 2026-05-07 22:00)
+
+m's framing:
+
+> "I would also like to have 'Project Cards' View where we can customize how
+> we show the project card. Like what facts do we show and how the layout is.
+> I could imagine a card where we also see the next three events and the
+> last three, hoverable and clickable. So people get a really quick overview."
+
+Cards is a **third view mode** alongside Tree and Flat. It is reached via a
+view-mode segment-control at the top of the page (Tree | Cards | Flat). View
+mode persists in `localStorage` (`paliad.projects.viewMode`).
+
+### 5b.1 What a card represents
+
+One project per card. Cards is a **flat grid** ordered by:
+- Pinned first (within their group) when "Angepinnt" chip is off.
+- Then by `last_activity_at DESC` (most-recently-touched first; "last_activity_at"
+ = `MAX(updated_at across own + descendants' deadlines / appointments /
+ project_events)` — already computable from existing tables, optionally
+ cached as `paliad.projects.last_activity_at` updated by triggers if perf
+ warrants).
+
+Chips narrow the set the same way they narrow Tree / Flat. Search filters cards
+by substring in the same fields (title + parent path + reference + clientmatter).
+
+By default, Cards shows **leaf-ish projects only** (Cases, Patents, Verfahren,
+Projekte) — Mandanten and Litigation rows are scaffolding and don't have direct
+events / deadlines themselves under the t-139 hierarchy model. A toggle
+`[ ] Alle Ebenen anzeigen` shows Mandanten / Litigation cards too (with their
+subtree-aggregated counts and subtree event preview). Toggle persists in
+localStorage.
+
+### 5b.2 Card anatomy — default content
+
+```
+┌─────────────────────────────────────────────────┐
+│ ⋆ EP 1234567 — Verteidigung ⋯ │ ← Title row (pin star, ⋯ menu, type icon)
+│ [Patent] · [Aktiv] · 12345.678 │ ← Type + Status + ClientMatter chips
+│ │
+│ Müller AG › Müller vs. Apple │ ← Parent path (clickable breadcrumb)
+│ │
+│ ▸ 3 offene Fristen ▸ 1 überfällig │ ← Deadline counts (subtree-aggregated)
+│ │
+│ Nächste Termine │
+│ ░ 15 Mai · Klageerwiderung (Frist) │ ← Hoverable + clickable event row
+│ ░ 22 Mai · Mündliche Verhandlung (Termin) │
+│ ░ 03 Jun · Erwiderung (Frist) │
+│ │
+│ Zuletzt │
+│ ▣ heute · Frist erstellt von Anna Schmidt │ ← Verlauf entries from project_events
+│ ▣ gestern · Termin verschoben │
+│ ▣ 03 Mai · Genehmigung erteilt │
+│ │
+│ Team: AS · MK · +2 │ ← Avatar / initials chips of staffed team
+└─────────────────────────────────────────────────┘
+```
+
+Hover on any "Nächste Termine" or "Zuletzt" row → tooltip with the full title,
+context, and deep-link metadata. Click → navigate to the deadline / appointment /
+project detail (with `?focus=` on the relevant entity).
+
+Default visible facts (all on by default):
+1. Title + type icon + pin star + ⋯ menu
+2. Type chip
+3. Status chip
+4. ClientMatter
+5. Parent path breadcrumb
+6. Deadline counts (subtree)
+7. Next 3 events (Fristen + Termine, sorted asc, top 3)
+8. Last 3 Verlauf entries (project_events sorted desc, top 3)
+9. Team chips (initials, capped at 3 + "+N")
+
+### 5b.3 Customisation scope — LOCKED option (c) full drag-rearrange + named layouts (m 2026-05-07)
+
+m chose **full per-user customisation with named layouts**. Implementation:
+
+**New table — migration 061:**
+
+```sql
+CREATE TABLE paliad.user_card_layouts (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
+ name text NOT NULL, -- "Standard", "Kompakt", "Was steht an", …
+ is_default boolean NOT NULL DEFAULT false,
+ layout_json jsonb NOT NULL, -- ordered facts list + density + grid + counts
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (user_id, name)
+);
+
+-- At most one default layout per user (partial unique index)
+CREATE UNIQUE INDEX user_card_layouts_default
+ ON paliad.user_card_layouts (user_id)
+ WHERE is_default = true;
+
+ALTER TABLE paliad.user_card_layouts ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY user_card_layouts_owner_all
+ ON paliad.user_card_layouts FOR ALL
+ USING (auth.uid() = user_id)
+ WITH CHECK (auth.uid() = user_id);
+```
+
+**Layout JSON schema (server-side validated):**
+
+```ts
+type CardLayout = {
+ facts: Array<{
+ key: "title-row" | "type-chip" | "status-chip" | "client-matter"
+ | "parent-path" | "deadline-counts" | "next-events"
+ | "recent-verlauf" | "team-chips" | "reference" | "last-activity-at",
+ visible: boolean,
+ // Per-fact extras (only meaningful for some keys):
+ count?: number, // for next-events / recent-verlauf, 1..5
+ }>,
+ density: "compact" | "roomy",
+ gridColumns: "auto" | 2 | 3 | 4,
+ showAllLevels: boolean, // include Mandanten / Litigation rows as cards
+};
+```
+
+The `facts` array's order is the render order top-to-bottom within a card.
+Validator on the server rejects:
+- Unknown fact keys
+- Duplicate keys (each appears at most once)
+- `count` outside [1, 5]
+- More than one fact occupying the "title-row" slot (it must be the first
+ visible row)
+
+**Service layer — `CardLayoutService` (~120 LoC):**
+- `List(ctx, userID) ([]*CardLayout, error)` — sorted by `name ASC`, default first
+- `Get(ctx, userID, id) (*CardLayout, error)`
+- `GetDefault(ctx, userID) (*CardLayout, error)` — auto-creates the seed
+ "Standard" layout (the rich content set) on first call
+- `Create(ctx, userID, name, layout) (*CardLayout, error)` — `is_default = false`
+ unless this is the user's first
+- `Update(ctx, userID, id, name?, layout?, is_default?) error` — flipping
+ `is_default = true` clears the flag on the user's previous default in tx
+- `Delete(ctx, userID, id) error` — cannot delete the default; UI gates this
+
+**API surface (PR 2):**
+- `GET /api/user-card-layouts` → 200 with `[…]`
+- `POST /api/user-card-layouts` → 201 with the new row (body: name + layout)
+- `PATCH /api/user-card-layouts/{id}` → 200 (body: any of name / layout / is_default)
+- `DELETE /api/user-card-layouts/{id}` → 204
+- `POST /api/user-card-layouts/{id}/set-default` → 204 (sugar over PATCH)
+
+**Frontend customisation UI:**
+
+- Top of Cards view: layout dropdown:
+ ```
+ Layout: [Standard ▾] [+ Neues Layout] [⚙ Bearbeiten]
+ ```
+- The dropdown lists all the user's named layouts. Picking one switches
+ the active layout immediately (POST `/api/user-card-layouts/{id}/set-default`
+ updates the server-side default; `localStorage` mirrors for instant pickup
+ on next load).
+- "+ Neues Layout" prompts for a name (modal), seeds with the current
+ active layout's `layout_json`, opens edit mode on the new layout.
+- "⚙ Bearbeiten" puts cards into **edit mode**:
+ - Each card grows a faint outline.
+ - Each fact within a card grows a drag handle (≡) + visibility toggle (eye/eye-off).
+ - Drop zones appear between facts — drag a fact up or down to reorder.
+ - For `next-events` / `recent-verlauf`, a count stepper [-] [3] [+] appears.
+ - Top-of-page bar: density radio (Kompakt / Geräumig), gridColumns
+ select (Auto / 2 / 3 / 4), "Alle Ebenen anzeigen" toggle.
+ - Bottom-of-page: "Speichern" / "Verwerfen" / "Als Standard festlegen"
+ buttons.
+- HTML5 drag-and-drop is enough (Chromium / Safari / Firefox all support
+ draggable `
` elements with `dataTransfer`). No third-party DnD library.
+- Mobile: edit mode shows a long-press-and-drag affordance per fact
+ (`pointerdown` + 500ms hold).
+
+**v2 escape hatches:** firm-wide layout templates ("Was unsere Senior-PAs
+sehen"); per-project-type defaults (different layout for Mandant cards vs
+Case cards); cross-user sharing; export/import as JSON.
+
+### 5b.4 Default content — LOCKED option (α) rich (m 2026-05-07)
+
+The seed "Standard" layout (auto-created on first cards-view visit) carries
+all 9 facts shown in §5b.2:
+
+1. Title row (title + type icon + pin star + ⋯ menu) — `key: "title-row"`, always-visible & always-first
+2. Type chip — `key: "type-chip"`
+3. Status chip — `key: "status-chip"`
+4. ClientMatter — `key: "client-matter"`
+5. Parent path breadcrumb — `key: "parent-path"`
+6. Deadline counts (subtree) — `key: "deadline-counts"`
+7. Next 3 events — `key: "next-events"`, `count: 3`
+8. Last 3 Verlauf entries — `key: "recent-verlauf"`, `count: 3`
+9. Team chips — `key: "team-chips"`
+
+Density: `roomy`. Grid: `auto`. `showAllLevels: false`.
+
+The user can hide any fact (except `title-row`), reorder them, change counts,
+and create alternative named layouts. The seed itself can be edited — there's
+no "factory reset" beyond deleting the seed and creating a fresh one with the
+same layout via the editor.
+
+### 5b.5 Event preview implementation
+
+Cards mode needs per-project event rollups. Two approaches:
+
+1. **Embed in `/api/projects/tree` response.** Each node carries
+ `next_events: [...3]` and `recent_verlauf: [...3]`. Pros: one round-trip;
+ tree response is already the canonical primitive. Cons: ~6 extra rows ×
+ N projects = N×6 rows even when the user hasn't switched to Cards yet.
+2. **Separate `/api/projects/cards-preview` endpoint, called only on Cards
+ mode mount.** Lazy. Cons: second round-trip. Pros: doesn't bloat the tree
+ payload.
+
+Recommendation: **(2) separate endpoint, lazy-fetched on Cards mode**. Tree
+mode payload stays lean. The endpoint does a single SQL query (deadlines +
+appointments + project_events filtered to visible projects, partitioned by
+project_id, top 3 each direction). Cached server-side per user (5min TTL)
+because the data doesn't change rapidly.
+
+Endpoint shape:
+
+```
+GET /api/projects/cards-preview
+→ 200 [
+ {
+ project_id: "uuid",
+ next_events: [ { kind, id, title, event_date, …project_id…, route } …3 ],
+ recent_verlauf: [ { kind, id, title, event_date, actor_id, actor_name, route } …3 ],
+ team_initials: ["AS", "MK", …],
+ team_count: 5,
+ last_activity_at: "2026-05-07T14:30:00Z",
+ }, …
+ ]
+```
+
+`route` is the navigation target (`/projects/{pid}?focus=` for
+deadlines/appointments; `/projects/{pid}?tab=verlauf&focus=` for
+project_events). Computed server-side so the client doesn't hardcode URLs.
+
+### 5b.6 Hover affordance
+
+Each event row in a card is `