diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 8357d080c5..f8b8af1b15 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -2063,12 +2063,34 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(thread).toContain('noAgentsHint={noAgentsInPodLive ? noAgentsHint : null}'); expect(threadMessages).toContain('className="v2-chat__no-agents"'); expect(threadMessages).toContain('podChat.noAgentsInPod.action'); - expect(ruleBody(v2, '.v2-chat__no-agents')).toContain('margin: -4px 0 8px 50px'); + // The box lives in the ONE shared side-row slot rule (with the delivery + // hint); the derivation from `.v2-msg`'s grid is pinned by the next test. + const slot = v2.slice( + v2.indexOf('.v2-chat__delivery-hint,\n.v2-chat__no-agents {'), + v2.indexOf('}', v2.indexOf('.v2-chat__delivery-hint,\n.v2-chat__no-agents {')), + ); + expect(slot).toContain('margin: -4px 0 8px 36px'); + expect(slot).toContain('width: calc(100% - 36px)'); + // Their box is declared exactly once in the sheet — the shared slot — so + // there is no second copy left for the cascade to pick between. The + // margin pair is unique to this slot; `width: calc(100% - 36px)` is not + // asserted globally because `.v2-thread-block` carries a complement of + // its own one indent over. + expect(v2.match(/margin: -4px 0 8px 36px;/g) || []).toHaveLength(1); + // And neither row's own block restates a box. `ruleBody` cannot answer + // this: it takes the FIRST line-start match, and the shared rule's second + // selector line is itself a line-start `.v2-chat__no-agents {`, so it + // reports the shared rule as the row's own — the TASK-140 trap, one file + // over. lastIndexOf reaches the standalone block. + for (const selector of ['\n.v2-chat__delivery-hint {', '\n.v2-chat__no-agents {']) { + const from = v2.lastIndexOf(selector); + expect(from).toBeGreaterThan(-1); + expect(v2.slice(from, v2.indexOf('}', from))).not.toMatch(/(^|\s)(margin|width):/); + } // `.v2-chat__messages > *` sets width: 100%, so the 50px indent must come // OUT of the width. Without this the row overruns the pane by 50px and the // full-width phone act and the body line are cut (ux-lead's 390 shot). expect(ruleBody(v2, '.v2-chat__messages > *')).toContain('width: 100%'); - expect(ruleBody(v2, '.v2-chat__no-agents')).toContain('width: calc(100% - 50px)'); expect(ruleBody(v2, '.v2-chat__no-agents-kicker')).toContain('font-size: 11px'); expect(ruleBody(v2, '.v2-chat__no-agents-kicker')).toContain('text-transform: lowercase'); expect(ruleBody(v2, '.v2-chat__no-agents-body')).toContain('color: var(--v2-text-secondary)'); @@ -2081,6 +2103,51 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(phone).not.toMatch(/[^-]height: 44px/); }); + test('both chat side-rows share one slot whose indent is the message text column', () => { + // ux-lead's TASK-150 ruling: the delivery cue and the no-agent row must + // land on the message text column — derived from the grid those rows' + // own messages actually use, not a number by assumption. This guard reads + // the avatar track and the gap out of the rule in play and compares; if + // that column moves and the slot rule stays put, the suite fails instead + // of the two edges quietly parting. + // + // The grid in play is the THREAD override, not the base `.v2-msg` rule: + // these rows only ever render as siblings of a message inside + // `.v2-thread`, where `.v2-thread .v2-msg` (0,3,0) replaces the base grid + // (0,1,0). Deriving from the base rule is the mistake this rule shipped + // with — it read 38 + 12 = 50 and the gate measured both rows 14px right + // of `.v2-msg__content` at 390, 720 and 1440; 36px put them on it + // (ux-lead verified by injection on their fixture, right edge and + // scrollWidth unchanged). + const at = v2.indexOf('.v2-chat__delivery-hint,\n.v2-chat__no-agents {'); + expect(at).toBeGreaterThan(-1); + const slot = v2.slice(v2.indexOf('{', at) + 1, v2.indexOf('}', at)); + const threadGrid = ruleBody(v2, '.v2-thread .v2-msg'); + const lead = /grid-template-columns:\s*(\d+)px/.exec(threadGrid); + const gap = /(?:^|\s)gap:\s*(\d+)px/.exec(threadGrid); + expect(lead?.[1]).toBe('28'); + expect(gap?.[1]).toBe('8'); + const indent = `${Number(lead?.[1]) + Number(gap?.[1])}px`; + expect(indent).toBe('36px'); + // The base grid is deliberately NOT the target. Without this line a + // future reader finds 38 + 12 = 50 unreferenced and "corrects" the slot + // back to it, which is exactly how this shipped wrong the first time. + expect(ruleBody(v2, '.v2-msg')).toContain('grid-template-columns: 38px'); + expect(slot).toContain(`margin: -4px 0 8px ${indent};`); + expect(slot).toContain(`width: calc(100% - ${indent});`); + // Neither row restates its own box any more. A second declaration is + // exactly how the two drifted apart, and last-in-sheet-wins would pick + // between them silently — the delivery cue is the one that never had the + // width complement at all. + expect(ruleBody(v2, '.v2-chat__delivery-hint')).not.toMatch(/(^|\s)(margin|width):/); + // The phone block that carries the transcript must not restate the slot: + // the ≤760 act override is a BUTTON rule, and a row-level geometry rule in + // there would outrank the shared rule exactly where it matters (390). + const phone = mediaBlockContaining(v2, '.v2-chat__messages'); + expect(phone).toContain('padding-inline: 14px'); + expect(phone).not.toMatch(/\.v2-chat__(?:delivery-hint|no-agents)/); + }); + test('pod focus keeps one bounded panel above the existing board and stays usable on phones', () => { expect(podBoard).toContain('className="v2-board__focus"'); expect(podBoard).toContain('v2-board__focus-editor'); diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index 40d3246e51..143aa6d1e0 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -1818,9 +1818,10 @@ body.modern-ui.v2-canvas { /* Sender-only teaching cue shown once per pod/session when a message reaches a pod with agents but addresses none of them. Aligns with message text, - stays quiet/secondary, and disappears naturally with transcript scroll. */ + stays quiet/secondary, and disappears naturally with transcript scroll. + Box geometry is NOT here: it belongs to the side-row slot rule below, which + this row shares with the no-agent row. */ .v2-chat__delivery-hint { - margin: -4px 0 8px 50px; color: var(--v2-text-tertiary); font-size: 12px; line-height: 1.4; @@ -1831,6 +1832,38 @@ body.modern-ui.v2-canvas { font-weight: 600; } +/* The transcript's two side-rows — the sender-only delivery cue above and the + no-agent row below — occupy ONE slot, and that slot IS the message text + column of the transcript they sit in. Both are siblings of `.v2-msg` inside + `.v2-chat__messages`, so the indent is the avatar track plus the gap of the + rule that governs those rows HERE, which is the thread override + `.v2-thread .v2-msg { grid-template-columns: 28px minmax(0, 1fr); gap: 8px }` + — 28 + 8 = 36, NOT the base `.v2-msg` grid (38 + 12 = 50), which that + override replaces (0,3,0 beats 0,1,0). The first cut of this rule derived + from the base grid and was 14px out; ux-lead's #1860 gate measured it at + 390, 720 and 1440 — 50px put both rows 14px right of `.v2-msg__content`, + 36px put them on it (injection on their fixture, right edge and scrollWidth + unchanged). Both placements of these rows share their message's container + (transcript level, or inside a `.v2-thread-block` for a thread root), so the + same relative indent holds in both. + + The width subtracts the same indent: `.v2-chat__messages > *` sets + width: 100%, so an indent without its complement overruns the pane by exactly + the indent. That was the #1846 defect — at 390 the full-width act ran 64→426 + and the body line was cut (ux-lead's shot, 2026-09-23) — and Sam hit the same + class one indent over on `.v2-thread-block` (2026-08-24): indent + width must + sum to 100%. + + One rule for both rows, so they cannot drift apart again: this is the only + place either row's box is declared, and v2-layout-invariants.test.ts derives + the expected indent from `.v2-thread .v2-msg`'s own grid, so moving that + column fails the suite instead of silently misaligning these rows. */ +.v2-chat__delivery-hint, +.v2-chat__no-agents { + width: calc(100% - 36px); + margin: -4px 0 8px 36px; +} + /* The delivery hint above requires an agent to name (`agentsInPod > 0`), which leaves out the pod where nothing can answer at all — a team pod with no agent installed. This row sits in the same slot under the sent message, says why @@ -1841,13 +1874,8 @@ body.modern-ui.v2-canvas { flex-direction: column; align-items: flex-start; gap: 6px; - /* `.v2-chat__messages > *` sets width: 100%, so the 50px indent below has to - be subtracted or the row overruns the pane by exactly 50px: at 390 the - full-width act ran 64→426 and the body line was cut (ux-lead's shot, - 2026-09-23). Same rule as `.v2-thread-block` below — indent + width must - sum to 100% (Sam, 2026-08-24, the same bug one indent over). */ - width: calc(100% - 50px); - margin: -4px 0 8px 50px; + /* Box geometry lives in the shared side-row slot rule above, next to the + delivery cue it shares the slot with. */ } .v2-chat__no-agents-kicker {