import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { getMediaBlocks } from "./PlanningModeModal.test-helpers"; const PLANNING_CSS_PATH = resolve(__dirname, "..", "PlanningModeModal.css"); const TABLET_SUMMARY_ACTIONS_QUERY = "@media (min-width: 769px) and (max-width: 1024px)"; const MOBILE_ACTIONS_QUERY = "@media (max-width: 768px)"; const MOBILE_PLANNING_SHELL_QUERY = "@media (max-width: 768px), (max-height: 480px)"; /* FNXC:PlanningMode 2026-07-21-18:41: flush pane rules cover tablet + desktop (two-pane shell). */ const DESKTOP_PLANNING_WORKSPACE_QUERY = "@media (min-width: 769px)"; function loadPlanningCss(): string { return readFileSync(PLANNING_CSS_PATH, "utf-8"); } function findRule(css: string, selector: string): string | undefined { const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return css.match(new RegExp(`${escapedSelector}\\s*\\{[^}]*\\}`))?.[0]; } function findRules(css: string, selector: string): string[] { const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return [...css.matchAll(new RegExp(`${escapedSelector}\\s*\\{[^}]*\\}`, "g"))].map((match) => match[0]); } function expectSomeRule(css: string, selector: string, pattern: RegExp): void { expect(findRules(css, selector).some((rule) => pattern.test(rule))).toBe(true); } describe("PlanningModeModal CSS responsive action contract", () => { it("FN-6974 keeps the summary action footer from overflowing on tablet while preserving desktop and mobile affordances", () => { const css = loadPlanningCss(); const baseSummaryActionsRule = findRule(css, ".planning-summary-actions"); const baseSummaryRightRule = findRule(css, ".planning-summary-actions-right"); expect(baseSummaryActionsRule).toContain("justify-content: space-between;"); expect(baseSummaryRightRule).toContain("display: flex;"); const tabletCss = getMediaBlocks(css, TABLET_SUMMARY_ACTIONS_QUERY).join("\n"); expect(tabletCss).toBeTruthy(); expect(findRule(tabletCss, ".planning-summary-actions")).toMatch(/flex-wrap\s*:\s*wrap\s*;/); expect(findRule(tabletCss, ".planning-summary-actions")).toMatch(/min-width\s*:\s*0\s*;/); expect(findRule(tabletCss, ".planning-summary-actions-right")).toMatch(/flex-wrap\s*:\s*wrap\s*;/); expect(findRule(tabletCss, ".planning-summary-actions-right")).toMatch(/min-width\s*:\s*0\s*;/); expect(findRule(tabletCss, ".planning-summary-actions-right")).toMatch(/max-width\s*:\s*100%\s*;/); expect(findRule(tabletCss, ".planning-summary-actions .btn")).toMatch(/max-width\s*:\s*100%\s*;/); expect(findRule(tabletCss, ".planning-summary-actions .btn")).toMatch(/white-space\s*:\s*normal\s*;/); const mobileCss = getMediaBlocks(css, MOBILE_ACTIONS_QUERY).join("\n"); expectSomeRule(mobileCss, ".planning-actions", /flex-direction\s*:\s*column\s*;/); expectSomeRule(mobileCss, ".planning-summary-actions-right", /flex-direction\s*:\s*column\s*;/); expectSomeRule(mobileCss, ".planning-summary-actions-right", /width\s*:\s*100%\s*;/); }); // FNXC:PlanningMode 2026-06-25-13:10: regression for the embedded Planning view not // scrolling on mobile. The global mobile `.modal:not(.confirm-dialog), .modal-lg, ...` // 100dvh rule (specificity 0,2,0) matched the embedded shell and stretched it past its // bounded `.planning-view` pane, so `.planning-view { overflow:hidden }` clipped the // footer action buttons. The mobile embedded override must (a) qualify with // `.planning-view.open` so it outranks (0,3,0 > 0,2,0) that global rule, and (b) re-pin // `max-height` so the embedded shell cannot exceed its pane and the inner flex scroll // chain works. it("pins the embedded view to its bounded pane height on mobile so the footer scrolls into reach", () => { const css = loadPlanningCss(); const mobileCss = getMediaBlocks(css, MOBILE_ACTIONS_QUERY).join("\n"); const embeddedRule = findRule(mobileCss, ".planning-view.open .planning-modal--embedded"); expect(embeddedRule).toBeTruthy(); expect(embeddedRule).toMatch(/height\s*:\s*100%\s*;/); expect(embeddedRule).toMatch(/max-height\s*:\s*100%\s*;/); }); it("keeps question left and plan right on desktop, then uses full-view tabs on mobile", () => { const css = loadPlanningCss(); const desktopRule = findRule(css, ".planning-workspace"); expect(desktopRule).toMatch(/grid-template-areas\s*:\s*"question plan"\s*;/); // FNXC:PlanningMode 2026-07-21-16:47: interview panes sit flush — no outer workspace gutter or inter-pane gap. expect(desktopRule).toMatch(/padding\s*:\s*0\s*;/); expect(desktopRule).toMatch(/gap\s*:\s*0\s*;/); expect(findRule(css, ".planning-plan-pane")).toMatch(/grid-area\s*:\s*plan\s*;/); expectSomeRule(css, ".planning-question-pane", /grid-area\s*:\s*question\s*;/); expectSomeRule(css, ".planning-question-pane", /border-right\s*:\s*solid var\(--btn-border-width\)/); expect(findRule(css, ".planning-plan-pane,\n.planning-question-pane")).toMatch(/border-radius\s*:\s*0\s*;/); const mobileCss = getMediaBlocks(css, MOBILE_ACTIONS_QUERY).join("\n"); expect(findRule(mobileCss, ".planning-workspace--mobile-tab-question,\n .planning-workspace--mobile-tab-plan")).toMatch(/"tabs"\s*"content"/); expect(findRule(mobileCss, ".planning-workspace-tabs")).toMatch(/display\s*:\s*grid\s*;/); expect(findRule(mobileCss, ".planning-workspace--mobile-tab-question .planning-plan-pane,\n .planning-workspace--mobile-tab-plan .planning-question-pane")).toMatch(/display\s*:\s*none\s*;/); // FNXC:PlanningMode 2026-07-21-18:34: mobile question/plan review sit flush — no outer workspace gutter or nested pane chrome. expect(findRule(mobileCss, ".planning-workspace")).toMatch(/padding\s*:\s*0\s*;/); expect(findRule(mobileCss, ".planning-workspace")).toMatch(/gap\s*:\s*0\s*;/); expect(findRule(mobileCss, ".planning-question-pane")).toMatch(/border-right\s*:\s*none\s*;/); expect(findRule(mobileCss, ".planning-plan-pane,\n .planning-question-pane")).toMatch(/border\s*:\s*none\s*;/); expect(findRule(mobileCss, ".planning-plan-pane,\n .planning-question-pane")).toMatch(/border-radius\s*:\s*0\s*;/); expect(findRule(mobileCss, ".planning-question-pane .planning-question-scroll,\n .planning-plan-pane .planning-plan-scroll")).toMatch(/padding\s*:\s*0\s*;/); expect(findRule(mobileCss, ".planning-question-pane .planning-question-panel")).toMatch(/border\s*:\s*none\s*;/); expect(findRule(mobileCss, ".planning-plan-pane .planning-plan-document")).toMatch(/padding\s*:\s*var\(--space-lg\)\s*;/); }); it("keeps tablet and desktop planning content flush inside both panes with compact aligned action rows", () => { const css = loadPlanningCss(); const twoPaneCss = getMediaBlocks(css, DESKTOP_PLANNING_WORKSPACE_QUERY).join("\n"); const flushScrollRule = findRule(twoPaneCss, ".planning-question-pane .planning-question-scroll,\n .planning-plan-pane .planning-plan-scroll"); const questionPanelRule = findRule(twoPaneCss, ".planning-question-pane .planning-question-panel"); const planDocumentRule = findRule(twoPaneCss, ".planning-plan-pane .planning-plan-document"); const sharedActionsRule = findRule(twoPaneCss, ".planning-question-pane .planning-actions,\n .planning-plan-actions"); const sharedButtonsRule = findRule(twoPaneCss, ".planning-question-pane .planning-actions .btn,\n .planning-plan-actions .btn"); expect(flushScrollRule).toMatch(/padding\s*:\s*0\s*;/); expect(questionPanelRule).toMatch(/border\s*:\s*none\s*;/); expect(questionPanelRule).toMatch(/border-radius\s*:\s*0\s*;/); expect(planDocumentRule).toMatch(/width\s*:\s*100%\s*;/); expect(planDocumentRule).toMatch(/box-shadow\s*:\s*none\s*;/); expect(sharedActionsRule).toMatch(/padding\s*:\s*var\(--space-sm\) var\(--space-xl\)\s*;/); expect(sharedButtonsRule).toMatch(/min-height\s*:\s*calc\(var\(--space-2xl\) \+ var\(--space-sm\)\)\s*;/); expect(findRule(css, ".planning-question-panel")).toMatch(/border\s*:\s*var\(--btn-border-width\) solid var\(--border\)\s*;/); expect(findRules(css, ".planning-plan-document").some((rule) => /border-radius\s*:\s*var\(--radius-xl\)\s*;/.test(rule))).toBe(true); }); it("makes the history sheet full width on mobile while keeping its own scroll owner", () => { const css = loadPlanningCss(); expect(findRule(css, ".planning-history-scroll")).toMatch(/overflow-y\s*:\s*auto\s*;/); expect(findRule(css, ".planning-history-panel")).toMatch(/width\s*:\s*min\(100%, calc\(var\(--space-2xl\) \* 15\)\)\s*;/); const mobileCss = getMediaBlocks(css, MOBILE_ACTIONS_QUERY).join("\n"); expect(findRule(mobileCss, ".planning-history-panel")).toMatch(/width\s*:\s*100%\s*;/); }); it("uses consistent full-width header controls without crowding the mobile session title", () => { const css = loadPlanningCss(); const backRule = findRule(css, ".planning-session-back"); expect(findRule(css, ".planning-header-controls")).toMatch(/gap\s*:\s*var\(--space-sm\)\s*;/); expect(findRule(css, ".planning-header-controls .btn")).toMatch(/min-height\s*:\s*calc\(var\(--space-2xl\) \+ var\(--space-sm\)\)\s*;/); expect(backRule).toMatch(/display\s*:\s*inline-flex\s*;/); expect(backRule).toMatch(/min-width\s*:\s*calc\(var\(--space-md\) \* 2\.25\)\s*;/); expect(backRule).toMatch(/min-height\s*:\s*calc\(var\(--space-md\) \* 2\.25\)\s*;/); const mobileCss = getMediaBlocks(css, MOBILE_ACTIONS_QUERY).join("\n"); expect(findRule(mobileCss, ".planning-modal--embedded .modal-header--embedded")).toMatch(/flex-wrap\s*:\s*wrap\s*;/); expect(findRule(mobileCss, ".planning-header-controls")).toMatch(/grid-template-columns\s*:\s*minmax\(0, 1fr\)\s*;/); expect(findRule(mobileCss, ".planning-header-controls")).toMatch(/width\s*:\s*100%\s*;/); expect(findRule(mobileCss, ".planning-modal--embedded .modal-header--embedded .detail-title-row")).toMatch(/flex-wrap\s*:\s*nowrap\s*;/); expect(findRule(mobileCss, ".planning-modal--embedded .modal-header--embedded .detail-title-row")).toMatch(/overflow\s*:\s*hidden\s*;/); expect(findRule(mobileCss, ".planning-modal--embedded .modal-header--embedded .detail-title-row h3")).toMatch(/flex\s*:\s*1 1 auto\s*;/); expect(findRule(mobileCss, ".planning-modal--embedded .modal-header--embedded .detail-title-row h3")).toMatch(/min-width\s*:\s*0\s*;/); expect(findRule(mobileCss, ".planning-modal--embedded .modal-header--embedded .detail-title-row h3")).toMatch(/text-overflow\s*:\s*ellipsis\s*;/); expect(findRule(mobileCss, ".planning-modal--embedded .modal-header--embedded .detail-title-row h3")).toMatch(/white-space\s*:\s*nowrap\s*;/); expect(findRule(mobileCss, ".planning-modal--embedded .modal-header--embedded .detail-title-row > svg,\n .planning-modal--embedded .modal-header--embedded .detail-title-row > .btn-icon,\n .planning-modal--embedded .modal-header--embedded .planning-session-back")).toMatch(/flex\s*:\s*0 0 auto\s*;/); }); it("keeps tablet question and plan actions on one aligned row with a tight bottom inset", () => { const css = loadPlanningCss(); const tabletCss = getMediaBlocks(css, TABLET_SUMMARY_ACTIONS_QUERY).join("\n"); const sharedFooterRule = findRule(tabletCss, ".planning-question-pane .planning-actions,\n .planning-plan-actions"); const sharedButtonRule = findRule(tabletCss, ".planning-question-pane .planning-actions .btn,\n .planning-plan-actions .btn"); expect(sharedFooterRule).toMatch(/align-items\s*:\s*stretch\s*;/); expect(sharedFooterRule).toMatch(/min-height\s*:\s*calc\(var\(--space-2xl\) \+ var\(--space-xl\)\)\s*;/); expect(sharedFooterRule).toMatch(/padding\s*:\s*var\(--space-sm\) var\(--space-lg\) var\(--space-xs\)\s*;/); expect(sharedButtonRule).toMatch(/flex\s*:\s*1 1 0\s*;/); expect(sharedButtonRule).toMatch(/min-width\s*:\s*0\s*;/); expect(sharedButtonRule).toMatch(/min-height\s*:\s*calc\(var\(--space-2xl\) \+ var\(--space-md\)\)\s*;/); // FNXC:PlanningComments 2026-07-24-05:55: tablet keeps the 2-col grid so Add comment can span above Refine/Proceed. expectSomeRule(tabletCss, ".planning-plan-actions", /display\s*:\s*grid\s*;/); expectSomeRule(tabletCss, ".planning-plan-actions", /grid-template-columns\s*:\s*repeat\(2, minmax\(0, 1fr\)\)\s*;/); expect(findRule(tabletCss, ".planning-plan-actions .btn.planning-add-comment")).toMatch(/grid-column\s*:\s*1\s*\/\s*-1\s*;/); expect(findRule(tabletCss, ".planning-plan-actions .btn.planning-add-comment svg")).toMatch(/width\s*:\s*var\(--space-lg\)\s*;/); }); it("shows exactly one contextual comment trigger in the plan action rail at every breakpoint", () => { const css = loadPlanningCss(); const compactCss = getMediaBlocks(css, "@media (max-width: 1024px)").join("\n"); const mobileCss = getMediaBlocks(css, MOBILE_ACTIONS_QUERY).join("\n"); const railTriggerRule = findRule(compactCss, ".planning-plan-actions .btn.planning-add-comment"); const mobileEditorRule = findRule(mobileCss, ".planning-comment-editor"); /* FNXC:PlanningComments 2026-07-25-10:20: The --document / --mobile variant pair is deleted: both rendered, so desktop showed a duplicate Add-comment button at the end of the plan text. One rail trigger, never display:none'd. */ expect(css).not.toMatch(/planning-add-comment--(document|mobile)/); expect(findRule(css, ".planning-add-comment")).toMatch(/display\s*:\s*inline-flex\s*;/); expect(findRule(css, ".planning-add-comment")).not.toMatch(/display\s*:\s*none\s*;/); expect(railTriggerRule).toMatch(/display\s*:\s*flex\s*;/); expect(railTriggerRule).toMatch(/grid-column\s*:\s*1\s*\/\s*-1\s*;/); expect(railTriggerRule).toMatch(/margin-top\s*:\s*0\s*;/); expect(findRule(compactCss, ".planning-plan-actions .btn.planning-add-comment svg")).toMatch(/width\s*:\s*var\(--space-lg\)\s*;/); // FNXC:PlanningComments 2026-07-24-05:50: phone no longer overrides the rail trigger to fixed. expect(findRule(mobileCss, ".planning-plan-actions .btn.planning-add-comment")).toBeUndefined(); // FNXC:PlanningComments 2026-07-24-06:05: tablet+phone pin the composer; phone clears nav when keyboard closed. expect(findRule(compactCss, ".planning-comment-editor")).toMatch(/position\s*:\s*fixed\s*;/); expect(findRule(mobileCss, ".planning-comment-editor:not(.planning-comment-editor--keyboard-open)")).toMatch(/var\(--mobile-nav-height/); expect(mobileEditorRule).toBeUndefined(); }); it("pins only the plan-selection rail while its document scrolls in portrait and width-independent short landscape", () => { const css = loadPlanningCss(); const responsiveCss = getMediaBlocks(css, MOBILE_PLANNING_SHELL_QUERY).join("\n"); const boundedPaneRule = findRule(responsiveCss, ".planning-plan-review,\n .planning-plan-review > .planning-plan-pane"); const shrinkablePaneRules = findRules(responsiveCss, ".planning-plan-review > .planning-plan-pane"); const scrollOwnerRule = findRule(responsiveCss, ".planning-plan-pane > .planning-plan-scroll"); const pinnedActionsRule = findRule(responsiveCss, ".planning-plan-pane > .planning-plan-actions"); expect(boundedPaneRule).toMatch(/min-height\s*:\s*0\s*;/); expect(shrinkablePaneRules.some((rule) => /flex\s*:\s*1 1 0\s*;/.test(rule))).toBe(true); expect(scrollOwnerRule).toMatch(/flex\s*:\s*1 1 0\s*;/); expect(scrollOwnerRule).toMatch(/min-height\s*:\s*0\s*;/); expect(scrollOwnerRule).toMatch(/overflow-y\s*:\s*auto\s*;/); expect(pinnedActionsRule).toMatch(/flex\s*:\s*0 0 auto\s*;/); expect(responsiveCss).not.toMatch(/\.planning-actions\s*>\s*\.planning-plan-actions/); }); it("keeps the mobile sessions list scrolling above the bottom-pinned New session footer", () => { const css = loadPlanningCss(); const mobileShellCss = getMediaBlocks(css, MOBILE_PLANNING_SHELL_QUERY).join("\n"); const showListRule = findRule(mobileShellCss, ".planning-modal-body--show-list"); expect(showListRule).toBeTruthy(); expect(showListRule).toMatch(/flex\s*:\s*1\s*;/); expect(showListRule).toMatch(/min-height\s*:\s*0\s*;/); expect(showListRule).toMatch(/overflow\s*:\s*hidden\s*;/); const sidebarRule = findRule(mobileShellCss, ".planning-modal-body--show-list .planning-sidebar"); expect(sidebarRule).toBeTruthy(); expect(sidebarRule).toMatch(/flex\s*:\s*1 1 auto\s*;/); expect(sidebarRule).toMatch(/height\s*:\s*100%\s*;/); expect(sidebarRule).toMatch(/min-height\s*:\s*0\s*;/); expect(sidebarRule).toMatch(/max-height\s*:\s*100%\s*;/); const sidebarListRule = findRule(mobileShellCss, ".planning-modal-body--show-list .planning-sidebar-list"); expect(sidebarListRule).toBeTruthy(); expect(sidebarListRule).toMatch(/flex\s*:\s*1 1 auto\s*;/); expect(sidebarListRule).toMatch(/min-height\s*:\s*0\s*;/); expect(sidebarListRule).toMatch(/overflow-y\s*:\s*auto\s*;/); const footerRule = findRule(mobileShellCss, ".planning-modal-body--show-list .planning-sidebar-footer"); expect(footerRule).toBeTruthy(); expect(footerRule).toMatch(/flex-shrink\s*:\s*0\s*;/); }); });