import fs from 'fs'; import path from 'path'; import { blockAt, blockContaining } from '../lib/cssBlocks'; import { readV2Css } from '../__testkit__/v2Css'; /** * Layout-invariant guards for v2 CSS rules that regressed in production. * * These assert the *presence* of specific override rules, NOT computed layout. * jsdom has no layout engine — `scrollHeight`, `offsetHeight`, and `overflow` * clipping are all 0 / meaningless — so a render test structurally cannot see * these bugs. Both invariants below shipped broken and were caught only by a * real-browser (MCP Playwright) measurement: * * - Your Team card name was flex-crushed to a single char by the category * chip ("AI Citation Strategist" -> "/"). Fixed in #568 — and it had already * silently regressed once via a stale-base revert of the original fix * (d8140397), so a revert is a real, observed failure mode. * This is a deliberate stand-in until a browser-level layout test tier exists. * It precisely guards the thing that actually broke: the override rule going * missing. If these assertions feel brittle, that's the point — the rule they * pin is load-bearing, and it has been removed by accident before. */ const read = (rel: string): string => fs.readFileSync(path.join(__dirname, rel), 'utf8'); // The task board's own sheet (S4, lifted from the d215 frame). Its rules left // v2.css in the same change, so every board guard reads this file. const boardCss = read('../components/V2PodBoard.css'); // Settings keeps its own sheet, lifted from the d215 frame (TASK-688). const ss = read('../components/V2SettingsPage.css'); // The team's phone block: the `@media (max-width: 760px)` block that carries // `.v2-team__grid`, wherever it sits in the sheet — not the last one. const teamPhoneBlock = (css: string): string => blockContaining(css, '@media (max-width: 760px)', '.v2-team__grid'); // The `@media (max-width: 760px)` block that carries `selector`, wherever it // sits in the sheet. A phone override is indented inside a media query, so // `ruleBody` (which prefers a line-start selector) silently returns the desktop // rule instead — and a guard reading the wrong rule is green while the phone // layout is broken (TASK-140). const mediaBlockContaining = (css: string, selector: string): string => blockContaining(css, '@media (max-width: 760px)', selector); // `within` bounds the read to a slice already taken out of the sheet (an // at-rule's block, a media query's body). Pass it rather than passing the slice // as `css`: the call then names the sheet it read AND the scope it read in, so a // scoped read cannot be mistaken for a whole-sheet one. const ruleBody = ( css: string, selector: string, { within }: { within?: string } = {}, ): string => { const scope = within ?? css; // Prefer a selector at the start of a CSS line. A descendant selector can // contain the same text (`.parent .target {`) and is not the rule being // pinned. const lineStart = scope.indexOf(`\n${selector} {`); const start = lineStart === -1 ? scope.indexOf(`${selector} {`) : lineStart + 1; if (start === -1) return ''; const end = scope.indexOf('}', start); return end === -1 ? '' : scope.slice(start, end); }; // TASK-129 supersedes a large sidebar block in place. The current artboard // rules intentionally live later in the same stylesheet until the chat pass // removes the retired selectors, so inspect the last exact rule for overrides. const lastRuleBody = (css: string, selector: string): string => { const start = css.lastIndexOf(`\n${selector} {`); if (start === -1) return ''; const end = css.indexOf('}', start); return end === -1 ? '' : css.slice(start, end); }; // Some component rules deliberately share a declaration block with an // element variant (for example, button + link CTAs). The guard still needs to // inspect that one block without splitting the production selector just for a // test helper. const selectorRuleBody = (css: string, selector: string): string => { const selectorStart = css.indexOf(selector); if (selectorStart === -1) return ''; const bodyStart = css.indexOf('{', selectorStart); const bodyEnd = css.indexOf('}', bodyStart); return bodyStart === -1 || bodyEnd === -1 ? '' : css.slice(bodyStart, bodyEnd); }; // Brace-matched slice of one at-rule. `indexOf` to the next `@media` is not // enough on a sheet with nested blocks, and which block a naive slice returns // depends on the ORDER the file happens to put them in — a guard that asserts on // the wrong block reads as a pass. Module-level because three describes need it // and three copies is how the fourth one drifts. const mediaAt = (css: string, atRule: string): string => { const at = css.indexOf(atRule); if (at < 0) return ''; return blockAt(css, at); }; // The `@media (prefers-reduced-motion: reduce)` block that carries `needle`, not // the first such block in the sheet: v2-landing.css carries FIVE of them // (531 / 1395 / 1421 / 1447 / 1517) and a plain `grep -c prefers-reduced-motion` // reports six because the prose comment at 1356 mentions it. Each occurrence is // brace-matched, not sliced to the next `@media` — which block a slice lands in // depends on the order the file happens to put them in, so an assertion can end // up made about the wrong block and still read as a pass. const reducedMotionBlock = (css: string, needle: string): string => blockContaining(css, '@media (prefers-reduced-motion', needle); // Body of a rule that IS the whole selector — anchored at the start of a line, so // it cannot match a rule where this selector is the TAIL of a longer one. // `selectorRuleBody` finds the first substring match, which is wrong whenever an // override names the class before the base rule does: the even-row rules say // `.v2-landing__feature-row:nth-child(even) .v2-landing__feature-copy { order: 1 }` // two rules above the base `.v2-landing__feature-copy {` — and a substring check // reads the override's body instead of the rule's. const topLevelRuleBody = (css: string, selector: string): string => { const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const match = new RegExp('^' + escaped + ' \\{([^}]*)\\}', 'm').exec(css); return match ? match[1] : ''; }; const cssVariable = (css: string, name: string): string | undefined => ( new RegExp(`${name}\\s*:\\s*([^;]+);`).exec(css)?.[1].trim() ); const activeAccentBackgroundSelectors = (css: string, classNames: Set): string[] => ( [...new Set(Array.from(css.matchAll(/([^{}]+)\{([^{}]*)\}/g)).flatMap(([, rawSelector, body]) => { if (!/background\s*:\s*var\(--v2-accent(?:-fill)?\)/.test(body)) return []; return rawSelector .replace(/\/\*[\s\S]*?\*\//g, '') .split(',') .map((selector) => selector.trim().replace(/:hover(?::[^\s.{]+)?/g, '')) .filter((selector) => Array.from(classNames).some((className) => ( new RegExp(`\\.${className}(?![\\w-])`).test(selector) ))); }))].sort() ); // App.css is shared by legacy V1 and v2. A leading element selector in V1 // styles crosses that boundary unless it is rooted beneath the legacy canvas. // Keep html/body as the two deliberate document-level exceptions. const bareElementSelectors = (css: string): string[] => { const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ''); const selectorGroups = Array.from(withoutComments.matchAll(/([^{}]+)\{/g), ([, selector]) => selector.trim()); return selectorGroups .filter((selector) => !selector.startsWith('@')) .flatMap((selector) => selector.split(',').map((part) => part.trim())) .filter((selector) => { const element = /^([a-z][\w-]*)\b/i.exec(selector)?.[1].toLowerCase(); return Boolean(element && !['html', 'body'].includes(element)); }); }; describe('v2 layout invariants (CSS rule presence)', () => { test('/cli/authorize scrolls inside the .v2-root shell on a short viewport (TASK-682)', () => { // .v2-root is height 100vh + overflow hidden; the page must override it or // the lower controls (Deny / Authorize) are unreachable on a short screen. const cli = fs.readFileSync(path.join(__dirname, '../components/V2CliAuthorize.css'), 'utf8'); const root = /^\.v2-root\.v2-cli-authorize \{([^}]*)\}/m.exec(cli)?.[1] ?? ''; expect(root).toContain('overflow-y: auto'); expect(root).toContain('display: grid'); expect(root).not.toContain('place-items: center'); expect(/^\.v2-cli-authorize__card \{([^}]*)\}/m.exec(cli)?.[1]).toContain('margin: auto'); // The legacy V1 form rule (0,2,2) would paint the form #111827. expect(cli).toContain('.v2-root.v2-cli-authorize .v2-cli-authorize__card form {'); }); const v2 = readV2Css(); const tokens = read('../../../design-system/tokens.css'); const aprofile = read('../agents/v2-agent-profile.css'); const agentSettings = read('../agents/v2-agent-settings.css'); const byoCss = read('../components/V2AgentBYO.css'); // R05 lifted the Connect page onto the d215 frame (V2ConnectPage.css). const connectCss = read('../components/V2ConnectPage.css'); // R06 lifted the Your team page onto the d215 frame (V2YourTeamPage.css). const teamCss = read('../components/V2YourTeamPage.css'); const landing = read('../landing/v2-landing.css'); const podChat = read('../components/V2Thread.tsx'); const podsSidebar = read('../components/V2PodsSidebar.tsx'); const podAttention = read('../hooks/useV2PodAttention.ts'); const podHeaderMeta = read('../hooks/useV2PodHeaderMeta.ts'); const workspaceInspector = read('../components/V2Inspector.tsx'); const thread = read('../components/V2Thread.tsx'); const threadMessages = read('../components/V2ThreadMessages.tsx'); const messageRow = read('../components/V2MessageRow.tsx'); const composer = read('../components/V2Composer.tsx'); const segmented = read('../components/V2Segmented.tsx'); const primitives = read('../primitives/primitives.css'); const decisionCard = read('../components/V2DecisionCard.tsx'); const navRail = read('../components/V2NavRail.tsx'); const threadCard = read('../components/V2ThreadCard.tsx'); const v2Layout = read('../components/V2Layout.tsx'); const mobileTabs = read('../components/V2MobileTabs.tsx'); const podBoard = read('../components/V2PodBoard.tsx'); const activityPage = read('../components/V2ActivityPage.tsx'); const yourTeamPage = read('../components/V2YourTeamPage.tsx'); const featurePage = read('../components/V2FeaturePage.tsx'); const v2App = read('../V2App.tsx'); const app = read('../../App.tsx'); const appStyles = read('../../App.css'); const settingsPage = read('../components/V2SettingsPage.tsx'); const avatarCropDialog = read('../components/V2AvatarCropDialog.tsx'); const avatar = read('../components/V2Avatar.tsx'); const billingPanel = read('../components/V2BillingPanel.tsx'); const appsManagement = read('../../components/AppsManagement.tsx'); const podModel = read('../../../../backend/models/Pod.ts'); test('agent Settings key metadata wraps within the page at phone width', () => { expect(ruleBody(agentSettings, '.v2-agent-settings__column')).toContain('width: min(100%, 960px)'); expect(ruleBody(agentSettings, '.v2-agent-settings__key-meta')).toContain('flex-wrap: wrap'); expect(mediaBlockContaining(agentSettings, '.v2-agent-settings__key-meta')).toContain('display: grid'); }); test('agent Settings section headers stack at phone width so the key hint stays visible', () => { const phone = mediaBlockContaining(agentSettings, '.v2-agent-settings__section-head'); const header = selectorRuleBody(phone, '.v2-agent-settings__section-head'); expect(header).toContain('flex-direction: column'); expect(header).toContain('align-items: flex-start'); }); test('agent Settings phone rows keep the value cell visible under the label', () => { const surface = read('../agents/v2-agent-settings-surface.css'); const phone = surface.slice(surface.indexOf('@media (max-width: 600px)')); expect(ruleBody(phone, '.v2-agent-settings-page .lrow .vl')).toContain('grid-row: 2'); expect(ruleBody(phone, '.v2-agent-settings-page .lrow .ac')).toContain('grid-row: 1 / span 2'); }); test('agent Settings buttons retain their chrome over the v2 button reset', () => { const button = ruleBody(agentSettings, '.v2-root .v2-agent-settings button:not(.MuiButtonBase-root):not(.b):not(.seg > button):not(.v2-agent-wake-pods__fold)'); expect(button).toContain('border: 1px solid var(--v2-border)'); expect(button).toContain('background: var(--v2-surface)'); expect(ruleBody(agentSettings, '.v2-root .v2-agent-settings .v2-agent-settings__section-head button')).toContain('background: var(--v2-ink)'); }); test('agent Control reconnect remains an ink action with a focus edge', () => { const action = ruleBody(agentSettings, '.v2-root .v2-agent-settings a.v2-agent-settings__action'); expect(action).toContain('background: var(--v2-ink)'); expect(action).toContain('color: var(--v2-on-ink)'); expect(ruleBody(agentSettings, '.v2-root .v2-agent-settings a.v2-agent-settings__action:focus-visible')) .toContain('box-shadow: var(--v2-focus-ring)'); }); test('agent profile Reconnect keeps ink chrome over the primary and link rules', () => { const profile = read('../agents/v2-agent-profile.css'); const reconnect = ruleBody(profile, '.v2-root a.v2-aprofile__btn--reconnect'); expect(reconnect).toContain('background: var(--v2-ink)'); expect(reconnect).toContain('color: var(--v2-on-ink)'); expect(ruleBody(profile, '.v2-root a.v2-aprofile__btn--reconnect:hover')) .toContain('background: var(--v2-ink-hover)'); }); test('BYO hints do not add paragraph margins to flex gaps', () => { expect(ruleBody(byoCss, '.v2-byo__hint')).toContain('margin: 0'); }); test('BYO field hints take no space until focused or in error', () => { expect(ruleBody(byoCss, '.v2-feature--byo .v2-byo__field > .v2-byo__hint')).toContain('display: none'); expect(selectorRuleBody(byoCss, '.v2-feature--byo .v2-byo__field:focus-within > .v2-byo__hint, .v2-feature--byo .v2-byo__field--error > .v2-byo__hint')) .toContain('display: block'); // The page's rules left v2.css for the page's own sheet (R04 lift). expect(v2).not.toContain('.v2-byo__field'); }); test('the global button reset stays a low-specificity default', () => { const reset = ruleBody(v2, '.v2-root :where(button:not(.MuiButtonBase-root))'); expect(reset).toContain('font-family: inherit'); expect(reset).toContain('border: none'); expect(reset).toContain('background: none'); expect(reset).toContain('padding: 0'); expect(v2).not.toMatch(/^\.v2-root button:not\(\.MuiButtonBase-root\)\s*\{/m); }); test('authored utility controls remain visible after the button reset loses specificity', () => { const rail = selectorRuleBody(v2, '.v2-rail__feedback'); const languageTrigger = ruleBody(v2, '.v2-lang-switch__trigger'); const languageItem = ruleBody(v2, '.v2-lang-switch__item'); const mobile = mediaBlockContaining(v2, '.v2-inspector-backdrop'); // These are deliberate neutral controls, not accidental reset leaks. expect(rail).toContain('color: var(--v2-text-tertiary)'); expect(rail).toContain('background: transparent'); expect(rail).toContain('border-radius: var(--v2-radius-sm)'); expect(languageTrigger).toContain('padding: 4px 6px'); expect(languageItem).toContain('padding: 8px 12px'); expect(languageItem).toContain('color: var(--v2-text-secondary)'); expect(mobile).toContain('background: rgba(15, 17, 21, 0.42)'); }); // #2287 (QA BYO walk 1): "Other ways to connect" folds the alternatives so the // page shows one command, and the needs line is read before the Copy button. // jsdom cannot see either; sprint-review deleted all three rules and every // other test stayed green. // R04 (d215 frame): the disclosure is now a bordered row with a chevron, its // contents inset under the summary, and the needs line is a 13px secondary line. test('BYO "Other ways" is a bordered disclosure row with a chevron, and the needs line reads before the command', () => { const more = ruleBody(byoCss, '.v2-feature--byo .v2-byo__more'); expect(more).toContain('border-radius: 12px'); expect(more).toContain('box-shadow: inset 0 0 0 1px var(--v2-border-soft)'); const summary = ruleBody(byoCss, '.v2-feature--byo .v2-byo__more > summary'); expect(summary).toContain('cursor: pointer'); expect(summary).toContain('display: flex'); expect(summary).toContain('min-height: 52px'); expect(ruleBody(byoCss, '.v2-feature--byo .v2-byo__more > summary::after')).toContain('rotate(45deg)'); expect(ruleBody(byoCss, '.v2-feature--byo .v2-byo__more[open] > summary::after')).toContain('rotate(225deg)'); // The folded contents keep their spacing from the summary and each other. expect(ruleBody(byoCss, '.v2-feature--byo .v2-byo__more > :not(summary)')).toContain('margin: 0 16px 14px'); expect(ruleBody(byoCss, '.v2-feature--byo .v2-byo__needs')).toContain('font-size: 13px'); }); // #2670: the Cursor rule card's header names an unbroken save path; without // these two declarations it pushes the Copy button out of the card at 390px. test('Cursor rule card header wraps its save path inside the card', () => { expect(ruleBody(connectCss, '.v2-connect .v2-connect__rule .v2-connect__code-head span')).toContain('min-width: 0'); expect(ruleBody(connectCss, '.v2-connect .v2-connect__rule .v2-connect__code-head code')).toContain('overflow-wrap: anywhere'); }); test('Your Team card name owns its line so the category chip cannot crush it', () => { // Direction C: the name sits in its own column of the card head; the // crush guard is min-width 0 on the name and the head, not a flex basis. const rule = ruleBody(teamCss, '.v2-team .v2-team-card__name'); expect(rule).toContain('min-width: 0'); expect(ruleBody(teamCss, '.v2-team .v2-team-card__head')).toContain('min-width: 0'); // ruleBody reads the first, desktop rule; no phone override may undo it. for (const phone of [...teamCss.matchAll(/@media \(max-width: 760px\) \{[\s\S]*?\n\}/g)].map((m) => m[0])) { expect(phone).not.toMatch(/v2-team-card__name[^{]*\{[^}]*(nowrap|min-width: auto|flex-shrink: 0)/); } }); test('Your Team card name WRAPS — a primary identifier never one-line-ellipsizes (craft audit rule 1)', () => { // At 1440px half the fleet's names truncated ("Commonly…", "Pod Summ…"). // The name wraps to two lines; single-line nowrap+ellipsis was the defect. const rule = ruleBody(teamCss, '.v2-team .v2-team-card__name'); expect(rule).toContain('white-space: normal'); expect(rule).toContain('-webkit-line-clamp: 2'); expect(rule).not.toContain('white-space: nowrap'); }); test('Your Team keeps template copy out of cards and folds long pod lists', () => { expect(yourTeamPage).toContain('isTemplateDescription(description, display)'); expect(yourTeamPage).toContain("(a.podNames || []).slice(0, 2)"); expect(yourTeamPage).toContain('v2-team-card__pod--more'); }); test('the workspace grid owns the measured 56 / 260 / fluid / 272 geometry', () => { // Direction C (walk-1 ruling a, 2026-09-06): 14px page padding and three // 14px gaps leave the 782px chat track the artboard measured. expect(cssVariable(v2, '--v2-rail-w')).toBe('56px'); expect(cssVariable(v2, '--v2-pods-w')).toBe('260px'); expect(cssVariable(v2, '--v2-inspector-w')).toBe('272px'); expect(lastRuleBody(v2, '.v2-shell')).toContain('gap: 14px'); expect(lastRuleBody(v2, '.v2-shell')).toContain('padding: 14px'); }); test('the workspace keeps each artboard column width declared exactly once', () => { // cssVariable() intentionally reads the first match, so it would miss a // later media override. Only the chat track may contract before phone. for (const name of ['--v2-rail-w', '--v2-pods-w', '--v2-inspector-w']) { expect(v2.match(new RegExp(`${name}\\s*:`, 'g')) || []).toHaveLength(1); } }); test('the compact rail uses the artboard’s 36px logo and bare 38px nav glyphs (D223, TASK-735)', () => { const rail = ruleBody(v2, '.v2-rail'); const brand = ruleBody(v2, '.v2-rail__brand-icon'); const item = ruleBody(v2, '.v2-root button.v2-rail__item'); const active = ruleBody(v2, '.v2-root button.v2-rail__item--active'); expect(rail).toContain('padding: 6px 8px'); // The nav glyph must fit the rail's content box or the nav scrolls // sideways (staging smoke, "rail no h-scroll @1440px"): rail width, less // the pane's 1px border, less both side paddings. const railW = Number(/--v2-rail-w:\s*(\d+)px/.exec(v2)?.[1]); const padX = Number(/padding: \d+px (\d+)px/.exec(rail)?.[1]); const itemW = Number(/(?:^|\s)width: (\d+)px/.exec(item)?.[1]); expect(railW - 1 - 2 * padX).toBeGreaterThanOrEqual(itemW); expect(brand).toContain('width: 36px'); expect(brand).toContain('height: 36px'); expect(item).toContain('width: 38px'); expect(item).toContain('height: 38px'); expect(item).toContain('border-radius: var(--v2-r-10, 10px)'); // At rest a glyph has no box behind it; only the active item is filled. expect(item).toContain('background: transparent'); expect(active).toContain('background: var(--v2-ink)'); expect(active).toContain('linear-gradient(180deg, var(--v2-fill-a, var(--v2-ink)), var(--v2-fill-b, var(--v2-ink)))'); // TASK-701: inactive glyphs read a token, so the dark set can lift them. expect(item).toContain('color: var(--v2-rail-icon)'); }); test('the rail account avatar is the artboard’s 36px square with a hairline ring', () => { const accountButton = lastRuleBody(v2, '.v2-rail__account'); const accountAvatar = lastRuleBody(v2, '.v2-rail__account .v2-avatar'); const accountCluster = ruleBody(v2, '.v2-rail__user'); expect(accountButton).toContain('width: 36px'); expect(accountButton).toContain('height: 36px'); expect(accountAvatar).toContain('width: 36px'); expect(accountAvatar).toContain('height: 36px'); expect(accountAvatar).toContain('border-radius: var(--v2-r-9, 9px)'); expect(accountAvatar).toContain('border: 0'); expect(accountAvatar).toContain('box-shadow: 0 0 0 1px var(--v2-av-ring, var(--v2-border-soft))'); expect(accountCluster).toContain('align-items: center'); }); test('sidebar is direction C: a white panel with search, a Recent · All · Pinned segment, and rows that carry mark, pill and time', () => { // Walk-1 rulings (2026-09-06): the six 11px mono labels are gone; two // 12px semibold section heads plus a folded inventory replace them. const panel = lastRuleBody(v2, '.v2-pods'); const head = ruleBody(v2, '.v2-root button.v2-pods__section-head'); const row = ruleBody(v2, '.v2-root button.v2-pods__row'); const mark = ruleBody(v2, '.v2-pods__row-mark'); const meta = ruleBody(v2, '.v2-pods__row-meta'); const pill = ruleBody(v2, '.v2-pods__row-pill'); const search = lastRuleBody(v2, '.v2-pods__search'); // D223 (TASK-735): the artboard's .pane.pods, a 14px card ringed by a // shadow rather than a border, its rows inset by 10px. expect(panel).toContain('border: 0'); expect(panel).toContain('padding: 12px 10px'); expect(panel).toContain('border-radius: var(--v2-r-14, 14px)'); expect(panel).toContain('box-shadow: var(--v2-shadow-pane, 0 0 0 1px var(--v2-border-soft))'); expect(panel).toContain('background: var(--v2-surface)'); expect(head).toContain('font: 600 12px/16px var(--v2-font)'); expect(search).toContain('height: 34px'); expect(podsSidebar).toContain(''); // The + is the artboard's 32px raised key, a glyph rather than a character. // D223: the new-pod key is the primitives' 32px secondary icon key. expect(podsSidebar).toContain('className="v2p-b v2p-b-s v2p-ib v2-pods__new"'); expect(primitives).toMatch(/\.v2p-ib \{ width: 32px; height: 32px;/); expect(podsSidebar).toContain(''); expect(row).toContain('grid-template-columns: 24px minmax(0, 1fr) auto'); expect(row).toContain('min-height: 36px'); // Rows are inset with 8px corners; the selected one is not a full-bleed block. expect(row).toContain('border-radius: var(--v2-r-8, 8px)'); expect(mark).toContain('width: 24px'); expect(mark).toContain('height: 24px'); expect(mark).toContain('font: 600 11px/1 var(--v2-font-mono)'); const directMark = ruleBody(v2, '.v2-pods__row-mark.v2-avatar'); expect(directMark).toContain('border: 0'); expect(directMark).toContain('border-radius: var(--v2-r-6, 6px)'); expect(meta).toContain('font: 500 11px/16px var(--v2-font-mono)'); expect(pill).toContain('background: var(--v2-ink)'); expect(pill).toContain('height: 18px'); expect(podsSidebar).toContain("'podsSidebar.workspace.recent'"); // D215 (TASK-691): one segment replaced the Pinned / Recent headings and the // "Everything ▸" fold. expect(podsSidebar).toContain("'podsSidebar.workspace.all'"); expect(podsSidebar).toContain(' { const mobile = blockContaining(v2, '@media (max-width: 760px)', '.v2-root button.v2-pods__row'); const row = ruleBody(v2, '.v2-root button.v2-pods__row', { within: mobile }); const pin = ruleBody(v2, '.v2-root button.v2-pods__pin', { within: mobile }); expect(mobile).not.toBe(''); expect(row).toContain('min-height: 44px'); // D223 (TASK-735): the 24px mark and the 13.5px name hold at 390, as drawn. expect(row).toContain('grid-template-columns: 24px minmax(0, 1fr) auto'); expect(ruleBody(v2, '.v2-pods__row-mark', { within: mobile })).toBe(''); expect(ruleBody(v2, '.v2-pods__row-name', { within: mobile })).toBe(''); expect(pin).toContain('width: 28px'); expect(pin).toContain('height: 28px'); }); test('phone tab labels keep text contrast while the glyphs take the artboard grey (D223)', () => { expect(v2).toContain('.v2-root button.v2-mobile-tabs__item:not(.v2-mobile-tabs__item--active) .v2-mobile-tabs__label { color: var(--v2-text-muted); }'); // The glyph half: inactive tabs take the artboard's placeholder grey. const phone = blockContaining(v2, '@media (max-width: 760px)', '.v2-mobile-tabs__label {'); // Icons are controls, so they take the rail's 3:1 grey, not the artboard's placeholder. expect(ruleBody(v2, '.v2-root button.v2-mobile-tabs__item', { within: phone })).toContain('color: var(--v2-rail-icon)'); // D223 ruling: the artboard's sans family at the 12px sans floor. expect(ruleBody(v2, '.v2-mobile-tabs__label', { within: phone })).toContain('font: 500 12px/14px var(--v2-font)'); }); test('rail glyphs at rest are the nearest grey to the artboard that clears 3:1 (D223 ruling)', () => { // Measured on the rail's own surface, --v2-shell-bg #eef0f4: the // artboard's #98a2b3 is 2.26:1 and #828a99 is 3.05:1. expect(v2).toContain('--v2-shell-bg: #eef0f4;'); expect(v2).toContain('--v2-rail-icon: #828a99;'); expect(v2.match(/--v2-rail-icon: #5f6979;/g) || []).toHaveLength(2); }); test('the phone pods page leads with the artboard title bar, search behind an icon (D223)', () => { expect(podsSidebar).toContain('className="v2-pods__bar"'); expect(podsSidebar).toContain('v2-pods__search-open'); expect(podsSidebar).toContain("size={isPage ? 'md' : 'sm'}"); expect(ruleBody(v2, '.v2-pods__bar')).toContain('height: 52px'); expect(ruleBody(v2, '.v2-pods__page-title')).toContain('font: 800 21px/1 var(--v2-font-display, var(--v2-font))'); }); test('pod search shortcut hint hides on narrow and touch-only screens', () => { const hideHint = mediaAt(v2, '@media (max-width: 760px), (hover: none) and (pointer: coarse) {'); expect(hideHint).not.toBe(''); expect(ruleBody(v2, '.v2-pods__search-kbd', { within: hideHint })).toContain('display: none'); expect(v2).not.toContain('.v2-pods__search-kbd::before'); }); test('pod create actions beat the button reset and keep phone-sized targets', () => { const shared = selectorRuleBody(v2, '.v2-root button.v2-pods__create-cancel,'); const cancel = ruleBody(v2, '.v2-root button.v2-pods__create-cancel'); const submit = lastRuleBody(v2, '.v2-root button.v2-pods__create-submit'); const disabled = ruleBody(v2, '.v2-root button.v2-pods__create-submit:disabled'); const phone = blockContaining(v2, '@media (max-width: 760px)', '.v2-root button.v2-pods__create-cancel,'); expect(shared).toContain('padding: 6px 9px'); expect(shared).toContain('font-size: 12px'); expect(cancel).toContain('color: var(--v2-text-tertiary)'); expect(submit).toContain('background: var(--v2-ink)'); expect(submit).toContain('color: var(--v2-on-ink)'); expect(disabled).toContain('background: var(--v2-border-strong)'); expect(phone).not.toBe(''); expect(ruleBody(v2, '.v2-root button.v2-pods__create-submit', { within: phone })).toContain('min-height: 40px'); }); test('the selected pod fills from the row token, so dark can go neutral (TASK-701)', () => { const selected = selectorRuleBody(v2, '.v2-root button.v2-pods__row--selected'); expect(selected).toContain('background: var(--v2-row-active)'); // D223 (TASK-735): the artboard's .pod.on carries no accent bar. expect(selected).not.toContain('inset 3px 0 0'); expect(selected).toContain('font-weight: 600'); expect(podsSidebar).not.toContain('v2-pods__item--active'); expect(podsSidebar).not.toContain('v2-pods__item-dot'); }); test('row pills, inspector rows, and the phone tab count derive from one attention collection', () => { expect(podAttention).toContain("'/api/activity/decision-queue'"); expect(v2Layout).toContain('useV2PodAttention()'); expect(v2Layout).toContain('attentionItems={attention.items}'); expect(v2Layout).toContain('attentionCountByPod={attention.countByPod}'); expect(v2Layout).toContain('needsYouCount={attention.count}'); expect(v2Layout).toContain('attentionCount={attention.count === null ? null : (attention.countByPod[selectedPodId] || 0)}'); expect(v2Layout).toContain('needsYouCount={attention.count ?? 0}'); expect(podsSidebar).toContain('attentionCountByPod'); expect(podsSidebar).toContain('count > 0'); expect(workspaceInspector).toContain('attentionItems.filter'); expect(podsSidebar).not.toContain('useV2Unread'); }); test('pod kinds are an explicit reviewed mapping, not a complement of direct types', () => { expect(podsSidebar).toContain('const ROOM_POD_TYPES'); expect(podsSidebar).toContain('const ROOM_POD_TYPE_LABELS'); expect(podsSidebar).toContain('communityListed === true'); expect(podsSidebar).toContain('export const podKind'); expect(podsSidebar).toContain('export const isRoomPod'); expect(podsSidebar).toContain('podKind(pod) !== null'); expect(podsSidebar).not.toContain('pods.filter((pod) => !isDirectPod(pod))'); }); test('every stored pod type has exactly one reviewed sidebar group', () => { const entriesFor = (source: string, setName: string): string[] => { const match = source.match(new RegExp(`const ${setName} = new Set\\(\\[([\\s\\S]*?)\\]\\);`)); return Array.from(match?.[1].matchAll(/'([^']+)'/g) || [], ([, entry]) => entry); }; const schemaTypes = Array.from( podModel.match(/type:\s*\{[\s\S]*?enum:\s*\[([^\]]+)\],[\s\S]*?default:\s*'chat'/)?.[1].matchAll(/'([^']+)'/g) || [], ([, entry]) => entry, ); const roomTypes = entriesFor(podsSidebar, 'ROOM_POD_TYPES'); const directTypes = entriesFor(podsSidebar, 'DM_POD_TYPES'); const typeLabelBody = podsSidebar.match(/const ROOM_POD_TYPE_LABELS:[\s\S]*?= \{([\s\S]*?)\};/)?.[1] || ''; const mappedRoomTypes = Array.from(typeLabelBody.matchAll(/(?:'([^']+)'|(\w+)):\s*'[^']+'/g), ([, quoted, bare]) => quoted || bare); const sidebarTypes = [...roomTypes, ...directTypes]; expect(new Set(sidebarTypes).size).toBe(sidebarTypes.length); expect(sidebarTypes.sort()).toEqual(schemaTypes.sort()); expect(mappedRoomTypes.sort()).toEqual(roomTypes.sort()); }); test('bound channels left the sidebar for the pod header meta, read from the same binding endpoint', () => { expect(podHeaderMeta).toContain("'/api/integrations/user/all'"); expect(podHeaderMeta).toContain("row.status === 'connected'"); expect(thread).toContain('v2-pod-header__channel'); expect(podsSidebar).not.toContain('/api/integrations'); }); test('on the phone the pods list is a page and a pod is the next page — no drawer', () => { expect(v2Layout).toContain('phone && !selectedPodId'); expect(v2Layout).toContain('variant="page"'); expect(v2Layout).not.toContain('mobileNavOpen'); expect(v2).toContain('.v2-shell--list'); expect(v2).toContain('.v2-pods-aside:not(.v2-pods-aside--page) { display: none; }'); expect(thread).toContain('v2-thread__back'); expect(thread).not.toContain('v2-chat__mobile-nav-btn'); }); test('grouped messages keep the avatar column so text never shifts (craft audit rule 3)', () => { // Consecutive same-author messages within the grouping window drop the // header row; the ghost cell must match the D215 artboard's 36px avatar // column or grouped text mis-aligns with headed text by exactly the // avatar width — a defect jsdom cannot see. expect(ruleBody(v2, '.v2-thread .v2-msg')).toContain('grid-template-columns: 36px'); expect(ruleBody(v2, '.v2-thread .v2-msg__avatar-ghost')).toContain('width: 36px'); expect(ruleBody(v2, '.v2-msg__gutter-time')).toContain('width: 36px'); // S3b: the typing line is the D215 presence chip; it carries a 7px dot, // not faces, so nothing in it can take the 36px message column. expect(ruleBody(v2, '.v2-chat__typing-dot')).toContain('width: 7px'); expect(thread).not.toContain('v2-chat__typing-avatars'); // The phone hides the panel's Hide key; .v2-root button. outranks // the primitive key's display. expect(mediaBlockContaining(v2, '.v2-workspace-inspector__hide')).toMatch(/\.v2-root button\.v2-workspace-inspector__hide \{ display: none; \}/); expect(workspaceInspector).toContain('className="v2p-b v2p-b-g v2p-ib v2p-b-sm v2-workspace-inspector__head-button v2-workspace-inspector__hide"'); expect(v2).toContain('.v2-msg--grouped'); }); test('the workspace route uses the small replacement components, never the retired chat or bubble files', () => { expect(fs.existsSync(path.join(__dirname, '../components/V2PodChat.tsx'))).toBe(false); expect(fs.existsSync(path.join(__dirname, '../components/V2MessageBubble.tsx'))).toBe(false); expect(thread).toContain("from './V2ThreadMessages'"); expect(thread).toContain('V2ThreadHistoryStatus'); expect(threadMessages).toContain("import V2MessageRow from './V2MessageRow'"); expect(thread).toContain("import V2Composer"); expect(messageRow).toContain("import V2DecisionCard"); expect(composer).toContain('const V2Composer'); expect(decisionCard).toContain('const V2DecisionCard'); }); test('direct-agent liveness stays above the composer as a stable, readable row', () => { // A successful DM POST is not proof of an active seat. This row keeps // liveness and the bounded reply wait outside the scrollable transcript // and outside the textarea, so both remain visible while composing. const status = ruleBody(v2, '.v2-chat__agent-room-status'); expect(status).toContain('margin: 8px 24px 0'); expect(status).toContain('font-size: 12px'); const waiting = ruleBody(v2, '.v2-chat__agent-room-status--wait'); expect(waiting).toContain('background: var(--v2-surface-hover)'); expect(waiting).toContain('font-family: var(--v2-font-mono)'); }); test('runtime vocabulary stays off Your Team cards (ADR-022 D1, ratified)', () => { // The chip shipped in violation of the ratified rule; the craft audit // (finding 2) removed it. This pins the removal against reintroduction. expect(v2).not.toContain('.v2-team-card__runtime'); expect(teamCss).not.toContain('.v2-team-card__runtime'); }); test('touch inputs are 16px — iOS auto-zoom never fires and never strands the page zoomed', () => { // Every input under 16px makes iOS Safari zoom on focus and stay zoomed // on blur (2026-08-26). The floor lives in the touch media block and // must keep its !important (it has to beat per-component sizes including // a legacy 15px !important). expect(v2).toMatch(/@media \(hover: none\), \(pointer: coarse\) \{[\s\S]*?\.v2-root input,\n\s*\.v2-root textarea,\n\s*\.v2-root select \{\n\s*font-size: 16px !important;/); }); test('signup controls meet the 44px / 16px phone floor', () => { // Task-103: the full-register form was 39px tall with 14px text at 390px, // a tap target and Safari zoom regression on the first real-user path. const phoneStart = v2.indexOf('@media (max-width: 480px)'); const phoneBlock = v2.slice(phoneStart, v2.indexOf('@media', phoneStart + 10)); expect(phoneStart).toBeGreaterThan(-1); expect(phoneBlock).toMatch(/\.v2-login__input \{[\s\S]*?min-height: 44px;[\s\S]*?font-size: 16px;/); expect(phoneBlock).toMatch(/\.v2-root button\.v2-login__submit \{[\s\S]*?min-height: 44px;[\s\S]*?font-size: 16px;/); }); test('unverified shell banner keeps the named address visible on phones', () => { const phoneStart = v2.indexOf('@media (max-width: 480px)'); const phoneBlock = v2.slice(phoneStart, v2.indexOf('@media', phoneStart + 10)); const shell = ruleBody(v2, '.v2-authenticated-shell'); const banner = ruleBody(v2, '.v2-verification-banner'); const text = ruleBody(v2, '.v2-verification-banner__text'); const notice = ruleBody(v2, '.v2-verification-banner__notice'); expect(shell).toContain('grid-template-columns: minmax(0, 1fr)'); expect(banner).toContain('grid-template-columns: minmax(0, 1fr) auto auto'); expect(banner).toContain('min-width: 0'); expect(text).toContain('overflow-wrap: anywhere'); expect(text).not.toContain('white-space: nowrap'); expect(text).not.toContain('text-overflow: ellipsis'); expect(notice).not.toContain('position: absolute'); expect(phoneBlock).toMatch(/\.v2-verification-banner \{[\s\S]*?grid-template-areas:[\s\S]*?"content dismiss"[\s\S]*?"resend resend"[\s\S]*?font-size: 16px;/); expect(phoneBlock).toMatch(/\.v2-root button\.v2-verification-banner__resend \{[\s\S]*?justify-self: start;/); expect(ruleBody(v2, '.v2-root button.v2-verification-banner__resend,\n.v2-root button.v2-verification-banner__dismiss')).toContain('min-height: 44px'); expect(v2).toMatch(/\.v2-root button\.v2-verification-banner__dismiss \{[\s\S]*?width: 44px;/); }); test('Activity queue actions stay in the row grammar and wrap on narrow screens', () => { // Day-zero onboarding and approval actions share the queue row. Keeping // their action cluster explicit prevents a later button refactor from // forcing a third column past a 390px viewport. // The desktop declaration shares its rule with the buttons, so it is not // eligible for ruleBody's exact-selector helper. expect(v2).toMatch(/\.v2-activity__queue-actions,\n\.v2-activity__queue-row button \{\n\s*display: flex;[\s\S]*?gap: 6px;/); expect(v2).toMatch(/@media \(max-width: 640px\)[\s\S]*?\.v2-activity__queue-actions \{ grid-column: 2; \}[\s\S]*?\.v2-activity__queue-actions \{ flex-wrap: wrap; \}/); expect(v2).toMatch(/@media \(max-width: 640px\)[\s\S]*?\.v2-root \.v2-activity__queue-actions button \{ min-height: 44px; \}/); // Every non-option card act is 32 tall at desktop, level with the options (ux-lead 67327): // Approve / Deny / Mark handled / Reply / Open read 30 from the shared row-button rule. expect(ruleBody(v2, '.v2-root .v2-activity__queue-actions button')).toContain('min-height: 32px'); }); test('Activity details clamp to two lines and reveal full copy on hover, focus, or expand', () => { const detail = ruleBody(v2, '.v2-root .v2-activity__queue-detail'); expect(detail).toContain('display: -webkit-box'); expect(detail).toContain('-webkit-line-clamp: 2'); expect(detail).toContain('overflow: hidden'); const reveal = selectorRuleBody(v2, '.v2-root .v2-activity__queue-detail--expanded'); expect(reveal).toContain('display: block'); // Tab-in preview is kept; a reader's collapse is excluded so focus can't re-open it (#2407). const focusReveal = selectorRuleBody(v2, '.v2-root .v2-activity__queue-detail:focus-visible:not(.v2-activity__queue-detail--dismissed)'); expect(focusReveal).toContain('display: block'); expect(focusReveal).toContain('-webkit-line-clamp: unset'); expect(activityPage).toContain('v2-activity__queue-detail--dismissed'); expect(reveal).toContain('-webkit-line-clamp: unset'); const baseDetail = selectorRuleBody(v2, '.v2-root button.v2-activity__queue-detail'); expect(baseDetail).toContain('color: var(--v2-text-secondary)'); const mentionDetail = selectorRuleBody(v2, '.v2-root .v2-activity__queue-row--mention .v2-activity__queue-detail'); expect(mentionDetail).toContain('color: var(--v2-text-primary)'); expect(v2).toContain('.v2-root:lang(zh) .v2-activity__queue-detail,'); expect(v2).toContain('.v2-root .v2-activity__queue-detail:hover:not(:focus)'); expect(v2).toContain('@media (hover: hover)'); expect(activityPage).toContain('aria-expanded={expandedDetailIds.has(item.id)}'); expect(activityPage).toContain('v2-activity__queue-detail'); expect(activityPage).toContain('aria-label={t(\'activity.compose.placeholder\')}'); expect(activityPage).not.toContain('v2-activity__compose-label'); }); test('Activity pagination keeps the Show more affordance visible and keyboard-sized', () => { const more = ruleBody(v2, '.v2-activity__queue-more'); expect(more).toContain('min-height: 36px'); expect(more).toContain('border: 1px solid var(--v2-accent)'); expect(more).toContain('color: var(--v2-accent-text)'); expect(v2).toContain('.v2-activity__queue-more:hover:not(:disabled)'); expect(v2).toContain('.v2-activity__queue-more:disabled'); }); test('DecisionRequest options preserve authored order with one cobalt primary choice', () => { // The first build left options inside the narrow actions column. Generic // queue-button CSS once made every non-recommended option blue while the // first authored option looked secondary — exactly backwards for a fork card. const decisionActions = ruleBody(v2, '.v2-activity__queue-row--decision .v2-activity__queue-actions'); expect(decisionActions).toContain('grid-column: 1 / -1'); expect(decisionActions).toContain('justify-content: flex-start'); const decisionActionsOverride = lastRuleBody(v2, '.v2-activity__queue-row.v2-activity__queue-row--decision .v2-activity__queue-actions'); expect(decisionActionsOverride).toContain('grid-column: 1 / -1'); expect(decisionActionsOverride).toContain('max-width: none'); expect(decisionActionsOverride).toContain('width: 100%'); // Inline at 32px with Other… beside (ux-lead 66400 fix 4), never stacked bars. expect(decisionActionsOverride).toContain('flex-direction: row'); expect(decisionActionsOverride).toContain('flex-wrap: wrap'); expect(decisionActionsOverride).toContain('gap: 8px'); expect(lastRuleBody(v2, '.v2-activity__queue-row.v2-activity__queue-row--decision .v2-activity__decision-footer')).toContain('display: contents'); expect(ruleBody(v2, '.v2-activity__queue-row.v2-activity__queue-row--decision')).toContain('align-items: start'); expect(lastRuleBody(v2, '.v2-activity__queue-row.v2-activity__queue-row--decision .v2-activity__option-choice')) .toContain('flex: 0 1 auto'); expect(lastRuleBody(v2, '.v2-activity__queue-row.v2-activity__queue-row--decision .v2-activity__option-choice button')) .toContain('min-height: 32px'); expect(lastRuleBody(v2, '.v2-activity__queue-row.v2-activity__queue-row--decision .v2-activity__option-choice button')) .toContain('padding-inline: 12px'); const neutralOptionHover = ruleBody(v2, '.v2-activity__queue-row.v2-activity__queue-row--decision .v2-activity__option-choice button.v2-activity__option:not(.v2-activity__option--primary):hover:not(:disabled)'); expect(neutralOptionHover).toContain('background: var(--v2-surface-hover)'); expect(neutralOptionHover).toContain('color: var(--v2-text-primary)'); const primaryOptionHover = ruleBody(v2, '.v2-root .v2-activity__queue-actions button.v2-activity__option--primary:hover:not(:disabled)'); expect(primaryOptionHover).toContain('background: var(--v2-accent-strong)'); expect(primaryOptionHover).toContain('color: var(--v2-on-ink)'); expect(activityPage).toContain('aria-describedby={describedBy}'); expect(activityPage).toContain('v2-activity__decision-footer'); expect(decisionCard).toContain('aria-describedby={describedBy}'); expect(decisionCard).toContain('v2-decision-card__option-recommended'); expect(decisionCard).toContain('v2-decision-card__option-description'); expect(ruleBody(v2, '.v2-decision-card__options')).toContain('display: grid'); expect(ruleBody(v2, '.v2-decision-card__options')).toContain('gap: 12px'); expect(ruleBody(v2, '.v2-decision-card__option')).toContain('display: grid'); expect(ruleBody(v2, '.v2-decision-card__option')).toContain('gap: 4px'); const neutralOption = ruleBody(v2, '.v2-root .v2-activity__queue-actions button.v2-activity__option'); expect(neutralOption).toContain('border: 1px solid var(--v2-border)'); expect(neutralOption).toContain('background: var(--v2-surface)'); expect(neutralOption).toContain('border-radius: var(--v2-radius-sm)'); const primaryOption = ruleBody(v2, '.v2-root .v2-activity__queue-actions button.v2-activity__option--primary'); expect(primaryOption).toContain('background: var(--v2-accent)'); expect(primaryOption).toContain('color: var(--v2-on-ink)'); expect(v2).toContain('v2-activity__queue-action--bordered'); const borderedAction = ruleBody(v2, '.v2-root .v2-activity__queue-actions button.v2-activity__queue-action--bordered'); expect(borderedAction).toContain('border: 1px solid var(--v2-border)'); expect(ruleBody(v2, '.v2-root .v2-activity__compose .v2-activity__compose-picker-button')).toContain('border: 1px solid var(--v2-border)'); const composeMenu = ruleBody(v2, '.v2-root .v2-activity__compose .v2-activity__compose-picker-menu'); expect(composeMenu).toContain('max-height: 240px'); expect(composeMenu).toContain('overflow-y: auto'); const composeOption = ruleBody(v2, '.v2-root .v2-activity__compose .v2-activity__compose-picker-option'); expect(composeOption).toContain('min-height: 30px'); expect(composeOption).toContain('background: transparent'); expect(composeOption).toContain('color: var(--v2-text-primary)'); const composeOptionHover = ruleBody(v2, '.v2-root .v2-activity__compose .v2-activity__compose-picker-menu button.v2-activity__compose-picker-option:hover:not(:disabled)'); expect(composeOptionHover).toContain('background: var(--v2-surface-hover)'); expect(composeOptionHover).toContain('color: var(--v2-text-primary)'); expect(composeOptionHover).toContain('border-color: transparent'); expect(v2).toMatch(/@media \(max-width: 640px\) \{[\s\S]*?\.v2-root \.v2-activity__compose \.v2-activity__compose-picker-menu \.v2-activity__compose-picker-option \{ min-height: 44px; \}/); const otherOption = ruleBody(v2, '.v2-root .v2-activity__queue-actions button.v2-activity__option.v2-activity__queue-action--secondary'); expect(otherOption).toContain('color: var(--v2-accent-text)'); expect(v2).toContain('.v2-activity__option-description'); expect(activityPage).toContain('(option, index)'); expect(activityPage).not.toContain('.sort((a, b) => Number(Boolean(b.recommended))'); expect(thread).toContain("loadDecisionPages("); expect(thread).toContain("'/api/activity/decision-queue'"); expect(thread).toContain("'/api/activity/decision-history'"); expect(thread).toContain('DECISION_MESSAGE_ID_BATCH_SIZE = 200'); expect(thread).toContain('loadDecisionPagesForMessageIds'); expect(thread).toContain('loadedMessageIdsRef.current = [...new Set(messages'); expect(thread).toContain('if (pendingData)'); expect(thread).toContain("'/api/activity/decision-history'"); expect(thread).toContain('settledDecisionByMessageId'); }); test('the mobile inspector is a drawer, never display:none — the header avatars button must do something', () => { // Below 1024px the pane used display:none while V2Layout still mounted it // on tap: the avatar-stack button looked broken and members/files were // unreachable on phones (2026-08-26). The pane's presence in the DOM // is the open state, so the drawer needs no extra toggle class. // Slice the 1023px block out rather than regex across it — a lazy // [\s\S]*? happily crosses media-block boundaries and matches unrelated // rules pages later. const start = v2.indexOf('@media (max-width: 1023px)'); expect(start).toBeGreaterThan(-1); const block = v2.slice(start, v2.indexOf('@media', start + 10)); expect(block).toContain('.v2-shell:not(.v2-shell--feature-wide)'); expect(block).not.toMatch(/\.v2-shell\s*\{/); expect(block).not.toContain('display: none'); // S3c (D223): the drawer rule moved to chat/chat-inspector.css in its own // 1023px block; read that block the same way. const drawerStart = v2.indexOf('@media (max-width: 1023px) {\n /* Below the 3-pane width the inspector'); expect(drawerStart).toBeGreaterThan(-1); const drawer = v2.slice(drawerStart, v2.indexOf('@media', drawerStart + 10)); expect(drawer).toContain('.v2-pane--inspector'); expect(drawer).toContain('position: fixed'); expect(drawer).not.toContain('display: none'); // The 760px secondary-pane eraser out-specifies the drawer (0-4-0 vs // 0-1-0); the inspector must be in its :not chain or the drawer is a // mounted-but-invisible pane again — the exact live bug, twice. expect(v2).toContain(':not(.v2-pods-aside):not(.v2-pane--inspector)'); }); test('Artifacts (direction C, PR 5): display head, one segment grammar, bordered ext chip, mono numbers, phone block', () => { const artifactsPage = fs.readFileSync(path.join(__dirname, '../components/V2ArtifactsPage.tsx'), 'utf8'); expect(v2App).toContain("'v2-feature--artifacts'"); expect(v2App).toContain(''); expect(ruleBody(v2, '.v2-artifacts__title')).toContain('32px/1.1 var(--v2-font-display)'); expect(ruleBody(v2, '.v2-page-head__meta')).toContain('color: var(--v2-text-muted)'); expect(ruleBody(v2, '.v2-artifacts__seg')).toContain('border: 1px solid var(--v2-border)'); expect(ruleBody(v2, '.v2-root button.v2-artifacts__seg-button--active')).toContain('background: var(--v2-surface-hover)'); expect(ruleBody(v2, '.v2-root button.v2-artifacts__seg-button--active')).not.toContain('var(--v2-ink)'); const ext = ruleBody(v2, '.v2-artifacts__ext'); expect(ext).toContain('border: 1px solid var(--v2-border)'); expect(ext).toContain('var(--v2-font-mono)'); expect(ruleBody(v2, '.v2-artifacts__mono')).toContain('var(--v2-font-mono)'); expect(ruleBody(v2, '.v2-artifacts__table-wrap')).toContain('overflow-x: auto'); expect(ruleBody(v2, '.v2-root button.v2-artifacts__more')).toContain('color: var(--v2-accent-text)'); // page = text/html only shows no size; the ext chip is the extension, never a colour square. expect(artifactsPage).toContain("item.kind === 'page' ? '—' : formatSize(item.size)"); expect(artifactsPage).toContain('extOf(item.name)'); expect(v2).toMatch(/@media \(max-width: 760px\) \{[\s\S]*?\.v2-artifacts__controls \{ flex-direction: column; align-items: stretch; \}/); // ux-lead 66462: mono floor 11 on the chip; the table spans the content area (no centred cap); // ≤760 hides pod / shared-by / size and folds the pod under the name; the head stays 32. expect(ext).toContain('11px/14px var(--v2-font-mono)'); expect(ruleBody(v2, '.v2-workspace-inspector__ext')).toContain('11px/14px var(--v2-font-mono)'); expect(ruleBody(v2, '.v2-artifacts')).not.toContain('1040px'); expect(v2).toMatch(/@media \(max-width: 760px\) \{[\s\S]*?\.v2-artifacts__pod-line \{ display: block; flex: 1 1 100%; \}/); expect(v2).not.toMatch(/@media \(max-width: 760px\) \{[\s\S]*?\.v2-artifacts__title \{ font-size: 28px; \}/); // An image row opens the chat lightbox; pages and docs open in a new tab (66462 overrule). expect(artifactsPage).toContain("if (item.kind === 'image') { setLightbox(item); return; }"); expect(workspaceInspector).toContain("if (file.kind === 'image') { setLightbox(file); return; }"); // Inspector Files pane: same rows, ext chip in front, All N files as cobalt text. expect(workspaceInspector).toContain("'inspector.workspace.files'"); expect(workspaceInspector).toContain('/api/artifacts?podId='); expect(ruleBody(v2, '.v2-root button.v2-workspace-inspector__all-files')).toContain('color: var(--v2-accent-text)'); }); test('workspace inspector is the small three-card replacement, with a phone sheet rather than legacy tabs', () => { // TASK-129 replaces the 86KB tabbed inspector in the same cutover as the // artboard cards. Keeping the old component anywhere in the route would // turn this back into a skin over two competing inspector models. expect(fs.existsSync(path.join(__dirname, '../components/V2PodInspector.tsx'))).toBe(false); expect(workspaceInspector).toContain('v2-workspace-inspector__card v2-workspace-inspector__group'); expect(workspaceInspector).toContain("'inspector.workspace.agents'"); expect(workspaceInspector).toContain("'inspector.workspace.needsYou'"); // D215: the mono captions ("agents in sharpen") are gone; each group is a // 44px fold row at rest, and members / manage are head icon buttons. expect(workspaceInspector).not.toContain("'inspector.workspace.agentsIn'"); expect(workspaceInspector).not.toContain('shortRoomName'); expect(workspaceInspector).not.toContain('v2-workspace-inspector__foot'); expect(workspaceInspector).toContain('className="v2-workspace-inspector__fold"'); expect(ruleBody(v2, '.v2-root button.v2-workspace-inspector__fold')).toContain('height: 44px'); expect(ruleBody(v2, '.v2-workspace-inspector__card.v2-workspace-inspector__group')).toContain('flex: none'); expect(ruleBody(v2, '.v2-workspace-inspector__fold-label')).toContain('text-overflow: ellipsis'); expect(ruleBody(v2, '.v2-workspace-inspector__head')).toContain('flex: none'); // board · today left the inspector on ruling 66311; the board keeps its own tab. expect(workspaceInspector).not.toContain("'inspector.workspace.boardToday'"); expect(workspaceInspector).toContain('v2-workspace-inspector__state--${state.kind}'); expect(workspaceInspector).not.toContain('v2-inspector__tabs'); expect(workspaceInspector).not.toContain('v2-inspector__progress'); const card = ruleBody(v2, '.v2-workspace-inspector__card'); // D223 (TASK-735): the artboard's details.grp, a 14px card ringed by the pane shadow. expect(card).toContain('border: 0'); expect(card).toContain('box-shadow: var(--v2-shadow-pane, 0 0 0 1px var(--v2-border-soft))'); expect(card).toContain('border-radius: var(--v2-r-14, 14px)'); expect(ruleBody(v2, '.v2-workspace-inspector__state--needs-you')).toContain('background: var(--v2-accent)'); expect(ruleBody(v2, '.v2-workspace-inspector__state--working')).toContain('animation: v2-pulse'); const stateDot = ruleBody(v2, '.v2-workspace-inspector__state'); expect(stateDot).toContain('width: 8px'); expect(stateDot).toContain('height: 8px'); const avatar = ruleBody(v2, '.v2-workspace-inspector__avatar.v2-avatar'); expect(avatar).toContain('border: 0'); expect(avatar).toContain('box-shadow: none'); const label = ruleBody(v2, '.v2-workspace-inspector__label'); expect(label).toContain('font: 600 13px/18px var(--v2-font)'); expect(label).toContain('white-space: nowrap'); expect(label).not.toContain('text-transform: lowercase'); const close = selectorRuleBody(v2, '.v2-root button.v2-workspace-inspector__close'); expect(close).toContain('display: none'); expect(v2).toContain('.v2-root button.v2-workspace-inspector__close { display: inline-grid; }'); const phoneSheetStart = v2.lastIndexOf('/* The inspector is a bottom sheet on phones'); const phoneSheet = v2.slice(phoneSheetStart, v2.indexOf('/* ---------- Workspace inspector', phoneSheetStart)); expect(phoneSheetStart).toBeGreaterThan(-1); expect(phoneSheet).toContain('top: auto'); expect(phoneSheet).toContain('width: 100%'); expect(v2Layout).toContain('className="v2-inspector-backdrop"'); }); test('v2 claims the page canvas — the V1 dark body cannot show through', () => { // App.tsx stamps `modern-ui` (V1 dark gradient) on unconditionally; // .v2-root paints only its own box. Without this override the V1 dark // showed on load flash, overscroll, and gaps (2026-08-24). The // compound selector must out-rank body.modern-ui so bundle order can // never decide the canvas color. Keep the literal in lockstep with // --v2-page-bg (tokens don't reach body — it sits outside .v2-root). expect(v2).toContain('body.modern-ui.v2-canvas'); expect(v2).toMatch(/body\.v2-canvas,\nbody\.modern-ui\.v2-canvas \{[\s\S]*?background: #f8f8fb/); expect(v2).toContain('--v2-page-bg: #eef0f4'); // The flash half of the same bug: App.css's BARE body rule painted every // load dark before React could stamp any class. The dark default is // V1-scoped under body.modern-ui; the bare default matches v2's canvas. const appCss = read('../../App.css'); // The standalone `body {` rule, not the `html,\nbody {` reset above it — // hence the no-comma-before lookbehind. const bareBody = appCss.match(/(? // before first paint and CSS hides the prerender; crawlers without JS // still see the content. The script must PRECEDE the style block that // uses it, and both must precede . const indexHtml = read('../../../index.html'); // Rev 2 (the flash was re-sighted, 2026-08-26): the script-gate had a // timing window — delayed head-script execution painted the prerender. // Hidden-by-default has none: #seo-page is display:none from the first // byte, and