From aff02c575260014473d53dd789fe214950af057b Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:22:32 -0700 Subject: [PATCH 1/2] fix(chat): one shared slot rule for the transcript's two side-rows (TASK-150) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delivery cue and the no-agent row both hang under the sent message on the message text column. Their box was declared twice: the cue had the 50px indent without the width complement, so under `.v2-chat__messages > * { width: 100% }` it overran the pane by exactly 50px (#1846 fixed the same defect on the no-agent row). Both rows are siblings of `.v2-msg` inside `.v2-chat__messages`, so the indent is that grid's avatar column plus its gap. One rule now owns both boxes, and v2-layout-invariants derives the expected indent from `.v2-msg`'s own grid rather than restating 50 — so moving that column fails the suite instead of parting the two edges silently. --- .../v2/__tests__/v2-layout-invariants.test.ts | 58 ++++++++++++++++++- frontend/src/v2/v2.css | 36 +++++++++--- 2 files changed, 83 insertions(+), 11 deletions(-) diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 8357d080c5..137ba79839 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -2063,12 +2063,33 @@ 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 50px'); + expect(slot).toContain('width: calc(100% - 50px)'); + // 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% - 50px)` is not + // (`.v2-thread-block` carries the same complement one indent over). + expect(v2.match(/margin: -4px 0 8px 50px;/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 +2102,39 @@ 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 `.v2-msg` + // actually uses, not 50px by assumption. So this guard reads the avatar + // track and the gap out of `.v2-msg` itself and compares; if that column + // moves and the slot rule stays put, the suite fails instead of the two + // edges quietly parting (ux-lead measured them 14px apart at 390 and 1440 + // on the #1846 fixture). + 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 grid = ruleBody(v2, '.v2-msg'); + const lead = /grid-template-columns:\s*(\d+)px/.exec(grid); + const gap = /(?:^|\s)gap:\s*(\d+)px/.exec(grid); + expect(lead?.[1]).toBe('38'); + expect(gap?.[1]).toBe('12'); + const indent = `${Number(lead?.[1]) + Number(gap?.[1])}px`; + expect(indent).toBe('50px'); + 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..25b60534a9 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,28 @@ 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. Both are siblings of `.v2-msg` inside `.v2-chat__messages`, so the + indent is that grid's avatar column plus its gap (38 + 12 = 50) rather than + a number of its own, and the width subtracts the same indent: the + `.v2-chat__messages > *` rule 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-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% - 50px); + margin: -4px 0 8px 50px; +} + /* 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 +1864,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 { From 59aa6364b3005a9473b1068faf12b2be0e9bad1d Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:00:23 -0700 Subject: [PATCH 2/2] =?UTF-8?q?fix(chat):=20the=20side-row=20slot=20is=203?= =?UTF-8?q?6px=20=E2=80=94=20the=20thread's=20grid,=20not=20the=20base=20m?= =?UTF-8?q?essage=20grid=20(TASK-150)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ux-lead's #1860 gate failed the indent: 50px put both side-rows 14px right of .v2-msg__content at 390, 720 and 1440. They are right, and the mistake was mine in a way the guard could not see. The first cut derived the indent from the base .v2-msg rule (38 + 12 = 50) and read the number off the rule that governs the message grid — but these rows only ever render as siblings of a message inside .v2-thread, where '.v2-thread .v2-msg { grid-template-columns: 28px minmax(0, 1fr); gap: 8px }' (0,3,0) replaces the base grid (0,1,0). 28 + 8 = 36, and 50 - 36 is exactly the 14px the fixture measured. Same class of trap as ruleBody preferring a line-start selector: the helper read a real rule, just not the one in play. Both placements of these rows share their message's container — transcript level, or inside a .v2-thread-block for a thread root — so the relative indent holds in both. The guard now derives from .v2-thread .v2-msg instead, and asserts the base grid is 38px specifically so a future reader cannot 'correct' 36 back to 50. Five mutations, each applied in isolation with the restore verified against the pre-mutation content (36px present, no diff): thread lead 28->32 (reds the derivation and the existing avatar-column test), thread gap 8->10 (reds the two slot tests), slot 36->50 (reds the two slot tests), slot 36->32 (the replies-tier mistake, reds the two slot tests), base grid 38->42 (reds only the new base-is-not-the-target assertion). --- .../v2/__tests__/v2-layout-invariants.test.ts | 47 ++++++++++++------- frontend/src/v2/v2.css | 36 +++++++++----- 2 files changed, 53 insertions(+), 30 deletions(-) diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 137ba79839..f8b8af1b15 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -2069,13 +2069,14 @@ describe('v2 layout invariants (CSS rule presence)', () => { 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 50px'); - expect(slot).toContain('width: calc(100% - 50px)'); + 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% - 50px)` is not - // (`.v2-thread-block` carries the same complement one indent over). - expect(v2.match(/margin: -4px 0 8px 50px;/g) || []).toHaveLength(1); + // 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 @@ -2104,22 +2105,34 @@ describe('v2 layout invariants (CSS rule presence)', () => { 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 `.v2-msg` - // actually uses, not 50px by assumption. So this guard reads the avatar - // track and the gap out of `.v2-msg` itself and compares; if that column - // moves and the slot rule stays put, the suite fails instead of the two - // edges quietly parting (ux-lead measured them 14px apart at 390 and 1440 - // on the #1846 fixture). + // 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 grid = ruleBody(v2, '.v2-msg'); - const lead = /grid-template-columns:\s*(\d+)px/.exec(grid); - const gap = /(?:^|\s)gap:\s*(\d+)px/.exec(grid); - expect(lead?.[1]).toBe('38'); - expect(gap?.[1]).toBe('12'); + 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('50px'); + 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 diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index 25b60534a9..143aa6d1e0 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -1834,24 +1834,34 @@ body.modern-ui.v2-canvas { /* 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. Both are siblings of `.v2-msg` inside `.v2-chat__messages`, so the - indent is that grid's avatar column plus its gap (38 + 12 = 50) rather than - a number of its own, and the width subtracts the same indent: the - `.v2-chat__messages > *` rule 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%. + 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-msg`'s own grid, so moving that column fails - the suite instead of silently misaligning these rows. */ + 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% - 50px); - margin: -4px 0 8px 50px; + width: calc(100% - 36px); + margin: -4px 0 8px 36px; } /* The delivery hint above requires an agent to name (`agentsInPod > 0`), which