Skip to content

Fade content into the top edges of the document and Chat - #514

Open
MaggieAppleton wants to merge 4 commits into
mainfrom
design-scroll-edge-fades
Open

MaggieAppleton wants to merge 4 commits into
mainfrom
design-scroll-edge-fades

Conversation

@MaggieAppleton

@MaggieAppleton MaggieAppleton commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Content scrolling under the views bar (Document · Decisions · Build) or the top of Chat was hard-cut by the scroller's edge, slicing headings and messages in half. It now dissolves into the surface.

How

  • A sticky ::before on each pane's scroller ([data-plan-scroll], [data-plan-decisions-scroll], [data-build-view-scroll], [data-chat-scroll]) draws a gradient from the surface colour to transparent. It is folded out of flow with a negative margin, so there's no layout shift (the e2e test checks this).
  • Surface tokens: --color-page on the paper; --color-ground for split Chat; --color-chat-pane for compact Chat. Height is a new token, --scroll-edge-fade-size (56px). The gradient uses an eased scrim curve (12 color-mix stops) rather than two linear stops, so it has no visible band edge.
  • It only shows once content is underneath. A scroll-state(scrollable: top) container query on each scroller reveals it with a 120ms opacity transition, so at rest it's invisible. There's no JS scroll listener and no scroll-timeline animation: a scroll-driven animation counts as running forever, and the e2e checks that wait for motion to settle timed out in CI. Browsers without scroll-state queries get no fade.
  • pointer-events: none, z-index: 3. Decision markers (2) pass under it. The comment menu (3, later in the DOM), rails (40), the table toolbar (50) and the selection bar (50) stay above it.
  • Sticky offsets start inside a scroller's padding. Without a correction, Chat's fade sat 12px below the clip edge with readable text above it, and Decisions sat 48px down. --scroll-edge-inset pulls the fade back to the true clip edge, so it's at full opacity exactly where content is clipped.
  • prefers-reduced-transparency: reduce: the gradient stays solid for its first half before fading. The fade is driven by scroll, not time, so reduced motion is unaffected.
  • No bottom rule was added to the tabs bar.
  • Phone is unchanged on purpose. The phone top bar is already a translucent material with a hairline, and content scrolls under it (see the last screenshot), so the hard cut doesn't happen there.

Document under the tabs, mid-scroll

Before After
Document before 2x Document after 2x
Document before 1x Document after 1x

Chat top, mid-scroll

Before After
Chat before 2x Chat after 2x
Chat before 1x Chat after 1x

Decisions

Before After
Decisions before Decisions after

Zoom child (after)

Zoom child after

Phone (unchanged: the bar is already translucent material)

Phone document

Tests

  • New e2e test in e2e/paper-layout.e2e.ts. It checks the scroller starts at the views bar's lower edge and that hiding the fade doesn't change layout. It also checks the fade is sticky and pointer-events: none, invisible at rest and visible once scrolled, and that animations still settle. In Chat, it's visible at the newest message and hidden at the top.
  • The new test passes locally. Other sessions hold the shared e2e ports, so the full suite relies on CI.
  • bun run types passes. The token, design-contract and design-check findings come only from untracked local design-jig files, not this diff.

🤖 Generated with Claude Code

MaggieAppleton and others added 3 commits October 12, 2026 12:32
Content scrolling under the views bar or the top of Chat now dissolves into
the surface instead of being cut by the scroller's edge. A sticky ::before on
each pane's scroller carries a paper-to-transparent gradient, folded out of
flow by a negative margin, and a scroll-driven animation reveals it over the
first 24px of scroll so nothing is veiled at rest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A 56px scrim-curve gradient replaces the 24px linear one, so the fade has no
visible band edge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A scroll-driven animation never stops running, so every check that waits for
motion to settle timed out. A scroll-state container query with a short
opacity transition shows the fade once content has scrolled under the edge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaggieAppleton

Copy link
Copy Markdown
Collaborator Author

Review: Changes needed (one small fix; otherwise this is ready)

I checked it in headless Chromium against a fake server, at 1x and 2x. I used a seeded long document, 30 Chat messages, 4 decisions and a Zoom child.

What works

  • The fade starts exactly at the clip edge everywhere. I painted the ::before magenta and measured where it starts against the scroller's top. The offset is 0px in the document (92), split Chat (12), compact Chat at 720px wide (109), Decisions (92), Build (92, with filler injected because Build doesn't scroll by default) and the Zoom child (52). The fade is 56px tall in each. No readable text sits above it, and --scroll-edge-inset does its job.
  • The surface colours match. I sampled pixels inside and below the fade: split Chat 244/244/241 on the ground, compact Chat 252/252/251 on the chat pane, the document white on the page. I saw no band edge at 1x or 2x. The eased curve reads soft.
  • At rest it's invisible. Opacity is 0 at scrollTop 0 in every pane and 1 once scrolled.
  • Z-order is right. Table rails and drag handles, the hover comment card, the selection toolbar, the slash menu and the "comment without prose" pill all sit above the fade. Decision cards pass under it.
  • No side effects from containment. container-type: scroll-state leaves computed contain: none. With scroll-state on and off, the first 400 descendants of both scrollers have identical rects and scrollHeight is unchanged. An IntersectionObserver rooted on the Chat scroller still fires. Sticky positioning works.
  • WebKit 26.5 (Playwright) degrades cleanly. CSS.supports("container-type: scroll-state") is false and the fade stays at opacity 0. Hiding the ::before doesn't change layout. Because inline-size scroll-state is invalid there, the document scroller falls back to the editor's own container-type: inline-size, so wide blocks still work. The result is today's hard cut. Firefox also lacks scroll-state queries, so the gap is desktop Safari and Firefox. Phones are excluded on purpose. It's a missing nicety, not a regression.
  • No performance cost on a 1500-paragraph document. Over 300 scroll frames, median and p95 frame times match with the fade on and off (8.3 / 9.3ms vs 8.3 / 9.2ms). Layout time is the same (12.4 vs 12.1ms). The fade adds about 15 style recalculations, only when scroll state flips.
  • CI is green: container, e2e, and format/lint/types/tests.

Needs fixing

  1. apps/web/src/theme.css:916-921: the prefers-reduced-transparency override never applies. Its :is(...) lists plain [data-plan-decisions-scroll], which gives a specificity of (0,3,1). The base rule's [data-plan-decisions-scroll]:not(.plan-decisions-content--empty) gives (0,4,1), so the base rule wins. With prefers-reduced-transparency: reduce emulated, the computed ::before background is still the 12-stop scrim. Fix: use the same selector list as the base rule, including :not(.plan-decisions-content--empty), or wrap both :is() lists in :where(). Please add a small check, for example in the new e2e via CDP Emulation.setEmulatedMedia with prefers-reduced-transparency.

Nits (optional)

  • The e2e covers the document and Chat only. Decisions and Build depend on --scroll-edge-inset being right. That inset is the part most likely to drift if --document-decisions-top-inset changes. A one-line probe of where the fade starts in Decisions would guard it.

Its selector list was less specific than the base rule's, so the solid-first
gradient never applied. Cover it, and the Decisions fade's clip edge, in e2e.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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