From 6c1d8cc0cfa6ebedfd8acb11c5ab05e54b19929f Mon Sep 17 00:00:00 2001 From: mAi Date: Thu, 28 May 2026 00:20:46 +0200 Subject: [PATCH] =?UTF-8?q?feat(builder):=20B1=20=E2=80=94=20Litigation=20?= =?UTF-8?q?Builder=20shell=20+=20cold-open=20mode=20(m/paliad#153)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces cronus's U0-U4 catalog at /tools/procedures with a persistence-backed builder shell on top of B0's API surface (/api/builder/scenarios/*, t-paliad-340). PRD §7.1 B1 acceptance shipped: - Page header: scenario picker, name action, Akte picker stub, Stichtag input, search input, save status indicator. - Entry-mode radio (cold-open active; event-triggered + akte rendered disabled for B3/B4 layout stability). - Empty canvas with "Neues Szenario starten" CTA and a 5-most-recent list rendered when the user has saved scenarios. - Side panel "Meine Szenarien" with the Aktiv bucket; clicking an item loads the scenario into the canvas. - Add-proceeding inline picker (Forum chip row → Verfahren chip row → Hinzufügen). UPC v1; other forums chipped but disabled. - First proceeding triplet renders end-to-end via verfahrensablauf-core.calculateDeadlines + renderColumnsBody (the existing 3-column proaktiv|court|reaktiv body, read-only in B1). - Auto-save with 500ms debounce on name + stichtag patches; save status flips idle → saving → saved/error in the page header. New client modules under frontend/src/client/: - builder.ts — orchestrator (URL state, fetch, auto-save loop, canvas render, scenario-list re-paint). - builder-picker.ts — inline Forum/Verfahren popover for the add-proceeding affordance. - builder-triplet.ts — single-triplet header + body wrapper. procedures.tsx rewritten as the shell scaffolding (sidebar, page header, mode radio, two-column body); procedures.ts now boots the builder instead of toggling the 4-tab catalog. Legacy U0-U4 modules (verfahrensablauf.ts, verfahrensablauf-state.ts, VerfahrensablaufBody.tsx, procedures' tab toggle in client/procedures.ts, fristenrechner-* mounts) are no longer reachable from /tools/procedures but kept in the tree for the B6 cleanup sweep per PRD §7.4. i18n.ts grew 60 keys × 2 langs under builder.*. global.css grew a self-contained .builder-* block at the file tail. bun run build, go vet ./..., and go test ./... all green. --- frontend/src/client/builder-picker.ts | 147 ++++++ frontend/src/client/builder-triplet.ts | 83 +++ frontend/src/client/builder.ts | 586 +++++++++++++++++++++ frontend/src/client/i18n.ts | 126 +++++ frontend/src/client/procedures.ts | 147 +----- frontend/src/i18n-keys.ts | 61 +++ frontend/src/procedures.tsx | 247 +++++---- frontend/src/styles/global.css | 691 +++++++++++++++++++++++++ 8 files changed, 1817 insertions(+), 271 deletions(-) create mode 100644 frontend/src/client/builder-picker.ts create mode 100644 frontend/src/client/builder-triplet.ts create mode 100644 frontend/src/client/builder.ts diff --git a/frontend/src/client/builder-picker.ts b/frontend/src/client/builder-picker.ts new file mode 100644 index 0000000..8891f14 --- /dev/null +++ b/frontend/src/client/builder-picker.ts @@ -0,0 +1,147 @@ +// Add-proceeding inline picker for the Litigation Builder. +// +// PRD §3 + §3.1: "+ Verfahren hinzufügen" button at the bottom of the +// triplet stack opens an inline picker. Forum chip row (UPC for v1) +// gates the Verfahren chip row, click → callback. Designed for B1's +// single-triplet flow and B2's multi-triplet stacking with no shape +// change between slices. + +import { t } from "./i18n"; + +export interface ProceedingTypeMeta { + id: number; + code: string; + name: string; + nameEN: string; + // group / jurisdiction. The proceeding-types API returns "UPC" / + // "DE" / etc. as the canonical jurisdiction; for v1 the picker + // only renders UPC. + group?: string; + jurisdiction?: string; +} + +type OnPick = (meta: ProceedingTypeMeta) => void | Promise; + +let activePopover: HTMLElement | null = null; + +export function mountAddProceedingPicker( + anchor: HTMLElement, + types: ProceedingTypeMeta[], + onPick: OnPick, +): void { + closeActive(); + const pop = document.createElement("div"); + pop.className = "builder-picker-popover"; + pop.setAttribute("role", "dialog"); + pop.setAttribute("aria-label", t("builder.picker.aria")); + + const header = document.createElement("div"); + header.className = "builder-picker-header"; + header.innerHTML = ` + ${escHtml(t("builder.picker.title"))} + + `; + pop.appendChild(header); + + // Forum row — UPC only for v1. Disabled chips render greyed. + const forumRow = document.createElement("div"); + forumRow.className = "builder-picker-row"; + forumRow.innerHTML = ` + ${escHtml(t("builder.picker.axis.forum"))} +
+ + + + +
+ `; + pop.appendChild(forumRow); + + const procRow = document.createElement("div"); + procRow.className = "builder-picker-row"; + procRow.innerHTML = ` + ${escHtml(t("builder.picker.axis.proc"))} +
+ `; + pop.appendChild(procRow); + + const empty = document.createElement("p"); + empty.className = "builder-picker-empty"; + empty.hidden = true; + empty.textContent = t("builder.picker.empty"); + pop.appendChild(empty); + + const procHost = pop.querySelector("#builder-picker-proc-chips") as HTMLElement; + const lang = document.documentElement.lang === "en" ? "en" : "de"; + for (const meta of types) { + const label = lang === "en" ? (meta.nameEN || meta.name) : meta.name; + const chip = document.createElement("button"); + chip.type = "button"; + chip.className = "builder-picker-chip builder-picker-chip--proc"; + chip.setAttribute("data-code", meta.code); + chip.innerHTML = `${escHtml(meta.code)} + ${escHtml(label)}`; + chip.addEventListener("click", () => { + closeActive(); + void onPick(meta); + }); + procHost.appendChild(chip); + } + if (types.length === 0) empty.hidden = false; + + header.querySelector(".builder-picker-close")?.addEventListener("click", () => { + closeActive(); + }); + + // Position the popover under the anchor button. + positionUnder(pop, anchor); + document.body.appendChild(pop); + activePopover = pop; + document.addEventListener("click", onOutsideClick, true); + document.addEventListener("keydown", onEscape, true); +} + +function positionUnder(pop: HTMLElement, anchor: HTMLElement): void { + const rect = anchor.getBoundingClientRect(); + pop.style.position = "absolute"; + const top = rect.bottom + window.scrollY + 6; + // Default left = anchor's left; clamp so popover stays in viewport. + const left = Math.max(8, rect.left + window.scrollX); + pop.style.top = `${top}px`; + pop.style.left = `${left}px`; + pop.style.maxWidth = "min(640px, calc(100vw - 24px))"; + pop.style.zIndex = "60"; +} + +function onOutsideClick(ev: Event): void { + if (!activePopover) return; + const target = ev.target as Node; + if (activePopover.contains(target)) return; + closeActive(); +} + +function onEscape(ev: KeyboardEvent): void { + if (ev.key === "Escape") closeActive(); +} + +function closeActive(): void { + if (activePopover) { + activePopover.remove(); + activePopover = null; + } + document.removeEventListener("click", onOutsideClick, true); + document.removeEventListener("keydown", onEscape, true); +} + +function escHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function escAttr(s: string): string { + return s.replace(/&/g, "&").replace(/"/g, """); +} diff --git a/frontend/src/client/builder-triplet.ts b/frontend/src/client/builder-triplet.ts new file mode 100644 index 0000000..ede12eb --- /dev/null +++ b/frontend/src/client/builder-triplet.ts @@ -0,0 +1,83 @@ +// ProceedingTriplet renderer for the Litigation Builder. +// +// PRD §3.3 + §3.4 + §6.1: one triplet = jurisdiction badge + name + +// perspective indicator + Detailgrad + columnar `proaktiv | court | +// reaktiv` body. B1 ships the read-only render; B2 wires perspective + +// flag strip + collapse/remove + 3-state event cards. + +import { t, getLang } from "./i18n"; +import type { DeadlineResponse, Side } from "./views/verfahrensablauf-core"; +import type { BuilderProceeding } from "./builder"; +import type { ProceedingTypeMeta } from "./builder-picker"; + +export interface RenderTripletInput { + proceeding: BuilderProceeding; + meta: ProceedingTypeMeta; + data: DeadlineResponse | null; + side: Side; + columnsHtml: string; +} + +export function renderTriplet(input: RenderTripletInput): string { + const lang = getLang(); + const procLabel = lang === "en" + ? (input.meta.nameEN || input.meta.name) + : (input.meta.name || input.meta.nameEN); + const sideLabel = sidePillLabel(input.side); + const flagsBadge = activeFlagsBadge(input.proceeding.scenario_flags); + + const body = input.data + ? input.columnsHtml + : `
${escHtml(t("builder.triplet.loading"))}
`; + + return ` +
+ ${escHtml(jurisdictionFor(input.meta))} + ${escHtml(input.meta.code)} + ${escHtml(procLabel)} + ${sideLabel ? `${escHtml(sideLabel)}` : ""} + ${flagsBadge} +
+
+ ${body} +
+ `; +} + +function jurisdictionFor(meta: ProceedingTypeMeta): string { + if (meta.jurisdiction) return meta.jurisdiction; + if (meta.group) return meta.group; + const dot = meta.code.indexOf("."); + if (dot > 0) return meta.code.slice(0, dot).toUpperCase(); + return meta.code.toUpperCase(); +} + +function sidePillLabel(side: Side): string { + switch (side) { + case "claimant": + return t("builder.triplet.side.claimant"); + case "defendant": + return t("builder.triplet.side.defendant"); + default: + return ""; + } +} + +function activeFlagsBadge(flags: Record): string { + const active = Object.entries(flags).filter(([, v]) => v === true).map(([k]) => k); + if (active.length === 0) return ""; + const label = t("builder.triplet.flags.label"); + const chips = active.map((f) => + `${escHtml(f)}`, + ).join(""); + return `${escHtml(label)} ${chips}`; +} + +function escHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/frontend/src/client/builder.ts b/frontend/src/client/builder.ts new file mode 100644 index 0000000..c8bea75 --- /dev/null +++ b/frontend/src/client/builder.ts @@ -0,0 +1,586 @@ +// Litigation Builder client (m/paliad#153 PRD §3, B1). +// +// Boots /tools/procedures. Talks to the B0 surface +// (/api/builder/scenarios/*) for persistence and reuses +// verfahrensablauf-core for the per-triplet calc + 3-column render. +// +// B1 ships: +// - Cold-open empty canvas + "Neues Szenario starten" CTA + recent list. +// - Scenario picker, name action, Stichtag, auto-save (500ms debounce). +// - Add-proceeding picker (Forum chip row → Verfahren chip row → Hinzufügen). +// - Single triplet renders end-to-end with calc. +// - Side panel "Meine Szenarien" with Aktiv bucket. +// +// B2 extends: +// - Multi-triplet stack with `+ Verfahren hinzufügen`. +// - Per-triplet perspective + flag strip. +// - Spawn child triplets render inline. +// - 3-state event cards (planned/filed/skipped) + per-card optional horizon. + +import { t, tDyn, getLang } from "./i18n"; +import { + calculateDeadlines, + renderColumnsBody, + type DeadlineResponse, + type Side, +} from "./views/verfahrensablauf-core"; +import { mountAddProceedingPicker, type ProceedingTypeMeta } from "./builder-picker"; +import { renderTriplet } from "./builder-triplet"; + +// ──────────────────────────────────────────────────────────────────────────── +// Wire types — mirror internal/services/scenario_builder_service.go +// ──────────────────────────────────────────────────────────────────────────── + +export interface BuilderScenario { + id: string; + owner_id?: string; + name: string; + status: "active" | "archived" | "promoted"; + origin_project_id?: string; + promoted_project_id?: string; + stichtag?: string; + notes?: string; + created_at: string; + updated_at: string; +} + +export interface BuilderProceeding { + id: string; + scenario_id: string; + proceeding_type_id: number; + primary_party?: "claimant" | "defendant"; + scenario_flags: Record; + parent_scenario_proceeding_id?: string; + spawn_anchor_event_id?: string; + ordinal: number; + stichtag?: string; + detailgrad: "selected" | "all_options"; + appeal_target?: string; + collapsed: boolean; + created_at: string; + updated_at: string; +} + +export interface BuilderEvent { + id: string; + scenario_proceeding_id: string; + sequencing_rule_id?: string; + procedural_event_id?: string; + custom_label?: string; + state: "planned" | "filed" | "skipped"; + actual_date?: string; + skip_reason?: string; + notes?: string; + horizon_optional: number; + created_at: string; + updated_at: string; +} + +export interface BuilderScenarioDeep extends BuilderScenario { + proceedings: BuilderProceeding[]; + events: BuilderEvent[]; + shares: unknown[]; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Module state — single active scenario per tab. +// ──────────────────────────────────────────────────────────────────────────── + +interface State { + active: BuilderScenarioDeep | null; + list: BuilderScenario[]; + procTypes: ProceedingTypeMeta[]; + procTypesById: Map; + procTypesByCode: Map; + saveTimer: number | null; + // Pending field-level deltas merged before each PATCH flush. Avoids + // racing PATCHes overwriting each other when the user changes more + // than one field inside a 500ms window. + pending: { name?: string; stichtag?: string; notes?: string }; +} + +const state: State = { + active: null, + list: [], + procTypes: [], + procTypesById: new Map(), + procTypesByCode: new Map(), + saveTimer: null, + pending: {}, +}; + +// ──────────────────────────────────────────────────────────────────────────── +// Fetch helpers +// ──────────────────────────────────────────────────────────────────────────── + +async function fetchJSON(input: RequestInfo, init?: RequestInit): Promise { + try { + const resp = await fetch(input, init); + if (!resp.ok) { + const body = await resp.text().catch(() => ""); + console.error("builder fetch error:", resp.status, input, body); + return null; + } + if (resp.status === 204) return null; + return (await resp.json()) as T; + } catch (err) { + console.error("builder network error:", input, err); + return null; + } +} + +async function fetchScenarios(): Promise { + const out = await fetchJSON("/api/builder/scenarios?status=active"); + return Array.isArray(out) ? out : []; +} + +async function fetchScenarioDeep(id: string): Promise { + return await fetchJSON("/api/builder/scenarios/" + encodeURIComponent(id)); +} + +async function fetchProceedingTypes(): Promise { + // PRD v1 is UPC-only; later jurisdictions plug into the same picker + // shape (Forum chip row gates the Verfahren chip row). + const out = await fetchJSON( + "/api/tools/proceeding-types?jurisdiction=UPC&kind=proceeding", + ); + return Array.isArray(out) ? out : []; +} + +async function createScenario(name?: string): Promise { + const body: Record = {}; + if (name) body.name = name; + return await fetchJSON("/api/builder/scenarios", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +async function patchScenario(id: string, body: Record): Promise { + return await fetchJSON("/api/builder/scenarios/" + encodeURIComponent(id), { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +async function addProceeding( + scenarioID: string, + body: { proceeding_type_id: number; primary_party?: string }, +): Promise { + return await fetchJSON( + "/api/builder/scenarios/" + encodeURIComponent(scenarioID) + "/proceedings", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); +} + +// ──────────────────────────────────────────────────────────────────────────── +// URL state +// ──────────────────────────────────────────────────────────────────────────── + +function readScenarioFromUrl(): string | null { + return new URLSearchParams(window.location.search).get("scenario"); +} + +function writeScenarioToUrl(id: string | null): void { + const url = new URL(window.location.href); + if (id) url.searchParams.set("scenario", id); + else url.searchParams.delete("scenario"); + history.replaceState(null, "", url.pathname + url.search + url.hash); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Save indicator +// ──────────────────────────────────────────────────────────────────────────── + +type SaveState = "idle" | "saving" | "saved" | "error"; + +function setSaveState(s: SaveState): void { + const el = document.getElementById("builder-save-status"); + if (!el) return; + el.setAttribute("data-state", s); + const span = el.querySelector("span"); + if (!span) return; + const text = + s === "saving" ? t("builder.save.saving") : + s === "saved" ? t("builder.save.saved") : + s === "error" ? t("builder.save.error") : + t("builder.save.idle"); + const key = + s === "saving" ? "builder.save.saving" : + s === "saved" ? "builder.save.saved" : + s === "error" ? "builder.save.error" : + "builder.save.idle"; + span.setAttribute("data-i18n", key); + span.textContent = text; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Auto-save (500ms debounce per PRD §4.2 + §10). +// ──────────────────────────────────────────────────────────────────────────── + +function scheduleAutoSave(): void { + if (!state.active) return; + setSaveState("saving"); + if (state.saveTimer !== null) { + window.clearTimeout(state.saveTimer); + } + state.saveTimer = window.setTimeout(() => { + void flushAutoSave(); + }, 500); +} + +async function flushAutoSave(): Promise { + state.saveTimer = null; + if (!state.active) return; + const body = { ...state.pending }; + state.pending = {}; + if (Object.keys(body).length === 0) { + setSaveState("saved"); + return; + } + const updated = await patchScenario(state.active.id, body); + if (!updated) { + setSaveState("error"); + return; + } + state.active.name = updated.name; + state.active.status = updated.status; + state.active.stichtag = updated.stichtag; + state.active.notes = updated.notes; + state.active.updated_at = updated.updated_at; + setSaveState("saved"); + // Refresh the side panel so the just-saved scenario floats to top. + await refreshScenarioList(); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Side panel + dropdown +// ──────────────────────────────────────────────────────────────────────────── + +async function refreshScenarioList(): Promise { + state.list = await fetchScenarios(); + renderScenarioList(); + renderScenarioPicker(); +} + +function renderScenarioList(): void { + const ul = document.getElementById("builder-scenario-list-active"); + if (!ul) return; + if (state.list.length === 0) { + ul.innerHTML = `
  • Noch keine Szenarien.
  • `; + return; + } + const activeId = state.active?.id; + ul.innerHTML = state.list.map((sc) => { + const isActive = sc.id === activeId; + return ( + `
  • ` + + `
  • ` + ); + }).join(""); + ul.querySelectorAll(".builder-scenario-list-item").forEach((li) => { + const id = li.getAttribute("data-scenario-id"); + if (!id) return; + li.addEventListener("click", () => { + void loadScenario(id); + }); + }); +} + +function renderScenarioPicker(): void { + const sel = document.getElementById("builder-scenario-picker") as HTMLSelectElement | null; + if (!sel) return; + const placeholderText = t("builder.picker.placeholder"); + const opts: string[] = [``]; + for (const sc of state.list) { + const selected = sc.id === state.active?.id ? " selected" : ""; + opts.push(``); + } + sel.innerHTML = opts.join(""); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Canvas rendering +// ──────────────────────────────────────────────────────────────────────────── + +function showEmpty(): void { + const canvas = document.getElementById("builder-canvas"); + if (!canvas) return; + canvas.innerHTML = ""; + const empty = document.createElement("div"); + empty.id = "builder-empty"; + empty.className = "builder-empty"; + empty.innerHTML = ` +

    ${escHtml(t("builder.empty.headline"))}

    +

    ${escHtml(t("builder.empty.hint"))}

    + + ${renderRecentList()} + `; + canvas.appendChild(empty); + document.getElementById("builder-cta-new")?.addEventListener("click", () => { + void onNewScenarioClick(); + }); + empty.querySelectorAll(".builder-recent-item").forEach((li) => { + const id = li.getAttribute("data-scenario-id"); + if (!id) return; + li.addEventListener("click", () => { + void loadScenario(id); + }); + }); +} + +function renderRecentList(): string { + if (state.list.length === 0) return ""; + const recent = state.list.slice(0, 5); + const items = recent.map((sc) => ( + `
  • ` + + `${escHtml(sc.name)}` + + `
  • ` + )).join(""); + return ( + `
    ` + + `

    ${escHtml(t("builder.empty.recent"))}

    ` + + `
      ${items}
    ` + + `
    ` + ); +} + +function renderCanvas(): void { + if (!state.active) { + showEmpty(); + return; + } + const canvas = document.getElementById("builder-canvas"); + if (!canvas) return; + canvas.innerHTML = ""; + + // Top-level proceedings sorted by ordinal (parent_scenario_proceeding_id IS NULL). + const topLevel = state.active.proceedings + .filter((p) => !p.parent_scenario_proceeding_id) + .sort((a, b) => a.ordinal - b.ordinal); + + for (const proc of topLevel) { + const tripletHost = document.createElement("article"); + tripletHost.className = "builder-triplet-host"; + tripletHost.setAttribute("data-proceeding-id", proc.id); + canvas.appendChild(tripletHost); + void renderProceedingTriplet(proc, tripletHost); + } + + // Add-proceeding affordance always at the bottom — even B1's single + // triplet flow needs a Hinzufügen affordance once the canvas is empty + // OR exactly one triplet renders (the cold-open CTA can't survive + // post-create). + const addBtn = document.createElement("button"); + addBtn.type = "button"; + addBtn.className = "builder-add-proceeding-btn"; + addBtn.id = "builder-add-proceeding-btn"; + addBtn.textContent = t("builder.canvas.add_proceeding"); + addBtn.addEventListener("click", () => { + openAddProceedingPicker(addBtn); + }); + canvas.appendChild(addBtn); +} + +async function renderProceedingTriplet( + proc: BuilderProceeding, + host: HTMLElement, +): Promise { + const meta = state.procTypesById.get(proc.proceeding_type_id); + if (!meta) { + host.innerHTML = `
    ${escHtml( + tDyn("builder.triplet.unknown_proceeding"), + )}
    `; + return; + } + const stichtag = proc.stichtag || state.active?.stichtag || todayISO(); + const data: DeadlineResponse | null = await calculateDeadlines({ + proceedingType: meta.code, + triggerDate: stichtag, + flags: scenarioFlagsToArray(proc.scenario_flags), + }); + const side: Side = (proc.primary_party as Side) || null; + const tripletHtml = renderTriplet({ + proceeding: proc, + meta, + data, + side, + columnsHtml: data ? renderColumnsBody(data, { editable: false, side, showDurations: false }) : "", + }); + host.innerHTML = tripletHtml; +} + +function scenarioFlagsToArray(flags: Record): string[] { + // The calc API still consumes the historical flat-flag array form + // (string slug per active flag). Builder scenario_flags is the + // jsonb {flag_name: true|false|null} shape — translate by picking + // every truthy key. + const out: string[] = []; + for (const [k, v] of Object.entries(flags)) { + if (v === true) out.push(k); + } + return out; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Actions +// ──────────────────────────────────────────────────────────────────────────── + +async function loadScenario(id: string): Promise { + const deep = await fetchScenarioDeep(id); + if (!deep) { + setSaveState("error"); + return; + } + state.active = deep; + state.pending = {}; + writeScenarioToUrl(id); + setSaveState("saved"); + // Sync header inputs to scenario state. + const stichtagInput = document.getElementById("builder-stichtag-input") as HTMLInputElement | null; + if (stichtagInput && deep.stichtag) stichtagInput.value = deep.stichtag.slice(0, 10); + const rename = document.getElementById("builder-rename-btn") as HTMLButtonElement | null; + if (rename) rename.disabled = false; + renderScenarioPicker(); + renderScenarioList(); + renderCanvas(); +} + +async function onNewScenarioClick(): Promise { + // Scratch scenario per PRD §2.1 — anonymous until "Benennen". Server + // applies the default name "Unbenanntes Szenario". + const sc = await createScenario(); + if (!sc) { + setSaveState("error"); + return; + } + state.list.unshift(sc); + await loadScenario(sc.id); + // Open the add-proceeding picker so the user lands on the next action. + const btn = document.getElementById("builder-add-proceeding-btn") as HTMLElement | null; + if (btn) openAddProceedingPicker(btn); +} + +function openAddProceedingPicker(anchor: HTMLElement): void { + if (!state.active) return; + mountAddProceedingPicker(anchor, state.procTypes, async (meta) => { + if (!state.active) return; + const proc = await addProceeding(state.active.id, { + proceeding_type_id: meta.id, + }); + if (!proc) { + setSaveState("error"); + return; + } + state.active.proceedings.push(proc); + setSaveState("saved"); + renderCanvas(); + }); +} + +async function onRenameClick(): Promise { + if (!state.active) return; + const current = state.active.name; + const next = window.prompt(t("builder.action.rename.prompt"), current); + if (next === null) return; + const trimmed = next.trim(); + if (!trimmed || trimmed === current) return; + state.pending.name = trimmed; + scheduleAutoSave(); + state.active.name = trimmed; + renderScenarioPicker(); + renderScenarioList(); +} + +function onStichtagChange(value: string): void { + if (!state.active) return; + state.active.stichtag = value; + state.pending.stichtag = value; + scheduleAutoSave(); + // Re-render: the triplet's calc result depends on stichtag. + renderCanvas(); +} + +// ──────────────────────────────────────────────────────────────────────────── +// Wiring +// ──────────────────────────────────────────────────────────────────────────── + +function wirePageHeader(): void { + document.getElementById("builder-rename-btn")?.addEventListener("click", () => { + void onRenameClick(); + }); + document.getElementById("builder-new-scenario-btn")?.addEventListener("click", () => { + void onNewScenarioClick(); + }); + document.getElementById("builder-cta-new")?.addEventListener("click", () => { + void onNewScenarioClick(); + }); + const picker = document.getElementById("builder-scenario-picker") as HTMLSelectElement | null; + picker?.addEventListener("change", () => { + const id = picker.value; + if (id) void loadScenario(id); + else { + state.active = null; + writeScenarioToUrl(null); + renderCanvas(); + } + }); + const stichtag = document.getElementById("builder-stichtag-input") as HTMLInputElement | null; + stichtag?.addEventListener("change", () => { + onStichtagChange(stichtag.value); + }); +} + +export async function mountBuilder(): Promise { + wirePageHeader(); + // Load proceeding type catalog (Forum=UPC, Kind=proceeding) up-front + // so the add-proceeding picker is instant. PRD §0.4 — UPC v1. + state.procTypes = await fetchProceedingTypes(); + state.procTypesById = new Map(state.procTypes.map((p) => [p.id, p])); + state.procTypesByCode = new Map(state.procTypes.map((p) => [p.code, p])); + await refreshScenarioList(); + const requested = readScenarioFromUrl(); + if (requested && state.list.some((s) => s.id === requested)) { + await loadScenario(requested); + } else { + renderCanvas(); + } + setSaveState("idle"); +} + +// ──────────────────────────────────────────────────────────────────────────── +// helpers +// ──────────────────────────────────────────────────────────────────────────── + +function todayISO(): string { + return new Date().toISOString().slice(0, 10); +} + +export function escAttr(s: string): string { + return s.replace(/&/g, "&").replace(/"/g, """); +} + +export function escHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +// Re-export getLang so the per-page bundle pulls i18n into the dep +// graph (the i18n module's side-effect-free initialiser otherwise +// gets tree-shaken when only string keys are referenced). +export { getLang }; diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index 1d6312a..6831b48 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -214,6 +214,69 @@ const translations: Record> = { "procedures.panel.akte.placeholder": "Akten-Einstieg folgt in einem sp\u00e4teren Slice.", "nav.procedures": "Verfahren & Fristen", + // Litigation Builder (m/paliad#153 B1+B2) + "builder.subtitle": "Litigation Builder \u2014 Szenarien bauen, Verfahren stapeln, Fristen behalten.", + "builder.header.scenario": "Szenario:", + "builder.header.akte": "Akte:", + "builder.header.stichtag": "Stichtag:", + "builder.header.search": "Suche:", + "builder.akte.none": "\u2014 ohne \u2014", + "builder.search.placeholder": "Ereignis, Szenario, Akte \u2026", + "builder.action.rename": "Benennen", + "builder.action.rename.prompt": "Name f\u00fcr dieses Szenario:", + "builder.action.share": "Teilen", + "builder.action.promote": "Als Projekt anlegen", + "builder.mode.cold": "\u00dcbersicht", + "builder.mode.event": "Ereignis", + "builder.mode.akte": "Aus Akte", + "builder.panel.title": "Meine Szenarien", + "builder.panel.new": "+ Neues Szenario", + "builder.panel.empty": "Noch keine Szenarien.", + "builder.bucket.active": "Aktiv", + "builder.empty.headline": "Noch kein Szenario ge\u00f6ffnet.", + "builder.empty.hint": "Starte ein neues Szenario, w\u00e4hle aus deiner Liste oder \u00fcbernimm eine Akte (B4).", + "builder.empty.cta": "Neues Szenario starten", + "builder.empty.recent": "Zuletzt bearbeitet", + "builder.picker.placeholder": "\u2014 Szenario w\u00e4hlen \u2014", + "builder.picker.title": "Verfahren hinzuf\u00fcgen", + "builder.picker.close": "Schlie\u00dfen", + "builder.picker.aria": "Verfahren ausw\u00e4hlen", + "builder.picker.axis.forum": "Forum:", + "builder.picker.axis.proc": "Verfahren:", + "builder.picker.empty": "Keine Verfahren verf\u00fcgbar.", + "builder.picker.future_jurisdiction": "Andere Foren folgen sp\u00e4ter.", + "builder.canvas.add_proceeding": "+ Verfahren hinzuf\u00fcgen", + "builder.triplet.loading": "Berechne Fristen \u2026", + "builder.triplet.unknown_proceeding": "Unbekannter Verfahrenstyp.", + "builder.triplet.side.claimant": "Kl\u00e4ger-Sicht", + "builder.triplet.side.defendant": "Beklagten-Sicht", + "builder.triplet.flags.label": "Optionen:", + "builder.triplet.perspective.label": "Perspektive:", + "builder.triplet.perspective.none": "keine", + "builder.triplet.perspective.claimant": "Kl\u00e4ger", + "builder.triplet.perspective.defendant": "Beklagter", + "builder.triplet.detailgrad.label": "Detailgrad:", + "builder.triplet.detailgrad.selected": "Gew\u00e4hlt", + "builder.triplet.detailgrad.all_options": "Alle Optionen", + "builder.triplet.remove": "Entfernen", + "builder.triplet.collapse": "Einklappen", + "builder.triplet.expand": "Ausklappen", + "builder.triplet.no_flags": "(keine Flags f\u00fcr diesen Verfahrenstyp)", + "builder.event.state.planned": "geplant", + "builder.event.state.filed": "eingereicht", + "builder.event.state.skipped": "ausgelassen", + "builder.event.action.file": "Einreichen", + "builder.event.action.skip": "Auslassen", + "builder.event.action.reset": "Zur\u00fcck zu geplant", + "builder.event.actual_date.prompt": "Datum der Einreichung:", + "builder.event.skip_reason.prompt": "Grund (optional):", + "builder.event.horizon.label": "+{n} Optionen \u25be", + "builder.event.horizon.hide": "Optionen ausblenden", + "builder.save.idle": "\u00a0", + "builder.save.saving": "Speichert \u2026", + "builder.save.saved": "Gespeichert \u2713", + "builder.save.error": "Speichern fehlgeschlagen", + "deadlines.step1": "Verfahrensart w\u00e4hlen", "deadlines.step2": "Ausgangsdatum eingeben", "deadlines.step2.perspective": "Perspektive und Datum", @@ -3418,6 +3481,69 @@ const translations: Record> = { "procedures.panel.akte.placeholder": "Matter entry ships in a later slice.", "nav.procedures": "Procedures & Deadlines", + // Litigation Builder (m/paliad#153 B1+B2) + "builder.subtitle": "Litigation Builder — build scenarios, stack proceedings, track deadlines.", + "builder.header.scenario": "Scenario:", + "builder.header.akte": "Matter:", + "builder.header.stichtag": "Anchor:", + "builder.header.search": "Search:", + "builder.akte.none": "— none —", + "builder.search.placeholder": "Event, scenario, matter …", + "builder.action.rename": "Name it", + "builder.action.rename.prompt": "Name for this scenario:", + "builder.action.share": "Share", + "builder.action.promote": "Create as project", + "builder.mode.cold": "Overview", + "builder.mode.event": "Event", + "builder.mode.akte": "From matter", + "builder.panel.title": "My scenarios", + "builder.panel.new": "+ New scenario", + "builder.panel.empty": "No scenarios yet.", + "builder.bucket.active": "Active", + "builder.empty.headline": "No scenario open.", + "builder.empty.hint": "Start a new scenario, pick one from your list, or load a matter (B4).", + "builder.empty.cta": "Start a new scenario", + "builder.empty.recent": "Recent", + "builder.picker.placeholder": "— pick a scenario —", + "builder.picker.title": "Add proceeding", + "builder.picker.close": "Close", + "builder.picker.aria": "Pick a proceeding", + "builder.picker.axis.forum": "Forum:", + "builder.picker.axis.proc": "Proceeding:", + "builder.picker.empty": "No proceedings available.", + "builder.picker.future_jurisdiction": "Other forums coming later.", + "builder.canvas.add_proceeding": "+ Add proceeding", + "builder.triplet.loading": "Calculating deadlines …", + "builder.triplet.unknown_proceeding": "Unknown proceeding type.", + "builder.triplet.side.claimant": "Claimant view", + "builder.triplet.side.defendant": "Defendant view", + "builder.triplet.flags.label": "Options:", + "builder.triplet.perspective.label": "Perspective:", + "builder.triplet.perspective.none": "none", + "builder.triplet.perspective.claimant": "Claimant", + "builder.triplet.perspective.defendant": "Defendant", + "builder.triplet.detailgrad.label": "Detail:", + "builder.triplet.detailgrad.selected": "Selected", + "builder.triplet.detailgrad.all_options": "All options", + "builder.triplet.remove": "Remove", + "builder.triplet.collapse": "Collapse", + "builder.triplet.expand": "Expand", + "builder.triplet.no_flags": "(no flags for this proceeding type)", + "builder.event.state.planned": "planned", + "builder.event.state.filed": "filed", + "builder.event.state.skipped": "skipped", + "builder.event.action.file": "File", + "builder.event.action.skip": "Skip", + "builder.event.action.reset": "Reset to planned", + "builder.event.actual_date.prompt": "Date of filing:", + "builder.event.skip_reason.prompt": "Reason (optional):", + "builder.event.horizon.label": "+{n} optional ▾", + "builder.event.horizon.hide": "Hide optional", + "builder.save.idle": " ", + "builder.save.saving": "Saving …", + "builder.save.saved": "Saved ✓", + "builder.save.error": "Save failed", + "deadlines.step1": "Select Proceeding Type", "deadlines.step2": "Enter Trigger Date", "deadlines.step2.perspective": "Perspective and Date", diff --git a/frontend/src/client/procedures.ts b/frontend/src/client/procedures.ts index 0723a22..c696f5d 100644 --- a/frontend/src/client/procedures.ts +++ b/frontend/src/client/procedures.ts @@ -1,150 +1,15 @@ -// /tools/procedures client (m/paliad#151, -// docs/design-unified-procedural-events-tool-2026-05-27.md). +// /tools/procedures bundle entry — Litigation Builder (m/paliad#153 B1). // -// Boot logic + tab switching for the unified procedural-events tool. -// Each entry tab mounts its own module; the search box and chip -// filters in the top filter strip are wired in U1+ as each slice adds -// its dimension-aware behaviour. -// -// U0 — Skeleton + tab toggling. -// U1 — Direkt suchen mounts Mode A. -// U2 — Geführt mounts Mode B wizard. -// U3 — Verfahren wählen wires the Verfahrensablauf wizard + detail-mode toggle. -// -// Mode A renders its shell into #fristen-overhaul-root (replacing -// children); Mode B renders into #fristen-overhaul-mode-host; the -// result view (post-commit) writes into #fristen-overhaul-root. To -// keep those IDs unique in the DOM, only the active tab's panel ever -// hosts the overhaul scaffold — installOverhaulHost() tears down any -// existing host and installs a fresh one inside the target panel -// before handing off to the per-mode module. +// Replaces cronus's U0-U4 catalog bootstrap. The page chrome is +// emitted by procedures.tsx; this file boots the i18n + sidebar +// runtime and hands off to builder.ts. import { initI18n } from "./i18n"; import { initSidebar } from "./sidebar"; -import { mountModeA } from "./fristenrechner-mode-a"; -import { mountResultView } from "./fristenrechner-result"; -import { mountWizard } from "./fristenrechner-wizard"; -import { initVerfahrensablauf } from "./verfahrensablauf"; - -type ProceduresTab = "proceeding" | "search" | "wizard" | "akte"; - -const TABS: ProceduresTab[] = ["proceeding", "search", "wizard", "akte"]; - -function readTabFromUrl(): ProceduresTab { - const params = new URLSearchParams(window.location.search); - const raw = params.get("mode"); - if (raw && (TABS as string[]).includes(raw)) return raw as ProceduresTab; - return "proceeding"; -} - -function writeTabToUrl(tab: ProceduresTab): void { - const url = new URL(window.location.href); - if (tab === "proceeding") { - url.searchParams.delete("mode"); - } else { - url.searchParams.set("mode", tab); - } - history.replaceState(null, "", url.pathname + url.search + url.hash); -} - -// installOverhaulHost moves the (legacy) #fristen-overhaul-root / -// #fristen-overhaul-mode-host scaffold under `panelId`. Always clears -// any existing host first, so the IDs stay unique across the page even -// when the user toggles between Direkt-suchen and Geführt — both Mode -// A and the wizard read these IDs from document.getElementById which -// returns the first match in DOM order, so two parallel hosts would -// cross-wire. -function installOverhaulHost(panelId: string): HTMLElement | null { - document.querySelectorAll("#fristen-overhaul-root").forEach((el) => el.remove()); - const panel = document.getElementById(panelId); - if (!panel) return null; - panel.innerHTML = ` -
    -
    -
    -
    -
    - `; - return panel; -} - -function setActiveTabUI(tab: ProceduresTab): void { - for (const t of TABS) { - const btn = document.getElementById(`procedures-tab-${t}`); - const panel = document.getElementById(`procedures-panel-${t}`); - const active = t === tab; - if (btn) { - btn.classList.toggle("is-active", active); - btn.setAttribute("aria-selected", active ? "true" : "false"); - } - if (panel) panel.hidden = !active; - } -} - -// Verfahrensablauf wiring is idempotent-unfriendly (module-local -// selectedType + lastResponse + listeners that re-bind on every -// proceeding click). Wire it exactly once per page load; on subsequent -// activations the existing DOM + listeners are reused so picked -// proceeding / dates / flags persist across tab switches. -let verfahrensablaufWired = false; - -async function activateTab(tab: ProceduresTab): Promise { - setActiveTabUI(tab); - if (tab === "search") { - installOverhaulHost("procedures-panel-search"); - await mountModeA(); - return; - } - if (tab === "wizard") { - installOverhaulHost("procedures-panel-wizard"); - await mountWizard(); - return; - } - if (tab === "proceeding") { - if (!verfahrensablaufWired) { - initVerfahrensablauf(); - verfahrensablaufWired = true; - } - } -} - -function wireTabs(): void { - for (const t of TABS) { - const btn = document.getElementById(`procedures-tab-${t}`); - if (!btn) continue; - btn.addEventListener("click", () => { - void activateTab(t); - writeTabToUrl(t); - }); - } -} - -// boot dispatches on the URL: a deep link with `?event=` jumps straight -// to the linear result view (the Direkt-suchen tab stays as the visible -// context). Otherwise the requested tab — defaulting to "proceeding" — -// activates per readTabFromUrl(). -async function boot(): Promise { - const params = new URLSearchParams(window.location.search); - const eventRef = params.get("event") || ""; - - if (eventRef) { - setActiveTabUI("search"); - installOverhaulHost("procedures-panel-search"); - await mountResultView({ - eventRef, - triggerDate: params.get("trigger_date") || undefined, - party: params.get("party") || undefined, - courtId: params.get("court_id") || undefined, - }); - return; - } - - await activateTab(readTabFromUrl()); -} +import { mountBuilder } from "./builder"; document.addEventListener("DOMContentLoaded", () => { initI18n(); initSidebar(); - wireTabs(); - void boot(); + void mountBuilder(); }); diff --git a/frontend/src/i18n-keys.ts b/frontend/src/i18n-keys.ts index 55befa6..e151d4b 100644 --- a/frontend/src/i18n-keys.ts +++ b/frontend/src/i18n-keys.ts @@ -728,6 +728,67 @@ export type I18nKey = | "bottomnav.add.title" | "bottomnav.badge.deadlines" | "bottomnav.menu" + | "builder.action.promote" + | "builder.action.rename" + | "builder.action.rename.prompt" + | "builder.action.share" + | "builder.akte.none" + | "builder.bucket.active" + | "builder.canvas.add_proceeding" + | "builder.empty.cta" + | "builder.empty.headline" + | "builder.empty.hint" + | "builder.empty.recent" + | "builder.event.action.file" + | "builder.event.action.reset" + | "builder.event.action.skip" + | "builder.event.actual_date.prompt" + | "builder.event.horizon.hide" + | "builder.event.horizon.label" + | "builder.event.skip_reason.prompt" + | "builder.event.state.filed" + | "builder.event.state.planned" + | "builder.event.state.skipped" + | "builder.header.akte" + | "builder.header.scenario" + | "builder.header.search" + | "builder.header.stichtag" + | "builder.mode.akte" + | "builder.mode.cold" + | "builder.mode.event" + | "builder.panel.empty" + | "builder.panel.new" + | "builder.panel.title" + | "builder.picker.aria" + | "builder.picker.axis.forum" + | "builder.picker.axis.proc" + | "builder.picker.close" + | "builder.picker.empty" + | "builder.picker.future_jurisdiction" + | "builder.picker.placeholder" + | "builder.picker.title" + | "builder.save.error" + | "builder.save.idle" + | "builder.save.saved" + | "builder.save.saving" + | "builder.search.placeholder" + | "builder.subtitle" + | "builder.triplet.collapse" + | "builder.triplet.detailgrad.all_options" + | "builder.triplet.detailgrad.label" + | "builder.triplet.detailgrad.selected" + | "builder.triplet.expand" + | "builder.triplet.flags.label" + | "builder.triplet.loading" + | "builder.triplet.no_flags" + | "builder.triplet.perspective.claimant" + | "builder.triplet.perspective.defendant" + | "builder.triplet.perspective.label" + | "builder.triplet.perspective.none" + | "builder.triplet.remove" + | "builder.triplet.side.claimant" + | "builder.triplet.side.defendant" + | "builder.triplet.unknown_proceeding" | "cal.day.back_to_month" | "cal.day.fri" | "cal.day.mon" diff --git a/frontend/src/procedures.tsx b/frontend/src/procedures.tsx index a4bea0f..4c8a8cb 100644 --- a/frontend/src/procedures.tsx +++ b/frontend/src/procedures.tsx @@ -4,23 +4,19 @@ import { PaliadinWidget } from "./components/PaliadinWidget"; import { BottomNav } from "./components/BottomNav"; import { Footer } from "./components/Footer"; import { PWAHead } from "./components/PWAHead"; -import { VerfahrensablaufBody } from "./components/VerfahrensablaufBody"; -// U0 — Skeleton for the unified procedural-events tool -// (m/paliad#151, design docs/design-unified-procedural-events-tool-2026-05-27.md). +// /tools/procedures — Litigation Builder (m/paliad#153 PRD §3). // -// Folds /tools/fristenrechner (Mode A + Mode B + result) and -// /tools/verfahrensablauf into a single page at /tools/procedures. Each -// later slice fills one of the four entry tabs: +// Replaces cronus's 4-tab catalog (U0-U4) with a persistence-backed +// builder shell. Server-rendered chrome is minimal — the page-header +// scenario picker, side panel, and canvas are all hydrated by +// `builder.ts` at boot. The builder loads scenarios from +// /api/builder/scenarios (B0 surface, t-paliad-340) and renders the +// per-proceeding triplets with the existing verfahrensablauf-core calc. // -// U1 — Direkt suchen (Mode A search) -// U2 — Geführt (Mode B wizard) -// U3 — Verfahren (Verfahrensablauf tree + 3-way detail filter) -// U4 — Hard-cut 301 (drop legacy pages, redirect URLs) -// -// This file ships only the page chrome — sidebar, header, filter strip -// with search box, four entry-mode tabs, and the host containers the -// later slices mount their UI into. No data wiring. +// B1 — Builder shell + cold-open mode + single triplet end-to-end. +// B2 — Multi-triplet stack + spawn nesting + per-event state machine. +// B3+ — event-triggered + Akte modes, sharing, promotion (head-gated). export function renderProcedures(): string { const today = new Date().toISOString().split("T")[0]; @@ -36,151 +32,142 @@ export function renderProcedures(): string { Verfahren & Fristen — Paliad - +
    -
    +

    Verfahren & Fristen

    -

    - Verfahrensablauf, Fristenrechner und gerührte Suche in einem Tool. +

    + Litigation Builder — Szenarien bauen, Verfahren stapeln, Fristen behalten.

    - {/* Shared filter strip — search box + four chip groups - (forum / proceeding / event_kind / party). Lives at the - top of the page so every entry tab and output mode reads - the same active filter set (design §4 + m's Q3 - divergence: search composes with chip filters). U0 - ships the markup only; chip hydration + search wiring - arrive with U1-U3. */} -
    -
    - - + {/* Page header (PRD §3.1): scenario picker · save state · name · share · promote + · Akte picker · Stichtag input. B1 wires the scenario picker + + name action + Stichtag + save indicator. Akte / share / + promote land at B4 / B5; the affordances render disabled in + B1 so the layout is stable across slices. */} +
    +
    + + +   + + + + +
    -
    -
    - Forum: -
    -
    -
    - Verfahren: -
    -
    -
    - Ereignisart: -
    -
    -
    - Partei: -
    -
    +
    + + +
    - {/* Entry-mode tab strip — all four tabs visible from boot - (m's Q3 divergence). The active tab is URL-driven - (?mode=proceeding|search|wizard|akte); cold open lands - on "proceeding" per design §11.5.Q3. */} -
    diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 7de21e5..349e8df 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -19811,3 +19811,694 @@ a.fristen-overhaul-rule-source { width: 100%; } } + +/* --- Litigation Builder (m/paliad#153 B1+B2) --- */ + +.builder-page .tool-header { + margin-bottom: 0.75rem; +} + +.builder-pageheader { + display: flex; + flex-direction: column; + gap: 0.4rem; + padding: 0.75rem 1rem; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 0.5rem; + margin-bottom: 0.6rem; +} + +.builder-pageheader-row { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.builder-pageheader-field { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.9rem; +} + +.builder-pageheader-field--grow { + flex: 1 1 220px; +} + +.builder-pageheader-label { + color: var(--color-text-subtle); + font-weight: 500; + white-space: nowrap; +} + +.builder-pageheader-spacer { + flex: 1 1 auto; +} + +.builder-scenario-picker, +.builder-akte-picker, +.builder-stichtag-input, +.builder-search-input { + font: inherit; + padding: 0.3rem 0.55rem; + border: 1px solid var(--color-border); + border-radius: 0.3rem; + background: var(--color-surface-2); + color: var(--color-text); + min-width: 200px; +} + +.builder-search-input { + min-width: 260px; +} + +.builder-scenario-picker:disabled, +.builder-akte-picker:disabled, +.builder-search-input:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.builder-save-status { + font-size: 0.85rem; + color: var(--color-text-subtle); + min-width: 8rem; + display: inline-flex; + align-items: center; + gap: 0.3rem; +} + +.builder-save-status[data-state="saving"] { color: var(--color-text-subtle); } +.builder-save-status[data-state="saved"] { color: var(--color-accent-strong-fg); } +.builder-save-status[data-state="error"] { color: var(--status-red-fg, #c5503a); } + +.builder-action-btn { + font: inherit; + padding: 0.35rem 0.85rem; + border-radius: 0.3rem; + cursor: pointer; + background: var(--color-surface-2); + border: 1px solid var(--color-border); + color: var(--color-text); +} + +.builder-action-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.builder-action-btn--primary { + background: var(--color-accent); + border-color: var(--color-accent); + color: var(--color-accent-dark); + font-weight: 500; +} + +.builder-action-btn--primary:hover:not(:disabled) { + background: var(--color-accent-light); +} + +.builder-action-btn--secondary:hover:not(:disabled) { + background: var(--color-surface-muted); +} + +.builder-modebar { + display: inline-flex; + border: 1px solid var(--color-border); + border-radius: 999px; + background: var(--color-surface); + padding: 0.15rem; + margin-bottom: 0.75rem; +} + +.builder-mode { + font: inherit; + background: transparent; + border: 0; + padding: 0.3rem 0.9rem; + border-radius: 999px; + cursor: pointer; + color: var(--color-text-subtle); +} + +.builder-mode.is-active { + background: var(--color-segment-active-bg); + color: var(--color-segment-active-fg); +} + +.builder-mode:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.builder-body { + display: grid; + grid-template-columns: 240px 1fr; + gap: 1rem; + align-items: start; +} + +.builder-sidepanel { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 0.5rem; + padding: 0.75rem; + position: sticky; + top: 1rem; + max-height: calc(100vh - 2rem); + overflow: auto; +} + +.builder-sidepanel-header { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 0.5rem; +} + +.builder-sidepanel-title { + font-size: 0.95rem; + margin: 0; +} + +.builder-sidepanel-newbtn { + font: inherit; + font-size: 0.8rem; + background: var(--color-accent); + border: 1px solid var(--color-accent); + border-radius: 0.3rem; + padding: 0.2rem 0.5rem; + cursor: pointer; + color: var(--color-accent-dark); +} + +.builder-sidepanel-newbtn:hover { + background: var(--color-accent-light); +} + +.builder-bucket-label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-subtle); + margin: 0.5rem 0 0.3rem; +} + +.builder-scenario-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.builder-scenario-list-empty { + font-size: 0.85rem; + color: var(--color-text-subtle); + font-style: italic; +} + +.builder-scenario-list-item { + cursor: pointer; + border-radius: 0.3rem; +} + +.builder-scenario-list-item.is-active { + background: var(--color-accent-soft-bg); +} + +.builder-scenario-list-link { + display: block; + width: 100%; + text-align: left; + background: transparent; + border: 0; + padding: 0.4rem 0.5rem; + font: inherit; + color: inherit; + cursor: pointer; +} + +.builder-scenario-list-item:hover { + background: var(--color-surface-muted); +} + +.builder-canvas-wrap { + min-height: 320px; +} + +.builder-canvas { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.builder-empty { + background: var(--color-surface); + border: 1px dashed var(--color-border); + border-radius: 0.5rem; + padding: 2rem; + text-align: center; +} + +.builder-empty-headline { + font-size: 1.05rem; + margin: 0 0 0.4rem; +} + +.builder-empty-hint { + color: var(--color-text-subtle); + margin: 0 0 1rem; +} + +.builder-cta-new { + font: inherit; + background: var(--color-accent); + border: 1px solid var(--color-accent); + border-radius: 0.3rem; + padding: 0.55rem 1.2rem; + cursor: pointer; + color: var(--color-accent-dark); + font-weight: 500; +} + +.builder-cta-new:hover { + background: var(--color-accent-light); +} + +.builder-recent { + margin-top: 1.5rem; + text-align: left; +} + +.builder-recent-title { + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-subtle); + margin: 0 0 0.5rem; +} + +.builder-recent-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.builder-recent-item { + padding: 0.4rem 0.6rem; + background: var(--color-surface-2); + border-radius: 0.3rem; + cursor: pointer; +} + +.builder-recent-item:hover { + background: var(--color-surface-muted); +} + +.builder-triplet-host { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 0.5rem; + overflow: hidden; +} + +.builder-triplet-host[data-child="true"] { + margin-left: 1.5rem; + border-left: 3px solid var(--color-accent); +} + +.builder-triplet-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.55rem 0.85rem; + background: var(--color-surface-2); + border-bottom: 1px solid var(--color-border); + font-size: 0.9rem; + flex-wrap: wrap; +} + +.builder-triplet-jurisdiction { + background: var(--color-accent); + color: var(--color-accent-dark); + font-weight: 600; + font-size: 0.7rem; + padding: 0.1rem 0.4rem; + border-radius: 0.25rem; + letter-spacing: 0.05em; +} + +.builder-triplet-code { + font-family: ui-monospace, Menlo, monospace; + font-size: 0.78rem; + color: var(--color-text-subtle); +} + +.builder-triplet-name { + font-weight: 500; + margin-right: auto; +} + +.builder-triplet-side { + background: var(--color-accent-soft-bg); + color: var(--color-accent-soft-fg); + border: 1px solid var(--color-accent-soft-border); + padding: 0.1rem 0.45rem; + border-radius: 0.25rem; + font-size: 0.75rem; +} + +.builder-triplet-flags { + font-size: 0.78rem; + color: var(--color-text-subtle); + display: inline-flex; + align-items: center; + gap: 0.3rem; +} + +.builder-triplet-flag-chip { + background: var(--color-accent-soft-bg); + border: 1px solid var(--color-accent-soft-border); + color: var(--color-accent-soft-fg); + padding: 0.05rem 0.4rem; + border-radius: 0.25rem; + font-family: ui-monospace, Menlo, monospace; +} + +.builder-triplet-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.55rem; + padding: 0.45rem 0.85rem; + border-bottom: 1px solid var(--color-border); + background: var(--color-surface); + font-size: 0.85rem; +} + +.builder-triplet-controls-label { + color: var(--color-text-subtle); +} + +.builder-triplet-perspective, +.builder-triplet-detailgrad { + display: inline-flex; + align-items: center; + border: 1px solid var(--color-border); + border-radius: 999px; + background: var(--color-surface-2); + padding: 0.1rem; +} + +.builder-triplet-perspective button, +.builder-triplet-detailgrad button { + font: inherit; + font-size: 0.78rem; + border: 0; + background: transparent; + padding: 0.2rem 0.6rem; + border-radius: 999px; + cursor: pointer; + color: var(--color-text-subtle); +} + +.builder-triplet-perspective button.is-active, +.builder-triplet-detailgrad button.is-active { + background: var(--color-segment-active-bg); + color: var(--color-segment-active-fg); +} + +.builder-triplet-remove { + margin-left: auto; + font: inherit; + font-size: 0.78rem; + background: transparent; + border: 1px solid var(--color-border); + border-radius: 0.25rem; + padding: 0.15rem 0.55rem; + cursor: pointer; + color: var(--color-text-subtle); +} + +.builder-triplet-remove:hover { + border-color: var(--status-red-border, #d08070); + color: var(--status-red-fg, #c5503a); +} + +.builder-triplet-flagstrip { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + padding: 0.45rem 0.85rem; + border-bottom: 1px solid var(--color-border); + background: var(--color-surface); + font-size: 0.85rem; +} + +.builder-triplet-flag-toggle { + display: inline-flex; + align-items: center; + gap: 0.3rem; + cursor: pointer; +} + +.builder-triplet-flag-empty { + font-style: italic; + color: var(--color-text-subtle); +} + +.builder-triplet-body { + padding: 0.85rem; +} + +.builder-triplet-loading, +.builder-triplet-error { + padding: 1rem; + text-align: center; + color: var(--color-text-subtle); + font-style: italic; +} + +.builder-add-proceeding-btn { + font: inherit; + background: var(--color-surface-2); + border: 1px dashed var(--color-border); + border-radius: 0.5rem; + padding: 0.7rem; + cursor: pointer; + color: var(--color-text-subtle); +} + +.builder-add-proceeding-btn:hover { + background: var(--color-accent-soft-bg); + border-color: var(--color-accent-soft-border); + color: var(--color-accent-soft-fg); +} + +/* Add-proceeding popover */ + +.builder-picker-popover { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 0.5rem; + box-shadow: 0 6px 20px rgba(0,0,0,0.12); + padding: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.6rem; + min-width: 380px; +} + +.builder-picker-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.builder-picker-title { + font-size: 0.95rem; +} + +.builder-picker-close { + font: inherit; + font-size: 1.2rem; + background: transparent; + border: 0; + cursor: pointer; + color: var(--color-text-subtle); +} + +.builder-picker-row { + display: flex; + align-items: flex-start; + gap: 0.5rem; +} + +.builder-picker-axis-label { + flex: 0 0 6rem; + font-size: 0.85rem; + color: var(--color-text-subtle); + padding-top: 0.25rem; +} + +.builder-picker-chips { + display: inline-flex; + flex-wrap: wrap; + gap: 0.3rem; +} + +.builder-picker-chips--wrap { + flex: 1; +} + +.builder-picker-chip { + font: inherit; + font-size: 0.85rem; + background: var(--color-surface-2); + border: 1px solid var(--color-border); + padding: 0.25rem 0.55rem; + border-radius: 999px; + cursor: pointer; + color: var(--color-text); +} + +.builder-picker-chip.is-active { + background: var(--color-segment-active-bg); + color: var(--color-segment-active-fg); + border-color: var(--color-segment-active-border); +} + +.builder-picker-chip:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.builder-picker-chip:hover:not(:disabled) { + background: var(--color-accent-soft-bg); +} + +.builder-picker-chip--proc { + display: inline-flex; + align-items: baseline; + gap: 0.4rem; + text-align: left; +} + +.builder-picker-chip-code { + font-family: ui-monospace, Menlo, monospace; + font-size: 0.72rem; + color: var(--color-text-subtle); +} + +.builder-picker-empty { + font-size: 0.85rem; + color: var(--color-text-subtle); + font-style: italic; +} + +/* Event-card state overrides (B2). The 3-state machine sits on top of + the existing .fr-col-item card. The Builder render passes editable=false + to renderColumnsBody and overlays its own per-card state attributes + on top of the card root via data-builder-state. */ + +.fr-col-item[data-builder-state="filed"] { + background: var(--color-accent-soft-bg); + border-left: 3px solid var(--color-accent); +} + +.fr-col-item[data-builder-state="filed"] .timeline-name::before { + content: "✓ "; + color: var(--color-accent-soft-fg); + font-weight: 600; +} + +.fr-col-item[data-builder-state="skipped"] { + opacity: 0.55; +} + +.fr-col-item[data-builder-state="skipped"] .timeline-name { + text-decoration: line-through; +} + +.builder-event-actions { + display: flex; + gap: 0.3rem; + margin-top: 0.4rem; +} + +.builder-event-action { + font: inherit; + font-size: 0.72rem; + background: var(--color-surface-2); + border: 1px solid var(--color-border); + border-radius: 0.25rem; + padding: 0.15rem 0.45rem; + cursor: pointer; + color: var(--color-text); +} + +.builder-event-action:hover { + background: var(--color-accent-soft-bg); +} + +.builder-event-action[data-action="file"] { + background: var(--color-accent); + border-color: var(--color-accent); + color: var(--color-accent-dark); +} + +.builder-event-action[data-action="file"]:hover { + background: var(--color-accent-light); +} + +.builder-event-horizon-chip { + display: inline-block; + font-size: 0.72rem; + color: var(--color-accent-soft-fg); + background: var(--color-accent-soft-bg); + border: 1px solid var(--color-accent-soft-border); + border-radius: 0.25rem; + padding: 0.1rem 0.45rem; + margin-top: 0.3rem; + cursor: pointer; +} + +.builder-event-horizon-chip:hover { + background: var(--color-accent-strong-bg); +} + +/* Responsive: collapse side panel into stacked block on narrow viewports. */ + +@media (max-width: 900px) { + .builder-body { + grid-template-columns: 1fr; + } + .builder-sidepanel { + position: static; + max-height: none; + } +} + +@media (max-width: 640px) { + .builder-pageheader-row { + flex-direction: column; + align-items: stretch; + gap: 0.4rem; + } + .builder-pageheader-field { + flex-wrap: wrap; + } + .builder-scenario-picker, + .builder-akte-picker, + .builder-search-input { + min-width: 0; + width: 100%; + } +}