From 090e8b7b31b7b8c8b9900596af0b2ade4f94e1db Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Thu, 12 Nov 2026 15:01:26 -0800 Subject: [PATCH 1/3] Let a workflow run's shade reach the hairline above its line In Chat's workflow runs card, the hairline between two runs was the lower run's row border, outside its line. A hovered line's shade stopped under it, and a pointer on that pixel was on no line, so moving down the card crossed a 1px unshaded band between every two lines. The hairline is now the line's own top border, so the pointer on it is on the line, and the line's shade runs under it from the card's left edge to its right. The hairline is drawn over the shade, as before, at the same contrast against what is behind it. Nothing moves: the border is where the row's was, and the card keeps its box. run-card's hover test now checks that the second run's shade meets the run above it and that the hairline finds its line. Offsets are read from inside the line's border. It fails on b8bf8eb52 with a 1px gap. Assistant-model: Claude Opus 5.5 Assistant-workflow: inline (subagent) Assistant-verification: bun run types Assistant-verification: bun run ci Assistant-verification: playwright run-card (passes; the new check fails on b8bf8eb52) User-preference: "why don't the containers for the message hover still not encapsulate the entire box? I still see a gap ... with some margin or padding" Co-authored-by: Alex Lavaee --- apps/web/src/chat/run-card.css | 3 ++- apps/web/src/chat/run-row.css | 8 +++++++- e2e/run-card.e2e.ts | 19 ++++++++++++++++++- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/apps/web/src/chat/run-card.css b/apps/web/src/chat/run-card.css index e70ed0513..9913e46d6 100644 --- a/apps/web/src/chat/run-card.css +++ b/apps/web/src/chat/run-card.css @@ -36,7 +36,8 @@ } } -.run-row + .run-row { +/* The hairline between runs is the line's own, so the pointer on it is on the line (run-row.css). */ +.run-row + .run-row > .run-head { border-top: var(--edge-width) solid var(--color-edge); } diff --git a/apps/web/src/chat/run-row.css b/apps/web/src/chat/run-row.css index eb52e49eb..648104797 100644 --- a/apps/web/src/chat/run-row.css +++ b/apps/web/src/chat/run-row.css @@ -1,7 +1,9 @@ /* * A run's line in the workflow card is shaded across the card's whole width under the pointer, * square, as a Chat row is across Chat; the card's own corners round the first line and the last. - * The toggle keeps its own box for its hit area and focus ring. + * The hairline above a line is its own and the shade runs under it, so lines meet with no band + * between them and a pointer moving down the card shades one line after the next. The toggle keeps + * its own box for its hit area and focus ring. */ .run-stack { isolation: isolate; @@ -24,6 +26,10 @@ transition: opacity var(--duration-fast) var(--ease-out); } +.run-row + .run-row > .run-head::before { + inset-block-start: calc(-1 * var(--edge-width)); +} + .run-head:hover::before { opacity: 1; } diff --git a/e2e/run-card.e2e.ts b/e2e/run-card.e2e.ts index 4f8bf50b3..607edcfa7 100644 --- a/e2e/run-card.e2e.ts +++ b/e2e/run-card.e2e.ts @@ -248,7 +248,8 @@ test("a hovered run's line is shaded across the whole card, edge to edge, square let frame = node.closest("[data-run-stack]")!.getBoundingClientRect(); let left = box.left + Number.parseFloat(shade.left); let right = left + Number.parseFloat(shade.width); - let top = box.top + Number.parseFloat(shade.top); + // Offsets are from inside the line's border: the hairline above it is its own. + let top = box.top + node.clientTop + Number.parseFloat(shade.top); return { start: Math.round(left - frame.left), end: Math.round(frame.right - right), @@ -264,6 +265,22 @@ test("a hovered run's line is shaded across the whole card, edge to edge, square // The toggle draws no shade of its own inside it. await expect(toggle).toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); } + // The second run's line meets the run above it: its shade runs under the hairline between them, + // and the pointer on that hairline is on its line, so the pointer moving down the card passes + // from one line's shade into the next with no band between. + expect( + await card.locator("[data-run-toggle]").nth(1).locator("xpath=..").evaluate(node => { + let above = node.closest("li")!.previousElementSibling!.getBoundingClientRect(); + let box = node.getBoundingClientRect(); + let top = box.top + node.clientTop + + Number.parseFloat(getComputedStyle(node, "::before").top); + let hairline = document.elementFromPoint(box.left + box.width / 2, above.bottom + 0.5); + return { + gap: Math.round((top - above.bottom) * 10) / 10, + hit: hairline?.closest(".run-head") === node, + }; + }), + ).toEqual({ gap: 0, hit: true }); await page.mouse.move(0, 0); await expect.poll(() => card.locator("[data-run-toggle]").first().locator("xpath=..").evaluate(node => From 7ebe54ec9fa43d7799b3bb317d949e71fe2eece0 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Thu, 12 Nov 2026 15:01:31 -0800 Subject: [PATCH 2/3] Shade a Chat row over its whole pitch, as Slack's rows tile its pane A shown Chat row was shaded edge to edge but not top to bottom. Its shade reached 4px above a message (32px over a group's first, its name line and 8px) and 4px or 8px below, while messages in a group are 4px apart and the transcript's rows 16px. So neighbouring shades overlapped by 2px to 4px, and, more visibly, the pointer found the row only in the message's own box: in every gap, and over every avatar, it was on no row at all. Moving a mouse down Chat crossed unshaded bands of 6px to 8px between rows, and the shaded box looked inset. Each message's shade now reaches to the middle of the gap on both sides: 2px in a group, 8px between rows (over the name line for a group's first message, which stands 2px above its row). So neighbouring shades meet with no band and no overlap, past notices and other rows that are not messages, at a thread's lead and at both ends of the transcript. Under a mouse, inside a transcript, the shade itself and the avatar are where the pointer finds the row, so the pointer passes from one row's shade straight into the next one's. A touch screen keeps the rows' own boxes, so a reaction's 44px target that stands past its row still takes the tap. Every shaded state (hover, keyboard focus, menu or picker open, a long press, a jumped-to wash, an open thread's root) uses the same pitch, and a long-pressed row's toolbar lane is part of its own. Content does not move; the toolbar keeps standing 16px above its row's shade, 2px lower than before. With a thread open and the document folded to its bar, the thread's rows were shaded 20px past the thread panel, to the window's edge and past the header above them. They now stop at the panel's edge, where its header's rule ends: the folded pane's clip path and the thread's reach into the gutter after it are gone. The pane's clip keeps its unbounded top and foot, so lazy images above Chat still load (chat-images passes). chat-row-edges measures every message's shade against its pitch and sweeps a mouse's hit test down the container's start, the avatars and the text, in Chat and in the thread, at 1440 (folded), 1920, 1100, 640 and on a phone (extents only there), with a notice, a grouped pair, the agent's rows and an image row. It also hovers 4px into the gaps above and below a row, checks a jumped-to wash covers the shade's pitch, and measures a folded thread's rows against the thread panel. These fail on b8bf8eb52. Assistant-model: Claude Opus 5.5 Assistant-workflow: inline (subagent) Assistant-verification: bun run types Assistant-verification: bun run ci Assistant-verification: playwright chat-row-edges, run-card, chat-hover-contrast, chat-images, chat-motion, chat-phone, chat-threads, chat-toolbar-in-view, message-actions, message-toolbar-layout, pane-resize, pane-work (136 passed) Assistant-verification: playwright chat-row-edges and run-card on b8bf8eb52 (6 of the extended tests fail there) Assistant-verification: bun run design:behavior (106 passed) Assistant-verification: bun run build (initial JS 259,958 B raw / 82,474 B gzip; CSS 184,261 B raw / 32,644 B gzip) User-preference: "why don't the containers for the message hover still not encapsulate the entire box? I still see a gap ... with some margin or padding" Co-authored-by: Alex Lavaee --- apps/web/DESIGN.md | 2 +- apps/web/src/chat/actions/message-actions.css | 64 ++++-- apps/web/src/theme.css | 19 +- apps/web/src/workspace-sizing.css | 16 +- e2e/chat-row-edges.e2e.ts | 184 +++++++++++++++++- 5 files changed, 236 insertions(+), 49 deletions(-) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index f45dcacea..3c61278e1 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -208,7 +208,7 @@ Rectangular controls use a restrained four-step radius scale: small for grips an - **Chat threads, context usage and compaction:** Any delivered Chat message can open a side thread (_Reply in thread_). On desktop split layouts, opening a thread splits the Chat area the way Slack splits a channel and a thread: the thread panel takes roughly 40% of the combined Chat and Thread width (360–560px) to the right of main Chat (at least 420px), separated by a 1px divider and its own draggable resize handle (_Resize thread_). The document pane yields width first down to 480px; when the window cannot hold all three panes at their minimums, the document folds into a compact top strip keeping its title, presence and a _Document_ button that closes the thread and restores the sheet, while Chat and Thread share the space cleanly. On phones and compact viewports, a thread opens as a full-height sheet with _Back to Chat_; pressing `Escape` or closing returns focus to the message control or summary row that opened it. In main Chat, _Reply in thread_ is a button of the message's toolbar (see Message toolbar), the root message's row carries the selected row shade while its thread is open, and once a thread has replies it shows an inline summary button: up to three replier faces in first-reply order, _N replies_ in petrol, and the relative age (_Last reply 2m ago_, giving way to _View thread ›_ on hover or focus). Inside the thread panel, a quiet provenance line (_Forked from Chat at 3:24 PM. chopin remembers everything up to here._) leads the root message, drawn as Chat draws it, a hairline _N replies_ divider separates the replies, the root and every reply have the message toolbar and reaction pills without _Reply in thread_, as Slack's thread view has them (the root's reactions are its Chat message's, and a reaction chopin answers is answered in the thread), and the thread reuses the main composer through a single boundary with an _Also send to Chat_ checkbox underneath. A reply sent that way is one message in both transcripts; when it asks chopin, chopin's answer (each of its messages as it is shown), a failure banner and the sender's _Retry_ appear in main Chat too, while the turn runs in the thread's own session. A failed reply in the thread shows main Chat's failure banner, with _Retry_ for its sender, above the thread's composer. A harness's token usage is not drawn: neither composer has a context ring (`ComposerContext`, still in the copied `elements-composer`, is not used), and `elements-context-breakdown` appears only on the design-audit plate. When a session compacts context, a quiet inline spinner (_Compacting earlier messages…_) gives way to an expandable hairline divider (_Earlier messages were summarized_) built on `Unfold` with message and saved-token counts as spaced siblings. - **Machine fix-it steps:** One component (`fix-steps.tsx`, its words from `machine-fix.ts`), drawn at the top of the machine button's menu and under _How to fix_ in chopin's ephemeral row, and loaded only once a machine has something to fix. A bold title in the base role (_Can't connect to Build box_), one plain sentence of why in secondary ink (the machine's reported line in words: _A program it needs can't be found on it._, _It runs an older chopin that can't say why it stops._, else _It stops each time before it connects._), then numbered steps, a quiet tabular number in the gutter beside one action each in primary ink, a command never in the sentence but alone under it in a well like Chat's code blocks (the inset surface, a hairline, the large radius, monospace in the extra-small role, wrapping at a space or within a long word, never wider than the menu) with a 24px ghost Copy button that turns to a success check for two seconds and says _Copied_ to a screen reader. Only the viewer's own machine is told to run something (_On Build box, see why it stops:_ `chopin status`); another's is _Ask @mona to run this on their machine:_. The last step says nothing needs pressing (_Wait a moment._ with _It connects again by itself once it's fixed._ in secondary ink under it), and, where the viewer may choose, _Or choose another machine below._ (in the note, _Choose machine_ opens the menu). A hairline sets apart the foot: _Show details_ at its start unfolds, under it, the machine's own words and when it was last tried (_Last tried at 6:29 PM._), exactly as reported, in quiet monospace; at its end, _Learn more_ in petrol. How often it was tried is never said, here or anywhere a person reads: a machine is connecting, connected or can't connect. No alarm colour: nothing is lost and Chopin keeps trying. Inside the menu every control is one of its items, so the arrow keys reach them, and on a phone's sheet they work without closing it. The menu is a little wider (23rem) while it holds them. - **Message delivery:** Nothing a person sends to Chopin is held for later, so nothing about a message waits, is queued or carries a clock or ticks. A person's own message to the document's machine enters Chat at once as an ordinary message and the composer empties; while a turn runs it is given to that turn and reads as any other message, where it was sent among the Planner's own lines. While only this browser has it, because its own connection is not back or a starting machine is still attaching, it says _Sending_ in the tertiary ink at the line's end, faded in after a moment so that a send the server answers at once never shows it; once the server has it there is no mark. When the machine is away, still reconnecting in its hand-over window (_Still connecting to @mona's machine, so this message wasn't sent. Send it again in a moment._), or unattached, or when the running turn cannot take a message (_chopin can't take messages while it works on this machine. Stop it to ask now._), and the composer holds no newer draft, the typed words return to the draft (with the one button offering Stop while Chopin works, then Send once stopped), so nothing typed is lost, and chopin says why to this person alone, as Slack's ephemeral messages do: the last row of the transcript, under a quiet _Only visible to you_ with a small lock over the text column, is chopin's ordinary row (its mark and presence, its name, its tag and the time) with the server's sentence in the message role and, where the machine has something to fix, _How to fix_ in petrol after it, which unfolds the fix-it steps. Nothing about it is an error or an alert: the composer keeps its own edge and it is said politely, once. It stays until chopin takes a message or another refusal takes its place, and goes with the page. When a newer draft is already in the composer, or another refusal turns the message down, the message says so on itself: a warning glyph, _Couldn't send._ in the destructive ink with the reason in plain words (for a machine that is away: _@mona's machine is offline, so this message wasn't sent. Send it again when it's back._), and _Try again_ and _Delete_ in its menu (see Message toolbar), which act on this browser alone. While the page's offline notice shows, both give way to it and return with the connection. -- **Message rows:** Chat lays a message out as Slack does (`c-message_kit__gutter`), in Chopin's colours: a 36px rounded-square avatar (a person's face, or Chopin's mark for the Planner) 8px from the text, the name in bold with the time in the extra-small role and tertiary ink beside it, and the text under it in the base role (15px on about 22px, as Slack's `c-message_kit__message`). Consecutive messages from one person are grouped: only the first shows the avatar and name, and each after it shows its time in the avatar's gutter while its row is shown. A shown row is shaded across the pane's whole width, square, reaching over the name line of the first message in a group, in the Chat row shade (`--color-chat-row-hover`): ink at 4% over whatever the transcript lies on, so it is always the same darker step, 1.08:1 on the ground, the Chat pane and a phone's page alike, a little firmer than Slack's hovered `#f8f8f8` on white (1.06:1) and never a lighter one, which on the ground read as no hover at all. A message whose thread is open keeps its row in the selected row shade (`--color-chat-row-selected`, ink at 6.5%, 1.16:1, one such step past the hover), and a message jumped to is washed in the brand wash in the same square, full-width row, which then fades. Everything drawn on any of them meets AA against what is behind it, read from the rendered row by `e2e/chat-hover-contrast.e2e.ts`: the time 5.1:1 or more, petrol links and the replies count 4.5:1 or more, and the toolbar's icons 4.96:1 on its white box. Every one of these reaches Chat's very edges, the edges a person sees it lie in: beside the document, Chat has no frame of its own and lies on the ground from the sidebar's edge (or the window's) to the document's sheet, or to the thread's divider, and a thread from that divider to the sheet (or to the window's edge while the document is folded to its bar); alone, on a phone or in a narrow window, the edges are its pane's. A row is shaded across all of it, the 8px before the sheet where Resize chat is taken included, with its text where it always was: the transcript reaches past its column into the gutters around it, its rows keep their 20px gutters inside that, and its stack clips the shade at the edges. Nothing is set aside at either edge because the transcript draws its own scrollbar over its rows, as Slack's message pane does, and sets no lane aside for one: the system's bar is hidden on every engine (forced colours keep it), and a 6px round thumb inset in a 10px lane that stops 6px short of each end, in the strongest scrollbar ink (4:1 or more on the ground and every shade and wash), shows while the pane is scrolled or pointed at, darkens to the tertiary ink under the pointer and while dragged, and sits over the rows' end, clear of Resize chat's grip and of the toolbar 12px in; keyboard scrolling, anchoring and screen readers are the scroller's own. The workflow runs card under the transcript does the same inside its own box: a run's line is shaded in the hover surface across the card's whole width under the pointer, square, its first and last lines rounded only by the card's corners. Above its first row the transcript keeps 20px more for that row's toolbar, which stands over the row, so at the top of a Chat the first message's bar, its emoji and their shadow are whole. Who speaks is said the way Slack says an app is one. A person has their GitHub picture and no tag. Everyone else is an app with a 36px rounded-square app avatar and, between the name and the time, a tag of its kind, after Slack's `APP` (`c-app_badge`): the word in the extra-small role, bold capitals, secondary ink, 4px inside a box of ink at a tenth with the small radius, so it reads the same on a shaded row. The Planner is _Agent_, named `chopin`, with Chopin's mark; a research child is _Subagent_, named _Research_, with a sparkle on the brand wash, in its own row when it says it is ready or could not finish and when its report card arrives; each tool call is _Tool_, beside its name on its line. What an app attaches to its message (the Planner's tool calls once opened, the handoff to research and the research workers' progress) is drawn as Slack's attachment (`c-message_attachment`): a 4px rounded bar in the edge colour down its start edge, petrol while the work runs, and the content 12px beside it. The handoff and the workers are assistant-ui's `AgentHandoff` and `SubagentList`, copied unmodified and recorded, filling the attachment's width. +- **Message rows:** Chat lays a message out as Slack does (`c-message_kit__gutter`), in Chopin's colours: a 36px rounded-square avatar (a person's face, or Chopin's mark for the Planner) 8px from the text, the name in bold with the time in the extra-small role and tertiary ink beside it, and the text under it in the base role (15px on about 22px, as Slack's `c-message_kit__message`). Consecutive messages from one person are grouped: only the first shows the avatar and name, and each after it shows its time in the avatar's gutter while its row is shown. A shown row is shaded across the pane's whole width and its whole pitch, square, as Slack's rows tile its message pane: from the middle of the gap above it to the middle of the gap below it (half of the 4px between messages of a group, half of the 16px between the transcript's rows, over the name line of the first message in a group), so neighbouring rows' shades meet with no unshaded band between them and none over another, and a mouse anywhere in that pitch, the avatar and the gaps included, is on that row (a touch screen keeps the rows' own boxes, so a reaction's 44px target past its row still takes the tap); it is shaded in the Chat row shade (`--color-chat-row-hover`): ink at 4% over whatever the transcript lies on, so it is always the same darker step, 1.08:1 on the ground, the Chat pane and a phone's page alike, a little firmer than Slack's hovered `#f8f8f8` on white (1.06:1) and never a lighter one, which on the ground read as no hover at all. A message whose thread is open keeps its row in the selected row shade (`--color-chat-row-selected`, ink at 6.5%, 1.16:1, one such step past the hover), and a message jumped to is washed in the brand wash in the same square, full-width row, which then fades. Everything drawn on any of them meets AA against what is behind it, read from the rendered row by `e2e/chat-hover-contrast.e2e.ts`: the time 5.1:1 or more, petrol links and the replies count 4.5:1 or more, and the toolbar's icons 4.96:1 on its white box. Every one of these reaches Chat's very edges, the edges a person sees it lie in: beside the document, Chat has no frame of its own and lies on the ground from the sidebar's edge (or the window's) to the document's sheet, or to the thread's divider, and a thread from that divider to the sheet (or to its own panel's edge, where its header's rule ends, while the document is folded to its bar); alone, on a phone or in a narrow window, the edges are its pane's. A row is shaded across all of it, the 8px before the sheet where Resize chat is taken included, with its text where it always was: the transcript reaches past its column into the gutters around it, its rows keep their 20px gutters inside that, and its stack clips the shade at the edges. Nothing is set aside at either edge because the transcript draws its own scrollbar over its rows, as Slack's message pane does, and sets no lane aside for one: the system's bar is hidden on every engine (forced colours keep it), and a 6px round thumb inset in a 10px lane that stops 6px short of each end, in the strongest scrollbar ink (4:1 or more on the ground and every shade and wash), shows while the pane is scrolled or pointed at, darkens to the tertiary ink under the pointer and while dragged, and sits over the rows' end, clear of Resize chat's grip and of the toolbar 12px in; keyboard scrolling, anchoring and screen readers are the scroller's own. The workflow runs card under the transcript does the same inside its own box: a run's line is shaded in the hover surface across the card's whole width under the pointer, square, its first and last lines rounded only by the card's corners; the hairline above a line is its own and the shade runs under it, so a pointer moving down the card passes from one line's shade into the next with no band between. Above its first row the transcript keeps 20px more for that row's toolbar, which stands over the row, so at the top of a Chat the first message's bar, its emoji and their shadow are whole. Who speaks is said the way Slack says an app is one. A person has their GitHub picture and no tag. Everyone else is an app with a 36px rounded-square app avatar and, between the name and the time, a tag of its kind, after Slack's `APP` (`c-app_badge`): the word in the extra-small role, bold capitals, secondary ink, 4px inside a box of ink at a tenth with the small radius, so it reads the same on a shaded row. The Planner is _Agent_, named `chopin`, with Chopin's mark; a research child is _Subagent_, named _Research_, with a sparkle on the brand wash, in its own row when it says it is ready or could not finish and when its report card arrives; each tool call is _Tool_, beside its name on its line. What an app attaches to its message (the Planner's tool calls once opened, the handoff to research and the research workers' progress) is drawn as Slack's attachment (`c-message_attachment`): a 4px rounded bar in the edge colour down its start edge, petrol while the work runs, and the content 12px beside it. The handoff and the workers are assistant-ui's `AgentHandoff` and `SubagentList`, copied unmodified and recorded, filling the attachment's width. - **Message toolbar:** Every Chat message, a person's or the Planner's, has one floating box after Slack's (`c-message_actions__container` and `c-message_actions__group`): absolutely placed at the row's top right, 12px in from the row's right edge (from where Resize chat's grip begins, where the row is shaded over it) and 16px above its top, over the row's edge, so it never moves the text or changes the row's height. Nothing is set aside for it: every line of a message wraps only at the text column's end, and while the bar shows it may cover the end of the first line. A message whose top is scrolled out of the pane, short or tall, in Chat or a thread, keeps its bar in view as Slack's does: it is sticky on a rail down the row's end, so it stands at the top of what shows of the row, just clear of the pane's top fade (8px from the edge where nothing above is hidden), follows the pane as it scrolls, may stand over the lines beside it or the row below, leaves with the row's last line and is back at the row's corner once its top is in view; the browser keeps it there and nothing is measured. The shown row is drawn over the other rows (under their names and avatars, the pane's top fade and its scrollbar), so its bar is whole wherever it stands; a row with only its last line showing has its bar beside that line, half over the row below. It is a rounded square (8px, `--radius-lg`) of the page's surface with a hairline ring in the button outline's edge (ink at 12%, as Slack's is at 13%) and the strong resting shadow, so its white box stands off a shown row's shade (1.2:1, and 1.27:1 on the selected row), 2px inside, holding 32px square buttons with 4px corners, Slack's 20px icons and no gap between them, each greyed in the hover surface under the pointer and named by the shared dark tooltip above it. This bar and the reaction pills are the deliberate exceptions to the no-shadow, no-bordered-box rule. It holds, in order, three quick reactions (✅ _Looks right_, ❓ _Explain this_, 👎 _Not quite_, each named and with tooltips by those words and a toggle with `aria-pressed`), _Add reaction_, _Reply in thread_ (where the message can have a thread: it opens the thread, or starts it; never in a thread's own panel) and a vertical three-dots button, _Message actions_, and nothing else. Only one row shows its bar and shade at a time: the row whose menu or picker is open, else the hovered row, else the row with keyboard focus. The action row (`MessageActions`, `elements-message-actions` with `elements-surfaces`) and the overflow menu (`DropdownMenu`, shadcn's `dropdown-menu` on Base UI Menu) are copied from their registries into `src/components` (`THIRD_PARTY_NOTICES.md` and `src/components/sources.json`) and carry only a recorded extension-point patch (`message-actions.tsx.patch`) for Chopin's items, menu trigger and copy-to-check icon swap; Chopin's `.chat-message-toolbar` class on the copied row gives it Slack's box, and the menu keeps its registry geometry. The menu offers _Copy_ (the stored Markdown, character for character), _Copy as plain text_ (what the page shows: Markdown syntax gone, tables, strikethrough and task lists rendered as text, a link as its text, list items on their own lines, `@handle` and the Planner address as drawn), then _Try again_ and _Delete_ for a message the server refused. Copying swaps the menu item's copy icon for a check (`use-copy-to-clipboard`, MIT; see `THIRD_PARTY_NOTICES.md`) and its label for _Copied_, the menu closes when that ends and says _Copy_ again, and a polite live region says _Copied_ once. The bar shows on hover, on keyboard focus (one tab stop, arrow keys inside, a `toolbar`) and while its menu or the emoji picker is open; on a touch screen it comes from a long press, sits wholly above the message in a lane of its own so it never covers its text, and keeps its 32px visible buttons with 44px invisible `::after` touch targets. Add reaction opens Slack's emoji picker (see _Emoji_), and the picker takes one emoji typed or pasted, kept exactly as written. Reactions sit under the message as Slack's pills (`c-reaction`): 24px tall with round ends, 6px inside, the emoji and its count 2px apart, on the inset fill with a hairline ring in the edge colour; your own is in the accent's wash and ring with its count in bold, and a pointer on the row finds Slack's add-reaction pill (`c-reaction_add`, 34px wide) after them, a shortcut to the toolbar's _Add reaction_ that a keyboard and a screen reader reach there instead. Who reacted is in the tooltip and name; pressing a pill toggles yours. They are stored per message and per person, live for everyone in the room, and shown at once as pending (dimmed) when only this browser has the message or the connection is down, then sent when it can be. The server takes one extended grapheme cluster of at most 64 bytes as an emoji and 20 different reactions from a person to a message; one it turns down is not shown, and the message says _Couldn't react_ and why, in a polite status line that goes at the person's next reaction. - **Markdown text:** Read-only Markdown (Chat messages, a decision card's text and the phrases in it) is drawn by `MarkdownText`, copied from [assistant-ui](https://www.assistant-ui.com/elements/markdown-text)'s registry (`@assistant-ui/markdown-text`, with `tooltip-icon-button`, `use-copy-to-clipboard` and shadcn's `tooltip`) and recorded in `src/components/sources.json`. Because `markdown-text` has no standalone registry entry and Chat has no assistant-ui runtime, a recorded patch (`markdown-text.tsx.patch`) forwards props, sets `data-slot="markdown-text"`, and exposes `CodeHeader`'s button slot and `defaultComponents`, while `tsconfig.json` and `vite.config.ts` map `@assistant-ui/react` and `@assistant-ui/react-markdown` to Chopin's props-driven `react-markdown` seam (`src/chat/assistant-ui/markdown-primitive.tsx`). Its elements keep upstream's `aui-md-*` Tailwind classes, sizes, borders and radii on the generated geometry layer and take only Chopin's colours through `src/assistant-ui.css`; a decision card opts out of that root slot so its prose and diagram controls keep the document's fluid scale and focus ring. A surface passes only what differs: Chat draws a heading as a line of text, and a link to another site opens in a new tab while one into the document or another of Chopin's documents is followed in place, as the document's own are; a card reads no tables, strikethrough or autolinks except in an option's own visual, draws a Mermaid fence with the document's renderer through `componentsByLanguage`, and loads only the document's own images. Chat's messages also read GitHub's tables, strikethrough (two tildes) and task lists, whose boxes are named _Task_; footnotes and bare addresses stay plain text there, as they always were. A fenced block in Chat is Slack's code block in Chopin's colours, restyled from outside the copy by Chat's own unlayered rules (`src/chat/markdown.css`) as Chat's images are: one box the full width of the message's text, a hairline in the edge colour, the large radius and the inset ground, its one slim header row (upstream's `CodeHeader`, 4px above and beside its 24px `TooltipIconButton`, no band or rule under it) holding the language at the start in the extra-small role and tertiary ink and the copy control (_Copy code_, then for three seconds _Copied_ with a check, or _Couldn't copy_ with a warning glyph in the danger icon role, which a touch screen shows without a tooltip) at the end, then the code at Chopin's code text role (the mono stack, the small text role on 1.5, tabs of two), padded 12px, whose long lines scroll inside the block in a lane the keyboard reaches, on a phone too. A block whose fence names a language is coloured with the document's own highlighter (`@chopin/editor/code-tokens`, Shiki through `@pierre/diffs`, loaded with the first such block), each token in one of Chopin's roles at 4.5:1 or more on the inset ground: keywords in petrol ink, functions and types in the merged plum, strings in the success ink, numbers and constants in the warning ink, comments and punctuation in the tertiary ink, a diff's added and removed lines in the success and destructive inks; a block that names none, or names plain text, stays as written, and a block that streams in colours only the lines it gains. A card's code has none of this. A table scrolls inside its own lane. Raw HTML never reaches the page as markup: Chat drops it and a card shows it as the text it was written as. The collaborative editor is not drawn this way. A document shown read-only (an earlier version) stays in the editor's own read-only view, because it carries decisions, diagrams, comments and tables the dialect defines and MarkdownText does not. - **Touch targets:** A control keeps its visible box on a touch screen. Where its box is under 44px (`--control-target-coarse`) an invisible `::after` of that size grows around it, so a finger just outside the control still reaches it, and the control never gets bigger, taller or spaced differently to make room for one. This applies only when the primary pointer is coarse (`(pointer: coarse)`, mirrored as `data-plan-coarse-pointer`): a touch-screen laptop used with a mouse keeps the desktop's sizes. Stacked menu rows keep their own 28px, and a row that follows another stands 44px from it (one margin, which is the height the rows no longer take themselves) so the menu is as tall as before and neighbouring targets meet without overlapping. A control that clips its own overflow cannot grow a target past its edge. Copied assistant-ui elements follow the same rule through the generated layer. diff --git a/apps/web/src/chat/actions/message-actions.css b/apps/web/src/chat/actions/message-actions.css index 242707a81..d9863b1be 100644 --- a/apps/web/src/chat/actions/message-actions.css +++ b/apps/web/src/chat/actions/message-actions.css @@ -13,8 +13,15 @@ */ .chat-message-body { - /* How far the highlight reaches above the message: a little, or up over the name line of the first. */ - --chat-highlight-top: 0.25rem; + /* + * A row's pitch, as Slack's: its shade and its hit area reach halfway across the gap to the row + * before it and to the row after it, so the shades of neighbouring rows meet with no band between + * them and none over another. Messages of a group are 4px apart; the transcript's rows are 16px. + */ + --chat-gap-above: calc(var(--spacing) / 2); + --chat-gap-below: calc(var(--spacing) / 2); + /* How far the highlight reaches above the message: to the gap's middle, or over the name line. */ + --chat-highlight-top: var(--chat-gap-above); /* Slack's gutter: a 36px avatar and 8px beside it, and the row's 12px beyond that. */ --chat-bleed-start: 3.5rem; --chat-bleed-end: 0.75rem; @@ -83,11 +90,22 @@ } /* - * Slack's row is shaded 8px above its name line and 8px below its last line - * (`c-message_kit__gutter`); a message after the first of its group, 4px above it. + * A group's first message reaches to the middle of the 16px before its row, and its last to the + * middle of the 16px after it; a message that heads its group reaches over its name line, which + * stands 2px above the row, to the same middle (`c-message_kit__gutter`). */ +.chat-message-body:first-child { + --chat-gap-above: calc(var(--spacing) * 2); +} + +.chat-message-body:last-child { + --chat-gap-below: calc(var(--spacing) * 2); +} + .chat-message-body[data-chat-headed] { - --chat-highlight-top: calc(var(--text-base) * var(--text-base--line-height) + var(--spacing) * 2); + --chat-highlight-top: calc( + var(--text-base) * var(--text-base--line-height) - var(--spacing) / 2 + var(--spacing) * 2 + ); } /* @@ -106,14 +124,14 @@ } /* - * The whole message as one row as wide as the pane, square, as Slack shades a hovered message: a - * darker step of whatever the transcript lies on, never a lighter one. + * The whole message as one row as wide as the pane and as tall as its pitch, square, as Slack + * shades a hovered message: a darker step of whatever the transcript lies on, never a lighter one. */ .chat-message-body::before { content: ""; position: absolute; z-index: -1; - inset-block: calc(-1 * var(--chat-highlight-top)) calc(-1 * var(--spacing)); + inset-block: calc(-1 * var(--chat-highlight-top)) calc(-1 * var(--chat-gap-below)); inset-inline: calc(-1 * var(--chat-shade-start) - 100vw) calc(-1 * var(--chat-shade-end) - 100vw); background: var(--color-chat-row-hover); opacity: 0; @@ -121,9 +139,20 @@ transition: opacity var(--chat-fade) var(--ease-out); } -/* The last message of a group is shaded 8px below, to the gap before the next. */ -.chat-message-body:last-child::before { - inset-block-end: calc(-1 * var(--spacing) * 2); +/* + * Under a mouse the shade is also where the pointer finds its row, behind its content, and so is + * the avatar, as in Slack: a pointer moving down the transcript passes from one row's shade straight + * into the next one's. Only inside a transcript, whose stack clips the shade at its sides. A touch + * screen keeps the rows' own boxes, so a reaction's 44px target past its row still takes the tap. + */ +@media (hover: hover) and (pointer: fine) { + [data-chat-stack] .chat-message-body::before { + pointer-events: auto; + } + + [data-chat-entry] > [data-chat-avatar] { + pointer-events: none; + } } /* @@ -133,17 +162,13 @@ .chat-source-tint { position: absolute; z-index: -1; - inset-block: calc(-1 * var(--chat-highlight-top)) calc(-1 * var(--spacing)); + inset-block: calc(-1 * var(--chat-highlight-top)) calc(-1 * var(--chat-gap-below)); inset-inline: calc(-1 * var(--chat-shade-start) - 100vw) calc(-1 * var(--chat-shade-end) - 100vw); background: var(--color-brand-wash); pointer-events: none; animation: chat-source-tint calc(var(--duration-linger) * 2) var(--ease-out) forwards; } -.chat-message-body:last-child > .chat-source-tint { - inset-block-end: calc(-1 * var(--spacing) * 2); -} - @keyframes chat-source-tint { 0%, 40% { @@ -339,7 +364,7 @@ .chat-message-body[data-chat-engaged], .chat-message-body:has(:focus-visible) { margin-block-start: var(--chat-toolbar-lane); - --chat-highlight-top: calc(var(--chat-toolbar-lane) + 0.25rem); + --chat-highlight-top: calc(var(--chat-toolbar-lane) + var(--chat-gap-above)); } /* @@ -350,8 +375,9 @@ .chat-message-body[data-chat-headed]:has(:focus-visible) { margin-block-start: calc(var(--chat-toolbar-lane) + var(--spacing)); --chat-highlight-top: calc( - var(--chat-toolbar-lane) + var(--text-base) * var(--text-base--line-height) + - var(--spacing) * 3 + var(--chat-toolbar-lane) + var(--spacing) + var(--text-base) * var(--text-base--line-height) - + var(--spacing) / 2 + + var(--spacing) * 2 ); } diff --git a/apps/web/src/theme.css b/apps/web/src/theme.css index 8348b3d82..653b78730 100644 --- a/apps/web/src/theme.css +++ b/apps/web/src/theme.css @@ -1186,11 +1186,11 @@ body { /* * Split Chat is a track: its width moves the document, and Chat slides under its clip. - * Only the width clips, so the composer's focus ring can pass the panel's bottom edge, and only - * beyond the frame's gutters, which Chat's rows are shaded across (workspace-sizing.css). Its top - * and foot are unbounded, not merely far: an image's lazy loading counts a clip path's edges, and - * one a screen above and below Chat kept the images further up from loading. (An overflow clip's - * margin would do, but Chromium draws one only where both axes clip.) + * Only the width clips, so the composer's focus ring can pass the panel's bottom edge, and at its + * start only beyond the frame's gutter, which Chat's rows are shaded across (workspace-sizing.css). + * Its top and foot are unbounded, not merely far: an image's lazy loading counts a clip path's + * edges, and one a screen above and below Chat kept the images further up from loading. (An + * overflow clip's margin would do, but Chromium draws one only where both axes clip.) */ .workspace-root[data-workspace-mode="split"] .workspace-chat-panel { overflow: visible; @@ -1211,15 +1211,6 @@ body { width: var(--chat-width, 0px); } -.workspace-root[data-workspace-mode="split"][data-document-collapsed] .workspace-chat-panel { - clip-path: inset( - calc(-1px * infinity) - calc(-1 * (var(--spacing) * 3 + env(safe-area-inset-right))) - calc(-1px * infinity) - calc(-1 * (var(--spacing) * 3 + env(safe-area-inset-left))) - ); -} - .workspace-root[data-workspace-mode="split"] .workspace-chat-panel.is-open { width: var(--chat-width, 0px); } diff --git a/apps/web/src/workspace-sizing.css b/apps/web/src/workspace-sizing.css index 720b13a2d..22e79fe90 100644 --- a/apps/web/src/workspace-sizing.css +++ b/apps/web/src/workspace-sizing.css @@ -61,10 +61,9 @@ /* * Split Chat lies on the ground from the sidebar's edge (or the window's) to the document's sheet, * and a shown row is shaded across all of it, as Slack shades its message pane. The pane keeps its - * box and its track; its clip lets it draw into the frame's gutter before it, and the one after it - * while the document is folded to its bar (theme.css). Each transcript reaches into the gutters - * beside it and into the 8px before the sheet, and its rows keep their 20px gutter inside that, so - * the text stays where it was (overlay-scrollbar.css). + * box and its track; its clip lets it draw into the frame's gutter before it (theme.css). Each + * transcript reaches into the gutter beside it and into the 8px before the sheet, and its rows keep + * their 20px gutter inside that, so the text stays where it was (overlay-scrollbar.css). * * The 8px before the sheet is where Resize chat is taken: rows are shaded across it, and the thumb * stays clear of it, where it was. While a thread is open they are the thread's, and main Chat ends @@ -104,17 +103,20 @@ inset-inline-end: 0; } -/* With the document folded to its bar, the thread reaches the window's edge, and so does its thumb. */ +/* + * With the document folded to its bar there is no sheet: the thread ends at its own panel's edge, + * where its header's rule ends, and so does its thumb. + */ .workspace-root[data-workspace-mode="split"][data-document-collapsed] .chat-split-thread .chat-scroll-host { - margin-inline-end: calc(-1 * (var(--spacing) * 5 + env(safe-area-inset-right))); + margin-inline-end: 0; } .workspace-root[data-workspace-mode="split"][data-document-collapsed] .chat-split-thread [data-chat-stack] { - padding-inline-end: calc(var(--spacing) * 10 + env(safe-area-inset-right)); + padding-inline-end: calc(var(--spacing) * 5); } .workspace-root[data-workspace-mode="split"][data-document-collapsed] diff --git a/e2e/chat-row-edges.e2e.ts b/e2e/chat-row-edges.e2e.ts index 3594dd896..ddde25728 100644 --- a/e2e/chat-row-edges.e2e.ts +++ b/e2e/chat-row-edges.e2e.ts @@ -1,11 +1,13 @@ /** * Every row state Slack shades spans the Chat container from edge to edge, as Slack's message pane - * does, and nothing of the first message is cut off at the top of the transcript. + * does, and its row's whole pitch from top to bottom, and nothing of the first message is cut off at + * the top of the transcript. * * The container is what a person sees Chat lie on. Beside the document, Chat has no frame of its * own: it lies on the ground from the sidebar's edge (or the window's) to the document's sheet, or - * to the thread's divider, and the thread from that divider to the sheet (or the window's edge when - * the document folds to its bar). Alone, on a phone or in a narrow window, it is the pane it fills. + * to the thread's divider, and the thread from that divider to the sheet (or to its own panel's + * edge when the document folds to its bar). Alone, on a phone or in a narrow window, it is the pane + * it fills. * * The browser here keeps the system's classic scrollbars (Playwright hides them in headless * Chromium), which is where a native bar set a lane aside and the shade stopped short of the edge. @@ -91,7 +93,11 @@ async function shade(row: Locator, part: "::before" | ".chat-source-tint" = "::b let thread = root.querySelector("[data-thread-pane]"); if (pane) { let divider = Number.parseFloat(getComputedStyle(pane).borderLeftWidth); - view = { left: pane.getBoundingClientRect().left + divider, right: end }; + let panel = pane.querySelector("[data-thread-panel]")!.getBoundingClientRect(); + view = { + left: pane.getBoundingClientRect().left + divider, + right: sheet ? end : panel.right, + }; } else { view = { left: ground.left, right: thread ? thread.getBoundingClientRect().left : end }; } @@ -137,6 +143,107 @@ async function shade(row: Locator, part: "::before" | ".chat-source-tint" = "::b const EDGE_TO_EDGE = { start: 0, end: 0, radius: "0px", opacity: "1" }; +/** + * Where each message's shade begins and ends against its pitch, as Slack's rows tile its message + * pane: from the middle of the gap to the row before it to the middle of the gap to the row after + * it. A message's row is its own box, from its group's top when it heads the group and to its + * group's foot when it ends it; any other row of the transcript (a notice, a thread's lead, the + * transcript's foot) is its box and has no shade. So neighbouring shades meet with no band between + * them and none over another. The first row reaches as far above as half the transcript's gap. + * + * Then, under a mouse, down three lines (the container's start, the avatars' middle, the text's + * start), what the pointer finds at every pixel is the row whose pitch it is in, or no message + * between rows that are not messages: no band where a pointer moving down the transcript shades + * nothing. A touch screen has no pointer moving down it, and keeps the rows' own boxes. + * + * It returns what is out of place, as words, so none is what is right. + */ +function pitches(scroller: Locator): Promise { + return scroller.evaluate(view => { + let stack = view.querySelector(":scope > [data-chat-stack]")!; + let half = Number.parseFloat(getComputedStyle(stack).rowGap) / 2; + type Unit = { top: number; bottom: number; body?: HTMLElement; drawn?: [number, number] }; + let units: Unit[] = []; + for (let row of stack.children) { + let box = row.getBoundingClientRect(); + if (getComputedStyle(row).position === "absolute" || box.height === 0) continue; + let bodies = row.matches("[data-chat-entry]") + ? [...row.querySelectorAll(".chat-message-body")] + : []; + if (!bodies.length) { + units.push({ top: box.top, bottom: box.bottom }); + continue; + } + bodies.forEach((body, index) => { + let own = body.getBoundingClientRect(); + let style = getComputedStyle(body, "::before"); + let top = own.top + Number.parseFloat(style.top); + // On a touch screen an engaged message's toolbar stands in a lane above it, its own. + let lane = Number.parseFloat(getComputedStyle(body).marginBlockStart); + units.push({ + top: index === 0 ? box.top : own.top - lane, + bottom: index === bodies.length - 1 ? box.bottom : own.bottom, + body, + drawn: [top, top + Number.parseFloat(style.height)], + }); + }); + } + let wrong: string[] = []; + let owners: { top: number; bottom: number; body?: HTMLElement }[] = []; + units.forEach((unit, index) => { + let before = units[index - 1]; + let after = units[index + 1]; + let top = before ? (before.bottom + unit.top) / 2 : unit.top - half; + let bottom = after ? (unit.bottom + after.top) / 2 : unit.bottom + half; + owners.push({ top, bottom, body: unit.body }); + if (!unit.body) return; + let id = unit.body.dataset.chatMessageId; + let [drawnTop, drawnBottom] = unit.drawn!; + if (Math.abs(drawnTop - top) >= 0.5) { + wrong.push(`${id}'s shade starts ${(drawnTop - top).toFixed(1)}px from its pitch`); + } + if (Math.abs(drawnBottom - bottom) >= 0.5) { + wrong.push(`${id}'s shade ends ${(drawnBottom - bottom).toFixed(1)}px from its pitch`); + } + }); + if (!matchMedia("(hover: hover) and (pointer: fine)").matches) return wrong; + let frame = view.getBoundingClientRect(); + let first = stack.querySelector(".chat-message-body")!.getBoundingClientRect(); + let avatar = stack.querySelector("[data-chat-entry] > [data-chat-avatar]")! + .getBoundingClientRect(); + let lines = { + container: frame.left + 1, + avatars: avatar.left + avatar.width / 2, + text: first.left + 4, + }; + // Below what runs under a phone's top bar; the pixel either side of a boundary is either row's. + let from = frame.top + (Number.parseFloat(getComputedStyle(view).scrollPaddingTop) || 0); + for (let [line, x] of Object.entries(lines)) { + for (let y = Math.ceil(from) + 1; y < frame.bottom - 1; y++) { + let owner = owners.find(owner => y >= owner.top + 1 && y < owner.bottom - 1); + if (!owner) continue; + let hit = document.elementFromPoint(x, y); + // What is drawn over the transcript (a resize grip at its edge, a bar over its top) is not + // a band in it, nor is a neighbour's touch target that stands past its row. + if (!hit || !view.contains(hit)) continue; + let found = hit.closest(".chat-message-body") + ?? hit.closest("[data-chat-head]")?.nextElementSibling as HTMLElement | null + ?? undefined; + if (found && found !== owner.body && hit.closest("button")) continue; + if (found !== owner.body) { + wrong.push( + `at ${line} ${Math.round(y - frame.top)}px down the pointer finds ${ + found?.dataset.chatMessageId ?? "no message" + }, in ${owner.body?.dataset.chatMessageId ?? "no message"}'s pitch`, + ); + y = Math.ceil(owner.bottom); + } + } + } + return wrong; + }); +} + /** Whether the container sets a lane aside for a scrollbar: its client width short of its width. */ function lane(scroller: Locator): Promise { return scroller.evaluate(node => (node as HTMLElement).offsetWidth - node.clientWidth); @@ -157,7 +264,7 @@ function overhang(parts: Locator): Promise { ); } -test("on a desktop every shaded row spans the Chat container edge to edge, under classic scrollbars, with the thumb drawn over the rows", async ({ join, seed }) => { +test("on a desktop every shaded row spans the Chat container edge to edge and its whole pitch, under classic scrollbars, with the thumb drawn over the rows", async ({ join, seed }) => { await seed("# Rows\n", { transcript: transcript() }); let page = await join("ana", { viewport: { width: 1440, height: 900 } }); expect(await page.evaluate(() => document.documentElement.dataset.scrollbars)).toBe("classic"); @@ -168,10 +275,20 @@ test("on a desktop every shaded row spans the Chat container edge to edge, under let row = message(page, "note-20"); await row.scrollIntoViewIfNeeded(); + await expect.poll(() => pitches(scroller)).toEqual([]); // Hovered. await row.locator("[data-chat-message-text]").hover(); await expect.poll(() => shade(row)).toEqual(EDGE_TO_EDGE); + // And in the gap above its row and the gap below it, halfway to its neighbours. + for (let [at, past] of [["top", -4], ["bottom", 4]] as const) { + let point = await row.evaluate((node, [at, past]) => { + let entry = node.closest("[data-chat-entry]")!.getBoundingClientRect(); + return { x: node.getBoundingClientRect().left + 4, y: entry[at] + past }; + }, [at, past] as const); + await page.mouse.move(point.x, point.y); + await expect.poll(() => shade(row), `${past}px past its ${at}`).toEqual(EDGE_TO_EDGE); + } // Keyboard focus within the row. await page.mouse.move(0, 0); @@ -210,6 +327,18 @@ test("on a desktop every shaded row spans the Chat container edge to edge, under node.prepend(tint); }); expect(await shade(row, ".chat-source-tint")).toEqual(EDGE_TO_EDGE); + // Over the same pitch as the shade. + expect( + await row.evaluate(node => { + let tint = node.querySelector(".chat-source-tint")!.getBoundingClientRect(); + let style = getComputedStyle(node, "::before"); + let top = node.getBoundingClientRect().top + Number.parseFloat(style.top); + return [ + Math.round(tint.top - top), + Math.round(tint.bottom - top - Number.parseFloat(style.height)), + ]; + }), + ).toEqual([0, 0]); await row.evaluate(node => node.querySelector(".chat-source-tint")!.remove()); // The scrollbar is drawn over the rows' end: shown while the pane is scrolled or pointed at, @@ -257,6 +386,8 @@ test("on a desktop every shaded row spans the Chat container edge to edge, under await expect(reply).toBeVisible(); await page.mouse.move(0, 0); await expect.poll(() => shade(second)).toEqual(EDGE_TO_EDGE); + // The document folds to its bar here: the thread's rows end at its own panel's edge. + await expect(page.locator(".workspace-root")).toHaveAttribute("data-document-collapsed", "true"); await reply.locator("[data-chat-message-text]").hover(); await expect.poll(() => shade(reply)).toEqual(EDGE_TO_EDGE); expect(await lane(panel.locator("[data-chat-scroll]"))).toBe(0); @@ -266,9 +397,16 @@ test("on a desktop every shaded row spans the Chat container edge to edge, under expect(await overhang(replyBar)).toEqual([0]); expect(await overhang(reply.locator(".chat-emoji"))).toEqual([0]); expect(await overhang(panel.locator("[data-thread-root-message] .chat-emoji"))).toEqual([0, 0]); + // The thread's rows and Chat's tile their panes, the document folded. + await page.mouse.move(0, 0); + await expect.poll(() => pitches(panel.locator("[data-chat-scroll]"))).toEqual([]); + await expect.poll(() => pitches(scroller)).toEqual([]); }); -/** A person's message and one grouped under it, the agent's two, and a last pair. */ +/** + * A person's message and one grouped under it, a notice, the agent's two (the second with an + * image), and a last pair. + */ function kinds() { return [ { @@ -283,6 +421,12 @@ function kinds() { text: "The canary held.", ts: 1_700_000_030, }, + { + id: "notice", + author: { kind: "system" as const }, + text: "ana asked chopin to break the plan into tasks.", + ts: 1_700_000_060, + }, { id: "agent-1", author: { kind: "agent" as const }, @@ -294,6 +438,7 @@ function kinds() { author: { kind: "agent" as const }, text: "And left the drill on the plan.", ts: 1_700_000_100, + images: [{ src: `/images/${"b".repeat(64)}.png`, width: 640, height: 360 }], }, { id: "ana-1", @@ -317,7 +462,7 @@ for ( ["a 640 window, where Chat fills the frame", 640, false], ] as const ) { - test(`in ${label} every kind of row is shaded edge to edge, in Chat and in its thread`, async ({ join, seed }) => { + test(`in ${label} every kind of row is shaded edge to edge and over its whole pitch, in Chat and in its thread`, async ({ join, seed }) => { await seed("# Rows\n", { transcript: kinds() }); let page = await join("ana", { viewport: { width, height: 900 } }); if (!split) { @@ -333,6 +478,20 @@ for ( await row.locator("[data-chat-message-text]").hover({ position: { x: 4, y: 4 } }); await expect.poll(() => shade(row), id).toEqual(EDGE_TO_EDGE); } + let main = chatOf(page).locator(".chat-split-main [data-chat-scroll]"); + await page.mouse.move(0, 0); + await expect.poll(() => pitches(main)).toEqual([]); + // In the gap between the notice and the agent's row, 4px above the row, the pointer is on it. + let agent = message(page, "agent-1"); + let gap = await agent.evaluate(node => { + let text = node.getBoundingClientRect(); + return { + x: text.left + 4, + y: node.closest("[data-chat-entry]")!.getBoundingClientRect().top - 4, + }; + }); + await page.mouse.move(gap.x, gap.y); + await expect.poll(() => shade(agent)).toEqual(EDGE_TO_EDGE); let root = message(page, "ben-2"); await root.locator("[data-chat-message-text]").hover({ position: { x: 4, y: 4 } }); @@ -343,11 +502,17 @@ for ( await panel.getByRole("button", { name: "Send message" }).click(); let reply = panel.locator(".chat-message-body", { hasText: "Booked for three" }); await expect(reply).toBeVisible(); + await chatInput(panel).fill("And the room."); + await panel.getByRole("button", { name: "Send message" }).click(); + await expect(panel.locator(".chat-message-body", { hasText: "And the room." })).toBeVisible(); await reply.locator("[data-chat-message-text]").hover({ position: { x: 4, y: 4 } }); await expect.poll(() => shade(reply)).toEqual(EDGE_TO_EDGE); + await page.mouse.move(0, 0); + // The thread's replies, grouped under its lead, tile it as Chat's rows tile Chat. + await expect.poll(() => pitches(panel.locator("[data-chat-scroll]"))).toEqual([]); if (split) { - await page.mouse.move(0, 0); await expect.poll(() => shade(root)).toEqual(EDGE_TO_EDGE); + await expect.poll(() => pitches(main)).toEqual([]); } }); } @@ -395,8 +560,11 @@ test("on a phone an engaged row spans the Chat container edge to edge, and the f let row = message(page, "note-20"); await row.scrollIntoViewIfNeeded(); + await expect.poll(() => pitches(scroller)).toEqual([]); await holdOn(page, row.locator("[data-chat-message-text]")); await expect.poll(() => shade(row)).toEqual(EDGE_TO_EDGE); + // Its toolbar's lane above it is its own, and its shade still meets its neighbours'. + await expect.poll(() => pitches(scroller)).toEqual([]); await page.locator("body").evaluate(() => (document.activeElement as HTMLElement | null)?.blur()); await page.mouse.click(1, 1).catch(() => {}); From 3764b38be46203c2d57876454c1f60869424f247 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Thu, 12 Nov 2026 15:12:52 -0800 Subject: [PATCH 3/3] Shade a thread's root row halfway to the provenance and the replies' rule A thread's root is now drawn as Chat draws a message, inside the thread's lead, between the provenance line and the replies' rule, 12px from each. Its shade took the transcript's half gap of 8px, so it reached 2px over each of them. It now takes half of the lead's gap (6px), through one custom property for the half of the gap between rows (`--chat-row-half`), which a group's first and last messages and the name line's reach read. chat-row-edges reads the lead's rows one by one, so the root's row is measured against its pitch and swept for bands like any other; it failed on the rebased branch before this change, with the root found 2px into the lead's rows. Assistant-model: Claude Opus 5.5 Assistant-workflow: inline (subagent) Assistant-verification: bun run types Assistant-verification: bun run ci Assistant-verification: playwright chat-row-edges, chat-threads, chat-thread-reactions, message-toolbar-layout, chat-toolbar-in-view (43 passed) Assistant-verification: playwright run-card, chat-hover-contrast, chat-images, chat-motion, chat-phone, message-actions, pane-resize, pane-work (97 passed) User-preference: "why don't the containers for the message hover still not encapsulate the entire box? I still see a gap ... with some margin or padding" Co-authored-by: Alex Lavaee --- apps/web/src/chat/actions/message-actions.css | 9 +++++---- apps/web/src/chat/threads/thread-panel.css | 5 +++++ e2e/chat-row-edges.e2e.ts | 7 ++++++- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/apps/web/src/chat/actions/message-actions.css b/apps/web/src/chat/actions/message-actions.css index d9863b1be..98531ff8a 100644 --- a/apps/web/src/chat/actions/message-actions.css +++ b/apps/web/src/chat/actions/message-actions.css @@ -18,6 +18,7 @@ * before it and to the row after it, so the shades of neighbouring rows meet with no band between * them and none over another. Messages of a group are 4px apart; the transcript's rows are 16px. */ + --chat-row-half: calc(var(--spacing) * 2); --chat-gap-above: calc(var(--spacing) / 2); --chat-gap-below: calc(var(--spacing) / 2); /* How far the highlight reaches above the message: to the gap's middle, or over the name line. */ @@ -95,16 +96,16 @@ * stands 2px above the row, to the same middle (`c-message_kit__gutter`). */ .chat-message-body:first-child { - --chat-gap-above: calc(var(--spacing) * 2); + --chat-gap-above: var(--chat-row-half); } .chat-message-body:last-child { - --chat-gap-below: calc(var(--spacing) * 2); + --chat-gap-below: var(--chat-row-half); } .chat-message-body[data-chat-headed] { --chat-highlight-top: calc( - var(--text-base) * var(--text-base--line-height) - var(--spacing) / 2 + var(--spacing) * 2 + var(--text-base) * var(--text-base--line-height) - var(--spacing) / 2 + var(--chat-row-half) ); } @@ -377,7 +378,7 @@ --chat-highlight-top: calc( var(--chat-toolbar-lane) + var(--spacing) + var(--text-base) * var(--text-base--line-height) - var(--spacing) / 2 + - var(--spacing) * 2 + var(--chat-row-half) ); } diff --git a/apps/web/src/chat/threads/thread-panel.css b/apps/web/src/chat/threads/thread-panel.css index a5f11cd3b..abc6af8ca 100644 --- a/apps/web/src/chat/threads/thread-panel.css +++ b/apps/web/src/chat/threads/thread-panel.css @@ -67,6 +67,11 @@ line-height: 1.4; } +/* The root's row reaches halfway to the provenance above it and to the replies' rule below it. */ +[data-thread-root-message] .chat-message-body { + --chat-row-half: calc(var(--spacing) * 1.5); +} + .thread-replies-rule { display: flex; align-items: center; diff --git a/e2e/chat-row-edges.e2e.ts b/e2e/chat-row-edges.e2e.ts index ddde25728..8f80a905d 100644 --- a/e2e/chat-row-edges.e2e.ts +++ b/e2e/chat-row-edges.e2e.ts @@ -164,7 +164,12 @@ function pitches(scroller: Locator): Promise { let half = Number.parseFloat(getComputedStyle(stack).rowGap) / 2; type Unit = { top: number; bottom: number; body?: HTMLElement; drawn?: [number, number] }; let units: Unit[] = []; - for (let row of stack.children) { + // A thread's lead holds its root's row between its provenance and the replies' rule. + let rows = (at: Element): Element[] => + !at.matches("[data-chat-entry]") && at.querySelector("[data-chat-entry]") + ? [...at.children].flatMap(rows) + : [at]; + for (let row of [...stack.children].flatMap(rows)) { let box = row.getBoundingClientRect(); if (getComputedStyle(row).position === "absolute" || box.height === 0) continue; let bodies = row.matches("[data-chat-entry]")