FN-8634: balance Task Detail and terminal right padding

Balance perceived right-edge spacing across Task Detail and terminal shells.

- Move content padding outside scrollbar-owning Task Detail surfaces.
- Account for injected terminal viewport scrollbar tracks across modal variants.
- Add layout regression coverage and a patch changeset.

Files changed:
 .changeset/fn-8634-task-detail-right-padding.md    |   7 +
 .../__tests__/detail-body-mobile-overflow.test.ts  |   8 +-
 .../__tests__/task-detail-inset-symmetry.test.ts   | 271 +++++++++++----------
 .../dashboard/app/components/SessionTerminal.css   |  16 +-
 .../dashboard/app/components/SessionTerminal.tsx   |  14 +-
 .../dashboard/app/components/TaskDetailModal.css   |  49 ++--
 .../dashboard/app/components/TaskDetailModal.tsx   |  48 ++--
 .../dashboard/app/components/TerminalModal.css     |  11 +-
 .../components/__tests__/TaskChangesTab.test.tsx   |  23 +-
 ...etailModal.responsive-and-dependencies.test.tsx |  16 +-
 10 files changed, 273 insertions(+), 190 deletions(-)

Fusion-Task-Id: FN-8634

Fusion-Task-Lineage: 58cce580-5da0-47cf-a325-20b3707dbeef

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-31 18:32:43 -07:00
parent 42a2699b9a
commit c0ddadd746
10 changed files with 271 additions and 188 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Balance perceived right-edge padding in Task Detail and Terminal shells.
category: fix
dev: Moves content padding outside scrollbar-owning surfaces so painted tracks do not read as empty inset.

View File

@@ -32,7 +32,7 @@ describe("detail-body mobile overflow (FN-1331)", () => {
expect(rule).toContain("overflow-y: auto"); expect(rule).toContain("overflow-y: auto");
}); });
it("mobile .detail-body rule uses tokenized 14px-equivalent padding", () => { it("mobile .detail-body-content uses tokenized 14px-equivalent padding outside the scroller", () => {
const detailModalCss = readFileSync( const detailModalCss = readFileSync(
resolve(__dirname, "../components/TaskDetailModal.css"), resolve(__dirname, "../components/TaskDetailModal.css"),
"utf-8", "utf-8",
@@ -41,9 +41,9 @@ describe("detail-body mobile overflow (FN-1331)", () => {
expect(mobileBlockMatch).toBeTruthy(); expect(mobileBlockMatch).toBeTruthy();
const mobileBlock = mobileBlockMatch![1]; const mobileBlock = mobileBlockMatch![1];
const detailBodyMatch = mobileBlock.match(/\.detail-body\s*\{[^}]*\}/s); const detailBodyContentMatch = mobileBlock.match(/\.detail-body-content\s*\{[^}]*\}/s);
expect(detailBodyMatch).toBeTruthy(); expect(detailBodyContentMatch).toBeTruthy();
expect(detailBodyMatch![0]).toContain( expect(detailBodyContentMatch![0]).toContain(
"padding: calc(var(--space-md) + var(--space-xs) / 2);", "padding: calc(var(--space-md) + var(--space-xs) / 2);",
); );
}); });

View File

@@ -1,32 +1,30 @@
import { render } from "@testing-library/react";
import { createElement } from "react";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { loadAllAppCss } from "../test/cssFixture"; import { loadAllAppCss } from "../test/cssFixture";
/* /*
FNXC:TaskDetailLayout 2026-07-31-20:50: FNXC:TaskDetailLayout 2026-08-01-01:00:
FN-8630 diagnoses the desktop/tablet residual end inset as the UA scrollbar reservation on FN-8634 models perceived empty shell inset from resolved stylesheet declarations rather than
`.detail-body`, not header controls, section padding, or a breakpoint cascade. jsdom has no from jsdom geometry. A painted authored or injected end-side track occupies its band and is
layout or numeric native scrollbar width, so this test emulates the shell box model rather than subtracted; an unpainted reserved gutter remains visible whitespace. SCROLLBAR_GUTTER_PX is a
measuring geometry: ordered matching declarations expand padding shorthands, map LTR physical deterministic stand-in only and must never become a measured/native scrollbar value.
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 Task Detail formerly combined its padding with an authored both-edges gutter, while Terminal
must never be replaced with a measured native scrollbar. Absent content contributes no gutter; combined xterm's injected viewport track with xterm padding. Each mechanism fails only while a
`auto`/unset contributes end only, `stable` contributes end only, and `stable both-edges` track is painted. The attribution table resolves every chain, breakpoint, and variant from CSS;
contributes both sides. FN-8630 requires the latter contract on `.detail-body` so modal, Activity overlay clearance is deliberately excluded because it protects log text, not shell inset.
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; export const SCROLLBAR_GUTTER_PX = 12;
type Inset = { start: number; end: number }; type Inset = { start: number; end: number };
type CssRule = { selectors: string[]; declarations: Map<string, string>; media?: string }; type ScrollbarState = "present" | "absent";
type ShellVariant = "modal" | "pop-out" | "embedded"; type Variant = "modal" | "pop-out" | "embedded" | "dock" | "floating";
type Rule = { selectors: string[]; declarations: Map<string, string>; media?: string };
type Style = { inset: Inset; overflowY?: string; overflowX?: string; scrollbarGutter?: string; scrollbarWidth?: string };
type Attribution = { name: string; elements: string[]; trackOwner?: string; injectedViewport?: boolean };
const DEFAULT_PADDING: Inset = { start: 0, end: 0 }; const EMPTY_INSET: Inset = { start: 0, end: 0 };
const css = loadAllAppCss();
function splitCssValues(value: string): string[] { function splitCssValues(value: string): string[] {
const values: string[] = []; const values: string[] = [];
@@ -37,49 +35,42 @@ function splitCssValues(value: string): string[] {
if (character === ")") depth -= 1; if (character === ")") depth -= 1;
if (/\s/.test(character) && depth === 0) { if (/\s/.test(character) && depth === 0) {
if (token) values.push(token), token = ""; if (token) values.push(token), token = "";
} else { } else token += character;
token += character;
}
} }
if (token) values.push(token); if (token) values.push(token);
return values; return values;
} }
function parseDeclarations(block: string): Map<string, string> { function parseDeclarations(block: string): Map<string, string> {
return new Map( return new Map(block.split(";").flatMap((declaration) => {
block.split(";").flatMap((declaration) => { const colon = declaration.indexOf(":");
const colon = declaration.indexOf(":"); return colon < 0 ? [] : [[declaration.slice(0, colon).trim(), declaration.slice(colon + 1).trim()] as const];
if (colon === -1) return []; }));
return [[declaration.slice(0, colon).trim(), declaration.slice(colon + 1).trim()] as const];
}),
);
} }
function parseRules(css: string, media?: string): CssRule[] { function parseRules(source: string, media?: string): Rule[] {
const rules: CssRule[] = []; const rules: Rule[] = [];
let cursor = 0; for (let cursor = 0; cursor < source.length;) {
while (cursor < css.length) { const open = source.indexOf("{", cursor);
const open = css.indexOf("{", cursor); if (open < 0) break;
if (open === -1) break; const prelude = source.slice(cursor, open).trim();
const prelude = css.slice(cursor, open).trim();
let depth = 1; let depth = 1;
let close = open + 1; let close = open + 1;
while (close < css.length && depth > 0) { while (close < source.length && depth) {
if (css[close] === "{") depth += 1; if (source[close] === "{") depth += 1;
if (css[close] === "}") depth -= 1; if (source[close] === "}") depth -= 1;
close += 1; close += 1;
} }
const block = css.slice(open + 1, close - 1); const block = source.slice(open + 1, close - 1);
if (prelude.startsWith("@media")) { if (prelude.startsWith("@media")) rules.push(...parseRules(block, prelude));
rules.push(...parseRules(block, prelude)); else if (!prelude.startsWith("@")) rules.push({ selectors: prelude.split(",").map((selector) => selector.trim()), declarations: parseDeclarations(block), media });
} else if (!prelude.startsWith("@")) {
rules.push({ selectors: prelude.split(",").map((selector) => selector.trim()), declarations: parseDeclarations(block), media });
}
cursor = close; cursor = close;
} }
return rules; return rules;
} }
const rules = parseRules(css.replace(/\/\*[\s\S]*?\*\//g, ""));
function mediaMatches(media: string | undefined, width: number): boolean { function mediaMatches(media: string | undefined, width: number): boolean {
if (!media) return true; if (!media) return true;
const min = media.match(/min-width:\s*(\d+(?:\.\d+)?)px/)?.[1]; const min = media.match(/min-width:\s*(\d+(?:\.\d+)?)px/)?.[1];
@@ -87,23 +78,18 @@ function mediaMatches(media: string | undefined, width: number): boolean {
return (!min || width >= Number(min)) && (!max || width <= Number(max)); return (!min || width >= Number(min)) && (!max || width <= Number(max));
} }
function matchesElement(selector: string, element: "root" | "header" | "body" | "section", variant: ShellVariant): boolean { function variablesAt(width: number): Map<string, string> {
const terminalClass = { const variables = new Map<string, string>();
root: "task-detail-content", for (const rule of rules) {
header: "modal-header", if (!mediaMatches(rule.media, width) || !rule.selectors.includes(":root")) continue;
body: "detail-body", for (const [property, value] of rule.declarations) if (property.startsWith("--")) variables.set(property, value);
section: "detail-section", }
}[element]; return variables;
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, string>): string { function resolveVariables(value: string, variables: Map<string, string>): string {
let resolved = value; let resolved = value;
for (let iteration = 0; iteration < 8 && resolved.includes("var("); iteration += 1) { for (let index = 0; index < 8 && resolved.includes("var("); index += 1) {
resolved = resolved.replace(/var\((--[\w-]+)(?:,\s*[^)]+)?\)/g, (_match, name: string) => variables.get(name) ?? "0px"); resolved = resolved.replace(/var\((--[\w-]+)(?:,\s*[^)]+)?\)/g, (_match, name: string) => variables.get(name) ?? "0px");
} }
return resolved; return resolved;
@@ -111,6 +97,9 @@ function resolveVariables(value: string, variables: Map<string, string>): string
function cssNumber(value: string, variables: Map<string, string>): number { function cssNumber(value: string, variables: Map<string, string>): number {
const expression = resolveVariables(value, variables) const expression = resolveVariables(value, variables)
// Safe-area fallbacks are block-axis-only on the modeled mobile shells; resolve their
// declared fallback rather than asking jsdom for a physical viewport value.
.replace(/env\([^,]+,\s*([^)]+)\)/g, "$1")
.replace(/calc\(/g, "(") .replace(/calc\(/g, "(")
.replace(/px\b/g, "") .replace(/px\b/g, "")
.trim(); .trim();
@@ -118,105 +107,131 @@ function cssNumber(value: string, variables: Map<string, string>): number {
return Number(Function(`"use strict"; return (${expression});`)()); return Number(Function(`"use strict"; return (${expression});`)());
} }
function applyPadding(style: Inset, property: string, value: string, variables: Map<string, string>): Inset { function applyInset(inset: Inset, property: string, value: string, variables: Map<string, string>): Inset {
const next = { ...style }; const next = { ...inset };
const values = splitCssValues(resolveVariables(value, variables)).map((part) => cssNumber(part, variables)); const values = splitCssValues(resolveVariables(value, variables)).map((item) => cssNumber(item, variables));
if (property === "padding") { if (property === "padding" || property === "border-width") {
next.start = values.length === 1 ? values[0]! : values[3] ?? values[1]!; next.start = values.length === 1 ? values[0]! : values[3] ?? values[1]!;
next.end = values.length === 1 ? values[0]! : values[1]!; next.end = values.length === 1 ? values[0]! : values[1]!;
} else if (property === "padding-inline") { } else if (property === "padding-inline" || property === "border-inline-width") {
next.start = values[0]!; next.start = values[0]!;
next.end = values[1] ?? values[0]!; next.end = values[1] ?? values[0]!;
} else if (property === "padding-inline-start" || property === "padding-left") { } else if (["padding-inline-start", "padding-left", "border-inline-start-width", "border-left-width"].includes(property)) next.start = values[0]!;
next.start = values[0]!; else if (["padding-inline-end", "padding-right", "border-inline-end-width", "border-right-width"].includes(property)) next.end = values[0]!;
} else if (property === "padding-inline-end" || property === "padding-right") {
next.end = values[0]!;
}
return next; return next;
} }
function rootVariables(rules: CssRule[]): Map<string, string> { function selectorMatches(selector: string, element: string, variant: Variant): boolean {
const variables = new Map<string, string>(); const finalToken = selector.trim().split(/[ >+~]/).filter(Boolean).at(-1)?.replace(/:{1,2}[\w()-]+/g, "") ?? "";
for (const rule of rules) { if (!finalToken.split(".").includes(element)) return false;
if (!rule.selectors.includes(":root")) continue; if (selector.includes("task-detail-content--embedded") && variant !== "embedded") return false;
for (const [property, value] of rule.declarations) if (property.startsWith("--")) variables.set(property, value); if (selector.includes("floating-window--task-detail") && variant !== "pop-out") return false;
} if (selector.includes("terminal-modal--embedded") && variant !== "embedded") return false;
return variables; if (selector.includes("terminal-modal--floating") && variant !== "floating") return false;
if (selector.includes("terminal-modal--dock") && variant !== "dock") return false;
return true;
} }
function resolvedElementStyle(rules: CssRule[], variables: Map<string, string>, width: number, variant: ShellVariant, element: "root" | "header" | "body" | "section"): { inset: Inset; overflowY?: string; scrollbarGutter?: string } { function resolvedStyle(element: string, width: number, variant: Variant, sourceRules = rules): Style {
let inset = { ...DEFAULT_PADDING }; const variables = variablesAt(width);
let inset = { ...EMPTY_INSET };
let overflowY: string | undefined; let overflowY: string | undefined;
let overflowX: string | undefined;
let scrollbarGutter: string | undefined; let scrollbarGutter: string | undefined;
for (const rule of rules) { let scrollbarWidth: string | undefined;
if (!mediaMatches(rule.media, width) || !rule.selectors.some((selector) => matchesElement(selector, element, variant))) continue; for (const rule of sourceRules) {
if (!mediaMatches(rule.media, width) || !rule.selectors.some((selector) => selectorMatches(selector, element, variant))) continue;
for (const [property, value] of rule.declarations) { for (const [property, value] of rule.declarations) {
if (["padding", "padding-inline", "padding-inline-start", "padding-inline-end", "padding-left", "padding-right"].includes(property)) { if (["padding", "padding-inline", "padding-inline-start", "padding-inline-end", "padding-left", "padding-right", "border-width", "border-inline-width", "border-inline-start-width", "border-inline-end-width", "border-left-width", "border-right-width"].includes(property)) inset = applyInset(inset, property, value, variables);
inset = applyPadding(inset, property, value, variables); if (property === "overflow") overflowY = overflowX = value;
}
if (property === "overflow-y") overflowY = value; if (property === "overflow-y") overflowY = value;
if (property === "overflow-x") overflowX = value;
if (property === "scrollbar-gutter") scrollbarGutter = value; if (property === "scrollbar-gutter") scrollbarGutter = value;
if (property === "scrollbar-width") scrollbarWidth = value;
} }
} }
return { inset, overflowY, scrollbarGutter }; return { inset, overflowY, overflowX, scrollbarGutter, scrollbarWidth };
} }
/** Resolves the deterministic FN-8630 effective inset model; this is intentionally exported as the shared test helper. */ const ATTRIBUTIONS: Attribution[] = [
export function resolveTaskDetailInsets(css: string, width: number, variant: ShellVariant, scrollbarPresent: boolean): { body: Inset; header: Inset; scrollbarGutter?: string } { { name: "Task Detail header", elements: ["task-detail-content", "modal-header"] },
document.documentElement.dir = "ltr"; { name: "Task Detail sections", elements: ["task-detail-content", "detail-body", "detail-body-content", "detail-section"], trackOwner: "detail-body" },
Object.defineProperty(window, "innerWidth", { configurable: true, value: width }); { name: "Task Detail Activity", elements: ["task-detail-content", "detail-body", "detail-body-content", "detail-activity"], trackOwner: "detail-body" },
const rules = parseRules(css.replace(/\/\*[\s\S]*?\*\//g, "")); { name: "Task Detail tabs", elements: ["task-detail-content", "detail-body", "detail-body-content", "detail-tabs"], trackOwner: "detail-body" },
const variables = rootVariables(rules); { name: "Task Detail actions", elements: ["task-detail-content", "detail-body", "detail-body-content", "modal-actions"], trackOwner: "detail-body" },
const root = resolvedElementStyle(rules, variables, width, variant, "root").inset; { name: "Terminal output", elements: ["terminal-modal", "terminal-container", "terminal-xterm"], trackOwner: "terminal-xterm", injectedViewport: true },
const bodyStyle = resolvedElementStyle(rules, variables, width, variant, "body"); { name: "Terminal header", elements: ["terminal-modal", "terminal-header"] },
const section = resolvedElementStyle(rules, variables, width, variant, "section").inset; { name: "Terminal tabs", elements: ["terminal-modal", "terminal-tabs"] },
const headerStyle = resolvedElementStyle(rules, variables, width, variant, "header"); { name: "Embedded Session Terminal", elements: ["cli-session-terminal", "cli-session-terminal__viewport-shell", "cli-session-terminal__viewport"], trackOwner: "cli-session-terminal__viewport", injectedViewport: true },
const gutter = scrollbarPresent && /^(auto|scroll)$/.test(bodyStyle.overflowY ?? "") ];
? bodyStyle.scrollbarGutter === "stable both-edges"
? { start: SCROLLBAR_GUTTER_PX, end: SCROLLBAR_GUTTER_PX } function add(a: Inset, b: Inset): Inset { return { start: a.start + b.start, end: a.end + b.end }; }
: { start: 0, end: SCROLLBAR_GUTTER_PX }
: DEFAULT_PADDING; function perceivedInset(attribution: Attribution, width: number, variant: Variant, state: ScrollbarState, sourceRules = rules): Inset {
const styles = attribution.elements.map((element) => [element, resolvedStyle(element, width, variant, sourceRules)] as const);
const pad = styles.reduce((total, [, style]) => add(total, style.inset), { ...EMPTY_INSET });
const owner = styles.find(([element]) => element === attribution.trackOwner)?.[1];
const scrollbarVisible = state === "present" && owner?.scrollbarWidth !== "none";
const gutter = scrollbarVisible && owner?.scrollbarGutter === "stable both-edges"
? { start: SCROLLBAR_GUTTER_PX, end: SCROLLBAR_GUTTER_PX }
: scrollbarVisible && owner?.scrollbarGutter === "stable"
? { start: 0, end: SCROLLBAR_GUTTER_PX }
: EMPTY_INSET;
const ownerPadding = owner?.inset ?? EMPTY_INSET;
const authoredTrack = scrollbarVisible && owner?.scrollbarGutter ? Math.min(SCROLLBAR_GUTTER_PX, gutter.end) : 0;
// xterm owns an injected native viewport. CSS proves no authored gutter/scrollbar suppression;
// this explicit attribution is the deterministic substitute for unavailable jsdom geometry.
const injectedTrack = scrollbarVisible && attribution.injectedViewport && !owner?.scrollbarGutter && owner?.scrollbarWidth !== "none" ? SCROLLBAR_GUTTER_PX : 0;
const trackSharesPadding = ownerPadding.start > 0 || ownerPadding.end > 0;
return { return {
body: { start: root.start + bodyStyle.inset.start + section.start + gutter.start, end: root.end + bodyStyle.inset.end + section.end + gutter.end }, start: pad.start + gutter.start,
header: { start: root.start + headerStyle.inset.start, end: root.end + headerStyle.inset.end }, end: pad.end + gutter.end - (trackSharesPadding ? authoredTrack : 0) - (trackSharesPadding ? injectedTrack : 0),
scrollbarGutter: bodyStyle.scrollbarGutter,
}; };
} }
function renderShell(variant: ShellVariant): void { function expectSymmetric(sourceRules = rules): void {
const embedded = variant === "embedded" ? " task-detail-content--embedded" : ""; document.documentElement.dir = "ltr";
const popOut = variant === "pop-out" ? "floating-window--task-detail" : ""; for (const width of [1280, 900, 420]) {
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" }))))); for (const attribution of ATTRIBUTIONS) {
} const variants: Variant[] = attribution.name.startsWith("Task Detail") ? ["modal", "pop-out", "embedded"] : attribution.name.startsWith("Terminal") ? ["modal", "dock", "floating", "embedded"] : ["embedded"];
for (const variant of variants) for (const state of ["present", "absent"] as const) {
describe("FN-8630 Task Detail effective inset symmetry", () => { const inset = perceivedInset(attribution, width, variant, state, sourceRules);
const css = loadAllAppCss(); expect(inset.end, `${attribution.name}/${variant}/${width}px scrollbar=${state}`).toBe(inset.start);
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 });
}
} }
} }
}
}
describe("FN-8634 perceived Task Detail and Terminal shell inset symmetry", () => {
it("resolves every shell chain, breakpoint, variant, and scrollbar state exactly", () => {
expectSymmetric();
}); });
it("pins the diagnosed stable both-edges scrollbar-gutter contract", () => { it("proves the replaced Task Detail and Terminal mechanisms are red while a track is painted", () => {
for (const width of [1280, 900, 420]) { const preFixRules = rules.map((rule) => ({ ...rule, declarations: new Map(rule.declarations) }));
expect(resolveTaskDetailInsets(css, width, "modal", true).scrollbarGutter).toBe("stable both-edges"); const change = (selector: string, declarations: Record<string, string>): void => {
} const rule = preFixRules.find((candidate) => candidate.selectors.includes(selector));
expect(rule, `missing pre-fix selector ${selector}`).toBeDefined();
for (const [property, value] of Object.entries(declarations)) rule!.declarations.set(property, value);
};
// This is the stylesheet-only temporary revert documented in the task plan: it restores
// the exact padded-scroller mechanisms without asking jsdom to paint a native scrollbar.
change(".detail-body", { padding: "calc(var(--space-lg) + var(--space-xs))", "scrollbar-gutter": "stable both-edges" });
change(".detail-body-content", { padding: "0" });
change(".terminal-xterm", { padding: "var(--space-xs)" });
const taskDetail = perceivedInset(ATTRIBUTIONS.find(({ name }) => name === "Task Detail sections")!, 1280, "modal", "present", preFixRules);
const terminal = perceivedInset(ATTRIBUTIONS.find(({ name }) => name === "Terminal output")!, 1280, "dock", "present", preFixRules);
expect(taskDetail.end).not.toBe(taskDetail.start);
expect(terminal.end).not.toBe(terminal.start);
}); });
it("retains FN-8624 first-row overlay clearance while interventions remain inset-free", () => { it("pins stylesheet facts for track ownership and the separate Activity overlay contract", () => {
expect(resolvedStyle("detail-body", 1280, "modal")).toMatchObject({ inset: EMPTY_INSET, overflowY: "auto", scrollbarWidth: "thin", scrollbarGutter: undefined });
expect(resolvedStyle("terminal-xterm", 1280, "dock")).toMatchObject({ inset: EMPTY_INSET, scrollbarGutter: undefined, scrollbarWidth: undefined });
expect(resolvedStyle("terminal-container", 1280, "dock").inset).toEqual({ start: 4, end: 4 });
expect(resolvedStyle("cli-session-terminal__viewport", 1280, "embedded").inset).toEqual(EMPTY_INSET);
expect(resolvedStyle("cli-session-terminal__viewport-shell", 1280, "embedded").inset).toEqual({ start: 4, end: 4 });
expect(css).toMatch(/\.detail-activity:not\(\.detail-activity--interventions\) > h4[\s\S]*?padding-inline-end:\s*calc\(var\(--space-2xl\) \+ var\(--space-md\)\)/); 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;/); expect(css).toMatch(/\.detail-activity--interventions\s*\{\s*padding-inline-end:\s*0;/);
}); });
}); });

View File

@@ -126,13 +126,27 @@ FN-6811 recurrence #6 found the attach terminal imported its own CSS chunk but d
color: var(--text-muted); color: var(--text-muted);
} }
.cli-session-terminal__viewport { /*
FNXC:TerminalLayout 2026-08-01-01:00:
FN-8634 keeps the embedded SessionTerminal's xterm viewport unpadded because its injected
native scrollbar occupies the inline-end edge. The shell owns the symmetric token padding so
xterm fit and reflow keep their existing viewport dimensions while painted and absent tracks
have equal perceived empty inset.
*/
.cli-session-terminal__viewport-shell {
display: flex;
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
padding: var(--space-xs); padding: var(--space-xs);
background: var(--terminal-bg, var(--bg)); background: var(--terminal-bg, var(--bg));
} }
.cli-session-terminal__viewport {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
}
/* /*
FNXC:Terminal 2026-06-18-07:34: FNXC:Terminal 2026-06-18-07:34:
SessionTerminal hosts the same xterm DOM/canvas measurement subtree as TerminalModal under a different wrapper. Apply the FN-6638 real-iOS text-size-adjust invariant here too so CLI-agent attach terminals do not inherit Safari-inflated ASCII cell metrics while still using the shared font-load remeasure path for every preset and desktop WebGL. SessionTerminal hosts the same xterm DOM/canvas measurement subtree as TerminalModal under a different wrapper. Apply the FN-6638 real-iOS text-size-adjust invariant here too so CLI-agent attach terminals do not inherit Safari-inflated ASCII cell metrics while still using the shared font-load remeasure path for every preset and desktop WebGL.

View File

@@ -889,12 +889,14 @@ export function SessionTerminal({
)} )}
</header> </header>
<div <div className="cli-session-terminal__viewport-shell">
className="cli-session-terminal__viewport" <div
ref={containerRef} className="cli-session-terminal__viewport"
data-testid="cli-terminal-viewport" ref={containerRef}
style={terminalGlyphStyle} data-testid="cli-terminal-viewport"
/> style={terminalGlyphStyle}
/>
</div>
{showConfirmAdvance && !advanceDismissed && ( {showConfirmAdvance && !advanceDismissed && (
<div className="cli-session-terminal__advance-strip" role="region"> <div className="cli-session-terminal__advance-strip" role="region">

View File

@@ -90,25 +90,21 @@ Task detail workflow badges share the board badge's slight token-based icon-to-l
} }
/* /*
FNXC:TaskDetailLayout 2026-07-31-20:52: FNXC:TaskDetailLayout 2026-08-01-00:48:
FN-8630 removes the residual desktop/tablet end-side inset that FN-8624 did not FN-8634 requires perceived-empty shell insets to remain symmetric for Task Detail modal,
cover: `.detail-body`'s thin native scrollbar reserved space only at inline-end. pop-out, embedded, and responsive shells in both scrollbar states. A painted track occupies
`stable both-edges` keeps effective Task Detail shell insets symmetric for modal, its end-side band, so the padded content must not share the scrolling element that owns it.
pop-out, and embedded variants at every breakpoint without measuring or compensating Keep shell content padding on `.detail-body-content`; `.detail-body` owns scrolling only.
for a UA scrollbar width.
The overlay-clearance exception remains deliberately child-local: non-Interventions Activity's non-Interventions first-row end clearance remains deliberately asymmetric and
`.detail-activity` first rows retain `calc(var(--space-2xl) + var(--space-md))` child-local so `.activity-expand-toggle--overlay` cannot cover log text (FN-8166/FN-8624).
end clearance on desktop/tablet and `calc(var(--space-2xl) + var(--space-sm))` on It is excluded from shell-inset symmetry; FN-7581 Interventions remains at zero clearance.
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 { .detail-body {
padding: calc(var(--space-lg) + var(--space-xs)); padding: 0;
min-width: 0; min-width: 0;
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
scrollbar-gutter: stable both-edges;
scrollbar-color: var(--border) transparent; scrollbar-color: var(--border) transparent;
scrollbar-width: thin; scrollbar-width: thin;
flex: 1; flex: 1;
@@ -119,6 +115,11 @@ The FN-7581 `.detail-activity--interventions` container remains at zero end inse
position: relative; position: relative;
} }
.detail-body-content {
min-width: 0;
padding: calc(var(--space-lg) + var(--space-xs));
}
.detail-body::-webkit-scrollbar { .detail-body::-webkit-scrollbar {
width: 6px; width: 6px;
} }
@@ -148,6 +149,11 @@ The FN-7581 `.detail-activity--interventions` container remains at zero end inse
/* /*
FNXC:TaskDetailPadding 2026-07-01-12:00: FNXC:TaskDetailPadding 2026-07-01-12:00:
Task-detail tabs share the `.detail-body` outer content inset so switching Activity, planner Chat, Plan, or standard sections does not shift the shell padding. Chat-like tabs may only change flex and internal-scroll ownership here; transcript/composer spacing stays inside their own surfaces. Task-detail tabs share the `.detail-body` outer content inset so switching Activity, planner Chat, Plan, or standard sections does not shift the shell padding. Chat-like tabs may only change flex and internal-scroll ownership here; transcript/composer spacing stays inside their own surfaces.
FNXC:TaskDetailLayout 2026-08-01-00:58:
FN-8634 moves shell padding into `.detail-body-content` to leave the scrollbar outside the
content inset. When a tab delegates scrolling to an inner surface, that wrapper must preserve
the previous direct-child flex contract so the transcript and agent log still fill the modal.
*/ */
.detail-body--chat, .detail-body--chat,
.detail-body--planner-chat { .detail-body--planner-chat {
@@ -157,6 +163,15 @@ Task-detail tabs share the `.detail-body` outer content inset so switching Activ
overflow-y: hidden; overflow-y: hidden;
} }
.detail-body--agent-log > .detail-body-content,
.detail-body--chat > .detail-body-content,
.detail-body--planner-chat > .detail-body-content {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.detail-title { .detail-title {
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
@@ -3410,12 +3425,16 @@ Live and Feed Activity expansion overlays the content instead of reserving a too
visible scrollbar outside this breakpoint. visible scrollbar outside this breakpoint.
*/ */
.detail-body { .detail-body {
padding: calc(var(--space-md) + var(--space-xs) / 2); padding: 0;
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
scrollbar-width: none; scrollbar-width: none;
} }
.detail-body-content {
padding: calc(var(--space-md) + var(--space-xs) / 2);
}
.detail-body::-webkit-scrollbar { .detail-body::-webkit-scrollbar {
display: none; display: none;
} }

View File

@@ -4516,6 +4516,7 @@ export function TaskDetailContent({
</div> </div>
</div> </div>
<div className={`detail-body${activeTab === "chat" && activitySegment === "raw-logs" && !isEditing ? " detail-body--agent-log" : ""}${activeTab === "chat" && (activitySegment === "current" || isActivityExpanded) && !isEditing ? " detail-body--chat" : ""}${activeTab === "planner-chat" && !isEditing ? " detail-body--planner-chat" : ""}`}> <div className={`detail-body${activeTab === "chat" && activitySegment === "raw-logs" && !isEditing ? " detail-body--agent-log" : ""}${activeTab === "chat" && (activitySegment === "current" || isActivityExpanded) && !isEditing ? " detail-body--chat" : ""}${activeTab === "planner-chat" && !isEditing ? " detail-body--planner-chat" : ""}`}>
<div className="detail-body-content">
{isEditing ? ( {isEditing ? (
<div className="modal-edit-form"> <div className="modal-edit-form">
<TaskForm <TaskForm
@@ -6945,6 +6946,7 @@ export function TaskDetailContent({
</div> </div>
</> </>
)} )}
</div>
</div> </div>
{showRefineModal && ( {showRefineModal && (
<div <div
@@ -6961,29 +6963,31 @@ export function TaskDetailContent({
</button> </button>
</div> </div>
<div className="detail-body"> <div className="detail-body">
<p className="detail-refine-help"> <div className="detail-body-content">
{t("taskDetail.refine.help", "Describe what needs to be refined or improved...")} <p className="detail-refine-help">
</p> {t("taskDetail.refine.help", "Describe what needs to be refined or improved...")}
<textarea </p>
className="detail-refine-textarea" <textarea
value={refineFeedback} className="detail-refine-textarea"
onChange={(e) => setRefineFeedback(e.target.value)} value={refineFeedback}
placeholder={t("taskDetail.refine.placeholder", "Enter your feedback here...")} onChange={(e) => setRefineFeedback(e.target.value)}
rows={6} placeholder={t("taskDetail.refine.placeholder", "Enter your feedback here...")}
maxLength={2000} rows={6}
autoFocus maxLength={2000}
/> autoFocus
<div className="detail-refine-input-group"> />
<div className="detail-refine-char-count"> <div className="detail-refine-input-group">
{t("taskDetail.refine.charCount", "{{count}}/2000 characters", { count: refineFeedback.length })} <div className="detail-refine-char-count">
{t("taskDetail.refine.charCount", "{{count}}/2000 characters", { count: refineFeedback.length })}
</div>
<button
className="btn btn-primary btn-sm"
onClick={handleSubmitRefine}
disabled={!refineFeedback.trim() || isRefining}
>
{isRefining ? t("taskDetail.refine.creating", "Creating...") : t("taskDetail.refine.createBtn", "Create Refinement Task")}
</button>
</div> </div>
<button
className="btn btn-primary btn-sm"
onClick={handleSubmitRefine}
disabled={!refineFeedback.trim() || isRefining}
>
{isRefining ? t("taskDetail.refine.creating", "Creating...") : t("taskDetail.refine.createBtn", "Create Refinement Task")}
</button>
</div> </div>
</div> </div>
<div className="modal-actions"> <div className="modal-actions">

View File

@@ -1034,10 +1034,19 @@ The terminal header pop-out/dock affordance is an icon-only utility control. It
/* === PTY Terminal Styles === */ /* === PTY Terminal Styles === */
/*
FNXC:TerminalLayout 2026-08-01-00:48:
FN-8634 fixes the operator-reported Terminal end-side excess across modal, dock, floating,
and embedded shells. xterm injects a native viewport track inside `.terminal-xterm`; padding
on that same element made the painted end track appear as extra whitespace. Keep the symmetric
shell padding on this clipped container and leave xterm's overflow, fit, and reflow ownership
unchanged so both painted-track and no-track states have equal perceived empty insets.
*/
.terminal-container { .terminal-container {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
position: relative; position: relative;
padding: var(--space-xs);
background: var(--terminal-bg); background: var(--terminal-bg);
overflow: hidden; overflow: hidden;
} }
@@ -1046,7 +1055,7 @@ The terminal header pop-out/dock affordance is an icon-only utility control. It
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
padding: var(--space-xs); padding: 0;
} }
/* /*

View File

@@ -1145,11 +1145,13 @@ describe("TaskChangesTab — compact spacing class", () => {
const { container } = render( const { container } = render(
<div className="detail-body" data-testid="mobile-detail-body" style={{ maxInlineSize: "430px", overflowX: "hidden" }}> <div className="detail-body" data-testid="mobile-detail-body" style={{ maxInlineSize: "430px", overflowX: "hidden" }}>
<TaskChangesTab <div className="detail-body-content">
taskId="FN-6997" <TaskChangesTab
worktree="/path/to/worktree" taskId="FN-6997"
column="in-progress" worktree="/path/to/worktree"
/> column="in-progress"
/>
</div>
</div>, </div>,
); );
@@ -1158,7 +1160,7 @@ describe("TaskChangesTab — compact spacing class", () => {
}); });
const mobileDetailBody = screen.getByTestId("mobile-detail-body"); const mobileDetailBody = screen.getByTestId("mobile-detail-body");
const taskTab = container.querySelector(".detail-body > .task-changes-tab"); const taskTab = container.querySelector(".detail-body > .detail-body-content > .task-changes-tab");
const fileList = taskTab?.querySelector(".changes-file-list.task-changes-file-list--compact"); const fileList = taskTab?.querySelector(".changes-file-list.task-changes-file-list--compact");
const diffPatch = fileList?.querySelector(".changes-diff-patch.changes-diff-patch--wrap"); const diffPatch = fileList?.querySelector(".changes-diff-patch.changes-diff-patch--wrap");
@@ -1194,18 +1196,21 @@ describe("TaskChangesTab — compact spacing class", () => {
expect(rule).toContain("max-width: calc(100% + (calc(var(--space-md) + var(--space-xs) / 2) * 2));"); expect(rule).toContain("max-width: calc(100% + (calc(var(--space-md) + var(--space-xs) / 2) * 2));");
}); });
it("keeps mobile widening equal and opposite to detail-body padding while preserving overflow containment", () => { it("keeps mobile widening equal and opposite to detail-body content padding while preserving overflow containment", () => {
const css = loadAllAppCss(); const css = loadAllAppCss();
const detailBodyMobileRuleMatch = css.match(/\.detail-body\s*\{\s*padding:\s*calc\(var\(--space-md\) \+ var\(--space-xs\) \/ 2\);([\s\S]*?)\}/); const detailBodyMobileRuleMatch = css.match(/\.detail-body\s*\{\s*padding:\s*0;([\s\S]*?)\}/);
const detailBodyContentMobileRuleMatch = css.match(/\.detail-body-content\s*\{\s*padding:\s*calc\(var\(--space-md\) \+ var\(--space-xs\) \/ 2\);([\s\S]*?)\}/);
const compactListMobileRuleMatch = css.match(/@media\s*\(max-width:\s*768px\)\s*\{\s*\.task-changes-tab\s+\.changes-file-list\.task-changes-file-list--compact\s*\{([\s\S]*?)\}/); const compactListMobileRuleMatch = css.match(/@media\s*\(max-width:\s*768px\)\s*\{\s*\.task-changes-tab\s+\.changes-file-list\.task-changes-file-list--compact\s*\{([\s\S]*?)\}/);
expect(detailBodyMobileRuleMatch).toBeTruthy(); expect(detailBodyMobileRuleMatch).toBeTruthy();
expect(detailBodyContentMobileRuleMatch).toBeTruthy();
expect(compactListMobileRuleMatch).toBeTruthy(); expect(compactListMobileRuleMatch).toBeTruthy();
const mobilePadding = "calc(var(--space-md) + var(--space-xs) / 2)"; const mobilePadding = "calc(var(--space-md) + var(--space-xs) / 2)";
expect(css).toContain("@media (max-width: 768px)"); expect(css).toContain("@media (max-width: 768px)");
expect(detailBodyMobileRuleMatch![0]).toContain(`padding: ${mobilePadding};`); expect(detailBodyMobileRuleMatch![0]).toContain("padding: 0;");
expect(detailBodyMobileRuleMatch![1]).toContain("overflow-x: hidden;"); expect(detailBodyMobileRuleMatch![1]).toContain("overflow-x: hidden;");
expect(detailBodyContentMobileRuleMatch![0]).toContain(`padding: ${mobilePadding};`);
expect(compactListMobileRuleMatch![1]).toContain(`margin-left: calc(-1 * ${mobilePadding});`); expect(compactListMobileRuleMatch![1]).toContain(`margin-left: calc(-1 * ${mobilePadding});`);
expect(compactListMobileRuleMatch![1]).toContain(`margin-right: calc(-1 * ${mobilePadding});`); expect(compactListMobileRuleMatch![1]).toContain(`margin-right: calc(-1 * ${mobilePadding});`);
expect(compactListMobileRuleMatch![1]).toContain(`max-width: calc(100% + (${mobilePadding} * 2));`); expect(compactListMobileRuleMatch![1]).toContain(`max-width: calc(100% + (${mobilePadding} * 2));`);

View File

@@ -214,12 +214,16 @@ describe("TaskDetailModal", () => {
const expandedPlannerSectionBlock = getExactCssRuleBlock(css, ".task-detail-content--planner-chat-expanded .detail-section--planner-chat"); const expandedPlannerSectionBlock = getExactCssRuleBlock(css, ".task-detail-content--planner-chat-expanded .detail-section--planner-chat");
const mobileBodyBlock = getCssAtRuleBlockContainingExactRule(css, "@media (max-width: 768px)", ".detail-body"); const mobileBodyBlock = getCssAtRuleBlockContainingExactRule(css, "@media (max-width: 768px)", ".detail-body");
const mobileDetailBodyBlock = getExactCssRuleBlock(mobileBodyBlock, ".detail-body"); const mobileDetailBodyBlock = getExactCssRuleBlock(mobileBodyBlock, ".detail-body");
const detailBodyContentBlock = getExactCssRuleBlock(css, ".detail-body-content");
const mobileDetailBodyContentBlock = getExactCssRuleBlock(mobileBodyBlock, ".detail-body-content");
const mobilePlannerBlock = getCssAtRuleBlockContaining(css, "@media (max-width: 768px)", ".detail-body--chat"); const mobilePlannerBlock = getCssAtRuleBlockContaining(css, "@media (max-width: 768px)", ".detail-body--chat");
const mobilePlannerBodyBlock = getStandaloneCssRuleBlock(mobilePlannerBlock, ".detail-body--planner-chat"); const mobilePlannerBodyBlock = getStandaloneCssRuleBlock(mobilePlannerBlock, ".detail-body--planner-chat");
const mobileExpandedPlannerBodyBlock = getExactCssRuleBlock(mobilePlannerBlock, ".task-detail-content--planner-chat-expanded .detail-body--planner-chat"); const mobileExpandedPlannerBodyBlock = getExactCssRuleBlock(mobilePlannerBlock, ".task-detail-content--planner-chat-expanded .detail-body--planner-chat");
expect(detailBodyBlock).toContain("padding: calc(var(--space-lg) + var(--space-xs));"); expect(detailBodyBlock).toContain("padding: 0;");
expect(mobileDetailBodyBlock).toContain("padding: calc(var(--space-md) + var(--space-xs) / 2);"); expect(detailBodyContentBlock).toContain("padding: calc(var(--space-lg) + var(--space-xs));");
expect(mobileDetailBodyBlock).toContain("padding: 0;");
expect(mobileDetailBodyContentBlock).toContain("padding: calc(var(--space-md) + var(--space-xs) / 2);");
expectNoSpacingOverrides(activityBodyBlock, "desktop Activity body modifier"); expectNoSpacingOverrides(activityBodyBlock, "desktop Activity body modifier");
expectNoSpacingOverrides(plannerBodyBlock, "desktop planner body modifier"); expectNoSpacingOverrides(plannerBodyBlock, "desktop planner body modifier");
expect(expandedPlannerBodyBlock).toContain("flex: 1;"); expect(expandedPlannerBodyBlock).toContain("flex: 1;");
@@ -748,6 +752,7 @@ describe("TaskDetailModal", () => {
This contract covers modal, pop-out, embedded, and mobile task-detail surfaces. This contract covers modal, pop-out, embedded, and mobile task-detail surfaces.
*/ */
const mobileDetailBodyBlock = getExactCssRuleBlock(mobileBlock, ".detail-body"); const mobileDetailBodyBlock = getExactCssRuleBlock(mobileBlock, ".detail-body");
const mobileDetailBodyContentBlock = getExactCssRuleBlock(mobileBlock, ".detail-body-content");
const baseInterventionsBlock = getExactCssRuleBlock(css, ".detail-activity--interventions"); const baseInterventionsBlock = getExactCssRuleBlock(css, ".detail-activity--interventions");
const mobilePrBlock = getExactCssRuleBlock( const mobilePrBlock = getExactCssRuleBlock(
getCssAtRuleBlockContainingExactRule(css, "@media (max-width: 768px)", ".detail-pr-tab"), getCssAtRuleBlockContainingExactRule(css, "@media (max-width: 768px)", ".detail-pr-tab"),
@@ -759,7 +764,8 @@ describe("TaskDetailModal", () => {
); );
const allMobileCss = getCssAtRuleBlocks(css, "@media (max-width: 768px)").join("\n"); const allMobileCss = getCssAtRuleBlocks(css, "@media (max-width: 768px)").join("\n");
expect(mobileDetailBodyBlock).toContain("padding: calc(var(--space-md) + var(--space-xs) / 2);"); expect(mobileDetailBodyBlock).toContain("padding: 0;");
expect(mobileDetailBodyContentBlock).toContain("padding: calc(var(--space-md) + var(--space-xs) / 2);");
expect(mobileDetailBodyBlock).toContain("overflow-x: hidden;"); expect(mobileDetailBodyBlock).toContain("overflow-x: hidden;");
expect(baseInterventionsBlock).toContain("padding-inline-end: 0;"); expect(baseInterventionsBlock).toContain("padding-inline-end: 0;");
expect(mobileBlock).toContain(".detail-activity:not(.detail-activity--interventions) > h4,"); expect(mobileBlock).toContain(".detail-activity:not(.detail-activity--interventions) > h4,");
@@ -797,6 +803,7 @@ describe("TaskDetailModal", () => {
const baseScrollbarBlock = getExactCssRuleBlock(css, ".detail-body::-webkit-scrollbar"); const baseScrollbarBlock = getExactCssRuleBlock(css, ".detail-body::-webkit-scrollbar");
const mobileBlock = getCssAtRuleBlockContainingExactRule(css, "@media (max-width: 768px)", ".detail-body"); const mobileBlock = getCssAtRuleBlockContainingExactRule(css, "@media (max-width: 768px)", ".detail-body");
const mobileDetailBodyBlock = getExactCssRuleBlock(mobileBlock, ".detail-body"); const mobileDetailBodyBlock = getExactCssRuleBlock(mobileBlock, ".detail-body");
const mobileDetailBodyContentBlock = getExactCssRuleBlock(mobileBlock, ".detail-body-content");
const mobileScrollbarBlock = getExactCssRuleBlock(mobileBlock, ".detail-body::-webkit-scrollbar"); const mobileScrollbarBlock = getExactCssRuleBlock(mobileBlock, ".detail-body::-webkit-scrollbar");
const mobileActivityBlock = getExactCssRuleBlock(mobileBlock, ".detail-activity"); const mobileActivityBlock = getExactCssRuleBlock(mobileBlock, ".detail-activity");
const mobileInterventionsBlock = getExactCssRuleBlock(mobileBlock, ".detail-activity--interventions"); const mobileInterventionsBlock = getExactCssRuleBlock(mobileBlock, ".detail-activity--interventions");
@@ -813,7 +820,8 @@ describe("TaskDetailModal", () => {
expect(baseDetailBodyBlock).toContain("scrollbar-width: thin;"); expect(baseDetailBodyBlock).toContain("scrollbar-width: thin;");
expect(baseScrollbarBlock).toContain("width: 6px;"); expect(baseScrollbarBlock).toContain("width: 6px;");
expect(mobileDetailBodyBlock).toContain("padding: calc(var(--space-md) + var(--space-xs) / 2);"); expect(mobileDetailBodyBlock).toContain("padding: 0;");
expect(mobileDetailBodyContentBlock).toContain("padding: calc(var(--space-md) + var(--space-xs) / 2);");
expect(mobileDetailBodyBlock).toContain("overflow-x: hidden;"); expect(mobileDetailBodyBlock).toContain("overflow-x: hidden;");
expect(mobileDetailBodyBlock).toContain("overflow-y: auto;"); expect(mobileDetailBodyBlock).toContain("overflow-y: auto;");
expect(mobileDetailBodyBlock).toContain("scrollbar-width: none;"); expect(mobileDetailBodyBlock).toContain("scrollbar-width: none;");