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");
});
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(
resolve(__dirname, "../components/TaskDetailModal.css"),
"utf-8",
@@ -41,9 +41,9 @@ describe("detail-body mobile overflow (FN-1331)", () => {
expect(mobileBlockMatch).toBeTruthy();
const mobileBlock = mobileBlockMatch![1];
const detailBodyMatch = mobileBlock.match(/\.detail-body\s*\{[^}]*\}/s);
expect(detailBodyMatch).toBeTruthy();
expect(detailBodyMatch![0]).toContain(
const detailBodyContentMatch = mobileBlock.match(/\.detail-body-content\s*\{[^}]*\}/s);
expect(detailBodyContentMatch).toBeTruthy();
expect(detailBodyContentMatch![0]).toContain(
"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 { 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.
FNXC:TaskDetailLayout 2026-08-01-01:00:
FN-8634 models perceived empty shell inset from resolved stylesheet declarations rather than
from jsdom geometry. A painted authored or injected end-side track occupies its band and is
subtracted; an unpainted reserved gutter remains visible whitespace. SCROLLBAR_GUTTER_PX is a
deterministic stand-in only and must never become a measured/native scrollbar value.
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.
Task Detail formerly combined its padding with an authored both-edges gutter, while Terminal
combined xterm's injected viewport track with xterm padding. Each mechanism fails only while a
track is painted. The attribution table resolves every chain, breakpoint, and variant from CSS;
Activity overlay clearance is deliberately excluded because it protects log text, not shell inset.
*/
export const SCROLLBAR_GUTTER_PX = 12;
type Inset = { start: number; end: number };
type CssRule = { selectors: string[]; declarations: Map<string, string>; media?: string };
type ShellVariant = "modal" | "pop-out" | "embedded";
type ScrollbarState = "present" | "absent";
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[] {
const values: string[] = [];
@@ -37,49 +35,42 @@ function splitCssValues(value: string): string[] {
if (character === ")") depth -= 1;
if (/\s/.test(character) && depth === 0) {
if (token) values.push(token), token = "";
} else {
token += character;
}
} else token += character;
}
if (token) values.push(token);
return values;
}
function parseDeclarations(block: string): Map<string, string> {
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];
}),
);
return new Map(block.split(";").flatMap((declaration) => {
const colon = declaration.indexOf(":");
return colon < 0 ? [] : [[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();
function parseRules(source: string, media?: string): Rule[] {
const rules: Rule[] = [];
for (let cursor = 0; cursor < source.length;) {
const open = source.indexOf("{", cursor);
if (open < 0) break;
const prelude = source.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;
while (close < source.length && depth) {
if (source[close] === "{") depth += 1;
if (source[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 });
}
const block = source.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;
}
const rules = parseRules(css.replace(/\/\*[\s\S]*?\*\//g, ""));
function mediaMatches(media: string | undefined, width: number): boolean {
if (!media) return true;
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));
}
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 variablesAt(width: number): Map<string, string> {
const variables = new Map<string, string>();
for (const rule of rules) {
if (!mediaMatches(rule.media, width) || !rule.selectors.includes(":root")) continue;
for (const [property, value] of rule.declarations) if (property.startsWith("--")) variables.set(property, value);
}
return variables;
}
function resolveVariables(value: string, variables: Map<string, string>): string {
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");
}
return resolved;
@@ -111,6 +97,9 @@ function resolveVariables(value: string, variables: Map<string, string>): string
function cssNumber(value: string, variables: Map<string, string>): number {
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(/px\b/g, "")
.trim();
@@ -118,105 +107,131 @@ function cssNumber(value: string, variables: Map<string, string>): number {
return Number(Function(`"use strict"; return (${expression});`)());
}
function applyPadding(style: Inset, property: string, value: string, variables: Map<string, string>): Inset {
const next = { ...style };
const values = splitCssValues(resolveVariables(value, variables)).map((part) => cssNumber(part, variables));
if (property === "padding") {
function applyInset(inset: Inset, property: string, value: string, variables: Map<string, string>): Inset {
const next = { ...inset };
const values = splitCssValues(resolveVariables(value, variables)).map((item) => cssNumber(item, variables));
if (property === "padding" || property === "border-width") {
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") {
} else if (property === "padding-inline" || property === "border-inline-width") {
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]!;
}
} else if (["padding-inline-start", "padding-left", "border-inline-start-width", "border-left-width"].includes(property)) next.start = values[0]!;
else if (["padding-inline-end", "padding-right", "border-inline-end-width", "border-right-width"].includes(property)) next.end = values[0]!;
return next;
}
function rootVariables(rules: CssRule[]): Map<string, string> {
const variables = new Map<string, string>();
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 selectorMatches(selector: string, element: string, variant: Variant): boolean {
const finalToken = selector.trim().split(/[ >+~]/).filter(Boolean).at(-1)?.replace(/:{1,2}[\w()-]+/g, "") ?? "";
if (!finalToken.split(".").includes(element)) 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;
if (selector.includes("terminal-modal--embedded") && variant !== "embedded") return false;
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 } {
let inset = { ...DEFAULT_PADDING };
function resolvedStyle(element: string, width: number, variant: Variant, sourceRules = rules): Style {
const variables = variablesAt(width);
let inset = { ...EMPTY_INSET };
let overflowY: string | undefined;
let overflowX: 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;
let scrollbarWidth: string | undefined;
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) {
if (["padding", "padding-inline", "padding-inline-start", "padding-inline-end", "padding-left", "padding-right"].includes(property)) {
inset = applyPadding(inset, property, value, variables);
}
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);
if (property === "overflow") overflowY = overflowX = value;
if (property === "overflow-y") overflowY = value;
if (property === "overflow-x") overflowX = 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. */
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;
const ATTRIBUTIONS: Attribution[] = [
{ name: "Task Detail header", elements: ["task-detail-content", "modal-header"] },
{ name: "Task Detail sections", elements: ["task-detail-content", "detail-body", "detail-body-content", "detail-section"], trackOwner: "detail-body" },
{ name: "Task Detail Activity", elements: ["task-detail-content", "detail-body", "detail-body-content", "detail-activity"], trackOwner: "detail-body" },
{ name: "Task Detail tabs", elements: ["task-detail-content", "detail-body", "detail-body-content", "detail-tabs"], trackOwner: "detail-body" },
{ name: "Task Detail actions", elements: ["task-detail-content", "detail-body", "detail-body-content", "modal-actions"], trackOwner: "detail-body" },
{ name: "Terminal output", elements: ["terminal-modal", "terminal-container", "terminal-xterm"], trackOwner: "terminal-xterm", injectedViewport: true },
{ name: "Terminal header", elements: ["terminal-modal", "terminal-header"] },
{ name: "Terminal tabs", elements: ["terminal-modal", "terminal-tabs"] },
{ name: "Embedded Session Terminal", elements: ["cli-session-terminal", "cli-session-terminal__viewport-shell", "cli-session-terminal__viewport"], trackOwner: "cli-session-terminal__viewport", injectedViewport: true },
];
function add(a: Inset, b: Inset): Inset { return { start: a.start + b.start, end: a.end + b.end }; }
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 {
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,
start: pad.start + gutter.start,
end: pad.end + gutter.end - (trackSharesPadding ? authoredTrack : 0) - (trackSharesPadding ? injectedTrack : 0),
};
}
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 });
}
function expectSymmetric(sourceRules = rules): void {
document.documentElement.dir = "ltr";
for (const width of [1280, 900, 420]) {
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) {
const inset = perceivedInset(attribution, width, variant, state, sourceRules);
expect(inset.end, `${attribution.name}/${variant}/${width}px scrollbar=${state}`).toBe(inset.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", () => {
for (const width of [1280, 900, 420]) {
expect(resolveTaskDetailInsets(css, width, "modal", true).scrollbarGutter).toBe("stable both-edges");
}
it("proves the replaced Task Detail and Terminal mechanisms are red while a track is painted", () => {
const preFixRules = rules.map((rule) => ({ ...rule, declarations: new Map(rule.declarations) }));
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\)\)/);
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;/);
});
});

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);
}
.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;
min-height: 0;
padding: var(--space-xs);
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:
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>
<div
className="cli-session-terminal__viewport"
ref={containerRef}
data-testid="cli-terminal-viewport"
style={terminalGlyphStyle}
/>
<div className="cli-session-terminal__viewport-shell">
<div
className="cli-session-terminal__viewport"
ref={containerRef}
data-testid="cli-terminal-viewport"
style={terminalGlyphStyle}
/>
</div>
{showConfirmAdvance && !advanceDismissed && (
<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:
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.
FNXC:TaskDetailLayout 2026-08-01-00:48:
FN-8634 requires perceived-empty shell insets to remain symmetric for Task Detail modal,
pop-out, embedded, and responsive shells in both scrollbar states. A painted track occupies
its end-side band, so the padded content must not share the scrolling element that owns it.
Keep shell content padding on `.detail-body-content`; `.detail-body` owns scrolling only.
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.
Activity's non-Interventions first-row end clearance remains deliberately asymmetric and
child-local so `.activity-expand-toggle--overlay` cannot cover log text (FN-8166/FN-8624).
It is excluded from shell-inset symmetry; FN-7581 Interventions remains at zero clearance.
*/
.detail-body {
padding: calc(var(--space-lg) + var(--space-xs));
padding: 0;
min-width: 0;
overflow-x: hidden;
overflow-y: auto;
scrollbar-gutter: stable both-edges;
scrollbar-color: var(--border) transparent;
scrollbar-width: thin;
flex: 1;
@@ -119,6 +115,11 @@ The FN-7581 `.detail-activity--interventions` container remains at zero end inse
position: relative;
}
.detail-body-content {
min-width: 0;
padding: calc(var(--space-lg) + var(--space-xs));
}
.detail-body::-webkit-scrollbar {
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:
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--planner-chat {
@@ -157,6 +163,15 @@ Task-detail tabs share the `.detail-body` outer content inset so switching Activ
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 {
font-size: 18px;
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.
*/
.detail-body {
padding: calc(var(--space-md) + var(--space-xs) / 2);
padding: 0;
overflow-x: hidden;
overflow-y: auto;
scrollbar-width: none;
}
.detail-body-content {
padding: calc(var(--space-md) + var(--space-xs) / 2);
}
.detail-body::-webkit-scrollbar {
display: none;
}

View File

@@ -4516,6 +4516,7 @@ export function TaskDetailContent({
</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-content">
{isEditing ? (
<div className="modal-edit-form">
<TaskForm
@@ -6945,6 +6946,7 @@ export function TaskDetailContent({
</div>
</>
)}
</div>
</div>
{showRefineModal && (
<div
@@ -6961,29 +6963,31 @@ export function TaskDetailContent({
</button>
</div>
<div className="detail-body">
<p className="detail-refine-help">
{t("taskDetail.refine.help", "Describe what needs to be refined or improved...")}
</p>
<textarea
className="detail-refine-textarea"
value={refineFeedback}
onChange={(e) => setRefineFeedback(e.target.value)}
placeholder={t("taskDetail.refine.placeholder", "Enter your feedback here...")}
rows={6}
maxLength={2000}
autoFocus
/>
<div className="detail-refine-input-group">
<div className="detail-refine-char-count">
{t("taskDetail.refine.charCount", "{{count}}/2000 characters", { count: refineFeedback.length })}
<div className="detail-body-content">
<p className="detail-refine-help">
{t("taskDetail.refine.help", "Describe what needs to be refined or improved...")}
</p>
<textarea
className="detail-refine-textarea"
value={refineFeedback}
onChange={(e) => setRefineFeedback(e.target.value)}
placeholder={t("taskDetail.refine.placeholder", "Enter your feedback here...")}
rows={6}
maxLength={2000}
autoFocus
/>
<div className="detail-refine-input-group">
<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>
<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 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 === */
/*
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 {
flex: 1;
min-height: 0;
position: relative;
padding: var(--space-xs);
background: var(--terminal-bg);
overflow: hidden;
}
@@ -1046,7 +1055,7 @@ The terminal header pop-out/dock affordance is an icon-only utility control. It
width: 100%;
height: 100%;
min-height: 0;
padding: var(--space-xs);
padding: 0;
}
/*

View File

@@ -1145,11 +1145,13 @@ describe("TaskChangesTab — compact spacing class", () => {
const { container } = render(
<div className="detail-body" data-testid="mobile-detail-body" style={{ maxInlineSize: "430px", overflowX: "hidden" }}>
<TaskChangesTab
taskId="FN-6997"
worktree="/path/to/worktree"
column="in-progress"
/>
<div className="detail-body-content">
<TaskChangesTab
taskId="FN-6997"
worktree="/path/to/worktree"
column="in-progress"
/>
</div>
</div>,
);
@@ -1158,7 +1160,7 @@ describe("TaskChangesTab — compact spacing class", () => {
});
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 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));");
});
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 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]*?)\}/);
expect(detailBodyMobileRuleMatch).toBeTruthy();
expect(detailBodyContentMobileRuleMatch).toBeTruthy();
expect(compactListMobileRuleMatch).toBeTruthy();
const mobilePadding = "calc(var(--space-md) + var(--space-xs) / 2)";
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(detailBodyContentMobileRuleMatch![0]).toContain(`padding: ${mobilePadding};`);
expect(compactListMobileRuleMatch![1]).toContain(`margin-left: calc(-1 * ${mobilePadding});`);
expect(compactListMobileRuleMatch![1]).toContain(`margin-right: calc(-1 * ${mobilePadding});`);
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 mobileBodyBlock = getCssAtRuleBlockContainingExactRule(css, "@media (max-width: 768px)", ".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 mobilePlannerBodyBlock = getStandaloneCssRuleBlock(mobilePlannerBlock, ".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(mobileDetailBodyBlock).toContain("padding: calc(var(--space-md) + var(--space-xs) / 2);");
expect(detailBodyBlock).toContain("padding: 0;");
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(plannerBodyBlock, "desktop planner body modifier");
expect(expandedPlannerBodyBlock).toContain("flex: 1;");
@@ -748,6 +752,7 @@ describe("TaskDetailModal", () => {
This contract covers modal, pop-out, embedded, and mobile task-detail surfaces.
*/
const mobileDetailBodyBlock = getExactCssRuleBlock(mobileBlock, ".detail-body");
const mobileDetailBodyContentBlock = getExactCssRuleBlock(mobileBlock, ".detail-body-content");
const baseInterventionsBlock = getExactCssRuleBlock(css, ".detail-activity--interventions");
const mobilePrBlock = getExactCssRuleBlock(
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");
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(baseInterventionsBlock).toContain("padding-inline-end: 0;");
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 mobileBlock = getCssAtRuleBlockContainingExactRule(css, "@media (max-width: 768px)", ".detail-body");
const mobileDetailBodyBlock = getExactCssRuleBlock(mobileBlock, ".detail-body");
const mobileDetailBodyContentBlock = getExactCssRuleBlock(mobileBlock, ".detail-body-content");
const mobileScrollbarBlock = getExactCssRuleBlock(mobileBlock, ".detail-body::-webkit-scrollbar");
const mobileActivityBlock = getExactCssRuleBlock(mobileBlock, ".detail-activity");
const mobileInterventionsBlock = getExactCssRuleBlock(mobileBlock, ".detail-activity--interventions");
@@ -813,7 +820,8 @@ describe("TaskDetailModal", () => {
expect(baseDetailBodyBlock).toContain("scrollbar-width: thin;");
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-y: auto;");
expect(mobileDetailBodyBlock).toContain("scrollbar-width: none;");