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.
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.
Deutschland zur Bestätigung ein: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.
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.
border-l-2 border-primary bg-primary/[0.09]. Depth 2 indent: pl-[28px] real.pl-[52px] real. Label always truncate. 240px panel provides ~134px for label at depth 4.bg-muted.localStorage.<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.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.
Deutschland zur Bestätigung ein: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.
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.role="combobox", aria-expanded,
aria-autocomplete="list", aria-activedescendant.
On open, focus stays on input. Backspace clears selected value before triggering search.validateNoAncestorCycle() —
the picker simply calls GET /api/tags?query=… and server omits invalid parents.
No client-side exclusion list needed.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.
banner-ok success banner in the edit panel.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.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.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.
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. |