feat(t-paliad-180): Slice 1 — Determinator row-stack cascade
Replace the four-layer Pathway B mess (mode radio + perspective chip strip
+ inbox chip strip + breadcrumb cascade) with a single `.fristen-row`
primitive rendered in a top-down stack. Every decision — mode, perspective,
inbox, cascade depth N — now uses the same shape (label · picked answer ·
inline "ändern") and three states (is-active / is-answered / is-prefilled).
The user finally sees their full decision path at a glance instead of
chasing breadcrumb crumbs after each drill. Click on any answered row (or
its ändern affordance) re-actives it; ändern on a cascade depth drops the
descendants (same drop-descendants semantic as today's breadcrumb-click).
Reset link and `🔍 Direkt suchen` escape-hatch live at the top of the stack
per design §6 Option B; the mode-toggle radio is gone, routing to
?mode=filter now flows through the mode row.
Visual-only refactor — narrowing engine (inboxFilterAllowsForums +
perspectiveAllowsParty) is unchanged. Slice 2 will add project-driven
prefills + auto-walk; Slice 3 covers mobile polish and search relocation.
Refs: docs/design-determinator-row-cascade-2026-05-13.md §10 Slice 1.
This commit is contained in:
@@ -2026,23 +2026,19 @@ function showPathway(path: Pathway, mode?: BMode) {
|
||||
function showBMode(mode: BMode) {
|
||||
const tree = document.getElementById("fristen-b1-panel");
|
||||
const filter = document.getElementById("fristen-b2-panel");
|
||||
const treeRadio = document.getElementById("fristen-b-mode-tree") as HTMLInputElement | null;
|
||||
const filterRadio = document.getElementById("fristen-b-mode-filter") as HTMLInputElement | null;
|
||||
if (!tree || !filter) return;
|
||||
tree.hidden = mode !== "tree";
|
||||
filter.hidden = mode !== "filter";
|
||||
if (treeRadio) treeRadio.checked = mode === "tree";
|
||||
if (filterRadio) filterRadio.checked = mode === "filter";
|
||||
|
||||
// Trigger tree load on entering tree mode. The cascade auto-renders
|
||||
// the breadcrumb / question / buttons + calls runB1Search() for the
|
||||
// current slug. With slug="" this fetches every concept reachable
|
||||
// from any leaf (browse-all, t-paliad-134) so the user sees the full
|
||||
// landscape before drilling in.
|
||||
// Trigger tree load on entering tree mode. renderRowStack repaints
|
||||
// every row + calls runB1Search() for the current slug. With slug=""
|
||||
// this fetches every concept reachable from any leaf (browse-all,
|
||||
// t-paliad-134) so the user sees the full landscape before drilling
|
||||
// in.
|
||||
if (mode === "tree") {
|
||||
const cascade = document.getElementById("fristen-b1-cascade");
|
||||
if (cascade && cascade.childElementCount === 0) {
|
||||
cascade.innerHTML = `<div class="fristen-b1-stub">${escHtml(t("deadlines.search.loading"))}</div>`;
|
||||
const stack = document.getElementById("fristen-row-stack");
|
||||
if (stack && stack.childElementCount === 0) {
|
||||
stack.innerHTML = `<div class="fristen-b1-stub">${escHtml(t("deadlines.search.loading"))}</div>`;
|
||||
}
|
||||
void loadAndRenderB1();
|
||||
}
|
||||
@@ -2185,13 +2181,12 @@ function clearStep1Context() {
|
||||
writeStep1ContextToURL(currentStep1Context);
|
||||
renderStep1Summary();
|
||||
hideStep2Card();
|
||||
// t-paliad-180: dropping the project context makes the perspective
|
||||
// row revert from is-prefilled to is-answered (the "aus Akte" tag
|
||||
// disappears) — repainting the row stack is the only thing needed;
|
||||
// we deliberately leave currentPerspective alone, since the user
|
||||
// may want to keep their pick when returning to Step 1.
|
||||
triggerCascadeRefresh();
|
||||
// t-paliad-164: hint dies with the project context. We deliberately
|
||||
// leave the perspective chip itself alone — the user may want to
|
||||
// keep their pick when returning to Step 1; we only clear the
|
||||
// "vorgegeben durch Akte" annotation since there's no Akte anymore.
|
||||
const hint = document.getElementById("fristen-perspective-hint");
|
||||
if (hint) hint.hidden = true;
|
||||
}
|
||||
|
||||
function renderStep1Summary() {
|
||||
@@ -2292,22 +2287,22 @@ function initPathwayFork() {
|
||||
});
|
||||
});
|
||||
|
||||
// Slice 3c — perspective chip strip in B1 panel. Hydrate from URL
|
||||
// first so the chip reflects ?role=… on initial paint; click flips
|
||||
// state + URL + cascade.
|
||||
// t-paliad-180: perspective + inbox chip strips folded into the
|
||||
// row stack; click wiring now lives inside renderRowStack(). We
|
||||
// still hydrate perspective from URL here so the row stack picks
|
||||
// up the correct picked-value on first paint.
|
||||
applyPerspective(readPerspectiveFromURL());
|
||||
document.querySelectorAll<HTMLButtonElement>(".fristen-perspective-bar .fristen-inbox-chip").forEach((chip) => {
|
||||
chip.addEventListener("click", () => {
|
||||
const isClear = chip.hasAttribute("data-perspective-clear");
|
||||
const next: Perspective = isClear ? null : ((chip.dataset.perspective as Perspective) ?? null);
|
||||
writePerspectiveToURL(next);
|
||||
applyPerspective(next);
|
||||
// t-paliad-164: any chip click is an explicit override; hide the
|
||||
// "vorgegeben durch Akte" hint so the bar reads as "user choice"
|
||||
// from here on.
|
||||
const hint = document.getElementById("fristen-perspective-hint");
|
||||
if (hint) hint.hidden = true;
|
||||
});
|
||||
|
||||
// t-paliad-180: search escape-hatch (Pathway B Direkt-suchen
|
||||
// button) routes to ?mode=filter. Reset link drops the cascade
|
||||
// slug back to root, keeping mode / perspective / inbox alone.
|
||||
document.getElementById("fristen-row-search-link")?.addEventListener("click", () => {
|
||||
setPathwayURL("b", "filter");
|
||||
showBMode("filter");
|
||||
});
|
||||
document.getElementById("fristen-row-reset")?.addEventListener("click", () => {
|
||||
currentActiveRow = null;
|
||||
navigateB1("");
|
||||
});
|
||||
|
||||
// Reselect: drop the locked context, return to Step 1.
|
||||
@@ -2357,16 +2352,9 @@ function initPathwayFork() {
|
||||
navigateToPathway("fork");
|
||||
});
|
||||
|
||||
// B1/B2 mode toggle inside Pathway B.
|
||||
const bModeRadios = document.querySelectorAll<HTMLInputElement>("input[name='fristen-b-mode']");
|
||||
bModeRadios.forEach((r) => {
|
||||
r.addEventListener("change", () => {
|
||||
if (!r.checked) return;
|
||||
const mode: BMode = r.value === "tree" ? "tree" : "filter";
|
||||
setPathwayURL("b", mode);
|
||||
showBMode(mode);
|
||||
});
|
||||
});
|
||||
// t-paliad-180: B1/B2 mode toggle moved into the row stack. The
|
||||
// mode row's click handler (renderRowStack → handleRowPick) drives
|
||||
// setPathwayURL + showBMode now; no more standalone radio.
|
||||
|
||||
// Quick-pick chips on the Step 2 shortcut row → jump straight to
|
||||
// Pathway B + filter mode + prefilled query. Same behaviour as the
|
||||
@@ -2397,17 +2385,11 @@ function initPathwayFork() {
|
||||
renderStep1Summary();
|
||||
if (currentStep1Context.kind !== "none") showStep2Card(); else hideStep2Card();
|
||||
applyPerspective(readPerspectiveFromURL());
|
||||
// t-paliad-164: restore the hint visibility from URL+project state.
|
||||
// The hint shows when the active URL perspective matches what the
|
||||
// current project's our_side would have predefined — i.e. the
|
||||
// "predefined-and-not-yet-overridden" state. Approximation: hint
|
||||
// visible iff project.our_side maps to currentPerspective.
|
||||
const hint = document.getElementById("fristen-perspective-hint");
|
||||
if (hint) {
|
||||
const proj = currentStep1Context.kind === "project" ? currentStep1Context.project : undefined;
|
||||
const expected = ourSideToPerspective(proj?.our_side);
|
||||
hint.hidden = !(proj && proj.our_side && expected === currentPerspective);
|
||||
}
|
||||
// t-paliad-180: the "aus Akte" tag is now part of the prefilled
|
||||
// row state; buildRowStack derives visibility from
|
||||
// project.our_side ↔ currentPerspective inside the renderer, so
|
||||
// no separate DOM hint flip is needed on popstate.
|
||||
currentActiveRow = null;
|
||||
const path = readPathwayFromURL();
|
||||
const mode = readBModeFromURL();
|
||||
showPathway(path, mode);
|
||||
@@ -2523,73 +2505,366 @@ function nodeStepQuestion(n: EventCategoryNode): string {
|
||||
: (n.step_question_en || n.step_question_de || "");
|
||||
}
|
||||
|
||||
function renderB1Cascade(currentSlug: string) {
|
||||
const cascade = document.getElementById("fristen-b1-cascade");
|
||||
if (!cascade || !eventCategoryTree) return;
|
||||
// t-paliad-180 Slice 1 — row-stack rendering.
|
||||
//
|
||||
// The Pathway B shell used to layer four functionally different decision
|
||||
// surfaces with four different visuals: a radio (mode), two pill strips
|
||||
// (perspective + inbox), and a button-grid cascade with a breadcrumb.
|
||||
// They're all "narrow the deadline-rule space" steps; the new model
|
||||
// renders every one of them as the same `.fristen-row` primitive in a
|
||||
// single stack — top-down, persistent, each row showing the question +
|
||||
// the picked answer + an inline "ändern" affordance.
|
||||
//
|
||||
// Slice 1 is visual-only: the narrowing engine (forum + perspective
|
||||
// filters) is unchanged. What changes is presentation — the user can
|
||||
// finally see their full decision path at a glance, and edit any row
|
||||
// without losing the rest. Slice 2 will add project-driven prefills +
|
||||
// auto-walk; Slice 3 polishes mobile + the search escape-hatch.
|
||||
|
||||
const trail = buildBreadcrumb(eventCategoryTree, currentSlug);
|
||||
const node = trail.length > 0 ? trail[trail.length - 1] : null;
|
||||
const rawChildScope = node ? (node.children || []) : eventCategoryTree;
|
||||
// #15 follow-up: drop children whose forums tag doesn't match the
|
||||
// active inbox channel. Nodes with no forums (neutral) stay visible
|
||||
// so the user always reaches court-event / Schriftsatz parents that
|
||||
// span jurisdictions. Slice 3c adds the perspective filter — leaves
|
||||
// tagged with a party that contradicts the active chip get hidden.
|
||||
const childScope = rawChildScope.filter((c) =>
|
||||
inboxFilterAllowsForums(c.forums) && perspectiveAllowsParty(c.party));
|
||||
type RowKind = "mode" | "perspective" | "inbox" | "cascade";
|
||||
type RowState = "active" | "answered" | "prefilled";
|
||||
|
||||
const breadcrumbHtml = trail.length === 0
|
||||
? ""
|
||||
: `<nav class="fristen-b1-breadcrumb" aria-label="Pfad">
|
||||
<button type="button" class="fristen-b1-crumb fristen-b1-crumb--root" data-slug="">
|
||||
${escHtml(t("deadlines.pathway.b.tree.reset"))}
|
||||
</button>
|
||||
${trail.map((c, i) =>
|
||||
`<span class="fristen-b1-crumb-sep" aria-hidden="true">›</span>
|
||||
<button type="button" class="fristen-b1-crumb${i === trail.length - 1 ? " fristen-b1-crumb--current" : ""}" data-slug="${escAttr(c.slug)}">
|
||||
${c.icon ? `<span class="fristen-b1-crumb-icon" aria-hidden="true">${escHtml(c.icon)}</span> ` : ""}${escHtml(nodeLabel(c))}
|
||||
</button>`).join("")}
|
||||
</nav>`;
|
||||
interface RowOption {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
isLeaf?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const question = node && node.step_question_de
|
||||
? `<p class="fristen-b1-question">${escHtml(nodeStepQuestion(node))}</p>`
|
||||
: trail.length === 0
|
||||
? `<p class="fristen-b1-question">${escHtml(t("deadlines.pathway.b.tree.start_question") || "Was ist passiert?")}</p>`
|
||||
: "";
|
||||
interface RowSpec {
|
||||
kind: RowKind;
|
||||
rowId: string;
|
||||
state: RowState;
|
||||
question: string;
|
||||
options: RowOption[];
|
||||
pickedValue?: string;
|
||||
pickedLabel?: string;
|
||||
pickedIcon?: string;
|
||||
// Cascade-only: the slug of the ancestor node whose children populate
|
||||
// this row's options. Used by the click handler to navigate to a
|
||||
// sibling without losing prefix state. Empty string = cascade root.
|
||||
cascadeParentSlug?: string;
|
||||
// Cascade-only: the slug to navigate to when the user clicks "ändern"
|
||||
// on this answered row. Always the row's parent slug (ancestor at
|
||||
// depth K-1), which drops descendants and re-actives this depth.
|
||||
cascadeRevertSlug?: string;
|
||||
}
|
||||
|
||||
let buttonsHtml = "";
|
||||
if (childScope.length > 0) {
|
||||
buttonsHtml = `<div class="fristen-b1-buttons">${
|
||||
childScope.map((c) =>
|
||||
`<button type="button" class="fristen-b1-button${c.is_leaf ? " fristen-b1-button--leaf" : ""}" data-slug="${escAttr(c.slug)}">
|
||||
${c.icon ? `<span class="fristen-b1-button-icon" aria-hidden="true">${escHtml(c.icon)}</span>` : ""}
|
||||
<span class="fristen-b1-button-label">${escHtml(nodeLabel(c))}</span>
|
||||
</button>`).join("")
|
||||
}</div>`;
|
||||
// currentActiveRow tracks which non-cascade row the user has clicked
|
||||
// "ändern" on. null = no override; the deepest cascade step is the
|
||||
// natural active row. Cleared on every successful pick.
|
||||
let currentActiveRow: string | null = null;
|
||||
|
||||
function perspectiveOptionLabel(value: string): string {
|
||||
if (value === "claimant") return t("deadlines.perspective.claimant.short");
|
||||
if (value === "defendant") return t("deadlines.perspective.defendant.short");
|
||||
return t("deadlines.perspective.both.short");
|
||||
}
|
||||
|
||||
function inboxOptionLabel(value: string): string {
|
||||
if (value === "cms") return "CMS";
|
||||
if (value === "bea") return "beA";
|
||||
if (value === "posteingang") return t("deadlines.inbox.posteingang");
|
||||
return t("deadlines.inbox.all");
|
||||
}
|
||||
|
||||
function modeOptionLabel(value: string): string {
|
||||
return value === "filter"
|
||||
? t("deadlines.pathway.b.mode.filter")
|
||||
: t("deadlines.pathway.b.mode.tree");
|
||||
}
|
||||
|
||||
function buildRowStack(currentSlug: string): RowSpec[] {
|
||||
const rows: RowSpec[] = [];
|
||||
|
||||
// R0 — Mode. Always present, defaults to "tree" so a fresh visit
|
||||
// renders as answered. ändern flips to active and lets the user
|
||||
// re-pick; selecting "filter" routes to ?mode=filter (replaces the
|
||||
// cascade with B2 search, handled by the row click wiring).
|
||||
const mode = readBModeFromURL();
|
||||
rows.push({
|
||||
kind: "mode",
|
||||
rowId: "mode",
|
||||
state: currentActiveRow === "mode" ? "active" : "answered",
|
||||
question: t("deadlines.row.mode.question"),
|
||||
options: [
|
||||
{ value: "tree", label: t("deadlines.pathway.b.mode.tree") },
|
||||
{ value: "filter", label: t("deadlines.pathway.b.mode.filter") },
|
||||
],
|
||||
pickedValue: mode,
|
||||
pickedLabel: modeOptionLabel(mode),
|
||||
});
|
||||
|
||||
// R1 — Perspective. Default null ("Beide") = no filter; the row
|
||||
// renders as answered with that label. t-paliad-164 carry-over: a
|
||||
// project-bound perspective shows as `is-prefilled` with a "aus Akte"
|
||||
// tag in the picked-answer area.
|
||||
const perspectiveValue = currentPerspective ?? "";
|
||||
const proj = currentStep1Context.kind === "project" ? currentStep1Context.project : undefined;
|
||||
const expectedFromAkte = ourSideToPerspective(proj?.our_side);
|
||||
const perspectivePrefilled =
|
||||
!!(proj && proj.our_side && expectedFromAkte === currentPerspective && currentPerspective !== null);
|
||||
rows.push({
|
||||
kind: "perspective",
|
||||
rowId: "perspective",
|
||||
state: currentActiveRow === "perspective"
|
||||
? "active"
|
||||
: perspectivePrefilled ? "prefilled" : "answered",
|
||||
question: t("deadlines.perspective.label"),
|
||||
options: [
|
||||
{ value: "claimant", label: t("deadlines.perspective.claimant.short"), title: t("deadlines.perspective.claimant.title") },
|
||||
{ value: "defendant", label: t("deadlines.perspective.defendant.short"), title: t("deadlines.perspective.defendant.title") },
|
||||
{ value: "", label: t("deadlines.perspective.both.short") },
|
||||
],
|
||||
pickedValue: perspectiveValue,
|
||||
pickedLabel: perspectiveOptionLabel(perspectiveValue),
|
||||
});
|
||||
|
||||
// R2 — Inbox channel. Default null ("Alle") = no filter. Slice 2 will
|
||||
// hide this row entirely for UPC projects (forum implies CMS); for
|
||||
// Slice 1 the row is always active/answered like any other.
|
||||
const inboxValue = currentInboxChannel ?? "";
|
||||
rows.push({
|
||||
kind: "inbox",
|
||||
rowId: "inbox",
|
||||
state: currentActiveRow === "inbox" ? "active" : "answered",
|
||||
question: t("deadlines.inbox.label"),
|
||||
options: [
|
||||
{ value: "cms", label: "CMS", title: t("deadlines.inbox.cms.title") },
|
||||
{ value: "bea", label: "beA", title: t("deadlines.inbox.bea.title") },
|
||||
{ value: "posteingang", label: t("deadlines.inbox.posteingang"), title: t("deadlines.inbox.posteingang.title") },
|
||||
{ value: "", label: t("deadlines.inbox.all") },
|
||||
],
|
||||
pickedValue: inboxValue,
|
||||
pickedLabel: inboxOptionLabel(inboxValue),
|
||||
});
|
||||
|
||||
// R3..Rn — Cascade. The trail is the chain of picked nodes from the
|
||||
// root down to the deepest pick. Each picked node renders as an
|
||||
// answered row whose question is the question asked at its parent
|
||||
// scope. When the deepest pick is non-leaf with surviving children
|
||||
// (after forum + perspective filters), an active row materialises
|
||||
// below to demand the next pick.
|
||||
const trail = eventCategoryTree
|
||||
? buildBreadcrumb(eventCategoryTree, currentSlug)
|
||||
: [];
|
||||
let parentScope: EventCategoryNode[] = eventCategoryTree || [];
|
||||
let parentSlug = "";
|
||||
let parentQuestion = t("deadlines.pathway.b.tree.start_question") || "Was ist passiert?";
|
||||
|
||||
for (let i = 0; i < trail.length; i++) {
|
||||
const node = trail[i];
|
||||
const scope = parentScope.filter((c) =>
|
||||
inboxFilterAllowsForums(c.forums) && perspectiveAllowsParty(c.party));
|
||||
rows.push({
|
||||
kind: "cascade",
|
||||
rowId: `cascade:${i}`,
|
||||
state: "answered",
|
||||
question: parentQuestion,
|
||||
options: scope.map(nodeToRowOption),
|
||||
pickedValue: node.slug,
|
||||
pickedLabel: nodeLabel(node),
|
||||
pickedIcon: node.icon,
|
||||
cascadeParentSlug: parentSlug,
|
||||
cascadeRevertSlug: parentSlug,
|
||||
});
|
||||
parentScope = node.children || [];
|
||||
parentSlug = node.slug;
|
||||
parentQuestion = nodeStepQuestion(node) || parentQuestion;
|
||||
}
|
||||
|
||||
// Step-back affordance on any non-root state.
|
||||
let backHtml = "";
|
||||
if (trail.length > 0) {
|
||||
const parentSlug = trail.length > 1 ? trail[trail.length - 2].slug : "";
|
||||
backHtml = `<button type="button" class="fristen-b1-step-back" data-slug="${escAttr(parentSlug)}">
|
||||
← ${escHtml(t("deadlines.pathway.b.tree.step.back"))}
|
||||
</button>`;
|
||||
// Active cascade row below the trail. Skip if the deepest node is a
|
||||
// leaf (no further pick) or if all children get filtered out.
|
||||
const deepest = trail.length > 0 ? trail[trail.length - 1] : null;
|
||||
if (!deepest || !deepest.is_leaf) {
|
||||
const activeScope = parentScope.filter((c) =>
|
||||
inboxFilterAllowsForums(c.forums) && perspectiveAllowsParty(c.party));
|
||||
if (activeScope.length > 0) {
|
||||
rows.push({
|
||||
kind: "cascade",
|
||||
rowId: `cascade:${trail.length}`,
|
||||
state: "active",
|
||||
question: parentQuestion,
|
||||
options: activeScope.map(nodeToRowOption),
|
||||
cascadeParentSlug: parentSlug,
|
||||
cascadeRevertSlug: parentSlug,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
cascade.innerHTML = `${breadcrumbHtml}${question}${buttonsHtml}${backHtml}`;
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Wire button clicks.
|
||||
cascade.querySelectorAll<HTMLButtonElement>(".fristen-b1-button, .fristen-b1-crumb, .fristen-b1-step-back").forEach((btn) => {
|
||||
function nodeToRowOption(c: EventCategoryNode): RowOption {
|
||||
return {
|
||||
value: c.slug,
|
||||
label: nodeLabel(c),
|
||||
icon: c.icon,
|
||||
isLeaf: c.is_leaf,
|
||||
};
|
||||
}
|
||||
|
||||
function rowOptionChipHtml(rowId: string, opt: RowOption, isPicked: boolean): string {
|
||||
const cls = [
|
||||
"fristen-row-chip",
|
||||
opt.isLeaf ? "fristen-row-chip--leaf" : "",
|
||||
isPicked ? "is-picked" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
const titleAttr = opt.title ? ` title="${escAttr(opt.title)}"` : "";
|
||||
const iconHtml = opt.icon
|
||||
? `<span class="fristen-row-chip-icon" aria-hidden="true">${escHtml(opt.icon)}</span>`
|
||||
: "";
|
||||
return `<button type="button" class="${cls}" data-row-id="${escAttr(rowId)}"
|
||||
data-row-value="${escAttr(opt.value)}"${titleAttr}>
|
||||
${iconHtml}<span class="fristen-row-chip-label">${escHtml(opt.label)}</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function rowHtml(row: RowSpec, rowNumber: number): string {
|
||||
const stateClass = `is-${row.state}`;
|
||||
const ariaCurrent = row.state === "active" ? ' aria-current="step"' : "";
|
||||
const dataKindAttr = ` data-row-kind="${escAttr(row.kind)}"`;
|
||||
const dataIdAttr = ` data-row-id="${escAttr(row.rowId)}"`;
|
||||
|
||||
if (row.state === "active") {
|
||||
const chipsHtml = row.options.map((o) =>
|
||||
rowOptionChipHtml(row.rowId, o, o.value === (row.pickedValue ?? ""))).join("");
|
||||
return `<div class="fristen-row ${stateClass}"${ariaCurrent}${dataKindAttr}${dataIdAttr}>
|
||||
<div class="fristen-row-head">
|
||||
<span class="fristen-row-num" aria-hidden="true">${rowNumber}</span>
|
||||
<span class="fristen-row-label">${escHtml(row.question)}</span>
|
||||
</div>
|
||||
<div class="fristen-row-body">${chipsHtml}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// answered + prefilled share the compact head-only layout.
|
||||
const iconHtml = row.pickedIcon
|
||||
? `<span class="fristen-row-answer-icon" aria-hidden="true">${escHtml(row.pickedIcon)}</span>`
|
||||
: "";
|
||||
const prefilledTag = row.state === "prefilled"
|
||||
? `<span class="fristen-row-prefilled-tag" data-i18n="deadlines.row.prefilled.from_akte">aus Akte</span>`
|
||||
: "";
|
||||
return `<div class="fristen-row ${stateClass}"${dataKindAttr}${dataIdAttr}>
|
||||
<div class="fristen-row-head">
|
||||
<span class="fristen-row-num" aria-hidden="true">${rowNumber}</span>
|
||||
<span class="fristen-row-label">${escHtml(row.question)}</span>
|
||||
<span class="fristen-row-answer">
|
||||
<span class="fristen-row-answer-check" aria-hidden="true">✓</span>
|
||||
${iconHtml}<span class="fristen-row-answer-label">${escHtml(row.pickedLabel || "")}</span>
|
||||
${prefilledTag}
|
||||
</span>
|
||||
<button type="button" class="fristen-row-edit" data-row-edit="${escAttr(row.rowId)}">
|
||||
<span data-i18n="deadlines.row.edit">ändern</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderRowStack(currentSlug: string) {
|
||||
const stack = document.getElementById("fristen-row-stack");
|
||||
if (!stack || !eventCategoryTree) return;
|
||||
|
||||
const rows = buildRowStack(currentSlug);
|
||||
stack.innerHTML = rows.map((r, i) => rowHtml(r, i + 1)).join("");
|
||||
|
||||
// Wire chip picks (active rows).
|
||||
stack.querySelectorAll<HTMLButtonElement>(".fristen-row-chip").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const slug = btn.dataset.slug || "";
|
||||
navigateB1(slug);
|
||||
const rowId = btn.dataset.rowId || "";
|
||||
const value = btn.dataset.rowValue || "";
|
||||
handleRowPick(rowId, value);
|
||||
});
|
||||
});
|
||||
|
||||
// Wire "ändern" affordance + whole-row click on answered/prefilled
|
||||
// rows. Whole-row click follows the CLAUDE.md "row-level click handler
|
||||
// that skips inner <a>/<button>" pattern — clicking the ändern button
|
||||
// is handled by its own listener, so the row-level handler skips it.
|
||||
stack.querySelectorAll<HTMLButtonElement>(".fristen-row-edit").forEach((btn) => {
|
||||
btn.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
const rowId = btn.dataset.rowEdit || "";
|
||||
handleRowEdit(rowId);
|
||||
});
|
||||
});
|
||||
stack.querySelectorAll<HTMLDivElement>(".fristen-row.is-answered, .fristen-row.is-prefilled")
|
||||
.forEach((row) => {
|
||||
row.addEventListener("click", (ev) => {
|
||||
const target = ev.target as Element | null;
|
||||
if (target && target.closest("a, button")) return;
|
||||
const rowId = (row as HTMLElement).dataset.rowId || "";
|
||||
handleRowEdit(rowId);
|
||||
});
|
||||
});
|
||||
|
||||
runB1Search(currentSlug);
|
||||
}
|
||||
|
||||
// handleRowPick routes a chip selection in an active row to the right
|
||||
// state mutator + URL writer. Cascade picks navigate to the child slug
|
||||
// (slugs encode the full path, so cascade depth is reconstructed on
|
||||
// every render); the other kinds update their per-kind state + clear
|
||||
// the "currently being edited" override.
|
||||
function handleRowPick(rowId: string, value: string) {
|
||||
if (rowId === "mode") {
|
||||
const next: BMode = value === "filter" ? "filter" : "tree";
|
||||
setPathwayURL("b", next);
|
||||
currentActiveRow = null;
|
||||
showBMode(next);
|
||||
return;
|
||||
}
|
||||
if (rowId === "perspective") {
|
||||
const next: Perspective = value === "claimant" || value === "defendant" ? value : null;
|
||||
writePerspectiveToURL(next);
|
||||
currentActiveRow = null;
|
||||
applyPerspective(next);
|
||||
return;
|
||||
}
|
||||
if (rowId === "inbox") {
|
||||
const next: InboxChannel = value === "cms" || value === "bea" || value === "posteingang"
|
||||
? value : null;
|
||||
writeInboxToURL(next);
|
||||
currentActiveRow = null;
|
||||
applyInboxFilter(next);
|
||||
applyFineForumsFromInbox(next);
|
||||
writeForumsToURL(true);
|
||||
void persistInboxPref(next);
|
||||
return;
|
||||
}
|
||||
if (rowId.startsWith("cascade:")) {
|
||||
currentActiveRow = null;
|
||||
navigateB1(value);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// handleRowEdit re-activates an answered row. For non-cascade rows that
|
||||
// flips the row to active in-place (toggle via currentActiveRow) so the
|
||||
// user can re-pick; the cascade below stays valid. For cascade rows, it
|
||||
// drops descendants by navigating to the row's revert slug — matching
|
||||
// today's breadcrumb-click semantic.
|
||||
function handleRowEdit(rowId: string) {
|
||||
if (rowId === "mode" || rowId === "perspective" || rowId === "inbox") {
|
||||
currentActiveRow = rowId;
|
||||
renderRowStack(readB1PathFromURL());
|
||||
return;
|
||||
}
|
||||
if (rowId.startsWith("cascade:")) {
|
||||
// Derive the revert slug from the trail: ändern on cascade depth K
|
||||
// reverts to depth K-1's slug (or "" at root), which drops
|
||||
// descendants and makes depth K the new active row.
|
||||
const idx = parseInt(rowId.slice("cascade:".length), 10);
|
||||
if (!Number.isFinite(idx)) return;
|
||||
const trail = buildBreadcrumb(eventCategoryTree || [], readB1PathFromURL());
|
||||
const revertSlug = idx > 0 ? (trail[idx - 1]?.slug || "") : "";
|
||||
currentActiveRow = null;
|
||||
navigateB1(revertSlug);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// b1SearchSeq guards against out-of-order responses when the user
|
||||
// click-cascades faster than the network. Only the latest invocation
|
||||
// gets to render its result.
|
||||
@@ -2660,7 +2935,7 @@ async function runB1Search(slug: string) {
|
||||
|
||||
function navigateB1(slug: string) {
|
||||
setB1PathInURL(slug);
|
||||
renderB1Cascade(slug);
|
||||
renderRowStack(slug);
|
||||
}
|
||||
|
||||
// loadAndRenderB1 fetches the tree (cached after first call) and
|
||||
@@ -2670,11 +2945,11 @@ function navigateB1(slug: string) {
|
||||
async function loadAndRenderB1() {
|
||||
try {
|
||||
await loadEventCategoryTree();
|
||||
renderB1Cascade(readB1PathFromURL());
|
||||
renderRowStack(readB1PathFromURL());
|
||||
} catch (e) {
|
||||
const cascade = document.getElementById("fristen-b1-cascade");
|
||||
if (cascade) {
|
||||
cascade.innerHTML = `<div class="fristen-b1-error">${escHtml(t("deadlines.pathway.b.tree.empty"))}</div>`;
|
||||
const stack = document.getElementById("fristen-row-stack");
|
||||
if (stack) {
|
||||
stack.innerHTML = `<div class="fristen-b1-error">${escHtml(t("deadlines.pathway.b.tree.empty"))}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2683,13 +2958,10 @@ async function initB1Cascade() {
|
||||
const panel = document.getElementById("fristen-b1-panel");
|
||||
if (!panel) return;
|
||||
|
||||
// Watch for tree mode becoming visible (Phase B's mode toggle).
|
||||
const treeRadio = document.getElementById("fristen-b-mode-tree") as HTMLInputElement | null;
|
||||
if (treeRadio) {
|
||||
treeRadio.addEventListener("change", () => {
|
||||
if (treeRadio.checked) loadAndRenderB1();
|
||||
});
|
||||
}
|
||||
// t-paliad-180: mode-radio retired; the row-stack's mode-row click
|
||||
// handler drives tree↔filter routing. No standalone change listener
|
||||
// needed here — showBMode() triggers loadAndRenderB1 when the
|
||||
// pathway enters tree mode.
|
||||
|
||||
// Initial render if the URL already lands in tree mode.
|
||||
const sp = new URLSearchParams(window.location.search);
|
||||
@@ -2702,6 +2974,7 @@ async function initB1Cascade() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get("path") === "b" && params.get("mode") === "tree") {
|
||||
// Always re-render — tree may not have loaded yet on first popstate.
|
||||
currentActiveRow = null;
|
||||
loadAndRenderB1();
|
||||
}
|
||||
});
|
||||
@@ -2905,26 +3178,15 @@ function applyFineForumsFromInbox(ch: InboxChannel) {
|
||||
function applyInboxFilter(ch: InboxChannel) {
|
||||
currentInboxChannel = ch;
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>(".fristen-inbox-chip").forEach((btn) => {
|
||||
const slug = btn.dataset.inbox || "";
|
||||
const isClear = btn.hasAttribute("data-inbox-clear");
|
||||
const active = (ch !== null && slug === ch) || (ch === null && isClear);
|
||||
btn.classList.toggle("fristen-inbox-chip--active", active);
|
||||
btn.setAttribute("aria-pressed", active ? "true" : "false");
|
||||
});
|
||||
|
||||
// Re-render the B1 cascade so its button set picks up the new forum
|
||||
// narrowing. Render is a no-op if the tree hasn't loaded yet or the
|
||||
// cascade DOM isn't mounted (Pathway B not visible) — both guards
|
||||
// already inside renderB1Cascade.
|
||||
//
|
||||
// t-paliad-180: the inbox chip strip is gone — chip state now lives
|
||||
// inside the row stack and is repainted on every renderRowStack call.
|
||||
// Pathway A's "Verlauf" is intentionally NOT filtered here (m's
|
||||
// 2026-05-08 feedback: the chip belongs inside the Determinator, not
|
||||
// page-wide). The .proceeding-group [data-forum] attributes stay on
|
||||
// the markup as documentation of intent but no longer drive
|
||||
// visibility.
|
||||
if (eventCategoryTree) {
|
||||
renderB1Cascade(readB1PathFromURL());
|
||||
renderRowStack(readB1PathFromURL());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2997,13 +3259,13 @@ function inboxFilterAllowsForums(forums: string[] | undefined): boolean {
|
||||
return forums.includes(active);
|
||||
}
|
||||
|
||||
// triggerCascadeRefresh re-renders the B1 cascade if the panel is
|
||||
// triggerCascadeRefresh re-renders the B1 row stack if the panel is
|
||||
// mounted. Call after any change that affects activeForumOnPage() or
|
||||
// the perspective filter (chip click, project selection, ad-hoc
|
||||
// selection, clear, perspective chip).
|
||||
// the perspective filter (row pick, project selection, ad-hoc
|
||||
// selection, clear, perspective change).
|
||||
function triggerCascadeRefresh() {
|
||||
if (eventCategoryTree && document.getElementById("fristen-b1-cascade")) {
|
||||
renderB1Cascade(readB1PathFromURL());
|
||||
if (eventCategoryTree && document.getElementById("fristen-row-stack")) {
|
||||
renderRowStack(readB1PathFromURL());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3036,13 +3298,8 @@ function writePerspectiveToURL(p: Perspective, replace = false) {
|
||||
|
||||
function applyPerspective(p: Perspective) {
|
||||
currentPerspective = p;
|
||||
document.querySelectorAll<HTMLButtonElement>(".fristen-perspective-bar .fristen-inbox-chip").forEach((btn) => {
|
||||
const slug = btn.dataset.perspective || "";
|
||||
const isClear = btn.hasAttribute("data-perspective-clear");
|
||||
const active = (p !== null && slug === p) || (p === null && isClear);
|
||||
btn.classList.toggle("fristen-inbox-chip--active", active);
|
||||
btn.setAttribute("aria-pressed", active ? "true" : "false");
|
||||
});
|
||||
// t-paliad-180: chip state lives inside the row stack now; one refresh
|
||||
// repaints the perspective row + re-narrows the cascade options.
|
||||
triggerCascadeRefresh();
|
||||
}
|
||||
|
||||
@@ -3056,33 +3313,23 @@ function ourSideToPerspective(os: string | null | undefined): Perspective {
|
||||
return null;
|
||||
}
|
||||
|
||||
// applyOurSidePredefine locks the perspective chip from
|
||||
// project.our_side when the user hasn't already explicitly picked
|
||||
// one. The URL is the "explicit pick" signal: if ?role= is present
|
||||
// at call time, the user (or a shared link) chose it and we don't
|
||||
// overwrite. When we do predefine, we write the same value to the
|
||||
// URL so back/forward + refresh round-trip cleanly, and we show the
|
||||
// "vorgegeben durch Akte" hint so the user knows where the
|
||||
// pre-selection came from. Clicking a chip clears the hint.
|
||||
// applyOurSidePredefine locks the perspective from project.our_side
|
||||
// when the user hasn't already explicitly picked one. The URL is the
|
||||
// "explicit pick" signal: if ?role= is present at call time, the user
|
||||
// (or a shared link) chose it and we don't overwrite. When we do
|
||||
// predefine, we write the same value to the URL so back/forward +
|
||||
// refresh round-trip cleanly. t-paliad-180: the "aus Akte" tag now
|
||||
// lives inline in the prefilled-row state — no separate hint element.
|
||||
//
|
||||
// `replaceURL=true` is for the deep-link / refresh path; `false` for
|
||||
// in-page project selection so back-button restores the empty state.
|
||||
function applyOurSidePredefine(project: ProjectOption | undefined, replaceURL: boolean) {
|
||||
const hint = document.getElementById("fristen-perspective-hint");
|
||||
if (!project || !project.our_side) {
|
||||
if (hint) hint.hidden = true;
|
||||
return;
|
||||
}
|
||||
// URL wins — user has an explicit pick. Don't clobber it; also no
|
||||
// hint, since the active perspective didn't come from the project.
|
||||
if (readPerspectiveFromURL() !== null) {
|
||||
if (hint) hint.hidden = true;
|
||||
return;
|
||||
}
|
||||
if (!project || !project.our_side) return;
|
||||
// URL wins — user has an explicit pick. Don't clobber it.
|
||||
if (readPerspectiveFromURL() !== null) return;
|
||||
const next = ourSideToPerspective(project.our_side);
|
||||
writePerspectiveToURL(next, replaceURL);
|
||||
applyPerspective(next);
|
||||
if (hint) hint.hidden = false;
|
||||
}
|
||||
|
||||
// perspectiveAllowsParty returns true when a node tagged with `party`
|
||||
@@ -3115,8 +3362,11 @@ async function persistInboxPref(ch: InboxChannel) {
|
||||
}
|
||||
|
||||
async function initInboxFilter() {
|
||||
const bar = document.getElementById("fristen-inbox-bar");
|
||||
if (!bar) return;
|
||||
// t-paliad-180: the standalone inbox chip strip is retired; inbox
|
||||
// state still drives cascade narrowing + B2 fine-bucket sync, just
|
||||
// surfaced through the row-stack row now. This init still hydrates
|
||||
// from URL / saved preference + wires the popstate restore.
|
||||
if (!document.getElementById("fristen-b1-panel")) return;
|
||||
|
||||
let initial: InboxChannel = readInboxFromURL();
|
||||
if (initial === null) {
|
||||
@@ -3144,21 +3394,6 @@ async function initInboxFilter() {
|
||||
writeForumsToURL(true);
|
||||
}
|
||||
|
||||
bar.querySelectorAll<HTMLButtonElement>(".fristen-inbox-chip").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const isClear = btn.hasAttribute("data-inbox-clear");
|
||||
const next: InboxChannel = isClear ? null : ((btn.dataset.inbox as InboxChannel) ?? null);
|
||||
writeInboxToURL(next);
|
||||
applyInboxFilter(next);
|
||||
// User click is an explicit signal: re-narrow the B2 fine chips
|
||||
// to match the new inbox. "Alle" clears both inbox and fine chips
|
||||
// — that's the user's reset affordance.
|
||||
applyFineForumsFromInbox(next);
|
||||
writeForumsToURL(true);
|
||||
void persistInboxPref(next);
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("popstate", () => {
|
||||
const newInbox = readInboxFromURL();
|
||||
applyInboxFilter(newInbox);
|
||||
|
||||
@@ -370,6 +370,13 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.pathway.b.tree.empty": "Keine Treffer für diesen Pfad.",
|
||||
"deadlines.pathway.b.tree.reset": "Neu starten",
|
||||
"deadlines.pathway.b.tree.start_question": "Was ist passiert?",
|
||||
"deadlines.row.mode.question": "Wie suchen?",
|
||||
"deadlines.row.edit": "ändern",
|
||||
"deadlines.row.prefilled.from_akte": "aus Akte",
|
||||
"deadlines.row.reset": "Pfad zurücksetzen",
|
||||
"deadlines.row.reset.title": "Pfad zurücksetzen — alle Cascade-Antworten verwerfen",
|
||||
"deadlines.row.search.link": "Direkt suchen",
|
||||
"deadlines.row.search.link.title": "Direkt nach einer Frist suchen — überspringt den Entscheidungsbaum",
|
||||
"deadlines.inbox.label": "Wo kam es an?",
|
||||
"deadlines.inbox.cms.title": "UPC — über CMS",
|
||||
"deadlines.inbox.bea.title": "Nationale Verfahren — über beA",
|
||||
@@ -2925,6 +2932,13 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.pathway.b.tree.empty": "No matches for this path.",
|
||||
"deadlines.pathway.b.tree.reset": "Restart",
|
||||
"deadlines.pathway.b.tree.start_question": "What happened?",
|
||||
"deadlines.row.mode.question": "How to search?",
|
||||
"deadlines.row.edit": "edit",
|
||||
"deadlines.row.prefilled.from_akte": "from matter",
|
||||
"deadlines.row.reset": "Reset path",
|
||||
"deadlines.row.reset.title": "Reset path — discard all cascade answers",
|
||||
"deadlines.row.search.link": "Search directly",
|
||||
"deadlines.row.search.link.title": "Search directly for a deadline — skips the decision tree",
|
||||
"deadlines.inbox.label": "Where did it arrive?",
|
||||
"deadlines.inbox.cms.title": "UPC — via CMS",
|
||||
"deadlines.inbox.bea.title": "National-DE — via beA",
|
||||
|
||||
@@ -234,78 +234,30 @@ export function renderFristenrechner(): string {
|
||||
<span data-i18n="deadlines.pathway.b.title">Frist eintragen aufgrund Ereignis</span>
|
||||
</h2>
|
||||
|
||||
<div className="fristen-mode-toggle" role="radiogroup" aria-label="B1/B2 mode">
|
||||
<label className="fristen-mode-toggle-option">
|
||||
<input type="radio" name="fristen-b-mode" value="tree" id="fristen-b-mode-tree" />
|
||||
<span data-i18n="deadlines.pathway.b.mode.tree">Schritt-für-Schritt (Entscheidungsbaum)</span>
|
||||
</label>
|
||||
<label className="fristen-mode-toggle-option">
|
||||
<input type="radio" name="fristen-b-mode" value="filter" id="fristen-b-mode-filter" />
|
||||
<span data-i18n="deadlines.pathway.b.mode.filter">Filter / Suche</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* B1 panel — decision tree above + concept-card results below.
|
||||
fristen-b1-cascade hosts the breadcrumb / question / button row.
|
||||
fristen-b1-results hosts the narrowing concept-card list,
|
||||
populated by runB1Search() in fristenrechner.ts. The cards
|
||||
reuse renderConceptCard() (B2's card shape).
|
||||
|
||||
m/paliad#15 follow-up: the inbox-channel chip lives at the
|
||||
top of THIS panel (not page-level) — m's call: "inside the
|
||||
decision tree because it helps us to determine what to do
|
||||
next". The chip narrows the cascade entry-points + B2 fine
|
||||
forum filter; Pathway A's Verlauf doesn't see it. */}
|
||||
{/* B1 panel — row-stack cascade (t-paliad-180 Slice 1).
|
||||
Mode / perspective / inbox / cascade-steps all render as
|
||||
`.fristen-row` instances inside `#fristen-row-stack`. The
|
||||
stack-header above hosts the "Direkt suchen" escape hatch
|
||||
(Pathway B → ?mode=filter) and the "Pfad zurücksetzen"
|
||||
link, both of which used to be embedded in the cascade's
|
||||
breadcrumb. `#fristen-b1-results` is unchanged — concept
|
||||
cards still render below as the cascade narrows. */}
|
||||
<div className="fristen-b1-panel" id="fristen-b1-panel" data-mode="tree" hidden>
|
||||
{/* Slice 3c — perspective chip strip. Klägerseite vs
|
||||
Beklagtenseite hides cascade leaves whose party tag
|
||||
contradicts the user's side. "Beide" / no chip
|
||||
leaves the cascade unfiltered. */}
|
||||
<div className="fristen-perspective-bar" id="fristen-perspective-bar" role="group" aria-label="Perspective">
|
||||
<span className="fristen-inbox-bar-label" data-i18n="deadlines.perspective.label">Ich vertrete:</span>
|
||||
<div className="fristen-inbox-chips">
|
||||
<button type="button" className="fristen-inbox-chip" data-perspective="claimant"
|
||||
data-i18n-title="deadlines.perspective.claimant.title" title="Klägerseite (Proactive)">
|
||||
<span data-i18n="deadlines.perspective.claimant.short">Kläger</span>
|
||||
</button>
|
||||
<button type="button" className="fristen-inbox-chip" data-perspective="defendant"
|
||||
data-i18n-title="deadlines.perspective.defendant.title" title="Beklagtenseite (Reactive)">
|
||||
<span data-i18n="deadlines.perspective.defendant.short">Beklagter</span>
|
||||
</button>
|
||||
<button type="button" className="fristen-inbox-chip fristen-inbox-chip--clear" data-perspective-clear>
|
||||
<span data-i18n="deadlines.perspective.both.short">Beide</span>
|
||||
</button>
|
||||
</div>
|
||||
{/* t-paliad-164 — predefined-from-Akte hint. Hidden by
|
||||
default; client/fristenrechner.ts shows it when the
|
||||
active perspective came from project.our_side. The
|
||||
user can still click another chip to override. */}
|
||||
<span className="fristen-perspective-hint" id="fristen-perspective-hint"
|
||||
data-i18n="deadlines.perspective.predefined_hint" hidden>
|
||||
vorgegeben durch Akte
|
||||
</span>
|
||||
<div className="fristen-row-stack-header">
|
||||
<button type="button" className="fristen-row-search-link" id="fristen-row-search-link"
|
||||
data-i18n-title="deadlines.row.search.link.title"
|
||||
title="Direkt nach einer Frist suchen">
|
||||
<span aria-hidden="true">🔍</span>{" "}
|
||||
<span data-i18n="deadlines.row.search.link">Direkt suchen</span>
|
||||
</button>
|
||||
<button type="button" className="fristen-row-reset-link" id="fristen-row-reset"
|
||||
data-i18n-title="deadlines.row.reset.title"
|
||||
title="Pfad zurücksetzen — alle Cascade-Antworten verwerfen">
|
||||
<span aria-hidden="true">↺</span>{" "}
|
||||
<span data-i18n="deadlines.row.reset">Pfad zurücksetzen</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="fristen-inbox-bar" id="fristen-inbox-bar" role="group" aria-label="Inbox channel">
|
||||
<span className="fristen-inbox-bar-label" data-i18n="deadlines.inbox.label">Wo kam es an?</span>
|
||||
<div className="fristen-inbox-chips">
|
||||
<button type="button" className="fristen-inbox-chip" data-inbox="cms"
|
||||
data-i18n-title="deadlines.inbox.cms.title" title="UPC — über CMS">
|
||||
CMS
|
||||
</button>
|
||||
<button type="button" className="fristen-inbox-chip" data-inbox="bea"
|
||||
data-i18n-title="deadlines.inbox.bea.title" title="Nationale Verfahren — über beA">
|
||||
beA
|
||||
</button>
|
||||
<button type="button" className="fristen-inbox-chip" data-inbox="posteingang"
|
||||
data-i18n-title="deadlines.inbox.posteingang.title" title="Nationale Verfahren — Postzustellung">
|
||||
<span data-i18n="deadlines.inbox.posteingang">Posteingang</span>
|
||||
</button>
|
||||
<button type="button" className="fristen-inbox-chip fristen-inbox-chip--clear" data-inbox-clear>
|
||||
<span data-i18n="deadlines.inbox.all">Alle</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="fristen-b1-cascade" id="fristen-b1-cascade"></div>
|
||||
<div className="fristen-row-stack" id="fristen-row-stack" aria-live="polite"></div>
|
||||
<div className="fristen-b1-results" id="fristen-b1-results" aria-live="polite"></div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1090,6 +1090,13 @@ export type I18nKey =
|
||||
| "deadlines.proceeding.reselect"
|
||||
| "deadlines.proceeding.selected"
|
||||
| "deadlines.reset"
|
||||
| "deadlines.row.edit"
|
||||
| "deadlines.row.mode.question"
|
||||
| "deadlines.row.prefilled.from_akte"
|
||||
| "deadlines.row.reset"
|
||||
| "deadlines.row.reset.title"
|
||||
| "deadlines.row.search.link"
|
||||
| "deadlines.row.search.link.title"
|
||||
| "deadlines.save.cta"
|
||||
| "deadlines.save.cta.adhoc.hint"
|
||||
| "deadlines.save.error"
|
||||
|
||||
@@ -1666,23 +1666,6 @@ input[type="range"]::-moz-range-thumb {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.fristen-mode-toggle {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.fristen-mode-toggle-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.fristen-b1-stub {
|
||||
padding: 1.5rem;
|
||||
border: 1px dashed var(--color-border);
|
||||
@@ -1692,72 +1675,223 @@ input[type="range"]::-moz-range-thumb {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* B1 cascade — Phase C decision-tree UI (t-paliad-133) */
|
||||
|
||||
.fristen-b1-breadcrumb {
|
||||
/* t-paliad-180 Slice 1 — Determinator row-stack primitive.
|
||||
*
|
||||
* Every decision (mode, perspective, inbox, cascade depth N) renders as
|
||||
* a `.fristen-row` with three possible states: `is-active` (user is
|
||||
* picking — expands to show chips), `is-answered` (compact single-line
|
||||
* paper-trail row), `is-prefilled` (answered but the value came from
|
||||
* the project context, marked with a `aus Akte` tag).
|
||||
*
|
||||
* Layout is column-flex: rows stack top-to-bottom, the active row's
|
||||
* chip body wraps as a responsive grid. The whole-row click on an
|
||||
* answered/prefilled row re-activates it (handled via JS row-level
|
||||
* click handlers, never a `::before { inset: 0 }` overlay — see
|
||||
* CLAUDE.md "Whole-card / whole-row click").
|
||||
*/
|
||||
.fristen-row-stack-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.fristen-b1-crumb {
|
||||
.fristen-row-search-link,
|
||||
.fristen-row-reset-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
background: var(--color-bg-muted);
|
||||
gap: 0.35rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.65rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
transition: background 120ms;
|
||||
padding: 0.3rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
transition: border-color 120ms, color 120ms, background 120ms;
|
||||
}
|
||||
|
||||
.fristen-b1-crumb:hover {
|
||||
.fristen-row-search-link:hover,
|
||||
.fristen-row-reset-link:hover {
|
||||
border-color: var(--color-text-muted);
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg-subtle, var(--color-bg-muted));
|
||||
}
|
||||
|
||||
.fristen-row-search-link:focus-visible,
|
||||
.fristen-row-reset-link:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.fristen-row-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.fristen-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
padding: 0.5rem 0.85rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg);
|
||||
transition: background 120ms, border-color 120ms;
|
||||
}
|
||||
|
||||
.fristen-b1-crumb--current {
|
||||
.fristen-row.is-active {
|
||||
background: var(--color-bg-subtle, var(--color-bg-muted));
|
||||
border-color: var(--color-accent);
|
||||
border-left-width: 4px;
|
||||
padding: 0.85rem 1rem;
|
||||
}
|
||||
|
||||
.fristen-row.is-answered {
|
||||
background: transparent;
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.fristen-row.is-prefilled {
|
||||
background: color-mix(in srgb, var(--color-accent) 6%, transparent);
|
||||
border-color: color-mix(in srgb, var(--color-accent) 35%, var(--color-border));
|
||||
border-left-width: 4px;
|
||||
}
|
||||
|
||||
.fristen-row.is-answered,
|
||||
.fristen-row.is-prefilled {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fristen-row.is-answered:hover,
|
||||
.fristen-row.is-prefilled:hover {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-bg-subtle, var(--color-bg-muted));
|
||||
}
|
||||
|
||||
.fristen-row-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
min-height: 1.6rem;
|
||||
}
|
||||
|
||||
.fristen-row-num {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fristen-row.is-active .fristen-row-num {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent-text, #000);
|
||||
color: var(--color-text, #111);
|
||||
}
|
||||
|
||||
.fristen-row.is-prefilled .fristen-row-num {
|
||||
border-color: color-mix(in srgb, var(--color-accent) 50%, var(--color-border));
|
||||
}
|
||||
|
||||
.fristen-row-label {
|
||||
flex: 1 1 auto;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
cursor: default;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.fristen-b1-crumb--root {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.fristen-b1-crumb-sep {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.fristen-b1-question {
|
||||
margin: 0.5rem 0 1rem;
|
||||
.fristen-row.is-active .fristen-row-label {
|
||||
font-weight: 600;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.fristen-row-answer {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.fristen-b1-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 1rem;
|
||||
.fristen-row-answer-check {
|
||||
color: var(--color-accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fristen-b1-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.fristen-row-answer-icon {
|
||||
font-size: 1.05rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.fristen-row-prefilled-tag {
|
||||
margin-left: 0.4rem;
|
||||
padding: 0.05rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-accent) 18%, transparent);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.fristen-row-edit {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.15rem 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
text-underline-offset: 3px;
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 120ms, color 120ms, background 120ms;
|
||||
}
|
||||
|
||||
.fristen-row.is-answered:hover .fristen-row-edit,
|
||||
.fristen-row.is-prefilled .fristen-row-edit,
|
||||
.fristen-row:focus-within .fristen-row-edit {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.fristen-row-edit:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg-muted);
|
||||
}
|
||||
|
||||
.fristen-row-edit:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.fristen-row-body {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 0.5rem;
|
||||
padding: 0.85rem 1rem;
|
||||
margin-left: 2.1rem;
|
||||
}
|
||||
|
||||
.fristen-row-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.65rem 0.85rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg);
|
||||
@@ -1768,32 +1902,49 @@ input[type="range"]::-moz-range-thumb {
|
||||
transition: border-color 120ms, background 120ms;
|
||||
}
|
||||
|
||||
.fristen-b1-button:hover {
|
||||
.fristen-row-chip:hover {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-bg-muted);
|
||||
}
|
||||
|
||||
.fristen-b1-button:focus-visible {
|
||||
.fristen-row-chip:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.fristen-b1-button--leaf {
|
||||
/* Leaf nodes get a subtle marker so users sense they'll see results,
|
||||
not deeper buttons. */
|
||||
.fristen-row-chip.is-picked {
|
||||
border-color: var(--color-accent);
|
||||
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||
}
|
||||
|
||||
.fristen-row-chip--leaf {
|
||||
/* Leaf nodes get a subtle accent marker so users sense they'll see
|
||||
results, not deeper buttons. */
|
||||
border-left: 3px solid var(--color-accent);
|
||||
}
|
||||
|
||||
.fristen-b1-button-icon {
|
||||
font-size: 1.25rem;
|
||||
.fristen-row-chip-icon {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.fristen-b1-button-label {
|
||||
.fristen-row-chip-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.fristen-b1-step-back,
|
||||
@media (max-width: 640px) {
|
||||
.fristen-row-body {
|
||||
grid-template-columns: 1fr;
|
||||
margin-left: 0;
|
||||
}
|
||||
.fristen-row-head {
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.fristen-row-edit {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.fristen-b1-loosen-link {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -1806,13 +1957,11 @@ input[type="range"]::-moz-range-thumb {
|
||||
transition: border-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.fristen-b1-step-back:hover,
|
||||
.fristen-b1-loosen-link:hover {
|
||||
border-color: var(--color-text-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.fristen-b1-step-back:focus-visible,
|
||||
.fristen-b1-loosen-link:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
@@ -1958,94 +2107,6 @@ input[type="range"]::-moz-range-thumb {
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* m/paliad#15 inbox-channel pre-filter — slim chip strip above the
|
||||
pathway fork. Lives between tool-header and pathway-fork; styling
|
||||
echoes .fristen-search-chip but with a clearer "active" treatment so
|
||||
the user sees their persisted pref at a glance. */
|
||||
.fristen-inbox-bar,
|
||||
.fristen-perspective-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin: 0.25rem 0 1rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-border, #e5e5e5);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--color-bg-subtle, #fafafa);
|
||||
}
|
||||
|
||||
/* Perspective bar sits above the inbox bar inside the B1 panel; tighten
|
||||
* the bottom margin so the two strips read as a stacked pair. */
|
||||
.fristen-perspective-bar {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
/* t-paliad-164 — "vorgegeben durch Akte" hint shown next to the
|
||||
* perspective chips when project.our_side has predefined the chip.
|
||||
* Italic, muted, with a subtle leading bullet so it reads as
|
||||
* meta-info rather than a chip. The user can still click another
|
||||
* chip to override; the hint quietly disappears when they do. */
|
||||
.fristen-perspective-hint {
|
||||
font-size: 0.8rem;
|
||||
font-style: italic;
|
||||
color: var(--color-muted, #666);
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
|
||||
.fristen-perspective-hint::before {
|
||||
content: "·\00a0";
|
||||
}
|
||||
|
||||
.fristen-inbox-bar-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-muted, #666);
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.fristen-inbox-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.fristen-inbox-chip {
|
||||
padding: 0.3rem 0.75rem;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 999px;
|
||||
background: var(--color-bg, #fff);
|
||||
color: var(--color-text, #222);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: background 120ms, border-color 120ms, color 120ms;
|
||||
}
|
||||
|
||||
.fristen-inbox-chip:hover {
|
||||
background: var(--color-bg-subtle, #f4f4f4);
|
||||
border-color: var(--color-text-muted, #aaa);
|
||||
}
|
||||
|
||||
.fristen-inbox-chip:focus-visible {
|
||||
outline: 2px solid var(--color-accent, #c6f41c);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.fristen-inbox-chip--active {
|
||||
background: var(--color-accent, #c6f41c);
|
||||
border-color: var(--color-accent, #c6f41c);
|
||||
color: var(--color-text, #111);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fristen-inbox-chip--clear {
|
||||
color: var(--color-muted, #666);
|
||||
}
|
||||
|
||||
.fristen-inbox-chip--clear.fristen-inbox-chip--active {
|
||||
color: var(--color-text, #111);
|
||||
}
|
||||
|
||||
.fristen-search-results,
|
||||
.fristen-b1-results {
|
||||
margin-top: 1rem;
|
||||
@@ -10988,8 +11049,8 @@ dialog.quick-add-sheet::backdrop {
|
||||
.admin-team-filter-row,
|
||||
.fristen-pathway-fork,
|
||||
.fristen-pathway-back,
|
||||
.fristen-mode-toggle,
|
||||
.fristen-b1-cascade,
|
||||
.fristen-row-stack-header,
|
||||
.fristen-row-stack,
|
||||
.fristen-search,
|
||||
.fristen-search-chips,
|
||||
.fristen-view-toggle,
|
||||
|
||||
Reference in New Issue
Block a user