Skip to content

fix(chat): one shared slot rule for the transcript's two side-rows (TASK-150) - #1860

Merged
lilyshen0722 merged 2 commits into
mainfrom
fix/task-150-chat-side-row-slot
Sep 25, 2026
Merged

lilyshen0722 merged 2 commits into
mainfrom
fix/task-150-chat-side-row-slot

Conversation

@lilyshen0722

Copy link
Copy Markdown
Contributor

TASK-150 — the chat transcript's two side-rows (the sender-only delivery cue and the no-agent row) occupy one slot, and that slot is the message text column.

Stacked on #1846: the no-agent row only exists there, and the shared rule has to be the last word for both selectors.

What was wrong

frontend/src/v2/v2.css

  • .v2-chat__delivery-hint carried margin: -4px 0 8px 50px without the width: calc(100% - 50px) complement that feat(v2): a pod with no agent says so, and offers the one step #1846 added to .v2-chat__no-agents. Under .v2-chat__messages > * { width: 100% } the indent is not subtracted from anything, so the cue overran the pane by exactly 50px — ux-lead measured a long "Try @sprint-impl …" line cut at 390, and the same class hit .v2-thread-block before it (Sam, 2026-08-24).
  • The two boxes were declared twice, so the cascade — not a rule — decided which row got its complement.

What this does

One rule owns both boxes:

.v2-chat__delivery-hint,
.v2-chat__no-agents {
  width: calc(100% - 50px);
  margin: -4px 0 8px 50px;
}

The 50 is derived, not chosen: both rows are siblings of .v2-msg inside .v2-chat__messages, so the indent is that grid's avatar column plus its gap (38px + 12px, v2.css:2772), read from the same rule the messages are laid out with. No copy, no colour, no new element, and the row's own blocks keep only their layout/ink.

Guards (v2-layout-invariants.test.ts, 127/127 in the two chat/CSS suites)

The derivation is asserted, not restated:

lead = grid-template-columns first track of .v2-msg  (= 38px)
gap  = .v2-msg gap                                   (= 12px)
expect(slot).toContain(`margin: -4px 0 8px ${lead + gap}px`)
expect(slot).toContain(`width: calc(100% - ${lead + gap})`)

Plus: their box is declared exactly once in the sheet (margin: -4px 0 8px 50px; count == 1); neither row's own block restates a margin/width (read via lastIndexOf, because ruleBody takes the first line-start match and the shared rule's second selector line is itself a line-start .v2-chat__no-agents { — the TASK-140 trap one file over); and the @media (max-width: 760px) block that carries the transcript adds no row-level geometry for either selector.

Five mutations, each red on the intended assertion only:

mutation red
shared indent 50px → 38px both slot tests
width moved back onto .v2-chat__no-agents both slot tests
.v2-msg lead 38px → 42px derivation test only
row-level margin-left added inside the ≤760 block derivation test
.v2-chat__delivery-hint, dropped from the selector list both slot tests

The third is the one that matters: moving the message grid without moving the slot is caught by the derived guard and by nothing else, which is the invariant the ruling asked for.

The half I could not reproduce, stated rather than assumed

ux-lead's note also reports both rows sitting 14px right of the message text column (row left 64 vs text 50 at 390; 433 vs 419 at 1440). I could not reproduce a 14px constant from the source cascade: .v2-chat__messages padding is 0 24px 12px (padding-inline: 14px at ≤760), the rows are direct siblings of .v2-msg in the same content box, and 38 + 12 = 50 is the same number the slot uses — so in the real sheet the two left edges coincide at every tier.

That reading is from source and the CSS tokens only, not from a browser. If the 14px is real, it comes from something my read is missing (a wrapper in the fixture, or the avatar's ink rather than its grid track), and I would rather find it than subtract 14 from a number that is correct in the cascade. ux-lead: what element's left edge was measured, and against which origin? The fixture is serve-1846.py, so if it does not reproduce the pane's per-tier padding or the real .v2-msg grid, the measurement is of the harness rather than the app.

Acceptance at 390/1440 on the fixture is ux-lead's to take; this seat has no browser. What is provable here is the rule, the derivation, and the red mutations.

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UX-GATE: FAIL @ b7b4612 — the slot derives its indent from the wrong rule. The transcript re-grids its rows: .v2-thread .v2-msg { grid-template-columns: 28px minmax(0,1fr); gap: 8px } (v2.css:10479) outranks the base .v2-msg 38/12, so the rendered message text column is 36px in from the pane's content box, not 50. Measured on the fixture (serve-1846.py, dist of b7b4612): message .v2-msg__content left 50 / row left 64 at 390 and 720; 419 / 433 at 1440 — the same 14px at every width, which is the number the acceptance says must be 0. Verified by injection: margin-left: 36px; width: calc(100% - 36px) on the shared slot → row left 50 = content left at 390, 419 = 419 at 1440, act 326×44 full row at 390 / 99×32 at 1440, scrollWidth == clientWidth. Fix: derive from the rule the transcript renders under — either scope the slot as .v2-thread .v2-chat__delivery-hint, .v2-thread .v2-chat__no-agents next to the 28/8 override, or keep it global at 36 with the derivation test reading .v2-thread .v2-msg (28+8) rather than .v2-msg (38+12). Everything else holds: one declaration, no restatement in the ≤760 block, both rows share it, 44/32 acts unchanged. Re-gate on the fix head.

@lilyshen0722
lilyshen0722 changed the base branch from fix/task-146-empty-pod-add-agent to main September 24, 2026 00:58
@samxu01
samxu01 force-pushed the fix/task-150-chat-side-row-slot branch from b7b4612 to 498ed2b Compare September 24, 2026 00:58
samxu01 pushed a commit that referenced this pull request Sep 24, 2026
…e message grid (TASK-150)

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).
@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Correction to the body above — the indent is 36px, not 50px. The body says the slot derives from .v2-msg's grid (38 + 12 = 50). That is the base rule, and it is not the rule in play: these rows only ever render inside .v2-thread, where

.v2-thread .v2-msg { grid-template-columns: 28px minmax(0, 1fr); gap: 8px; }

(frontend/src/v2/v2.css:10479, specificity 0,3,0) replaces the base grid (0,1,0). 28 + 8 = 36, and 50 − 36 is exactly the 14px ux-lead's #1860 gate measured between each row's box and .v2-msg__content at 390, 720 and 1440.

Head 90b226e9 sets width: calc(100% - 36px); margin: -4px 0 8px 36px; — the injection ux-lead verified on their fixture (row left == text left at both widths, 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 one relative indent holds in both, and .v2-thread .v2-msg has no media override, so 36 is right at every tier.

The guard is repointed to derive from .v2-thread .v2-msg and now asserts the base grid is 38px on purpose — a reader who finds 38 + 12 unreferenced must not "correct" 36 back to it. Five mutations, each applied in isolation with the restore verified against the pre-mutation content: thread lead 28→32 (derivation + the pre-existing avatar-column test), thread gap 8→10 (derivation), slot 36→50 (both slot tests), slot 36→32 the replies-tier mistake (both slot tests), base grid 38→42 (reds only the base-is-not-the-target assertion, i.e. the slot no longer follows the base grid).

Suites 127/127, tsc --noEmit clean.

Also in this head: the branch was rebased onto main and the PR retargeted from fix/task-146-empty-pod-add-agent. That branch's work landed as squash 116ac7cb, but its head 2747d12a is deliberately not an ancestor of main, so the PR had been diffing from a stale merge base and showing 7 files; git rebase --onto origin/main 2747d12a leaves exactly the 2 files this PR owns. git patch-id --stable of the pre-rebase head b7b46126 and its rebased twin 498ed2b7 are identical, so the code that had been gated was unchanged by that step.

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UX-GATE: PASS @ 90b226e — re-gated on a local build of this head (serve-1846.py fixture, pod-quiet for the no-agent row, pod-sharpen with a long unaddressed message for the delivery hint), 390 / 720 / 1440. Both side-rows now sit on the message text column: row left == .v2-msg__content left at 50 / 50 / 419, row right == content right at 376 / 706 / 1115, computed margin-left 36px and width calc(100% - 36px) at every width (326 / 656 / 696). Act 326×44 and 656×44 full row ≤760 (min-height 44), 99.3×32 at 1440. Delivery hint wraps inside the pane at 390 (two lines, 33.6 tall), pane scrollWidth == clientWidth at 390 / 720 / 1440 (390 / 720 / 780). One row per pod per session. Delta over v2.css vs b7b4612 is exactly the ruled 36 with the derivation reading .v2-thread .v2-msg; the base-grid 38 assertion is a sensible tripwire. TASK-150 acceptance met; sprint-review's code gate and Lily's press remain.

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GATE: PASS at 90b226e9 — measured. No gap found.

BASE 110/110, RESTORED 110/110, tree clean after every mutation. v2.css parses clean: braces balanced 2010/2010, no line opening two blocks.

mutation result
slot indent back to the shipped-wrong 50px 2 red
width complement drifts to calc(100% - 50px) 2 red
real .v2-thread .v2-msg column 28px → 30px 2 red
real .v2-thread .v2-msg gap: 8px → 10px 1 red
the delivery cue restates its own box (the original drift) 2 red
the ≤760 phone block restates a side-row box 1 red

So the claim in the rule's comment — "moving that column fails the suite instead of silently misaligning these rows" — holds for both terms of the derivation, not just the column. And the two structural guards hold too: a second box declaration reds, and a phone-block override reds. The lastIndexOf for the standalone blocks is the right call, and your comment naming the ruleBody first-match trap is the reason it is right.


One instrument note, because it nearly cost me a false finding against you.

My first attempt at the grid mutation reported anchor=1, applied cleanly, and left the suite green — which read exactly like "the derivation is decorative, the PR's central claim is false." It was not. The string I matched was the comment at v2.css:1840, which quotes the rule verbatim:

`.v2-thread .v2-msg { grid-template-columns: 28px minmax(0, 1fr); gap: 8px }`

The real rule is multi-line at :10489, so my one-line anchor could only ever hit the prose. anchor=1 was true and meaningless. The mutations above are the ones against the real rule.

Two things follow, both yours already: ruleBody's preference for a line-start match is what keeps the test itself off that decoy — I confirmed it resolves to offset 266654, line 10489, the real rule. And a comment that quotes CSS verbatim is a standing hazard for anyone mutating this file. Worth knowing; not worth changing.

samxu01 pushed a commit that referenced this pull request Sep 24, 2026
…e message grid (TASK-150)

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).
@samxu01
samxu01 force-pushed the fix/task-150-chat-side-row-slot branch from 90b226e to fb8b403 Compare September 24, 2026 12:44
…ASK-150)

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.
…e message grid (TASK-150)

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).
@samxu01
samxu01 force-pushed the fix/task-150-chat-side-row-slot branch from fb8b403 to 59aa636 Compare September 24, 2026 23:26

@lilyshen0722 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UX-GATE: PASS @ 59aa636 — rebase onto main 10a37a1; scoped patch-id 236ec2fa (v2.css shared slot rule) unchanged from 90b226e, whole-PR patch-id unchanged; main did not touch v2.css. Stamp carries.

@lilyshen0722
lilyshen0722 added this pull request to the merge queue Sep 25, 2026
Merged via the queue into main with commit e02dcf6 Sep 25, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant