From f86d758f9b21bef49df2259754730549a5fc8ea7 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 31 Jul 2026 14:02:34 -0700 Subject: [PATCH] FN-8630: balance Task Detail scrollbar insets Keep Task Detail content symmetrically inset when its body scrolls. - Reserve stable scrollbar gutters on both inline edges of the scrollable detail body. - Add deterministic coverage for modal, pop-out, and embedded detail inset symmetry. - Publish a patch changeset for the layout correction. Files changed: .changeset/fn-8630-task-detail-right-padding.md | 7 + .../__tests__/task-detail-inset-symmetry.test.ts | 222 +++++++++++++++++++++ .../dashboard/app/components/TaskDetailModal.css | 15 ++ 3 files changed, 244 insertions(+) Fusion-Task-Id: FN-8630 Fusion-Task-Lineage: 9fd0c2bd-3370-4f86-ad12-9f06e5172c5b Co-authored-by: Fusion (runfusion.ai) --- .../fn-8630-task-detail-right-padding.md | 7 + .../task-detail-inset-symmetry.test.ts | 222 ++++++++++++++++++ .../app/components/TaskDetailModal.css | 15 ++ 3 files changed, 244 insertions(+) create mode 100644 .changeset/fn-8630-task-detail-right-padding.md create mode 100644 packages/dashboard/app/__tests__/task-detail-inset-symmetry.test.ts diff --git a/.changeset/fn-8630-task-detail-right-padding.md b/.changeset/fn-8630-task-detail-right-padding.md new file mode 100644 index 0000000000..84604b491f --- /dev/null +++ b/.changeset/fn-8630-task-detail-right-padding.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Keep Task Detail content centered when its body scrolls. +category: fix +dev: Uses stable both-edge scrollbar gutters with a deterministic inset regression model. diff --git a/packages/dashboard/app/__tests__/task-detail-inset-symmetry.test.ts b/packages/dashboard/app/__tests__/task-detail-inset-symmetry.test.ts new file mode 100644 index 0000000000..892f857d8d --- /dev/null +++ b/packages/dashboard/app/__tests__/task-detail-inset-symmetry.test.ts @@ -0,0 +1,222 @@ +import { render } from "@testing-library/react"; +import { createElement } from "react"; +import { describe, expect, it } from "vitest"; +import { loadAllAppCss } from "../test/cssFixture"; + +/* +FNXC:TaskDetailLayout 2026-07-31-20:50: +FN-8630 diagnoses the desktop/tablet residual end inset as the UA scrollbar reservation on +`.detail-body`, not header controls, section padding, or a breakpoint cascade. jsdom has no +layout or numeric native scrollbar width, so this test emulates the shell box model rather than +measuring geometry: ordered matching declarations expand padding shorthands, map LTR physical +left/right to logical start/end, and sum root, body/header, and section padding. + +Long content uses SCROLLBAR_GUTTER_PX only as a deterministic stand-in for UA reservation; it +must never be replaced with a measured native scrollbar. Absent content contributes no gutter; +`auto`/unset contributes end only, `stable` contributes end only, and `stable both-edges` +contributes both sides. FN-8630 requires the latter contract on `.detail-body` so modal, +pop-out, and embedded Task Detail shells remain symmetric at every breakpoint. The separate +FN-8624 first-row overlay-clearance assertions preserve the tokenized exception that prevents +`.activity-expand-toggle--overlay` from covering log text. +*/ + +export const SCROLLBAR_GUTTER_PX = 12; + +type Inset = { start: number; end: number }; +type CssRule = { selectors: string[]; declarations: Map; media?: string }; +type ShellVariant = "modal" | "pop-out" | "embedded"; + +const DEFAULT_PADDING: Inset = { start: 0, end: 0 }; + +function splitCssValues(value: string): string[] { + const values: string[] = []; + let token = ""; + let depth = 0; + for (const character of value.trim()) { + if (character === "(") depth += 1; + if (character === ")") depth -= 1; + if (/\s/.test(character) && depth === 0) { + if (token) values.push(token), token = ""; + } else { + token += character; + } + } + if (token) values.push(token); + return values; +} + +function parseDeclarations(block: string): Map { + return new Map( + block.split(";").flatMap((declaration) => { + const colon = declaration.indexOf(":"); + if (colon === -1) return []; + return [[declaration.slice(0, colon).trim(), declaration.slice(colon + 1).trim()] as const]; + }), + ); +} + +function parseRules(css: string, media?: string): CssRule[] { + const rules: CssRule[] = []; + let cursor = 0; + while (cursor < css.length) { + const open = css.indexOf("{", cursor); + if (open === -1) break; + const prelude = css.slice(cursor, open).trim(); + let depth = 1; + let close = open + 1; + while (close < css.length && depth > 0) { + if (css[close] === "{") depth += 1; + if (css[close] === "}") depth -= 1; + close += 1; + } + const block = css.slice(open + 1, close - 1); + if (prelude.startsWith("@media")) { + rules.push(...parseRules(block, prelude)); + } else if (!prelude.startsWith("@")) { + rules.push({ selectors: prelude.split(",").map((selector) => selector.trim()), declarations: parseDeclarations(block), media }); + } + cursor = close; + } + return rules; +} + +function mediaMatches(media: string | undefined, width: number): boolean { + if (!media) return true; + const min = media.match(/min-width:\s*(\d+(?:\.\d+)?)px/)?.[1]; + const max = media.match(/max-width:\s*(\d+(?:\.\d+)?)px/)?.[1]; + return (!min || width >= Number(min)) && (!max || width <= Number(max)); +} + +function matchesElement(selector: string, element: "root" | "header" | "body" | "section", variant: ShellVariant): boolean { + const terminalClass = { + root: "task-detail-content", + header: "modal-header", + body: "detail-body", + section: "detail-section", + }[element]; + const terminal = selector.trim().split(/[ >+~]/).filter(Boolean).at(-1) ?? ""; + if (!terminal.split(":")[0].split(".").includes(terminalClass)) return false; + if (selector.includes("task-detail-content--embedded") && variant !== "embedded") return false; + if (selector.includes("floating-window--task-detail") && variant !== "pop-out") return false; + return true; +} + +function resolveVariables(value: string, variables: Map): string { + let resolved = value; + for (let iteration = 0; iteration < 8 && resolved.includes("var("); iteration += 1) { + resolved = resolved.replace(/var\((--[\w-]+)(?:,\s*[^)]+)?\)/g, (_match, name: string) => variables.get(name) ?? "0px"); + } + return resolved; +} + +function cssNumber(value: string, variables: Map): number { + const expression = resolveVariables(value, variables) + .replace(/calc\(/g, "(") + .replace(/px\b/g, "") + .trim(); + if (!/^[\d.()+\-*/\s]+$/.test(expression)) throw new Error(`Unsupported deterministic inset value: ${value}`); + return Number(Function(`"use strict"; return (${expression});`)()); +} + +function applyPadding(style: Inset, property: string, value: string, variables: Map): Inset { + const next = { ...style }; + const values = splitCssValues(resolveVariables(value, variables)).map((part) => cssNumber(part, variables)); + if (property === "padding") { + next.start = values.length === 1 ? values[0]! : values[3] ?? values[1]!; + next.end = values.length === 1 ? values[0]! : values[1]!; + } else if (property === "padding-inline") { + next.start = values[0]!; + next.end = values[1] ?? values[0]!; + } else if (property === "padding-inline-start" || property === "padding-left") { + next.start = values[0]!; + } else if (property === "padding-inline-end" || property === "padding-right") { + next.end = values[0]!; + } + return next; +} + +function rootVariables(rules: CssRule[]): Map { + const variables = new Map(); + for (const rule of rules) { + if (!rule.selectors.includes(":root")) continue; + for (const [property, value] of rule.declarations) if (property.startsWith("--")) variables.set(property, value); + } + return variables; +} + +function resolvedElementStyle(rules: CssRule[], variables: Map, width: number, variant: ShellVariant, element: "root" | "header" | "body" | "section"): { inset: Inset; overflowY?: string; scrollbarGutter?: string } { + let inset = { ...DEFAULT_PADDING }; + let overflowY: string | undefined; + let scrollbarGutter: string | undefined; + for (const rule of rules) { + if (!mediaMatches(rule.media, width) || !rule.selectors.some((selector) => matchesElement(selector, element, variant))) continue; + for (const [property, value] of rule.declarations) { + if (["padding", "padding-inline", "padding-inline-start", "padding-inline-end", "padding-left", "padding-right"].includes(property)) { + inset = applyPadding(inset, property, value, variables); + } + if (property === "overflow-y") overflowY = value; + if (property === "scrollbar-gutter") scrollbarGutter = value; + } + } + return { inset, overflowY, scrollbarGutter }; +} + +/** Resolves the deterministic FN-8630 effective inset model; this is intentionally exported as the shared test helper. */ +export function resolveTaskDetailInsets(css: string, width: number, variant: ShellVariant, scrollbarPresent: boolean): { body: Inset; header: Inset; scrollbarGutter?: string } { + document.documentElement.dir = "ltr"; + Object.defineProperty(window, "innerWidth", { configurable: true, value: width }); + const rules = parseRules(css.replace(/\/\*[\s\S]*?\*\//g, "")); + const variables = rootVariables(rules); + const root = resolvedElementStyle(rules, variables, width, variant, "root").inset; + const bodyStyle = resolvedElementStyle(rules, variables, width, variant, "body"); + const section = resolvedElementStyle(rules, variables, width, variant, "section").inset; + const headerStyle = resolvedElementStyle(rules, variables, width, variant, "header"); + const gutter = scrollbarPresent && /^(auto|scroll)$/.test(bodyStyle.overflowY ?? "") + ? bodyStyle.scrollbarGutter === "stable both-edges" + ? { start: SCROLLBAR_GUTTER_PX, end: SCROLLBAR_GUTTER_PX } + : { start: 0, end: SCROLLBAR_GUTTER_PX } + : DEFAULT_PADDING; + return { + body: { start: root.start + bodyStyle.inset.start + section.start + gutter.start, end: root.end + bodyStyle.inset.end + section.end + gutter.end }, + header: { start: root.start + headerStyle.inset.start, end: root.end + headerStyle.inset.end }, + scrollbarGutter: bodyStyle.scrollbarGutter, + }; +} + +function renderShell(variant: ShellVariant): void { + const embedded = variant === "embedded" ? " task-detail-content--embedded" : ""; + const popOut = variant === "pop-out" ? "floating-window--task-detail" : ""; + render(createElement("div", { className: popOut }, createElement("div", { className: `task-detail-content${embedded}` }, createElement("header", { className: "modal-header" }), createElement("main", { className: "detail-body" }, createElement("section", { className: "detail-section" }))))); +} + +describe("FN-8630 Task Detail effective inset symmetry", () => { + const css = loadAllAppCss(); + const taskDetailCss = css.slice(css.indexOf("/* === Detail Modal ==="), css.indexOf("/* === Detail Modal ===") + css.slice(css.indexOf("/* === Detail Modal ===")).length); + + it("keeps the modeled shell inset symmetric across all required variants, widths, and scrollbar states", () => { + for (const width of [1280, 900, 420]) { + for (const variant of ["modal", "pop-out", "embedded"] as const) { + for (const scrollbarPresent of [false, true]) { + renderShell(variant); + const insets = resolveTaskDetailInsets(css, width, variant, scrollbarPresent); + expect(insets.body, `${variant} ${width}px scrollbar=${scrollbarPresent}`).toEqual({ start: insets.body.start, end: insets.body.start }); + expect(insets.header, `${variant} ${width}px header`).toEqual({ start: insets.header.start, end: insets.header.start }); + } + } + } + }); + + it("pins the diagnosed stable both-edges scrollbar-gutter contract", () => { + for (const width of [1280, 900, 420]) { + expect(resolveTaskDetailInsets(css, width, "modal", true).scrollbarGutter).toBe("stable both-edges"); + } + }); + + it("retains FN-8624 first-row overlay clearance while interventions remain inset-free", () => { + expect(css).toMatch(/\.detail-activity:not\(\.detail-activity--interventions\) > h4[\s\S]*?padding-inline-end:\s*calc\(var\(--space-2xl\) \+ var\(--space-md\)\)/); + const activityIndex = taskDetailCss.indexOf(".detail-activity {"); + const mobileCss = taskDetailCss.slice(taskDetailCss.indexOf("@media (max-width: 768px)", activityIndex)); + expect(mobileCss).toMatch(/\.detail-activity:not\(\.detail-activity--interventions\) > h4[\s\S]*?padding-inline-end:\s*calc\(var\(--space-2xl\) \+ var\(--space-sm\)\)/); + expect(css).toMatch(/\.detail-activity--interventions\s*\{\s*padding-inline-end:\s*0;/); + }); +}); diff --git a/packages/dashboard/app/components/TaskDetailModal.css b/packages/dashboard/app/components/TaskDetailModal.css index 34dd9e44dd..2fe92217d4 100644 --- a/packages/dashboard/app/components/TaskDetailModal.css +++ b/packages/dashboard/app/components/TaskDetailModal.css @@ -89,11 +89,26 @@ Task detail workflow badges share the board badge's slight token-based icon-to-l color: var(--done); } +/* +FNXC:TaskDetailLayout 2026-07-31-20:52: +FN-8630 removes the residual desktop/tablet end-side inset that FN-8624 did not +cover: `.detail-body`'s thin native scrollbar reserved space only at inline-end. +`stable both-edges` keeps effective Task Detail shell insets symmetric for modal, +pop-out, and embedded variants at every breakpoint without measuring or compensating +for a UA scrollbar width. + +The overlay-clearance exception remains deliberately child-local: non-Interventions +`.detail-activity` first rows retain `calc(var(--space-2xl) + var(--space-md))` +end clearance on desktop/tablet and `calc(var(--space-2xl) + var(--space-sm))` on +mobile so `.activity-expand-toggle--overlay` never covers log text (FN-8166/FN-8624). +The FN-7581 `.detail-activity--interventions` container remains at zero end inset. +*/ .detail-body { padding: calc(var(--space-lg) + var(--space-xs)); min-width: 0; overflow-x: hidden; overflow-y: auto; + scrollbar-gutter: stable both-edges; scrollbar-color: var(--border) transparent; scrollbar-width: thin; flex: 1;