fix(composer): pin activity strip edge fades to the container edges

- Bleed the pill strip's scroll viewport across the mounting row's px-5
  gutter (-mx-5 on the strip root, px-5 restored as scroller padding) so
  overflowing pills clip at the container's visual edge instead of the
  row's padded content box — previously a pill faded out mid-row, 20px
  shy of the edge, with a dead gutter after the gradient
- Keep at-rest alignment unchanged: the scroller's internal px-5 keeps
  the first pill on the row gutter in both mounting surfaces
  (ChannelComposerActivityRow and the thread panel toolbar row)
- Widen the edge fades from w-8 to w-12 so the gradient keeps a soft
  fade zone over the pills after spanning the 20px gutter
- Grow the pill label ticker viewport from h-3.5 to h-4 with a matching
  leading-4 line box so Inter's descenders ("g", "y") are no longer
  sheared off the label
- Extend the narrow-strip e2e test to assert both fades sit within 1px
  of the activity row's border-box edges, guarding the full-bleed
  geometry against regression

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-05 16:41:58 -07:00
committed by Carl
parent f68693654b
commit 2fe4b68091
2 changed files with 53 additions and 8 deletions
@@ -291,7 +291,12 @@ function BotActivityAgentPill({
displayName={agent.name}
size="xs"
/>
<span className="relative block h-3.5 min-w-0 flex-1 overflow-hidden">
{/* Ticker viewport: 16px tall with a matching 16px line box (leading-4
overrides the button's leading-none). Inter's ascent+descent ink is
~1.21em (~14.5px at text-xs) — taller than a leading-none line box —
and BOTH this span and the truncate span clip to their boxes, which
sheared descenders ("g", "y") off the label. */}
<span className="relative block h-4 min-w-0 flex-1 overflow-hidden leading-4">
<AnimatePresence initial={false} mode="popLayout">
<motion.span
animate={{ y: 0 }}
@@ -466,7 +471,9 @@ function AnimatedPillSlot({
* toolbars especially) the strip scrolls horizontally — scrollbar hidden,
* with edge gradient fades signalling the pills off view — instead of
* compressing every pill into an unreadable sliver or wrapping the
* fixed-height row.
* fixed-height row. The scroll viewport bleeds across the mounting row's
* gutter so the clip line and its fades sit at the container's visual
* edges, never mid-row on a pill.
*/
export function BotActivityComposerAction({
agents,
@@ -555,7 +562,15 @@ export function BotActivityComposerAction({
return (
// biome-ignore lint/a11y/noStaticElementInteractions: hover-only hold — keyboard focus drives the same hold via the pill triggers.
<div
className="relative min-w-0 flex-1"
// Full-bleed scroll viewport: -mx-5 cancels the mounting row's px-5
// gutter (ChannelComposerActivityRow and the thread panel's
// THREAD_PANEL_COMPOSER_GUTTER_CLASS — change together) and the
// scroller re-adds it as internal padding. Overflowing pills then clip
// at the CONTAINER's visual edge — where the edge fades sit — instead
// of getting cut at the padded content box, 20px shy of the edge with
// a dead gutter after the fade. At rest nothing changes: the padding
// keeps the first pill aligned to the gutter.
className="relative -mx-5 min-w-0 flex-1"
data-testid="bot-activity-strip"
onMouseEnter={() => setBarHovered(true)}
onMouseLeave={() => setBarHovered(false)}
@@ -564,9 +579,10 @@ export function BotActivityComposerAction({
strip is scrolled — motion measures positions relative to the
scroll offset instead of jumping. The negative-margin/padding pair
gives focus rings and pill shadows room inside the clip box
without changing the row's height. */}
without changing the row's height. px-5 restores the row gutter
inside the full-bleed viewport (see the wrapper above). */}
<motion.div
className="-my-1 flex items-center overflow-x-auto overscroll-x-contain py-1 scrollbar-none [&::-webkit-scrollbar]:hidden"
className="-my-1 flex items-center overflow-x-auto overscroll-x-contain px-5 py-1 scrollbar-none [&::-webkit-scrollbar]:hidden"
data-testid="bot-activity-strip-scroller"
layoutScroll
onScroll={updateFades}
@@ -602,18 +618,20 @@ export function BotActivityComposerAction({
</motion.div>
{/* Edge fades: obvious "more pills off view" affordance. Rendered only
for a side that actually has clipped content, so they never dim a
fully visible strip. */}
fully visible strip. They sit at the full-bleed viewport's edges —
the container's visual edges — and w-12 spans the 20px gutter plus
a soft zone over the pills scrolling under it. */}
{fades.start ? (
<div
aria-hidden
className="pointer-events-none absolute inset-y-0 left-0 z-10 w-8 bg-linear-to-r from-background via-background/60 to-transparent"
className="pointer-events-none absolute inset-y-0 left-0 z-10 w-12 bg-linear-to-r from-background via-background/60 to-transparent"
data-testid="bot-activity-strip-fade-start"
/>
) : null}
{fades.end ? (
<div
aria-hidden
className="pointer-events-none absolute inset-y-0 right-0 z-10 w-8 bg-linear-to-l from-background via-background/60 to-transparent"
className="pointer-events-none absolute inset-y-0 right-0 z-10 w-12 bg-linear-to-l from-background via-background/60 to-transparent"
data-testid="bot-activity-strip-fade-end"
/>
) : null}
+27
View File
@@ -2482,12 +2482,39 @@ test("narrow strip scrolls horizontally with edge fades instead of compressing p
0,
);
// The fade sits at the CONTAINER's visual edge, not at the row's padded
// content box: the full-bleed viewport cancels the row's px-5 gutter, so
// pills scroll all the way under a gradient pinned to the row's border
// box instead of getting cut mid-row with a dead gutter after the fade.
const rowBox = await page
.getByTestId("channel-composer-activity-row")
.boundingBox();
const fadeEndBox = await page
.getByTestId("bot-activity-strip-fade-end")
.boundingBox();
expect(fadeEndBox).not.toBeNull();
expect(rowBox).not.toBeNull();
if (fadeEndBox && rowBox) {
expect(
Math.abs(fadeEndBox.x + fadeEndBox.width - (rowBox.x + rowBox.width)),
).toBeLessThanOrEqual(1);
}
// Scrolling to the far end swaps the fades to the leading side.
await scroller.evaluate((el) => {
el.scrollLeft = el.scrollWidth;
});
await expect(page.getByTestId("bot-activity-strip-fade-start")).toBeVisible();
await expect(page.getByTestId("bot-activity-strip-fade-end")).toHaveCount(0);
// Same pinning on the leading edge once scrolled.
const fadeStartBox = await page
.getByTestId("bot-activity-strip-fade-start")
.boundingBox();
expect(fadeStartBox).not.toBeNull();
if (fadeStartBox && rowBox) {
expect(Math.abs(fadeStartBox.x - rowBox.x)).toBeLessThanOrEqual(1);
}
});
test("lone pill shrinks to fit a narrow container without scroll fades", async ({