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:
mAi
2026-05-16 00:38:19 +02:00
parent 84aadc838a
commit ff8f95abaa
5 changed files with 675 additions and 406 deletions

View File

@@ -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">&#10003;</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">&auml;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);

View File

@@ -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",

View File

@@ -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&uuml;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&auml;gerseite (Proactive)">
<span data-i18n="deadlines.perspective.claimant.short">Kl&auml;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">&#128269;</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&uuml;cksetzen — alle Cascade-Antworten verwerfen">
<span aria-hidden="true">&#8634;</span>{" "}
<span data-i18n="deadlines.row.reset">Pfad zur&uuml;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 &mdash; &uuml;ber CMS">
CMS
</button>
<button type="button" className="fristen-inbox-chip" data-inbox="bea"
data-i18n-title="deadlines.inbox.bea.title" title="Nationale Verfahren &mdash; &uuml;ber beA">
beA
</button>
<button type="button" className="fristen-inbox-chip" data-inbox="posteingang"
data-i18n-title="deadlines.inbox.posteingang.title" title="Nationale Verfahren &mdash; 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>

View File

@@ -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"

View File

@@ -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,