feat(app): feather the question dock's scroll edge (#59395)

This commit is contained in:
Kit Langton
2026-10-26 16:07:07 -07:00
committed by GitHub
Unverified
parent 82b572439c
commit 8a450706f0
4 changed files with 418 additions and 43 deletions
@@ -836,13 +836,16 @@ story("switches questions, folds and submits on the same frame with the dock's m
return frames
}, label)
// The last question's answers may be cut to the dock's height, so they come back to the height they rest at.
const resting = await question
.locator('[data-slot="question-options"]')
.evaluate((el) => Math.round(el.getBoundingClientRect().height))
const folded = await fold("Minimize question")
expect(folded.every((frame) => frame.height === 0 && frame.hidden && frame.animations === 0)).toBe(true)
const restored = await fold("Restore question")
expect(
restored.every(
(frame) => frame.height > 0 && frame.height === frame.list && !frame.hidden && frame.animations === 0,
),
restored.every((frame) => frame.height > 0 && frame.height === resting && !frame.hidden && frame.animations === 0),
).toBe(true)
// Submitting swaps the dock for the composer on one frame, with no closing flight.
@@ -1066,3 +1069,208 @@ story("toggles a lone many-answer question and sends it with Enter", async ({ mo
await page.keyboard.press("Enter")
await expect(question).toBeHidden()
})
const longList = async (mount: (id: string) => Promise<Locator>) => {
const component = await mount("session-question-switch--long-list")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
await expect(question.getByRole("checkbox").first()).toBeFocused()
// The answers box has opened to its first height once it stops changing.
const box = () => question.evaluate((root) => Math.round(root.getBoundingClientRect().height))
await expect.poll(async () => (await box()) === (await box())).toBe(true)
return question
}
const fit = (question: Locator) =>
question.evaluate((root) => {
const options = root.querySelector<HTMLElement>('[data-slot="question-options"]')!
const timeline = document.querySelector('[data-slot="session-timeline-scroll"] > .scroll-view__viewport')
const box = root.getBoundingClientRect()
return {
height: box.height,
// The whole dock, footer included, sits between the top of the timeline and the foot of the window.
within: box.top >= (timeline?.getBoundingClientRect().top ?? 0) - 0.5 && box.bottom <= innerHeight + 0.5,
scrolls: options.scrollHeight > options.clientHeight + 1,
top: options.scrollTop,
}
})
// Whether the whole of an answer's row shows, clear of the feathered edges.
const clear = (row: Locator) =>
row.evaluate((input) => {
const row = input.closest<HTMLElement>('[data-slot="question-option"]')!
const box = input.closest<HTMLElement>('[data-slot="question-options"]')!.getBoundingClientRect()
const at = row.getBoundingClientRect()
return at.top >= box.top + 20 - 0.5 && at.bottom <= box.bottom - 20 + 0.5
})
const feathered = (question: Locator) =>
question.locator('[data-slot="question-options"]').evaluate((options) => {
const mask = options.style.maskImage
// A gradient down the list may be written without its direction, which is the default.
return { top: /linear-gradient\((to bottom, )?transparent/.test(mask), bottom: mask.includes("to top") }
})
story("caps a long list of answers to the panel and scrolls them inside it", async ({ mount }) => {
const question = await longList(mount)
const rest = await fit(question)
expect(rest.within).toBe(true)
expect(rest.scrolls).toBe(true)
await expect(question.getByRole("checkbox", { name: /Footer/ })).not.toBeInViewport()
})
story("scrolls the highlighted answer into view with the keys", async ({ mount, page }) => {
const question = await longList(mount)
const footer = question.getByRole("checkbox", { name: /Footer/ })
await page.keyboard.press("End")
await page.keyboard.press("ArrowUp")
await expect(footer).toBeFocused()
expect(await clear(footer)).toBe(true)
await page.keyboard.press("Home")
await expect(question.getByRole("checkbox").first()).toBeFocused()
expect((await fit(question)).top).toBe(0)
})
story("feathers only the edges of the answers with more past them", async ({ mount }) => {
const question = await longList(mount)
// Scrolls to a share of the way down the list.
const edges = (share: number) =>
question.evaluate(async (root, share) => {
const options = root.querySelector<HTMLElement>('[data-slot="question-options"]')!
options.scrollTop = Math.round((options.scrollHeight - options.clientHeight) * share)
await new Promise((done) => options.addEventListener("scroll", done, { once: true }))
const mask = options.style.maskImage
// A gradient down the list may be written without its direction, which is the default.
return { top: /linear-gradient\((to bottom, )?transparent/.test(mask), bottom: mask.includes("to top") }
}, share)
expect(await edges(0.5)).toEqual({ top: true, bottom: true })
expect(await edges(1)).toEqual({ top: true, bottom: false })
expect(await edges(0)).toEqual({ top: false, bottom: true })
})
story("leaves a list that fits without feathered edges", async ({ mount }) => {
const component = await mount("session-question-switch--question-series")
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
await expect(question.getByRole("radio").first()).toBeFocused()
await expect
.poll(() => question.locator('[data-slot="question-options"]').evaluate((el) => el.style.maskImage))
.toBe("")
})
story(
"caps a long list to the panel in the app's layout, where the dock overflows a zero-height row",
async ({ mount }) => {
const question = await longList(mount)
// As in the session screen, the composer region moves into a zero-height row at the foot of the panel and overflows
// it upward, over the timeline.
await question.evaluate((root) => {
const dock = root.closest('[data-component="session-composer-dock"]')!
const row = document.createElement("div")
row.style.cssText =
"position: relative; display: flex; flex-direction: column; justify-content: flex-end; height: 0"
row.style.flexShrink = "0"
dock.replaceWith(row)
row.append(dock)
dispatchEvent(new Event("resize"))
})
// Measured against the empty row, the cap would fall to its 240px minimum.
await expect.poll(async () => (await fit(question)).height).toBeGreaterThan(400)
const rest = await fit(question)
expect(rest.within).toBe(true)
expect(rest.scrolls).toBe(true)
},
)
story("brings the picked answer back into view after switching questions", async ({ mount, page }) => {
const question = await longList(mount)
const footer = question.getByRole("checkbox", { name: /Footer/ })
const title = question.locator('[data-slot="question-header-title"]')
await page.keyboard.press("End")
await page.keyboard.press("ArrowUp")
await page.keyboard.press(" ")
await expect(footer).toBeChecked()
await page.keyboard.press("ArrowRight")
await expect(title).toHaveText("2 of 2 questions")
await page.keyboard.press("ArrowLeft")
await expect(title).toHaveText("1 of 2 questions")
await expect(footer).toBeFocused()
expect(await clear(footer)).toBe(true)
})
story("sizes the answers box to each question with the dock's motion off", async ({ mount, page }) => {
const question = await longList(mount)
const options = question.locator('[data-slot="question-options"]')
const rest = await fit(question)
expect(rest.scrolls).toBe(true)
expect(await feathered(question)).toEqual({ top: false, bottom: true })
await page.keyboard.press("ArrowRight")
await expect(question.getByRole("radio").first()).toBeFocused()
await expect.poll(() => feathered(question)).toEqual({ top: false, bottom: false })
expect(await options.evaluate((el) => el.style.height)).toBe("")
expect((await fit(question)).scrolls).toBe(false)
await page.keyboard.press("ArrowLeft")
await expect(question.getByRole("checkbox").first()).toBeFocused()
await expect.poll(() => feathered(question)).toEqual({ top: false, bottom: true })
expect((await fit(question)).height).toBeCloseTo(rest.height, 0)
})
story.describe("a long list with the question dock's motion on", () => {
story.beforeEach(({ page }) =>
page.addInitScript(() => localStorage.setItem("opencode.motion.v2", JSON.stringify({ "question.motion": 1 }))),
)
story("restores a long list without growing past where it rests", async ({ mount }) => {
const question = await longList(mount)
const rest = await fit(question)
await question.getByRole("button", { name: "Minimize question" }).click()
await expect(question.getByRole("button", { name: "Restore question" })).toBeVisible()
await expect
.poll(() =>
question.locator('[data-slot="question-options"]').evaluate((el) => el.getBoundingClientRect().height),
)
.toBe(0)
const frames = await question.evaluate(async (root) => {
const options = root.querySelector<HTMLElement>('[data-slot="question-options"]')!
root.querySelector<HTMLElement>('[data-slot="question-header-actions"] > button:last-of-type')!.click()
return Array.from({ length: 45 }).reduce<Promise<{ height: number; asked: number; given: number }[]>>(
async (list) => {
const frames = await list
await new Promise((done) => requestAnimationFrame(done))
return [
...frames,
{
height: root.getBoundingClientRect().height,
asked: parseFloat(options.style.height) || 0,
given: options.getBoundingClientRect().height,
},
]
},
Promise.resolve([]),
)
})
const heights = frames.map((frame) => frame.height)
expect(heights.every((height, i) => i === 0 || height >= heights[i - 1]! - 0.5)).toBe(true)
expect(Math.max(...heights)).toBeLessThanOrEqual(rest.height + 0.5)
expect(heights.at(-1)).toBeCloseTo(rest.height, 0)
// The answers box is never asked to open past the room it is given, which is what made it snap back at the end.
expect(frames.every((frame) => frame.asked <= frame.given + 0.5)).toBe(true)
})
})
@@ -89,3 +89,68 @@ export const QuestionSeries = {
/>
),
}
// A pick-any question with more answers than the dock has room for, then a short one.
export const LongList = {
render: () => (
<SessionPreview
title="Polish the docs site"
description="opencode · docs"
document={questionPendingDocument}
request={{
type: "question",
value: {
...activeQuestionRequest,
fields: [
{
key: "details",
type: "multiselect",
title: "Details",
description: "Which should the builder add next? Pick any.",
options: [
{
value: "typography",
label: "All typography (Recommended)",
description:
"Hanging punctuation, tabular figures, typesetting fixes, one label style, accent selection",
},
{ value: "links", label: "Heading links", description: "Hover shows #, click copies the section link" },
{
value: "contents",
label: "Sticky contents list",
description: "Section list in the margin on wide screens, highlights where you are",
},
{ value: "sidenotes", label: "Sidenotes", description: "Margin notes on desktop, inline on phone" },
{ value: "glossary", label: "Glossary terms", description: "Dotted underline, definition on hover" },
{ value: "fade", label: "Scroll fade", description: "Fade the edge of anything that scrolls sideways" },
{ value: "code", label: "Highlighted code lines", description: "Tint the lines that matter" },
{ value: "verdict", label: "Verdict line", description: "Answer or decision first, in the header" },
{
value: "checked",
label: "Checked / not checked",
description: "Marker for verified vs unverified claims",
},
{
value: "diagram",
label: "Diagram interaction",
description: "Hover highlights connections, click enlarges",
},
{ value: "footer", label: "Footer", description: "Source path to copy, plus the date it was made" },
],
},
{
key: "ship",
type: "string",
title: "Ship",
description: "Ship them in one pull request?",
options: [
{ value: "one", label: "One pull request" },
{ value: "split", label: "One per detail" },
],
},
],
},
}}
/>
),
}
@@ -153,6 +153,9 @@ const footer = {
const minimizing: DurationOptions = { duration: 0.3, bounce: 0 }
// How far a list that scrolls fades out at an edge with more answers past it.
const feather = 24
// Where a copy of the question sits on screen relative to its place at rest, how much of it shows, and how close it is
// to sharp and full size.
type Pose = { x: number; shown: number; settle: number }
@@ -163,8 +166,9 @@ const rest: Pose = { x: 0, shown: 1, settle: 1 }
// to 1 along the way.
type Track = { from: Pose; to: number; spring: Spring }
// The question being left: inert copies of its text and its answers, and how tall they are.
type Ghost = Track & { tab: number; els: HTMLElement[]; details: number; list: number }
// The question being left: inert copies of its text and its answers, how tall they are, and how far its answers were
// scrolled.
type Ghost = Track & { tab: number; els: HTMLElement[]; details: number; list: number; scroll: number }
type QuestionField = FormStringField | FormMultiselectField
@@ -343,30 +347,53 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
setStore("answers", store.tab, next ? [next] : [])
}
// The dock's heights from its last measure: at most, and of everything but the question text and the answers box.
// The answers box takes what the question text leaves, and opens only to the room it will have at rest, so it never
// grows past it and springs back.
const limit = { max: Infinity, chrome: 0 }
const cap = () => limit.max - limit.chrome
// The room the answers box has now, while the question text springs to its height, and once it is there.
const room = () => cap() - boxes.details.value
const settled = (details = content.details) => cap() - details
const scroll = { top: 0, view: 0 }
const measure = () => {
if (!root) return
const scroller = document.querySelector('[data-slot="session-timeline-scroll"] > .scroll-view__viewport')
const head = scroller instanceof HTMLElement ? scroller.firstElementChild : undefined
// The timeline starts under its sticky header, or at its own top when it has none.
const top =
head instanceof HTMLElement && head.classList.contains("sticky") ? head.getBoundingClientRect().bottom : 0
if (!top) {
root.style.removeProperty("--question-prompt-max-height")
return
}
head instanceof HTMLElement && head.classList.contains("sticky")
? head.getBoundingClientRect().bottom
: scroller instanceof HTMLElement
? scroller.getBoundingClientRect().top
: 0
const dock = root.closest('[data-component="session-composer-dock"]')
// The dock sits at the foot of the column it shares with the timeline, in the app inside a zero-height row that it
// overflows upward. Grown past the column it spills out of it, so the column bounds it, not the dock's own edge.
const column = bounds(dock?.parentElement)
const bottom = Math.min(window.innerHeight, column?.bottom ?? window.innerHeight)
if (!(dock instanceof HTMLElement)) return
// Whatever the dock holds besides the question, above it or below, takes from the same height.
const around =
dock instanceof HTMLElement
? Math.max(0, dock.getBoundingClientRect().height - root.getBoundingClientRect().height)
: 0
const dockBottom = dock.getBoundingClientRect().bottom
const below = Math.max(0, dockBottom - root.getBoundingClientRect().bottom)
const gap = 8
const max = Math.max(240, Math.floor(dockBottom - top - gap - below))
root.style.setProperty("--question-prompt-max-height", `${max}px`)
const height = bottom - Math.max(0, top, column?.top ?? 0) - around
limit.max = Math.max(240, Math.floor(height - gap))
// Minimized, the answers box is gone, so only an open dock is measured.
if (!store.minimized && optionsRef && detailsFrame)
limit.chrome = root.offsetHeight - optionsRef.offsetHeight - detailsFrame.offsetHeight
root.style.setProperty("--question-prompt-max-height", `${limit.max}px`)
if (content.list > 0) boxes.list.snap(Math.min(content.list, settled()))
paint()
}
const clamp = (i: number) => Math.max(0, Math.min(count() - 1, i))
@@ -389,7 +416,28 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
if (store.editing) return
const el = next === options().length ? customRef : optsRef[next]
el?.focus(how)
el?.focus({ preventScroll: true })
if (!how?.preventScroll) reveal(rowAt(next))
}
const rowAt = (i: number) => (i === options().length ? customRow : optsRef[i])
// A list that scrolls follows the highlighted answer at once, keeping it clear of the feathered edges.
const reveal = (row: HTMLElement | undefined, view = optionsRef?.clientHeight ?? 0) => {
if (!row || !optionsRef || !listRef) return
const top = row.offsetTop + listRef.offsetTop
const edge = Math.min(feather, view / 4)
optionsRef.scrollTop = Math.min(top - edge, Math.max(optionsRef.scrollTop, top + row.offsetHeight + edge - view))
scroll.top = optionsRef.scrollTop
pin()
}
// The leaving copy of the answers stays where they were on screen however far the live list scrolls under it.
const pin = () => {
const answers = ghost?.els[1]
if (ghost && answers) answers.style.top = `${scroll.top - ghost.scroll}px`
}
// Measuring reads layout, so it waits while the dock's own springs are moving and runs once they come to rest.
@@ -404,7 +452,7 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
}
onMount(() => {
requestMeasure()
measure()
makeEventListener(window, "resize", requestMeasure)
@@ -426,19 +474,28 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
if (!list || !details) return
const observer = new ResizeObserver((entries) => {
const fresh = { details: content.details === 0, list: content.list === 0 }
entries.forEach((entry) => {
const size = entry.borderBoxSize[0]?.blockSize
if (size === undefined) return
const key = entry.target === list ? "list" : "details"
const spring = boxes[key]
if (content[key] === 0 || !questionMotion()) spring.snap(size)
else tune(spring, resize()).set(size)
content[key] = size
if (key === "list") setStore("listHeight", size)
})
// The answers box opens only as far as the room the new question text leaves it at rest.
;(["details", "list"] as const).forEach((key) => {
const spring = boxes[key]
const size = key === "list" ? Math.min(content.list, settled()) : content.details
if (spring.target === size) return
if (fresh[key] || !questionMotion()) spring.snap(size)
else tune(spring, resize()).set(size)
})
wake()
})
@@ -451,7 +508,7 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
createEffect(() => {
const el = highlightRef
const index = store.focus
const row = index === options().length ? customRow : optsRef[index]
const row = rowAt(index)
void store.listHeight
if (!el || !row || !listRef) return
@@ -755,9 +812,10 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
...track(live, -dir * reach, moving, options, v.launch()),
tab: store.tab,
// The answers box scrolls back to the top for the new question, so the copy keeps its place on screen.
els: [copy(detailsRef, detailsFrame), copy(listRef, optionsTrack, -(optionsRef?.scrollTop ?? 0))],
els: [copy(detailsRef, detailsFrame), copy(listRef, optionsTrack, -scroll.top)],
details: content.details,
list: content.list,
scroll: scroll.top,
}
into = back
? track(leaving(back, v, into), 0, speed(back), options, v.launch())
@@ -789,18 +847,26 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
if (detailsFrame && content.details > 0) {
const shown = boxes.details.value
detailsFrame.style.height = sized ? `${shown.toFixed(2)}px` : ""
mask(detailsFrame, Math.max(content.details, ghost?.details ?? 0) - shown, shown, sliding)
mask(detailsFrame, 0, Math.max(content.details, ghost?.details ?? 0) - shown, shown, sliding)
}
if (optionsRef && content.list > 0) {
const shown = boxes.list.value
optionsRef.style.height = sized ? `${(shown * (1 - folded)).toFixed(2)}px` : folded > 0 ? "0px" : ""
mask(optionsRef, Math.max(content.list, ghost?.list ?? 0) - shown, shown, sliding)
// Without motion the dock's max height caps the answers box, which scrolls inside the room it leaves.
const shown = sized
? Math.min(boxes.list.value, room()) * (1 - folded)
: folded > 0
? 0
: Math.min(content.list, room())
optionsRef.style.height = sized ? `${shown.toFixed(2)}px` : folded > 0 ? "0px" : ""
scroll.view = shown
feathers()
}
if (optionsRef) {
optionsRef.style.opacity = folded > 0 ? (1 - folded).toFixed(4) : ""
optionsRef.style.visibility = folded > 0.98 ? "hidden" : ""
// Restoring shows the answers again at once, so the highlighted one can take focus.
optionsRef.style.visibility = folded > 0.98 && boxes.folded.target > 0 ? "hidden" : ""
}
if (chevron) chevron.style.transform = folded > 0 ? `rotate(${(folded * 180).toFixed(2)}deg)` : ""
@@ -810,6 +876,15 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
paintFooter()
}
// The answers fade out at whichever edge has more of them past it, while they open or scroll.
// It reads where the list is scrolled from the last scroll, never from layout, since it runs every frame.
const feathers = () => {
if (!optionsRef) return
const above = scroll.top
const shown = scroll.view
mask(optionsRef, above, Math.max(content.list, ghost?.list ?? 0) - above - shown, shown, !!(into || ghost))
}
const paintFooter = () => {
if (backBox) {
const shown = unit(foot.back.value)
@@ -924,7 +999,7 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
})
optsRef.length = options().length
if (optionsRef) optionsRef.scrollTop = 0
if (optionsRef) optionsRef.scrollTop = scroll.top = 0
const reduced = !questionMotion()
@@ -951,9 +1026,13 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
return
}
// The answers box is still springing to the new list's height, so focusing must not scroll it.
// The answers box is still springing to the new list's height, so the answer that takes focus comes into view of
// the height it will rest at.
focus(pickFocus(tab), { preventScroll: true })
if (optionsRef && detailsRef)
reveal(rowAt(store.focus), Math.min(optionsRef.scrollHeight, settled(detailsRef.offsetHeight)))
wake()
}
@@ -993,7 +1072,12 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
if (sending()) return
setStore("minimized", false)
fold(0)
focus(pickFocus())
paint()
// The answers box is still opening, so its answer comes into view of the height it will rest at.
const index = pickFocus()
focus(index, { preventScroll: true })
if (detailsRef) reveal(rowAt(index), Math.min(content.list, settled(detailsRef.offsetHeight)))
}
const fold = (target: number) => {
@@ -1189,6 +1273,11 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
aria-label={question()?.question}
inert={store.minimized}
aria-hidden={store.minimized ? "true" : undefined}
onScroll={(event) => {
scroll.top = event.currentTarget.scrollTop
pin()
feathers()
}}
// Space already acted on key down; its release must not click the button again.
onKeyUp={(event) => {
if (event.key !== " ") return
@@ -1397,13 +1486,15 @@ function place(el: HTMLElement, look: Look) {
// While a switch is under way, content sliding into a box's side gutters fades out there. While a box is shorter than
// what it holds, its bottom fades instead of ending hard, over no more than a third of the box.
function mask(el: HTMLElement, cut: number, shown: number, sliding: boolean) {
const bottom = Math.min(24, cut, shown / 3)
function mask(el: HTMLElement, above: number, below: number, shown: number, sliding: boolean) {
const top = Math.min(feather, above, shown / 3)
const bottom = Math.min(feather, below, shown / 3)
const layers = [
sliding
? "linear-gradient(to right, transparent, #000 var(--question-gutter), #000 calc(100% - var(--question-gutter)), transparent)"
: "",
top > 0.5 ? `linear-gradient(to bottom, transparent, #000 ${top.toFixed(2)}px)` : "",
bottom > 0.5 ? `linear-gradient(to top, transparent, #000 ${bottom.toFixed(2)}px)` : "",
].filter(Boolean)
@@ -1433,3 +1524,11 @@ function copy(el: HTMLElement, host: HTMLElement, top = 0) {
return clone
}
// The nearest of `el` and its ancestors that has a height.
function bounds(el: Element | null | undefined): DOMRect | undefined {
if (!el) return undefined
const rect = el.getBoundingClientRect()
return rect.height >= 1 ? rect : bounds(el.parentElement)
}
+11 -8
View File
@@ -285,14 +285,17 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void
"hidden md:flex": state.reviewOpened,
}}
>
<div data-slot="story-timeline" class="min-h-0 flex-1 overflow-y-auto py-6">
<SessionTimeline
document={props.document}
timelineDetail={props.timelineDetail}
editToolDefaultOpen
shellToolDefaultOpen
class="mx-auto w-full max-w-[840px]"
/>
{/* The padding is inside the scroller, so a tall dock below can shrink the timeline to nothing. */}
<div data-slot="story-timeline" class="min-h-0 flex-1 overflow-y-auto">
<div class="py-6">
<SessionTimeline
document={props.document}
timelineDetail={props.timelineDetail}
editToolDefaultOpen
shellToolDefaultOpen
class="mx-auto w-full max-w-[840px]"
/>
</div>
</div>
<SessionComposerRegion
controller={region}