# Admin — Schlagwörter: Complete Page Overhaul

Full redesign of the tag admin two-panel page to support infinite hierarchy depth. The admin area already has a three-column structure: EntityNav (navy sidebar) → Tags list panel → Edit panel. This spec redesigns panels 2 and 3 to handle a real tree, a tree-aware parent picker, ancestry breadcrumb, children preview, tag merge flow, and a destructive delete guard.

Route: /admin/tags Route: /admin/tags/[id] New endpoint: POST /api/tags/{id}/merge Breakpoints: 320 / 768 / 1024 / 1440 Changes: TagsListPanel.svelte · [id]/+page.svelte

Leonie Voss · UI/UX Design Lead · Familienarchiv · 2026-04-16

## 1 — Desktop: Full Three-Column Layout (lg, ≥1024px)

The admin shell already has three columns: EntityNav (navy, 120px at lg+) → entity list panel → edit panel. For tags, the list panel becomes a collapsible tree browser. The edit panel gains breadcrumb, children preview, merge zone, and a guarded delete zone. Nothing about the EntityNav changes.

Desktop lg (1280px) — child tag "Deutschland" selected (depth 2)
FAMILIENARCHIV Dokumente Personen Admin
Schlagwörter ‹
› Orte (38)
› Europa (22)
› Deutschland (11)
› Frankreich (5)
› Österreich (6)
› Asien (8)
› Personen (54)
› Ereignisse (19)
›Hochzeiten(7)
›Geburten(5)
›Reisen(7)
›Urkunden(12)
‹ Schlagwort bearbeiten — Deutschland
Name
⚠ Namensänderungen wirken sich auf alle verknüpften Dokumente aus.
Deutschland
Übergeordnetes Schlagwort
Europa
Orte › Europa
×
Farbe
Farbe von Orte (amber) — wird vererbt
Untergeordnete Schlagwörter
Berlin(3) München(4) Hamburg(2) Bayern(2) … und 2 weitere →
Zusammenführen
Alle Dokumente und untergeordnete Schlagwörter auf ein anderes Schlagwort übertragen und dieses danach löschen.
⇒ Mit anderem Schlagwort zusammenführen …
Löschen
11 Dokumente verknüpft · 6 Untergeordnete Schlagwörter
Nur dieses Schlagwort löschen
Untergeordnete werden zu Europa verschoben
Gesamten Teilbaum löschen
Löscht auch 6 untergeordnete Schlagwörter
Gib `Deutschland` zur Bestätigung ein:
Deutschland
Löschen
Abbrechen Speichern
Three columns left-to-right: EntityNav (unchanged, 120px navy sidebar) → Tree panel (240px, replaces flat list) → Edit panel (flex-1, ~860px at 1280px). The EntityNav and its flyout behaviour on tablet are not touched by this spec.

## 2 — Tablet: md breakpoint (768px)

At tablet width, EntityNav shrinks to 48px icon-only strip (existing behaviour, unchanged). The tree panel and edit panel share the remaining 720px. The tree panel collapses to 32px when not needed, giving the edit panel more room.

768px — tree + edit side by side
FAMILIENARCHIV
Schlagwörter ‹
›Orte(38)
›Europa(22)
›Deutschland(11)
›Frankreich(5)
›Asien(8)
›Personen(54)
Schlagwort bearbeiten — Deutschland
Name
Deutschland
Übergeordnetes Schlagwort
Europa
Orte › Europa
×
AbbrechenSpeichern
Tablet: EntityNav shrinks to icon-only strip (26px spec / 48px real). The tree panel and edit panel still sit side by side. Tree panel can be collapsed to give the edit panel more space.
768px — tree collapsed (more edit space)
FAMILIENARCHIV
› Schlagwörter
Schlagwort bearbeiten — Deutschland
Name
⚠ Namensänderungen wirken sich auf alle verknüpften Dokumente aus.
Deutschland
Übergeordnetes Schlagwort
Europa
Orte › Europa
×
Farbe
Farbe von Orte (amber) — wird vererbt
Untergeordnete (6)
Berlin(3)München(4)… und 4 weitere →
AbbrechenSpeichern
Collapsed tree: 32px real wide. Click the arrow to re-expand. Edit panel gets the freed space (~690px at 768px with EntityNav 48px + collapsed tree 32px).

## 3 — Tree Panel: Node States & Depth

Detailed view of each node type. True depth indentation: 12px per level real (7px at spec scale). The panel width increases from 200px → 240px to accommodate depth-3+ labels without truncation. On tablet this still fits: 48px EntityNav + 240px tree + remaining edit = ✓ at 768px.

Root — expanded, active child
›Orte(38)
›Europa(22)
›Deutschland(11)
›Frankreich(5)
›Asien(8)
Active: `border-l-2 border-primary bg-primary/[0.09]`. Depth 2 indent: `pl-[28px]` real.
Deep hierarchy — depth 3–4
›Orte(38)
›Europa(22)
›Deutschland(11)
›Bayern(4)
›Sachsen(2)
›Leipzig(1)
Depth-4 indent: `pl-[52px]` real. Label always `truncate`. 240px panel provides ~134px for label at depth 4.
Color dots — root only
›Natur(6)
›Ereignisse(19)
›Sonstiges(3)
›Familie(9)
Color dot only on root tags with a color set. Root without color: no dot, muted label color. Hover: `bg-muted`.
Collapsed panel handle (32px real)
› Schlagwörter
32px wide. Click expands panel. State saved per tag in `localStorage`.
A
Each chevron is a real `<button aria-expanded>` with `aria-controls` pointing to the child list. Keyboard navigation: → expands, ← collapses, ↑↓ move between visible nodes. The row link is a separate `<a>` for navigation — the chevron and the label/name are different interactive targets.

## 4 — Edit Panel: Root Tag vs. Child Tag

Root tags show the color picker. Child tags show an inherited-color indicator and a full ancestry breadcrumb. Both show the children preview section, merge zone, and delete guard.

Root tag "Orte" — color picker visible, no breadcrumb trail
Schlagwort bearbeiten — Orte
Name
⚠ Namensänderungen wirken sich auf alle verknüpften Dokumente aus.
Orte
Übergeordnetes Schlagwort
Kein übergeordnetes Schlagwort
×
Farbe
×
Untergeordnete Schlagwörter (5)
Europa(22) Asien(8) Amerika(5) Afrika(2) Australien(1)
Zusammenführen
Alle Dokumente und untergeordnete Schlagwörter übertragen.
⇒ Zusammenführen …
Löschen
38 Dok. · 5 Untergeordnete
Löschen
AbbrechenSpeichern
Root: single breadcrumb segment (no links). Color picker visible (no parent). When a parent is selected, the color picker slides out and the inherited-color indicator slides in.
Child tag "Deutschland" (depth 2) — full delete guard expanded
Schlagwort bearbeiten — Deutschland
Name
⚠ Namensänderungen wirken sich auf alle verknüpften Dokumente aus.
Deutschland
Übergeordnetes Schlagwort
Europa
Orte › Europa
×
Farbe
Farbe von Orte (amber) — wird vererbt
Untergeordnete Schlagwörter
Berlin(3)München(4)Hamburg(2)Bayern(2)… und 2 weitere →
Zusammenführen
Alle Dokumente und untergeordnete Schlagwörter auf ein anderes Schlagwort übertragen.
⇒ Zusammenführen …
Löschen
11 Dokumente verknüpft · 6 Untergeordnete Schlagwörter
Nur dieses Schlagwort löschen
Untergeordnete werden zu Europa verschoben
Gesamten Teilbaum löschen
Löscht auch 6 untergeordnete Schlagwörter unwiderruflich
Gib `Deutschland` zur Bestätigung ein:
Deutschland
Löschen
AbbrechenSpeichern
Delete guard: radio option selected + name confirmed → delete button enabled. The name input only appears after a radio is chosen. This prevents someone from typing before reading.

## 5 — Tree-Aware Parent Picker

Replaces the flat `<select>` at `[id]/+page.svelte:123`. Each result shows the tag name plus its full ancestry path as a subtitle. Tags that are self or descendants are excluded server-side — not shown as disabled, simply not in results.

Picker open — query "eur"
Übergeordnetes Schlagwort
eur
×
Europa
Orte › Europa
Mitteleuropa
Orte › Europa › Mitteleuropa
Südeuropa
Orte › Europa › Südeuropa
Path resolved client-side from the `tags` array already in layout server state — no extra API call. Color dot matches the root ancestor's color. Keyboard: ↑↓ navigate, Enter selects, Escape closes.
Value selected — clear affordance + color inheritance note
Übergeordnetes Schlagwort
Europa
Orte › Europa
×
Farbe amber von Orte wird automatisch vererbt.
When a parent with a color is selected, a helper text explains color inheritance. The color picker card hides. Clicking × restores the empty state and re-shows the color picker.
B
Accessibility: `role="combobox"`, `aria-expanded`, `aria-autocomplete="list"`, `aria-activedescendant`. On open, focus stays on input. Backspace clears selected value before triggering search.
C
Self and descendants excluded by the server. The server already has cycle detection in `validateNoAncestorCycle()` — the picker simply calls `GET /api/tags?query=…` and server omits invalid parents. No client-side exclusion list needed.

## 6 — Tag Merge: Two-Step Modal

Triggered from the amber merge zone in the edit panel. Step 1: pick target and see a live preview of what moves. Step 2: confirm with a visual from/to summary. After merge, redirect to the surviving tag.

Step 1 of 2 — Pick target, live preview
FAMILIENARCHIV
Schlagwort zusammenführen · Schritt 1 von 2
Ziel-Schlagwort wählen
Deutschland wird auf das Ziel-Schlagwort übertragen und anschließend gelöscht.
Ziel-Schlagwort
BRD
×
Vorschau der Änderungen
11Dokumente werden zu BRD verschoben
6Untergeordnete zu BRD umgehängt
Deutschland wird danach gelöscht.
Abbrechen Weiter →
Preview card updates live as target changes. "Weiter →" disabled until a valid target is selected. Target cannot be self, descendant, or a tag that would create a cycle.
Step 2 of 2 — Confirm summary
FAMILIENARCHIV
Schlagwort zusammenführen · Schritt 2 von 2
Zusammenführen bestätigen
Wird gelöscht
Deutschland
⇓
Überlebt
BRD
11
Dokumente
6
Untergeordnete
1
Gelöscht
Diese Aktion kann nicht rückgängig gemacht werden.
← Zurück Jetzt zusammenführen
Confirm button amber — not red. Data is not lost, it moves to the target. After merge: redirect to surviving tag ("BRD") with a `banner-ok` success banner in the edit panel.
D
New backend endpoint: `POST /api/tags/{id}/merge` body: `{"targetId": "uuid"}`. Atomically: (1) reassign all `document_tags` rows, (2) reparent children to targetId, (3) delete source. Requires `ADMIN_TAG` permission. Returns surviving tag. `DomainException` on self-merge or target-is-descendant.
E
Modal: `role="dialog"` `aria-modal="true"` `aria-labelledby` focus trap. `Escape` cancels and returns focus to the merge button. On error (e.g. duplicate name), modal stays open and shows `banner-err` above the summary — no silent redirect.

## 7 — Mobile (375px): EntityNav hidden, full-screen tree → edit

On mobile the EntityNav is `hidden md:flex` — completely gone (existing behaviour). The tag section shows either the tree list or the edit form full-screen, same as today. The tree list gets the same depth-aware nodes; the edit form gets breadcrumb and the new zones.

375px — Tree list (full screen)
9:41●●●
FAMILIENARCHIV
Alle Schlagwörter
›Orte(38)
›Europa(22)
›Deutschland(11)
›Frankreich(5)
›Asien(8)
›Personen(54)
›Ereignisse(19)
›Hochzeiten(7)
Mobile: EntityNav hidden. Tree list is full-width. No collapse button on mobile. Tapping a node navigates to the full-screen edit.
375px — Edit form (full screen)
9:41●●●
FAMILIENARCHIV
‹ Schlagwörter Deutschland
Name
Deutschland
Übergeordnetes Schlagwort
Europa
Orte › Europa
×
Untergeordnete (6)
Berlin(3)München(4)… und 4 weitere →
Zusammenführen
⇒ Zusammenführen …
Löschen
11 Dok. · 6 Untergeordnete
Löschen
AbbrechenSpeichern
Mobile edit: back link "‹ Schlagwörter" returns to tree list. All touch targets min 44×44px real. Merge modal opens full-screen on mobile.

## Implementation Reference

Exact Tailwind classes, real pixel values, and file locations for every changed element. Green rows are new. Rows without colour are updates to existing elements.

Element | Tailwind Classes | Real px / value | Notes / file
--- | --- | --- | ---
EntityNav (unchanged) | `md:w-12 lg:w-30 flex-shrink-0 bg-brand-navy` | 48px tablet / 120px desktop | No changes. `EntityNav.svelte`
Tree panel — expanded | `w-60 flex-shrink-0 bg-surface border-r border-line flex flex-col overflow-hidden` | 240px (was 200px) | Widen +40px to fit depth-3+ labels. `TagsListPanel.svelte`
Tree panel — collapsed | `w-8 flex-shrink-0 bg-surface border-r border-line flex flex-col items-center cursor-pointer` | 32px | Click handle to re-expand. Per-tag state in `localStorage`.
Tree node row | `flex items-center h-9 pr-2 cursor-pointer border-l-2 border-transparent hover:bg-muted transition-colors` | height: 36px | Row is split: chevron button + link are siblings, not nested
Tree node — active | `border-l-primary bg-primary/[0.09]` | 2px left border, 9% navy bg | `aria-current="page"` on the `<a>`
Depth indent (per level) | d0: `pl-[4px]` d1: `pl-[16px]` d2: `pl-[28px]` d3: `pl-[40px]` d4+: `pl-[52px]` | 12px per level | Computed from `TagTreeNodeDTO` depth. Labels: `truncate`
Chevron button | `w-4 h-9 flex items-center justify-center text-ink-3 hover:text-ink flex-shrink-0 transition-transform` | 16×36px (full row height) | `aria-expanded` + `aria-controls="{id}-children"`. SVG icon, not text.
Color dot (root tags) | `w-2 h-2 rounded-full flex-shrink-0 mr-1.5` + inline `style="background-color: var(--c-tag-{color})"` | 8×8px | Root tags with a color only. Child tags: no dot in tree.
Doc count suffix | `text-[11px] font-normal text-ink-3 ml-1 flex-shrink-0` | 11px | From `TagTreeNodeDTO.documentCount`. Format: `(12)`
Ancestry breadcrumb | `flex items-center flex-wrap gap-1.5 mb-3 px-3 py-2 bg-muted rounded-sm border border-line text-xs` | 12px, 8px/12px padding | Root: single non-linked span. `aria-label="Schlagwort-Pfad"`
Breadcrumb link | `text-primary font-semibold hover:underline underline-offset-2 focus-visible:ring-1 focus-visible:ring-focus-ring rounded-sm` |  | Real `<a href="/admin/tags/{id}">`
Parent picker (replaces `<select>`) | `w-full border border-line rounded-sm bg-muted px-3 py-2.5 text-sm text-ink cursor-pointer focus-visible:ring-2 focus-visible:ring-focus-ring outline-none` | height: ~40px; 14px font | Custom combobox. `role="combobox"` `aria-expanded` `aria-autocomplete="list"`
Picker dropdown option | `flex items-start gap-2 px-3 py-2.5 hover:bg-muted cursor-pointer` | min-height: 44px (two-line) | Path subtitle: `text-[11px] text-ink-3 mt-0.5`
Inherited color indicator | `flex items-center gap-2 mt-2 px-3 py-2 bg-muted border border-line rounded-sm text-xs text-ink-2` | 12px font | Only on child tags. Replaces color picker when parent is set.
Children chip | `inline-flex items-center gap-1.5 px-2.5 py-1 bg-muted border border-line rounded-full text-[11px] font-medium text-ink-2 hover:bg-canvas transition-colors` | 11px, pill shape | Wrapped in `<a href="/admin/tags/{id}">`
Merge zone | `border border-amber-200 bg-amber-50 rounded-sm p-4 mb-4` | 16px padding | Amber = recoverable. Data moves to target, nothing is lost.
Merge button | `inline-flex items-center gap-2 px-4 py-2.5 bg-amber-800 text-amber-50 rounded-sm text-xs font-bold uppercase tracking-widest hover:opacity-80 min-h-[44px]` | min 44px height |
Merge modal backdrop | `fixed inset-0 bg-ink/40 flex items-center justify-center z-50` | 40% overlay | `role="dialog"` `aria-modal="true"`. Focus trap. Escape cancels.
Modal confirm button (Step 2) | `px-5 py-2.5 bg-amber-800 text-amber-50 rounded-sm text-xs font-bold uppercase tracking-widest min-h-[44px]` | Amber | On success: redirect to surviving tag + `banner-ok`
Delete impact summary | `bg-red-50 border border-red-200 rounded-sm px-3 py-2 mb-3 text-xs text-red-800` | 12px | Counts from tree data already in page state — no extra API fetch needed
Delete radio option | `flex items-start gap-3 p-3 border border-red-200 bg-white rounded-sm cursor-pointer hover:bg-red-50 mb-2 min-h-[44px]` | min 44px | Custom radio. Real `<input type="radio">` visually hidden + styled indicator.
Delete name input | `hidden` until radio selected, then `w-full border border-red-200 rounded-sm bg-white px-3 py-2 text-sm focus:ring-1 focus:ring-red-400 outline-none` |  | Input appears only after a radio is chosen. Prevents pre-filling before reading the warning.
Delete confirm button | `bg-danger text-danger-fg rounded-sm px-4 py-2.5 text-xs font-bold uppercase tracking-widest hover:opacity-80 disabled:opacity-40 disabled:cursor-not-allowed min-h-[44px]` | min 44px. `--c-danger: #c0392b` | Enabled only when radio is selected AND name input matches exactly
NEW: POST /api/tags/{id}/merge | Body: `{"targetId": "uuid"}` | Returns: surviving `Tag` | Permission: `ADMIN_TAG`. Atomic transaction. `DomainException` on self-merge or descendant target.