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:
7
.changeset/fn-8634-task-detail-right-padding.md
Normal file
7
.changeset/fn-8634-task-detail-right-padding.md
Normal 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.
|
||||
@@ -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);",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -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));`);
|
||||
|
||||
@@ -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;");
|
||||
|
||||
Reference in New Issue
Block a user