fix(web): keep the staged attachment band in sight as the prompt scrolls

The scroller sat on .home-hero__prompt-flow, which is also the attachment
band's parent — so scrolling a long prompt carried the band away with the
text, and an attachment the user had just staged left the screen while
they were still writing about it.

Move the scroll down to a new .home-hero__prompt-surface holding the
prompt and its lead chip and nothing else. That element keeps the
position: relative the out-of-flow chip needs as a containing block, so
the chip still scrolls with the text it leads rather than pinning itself
to the top of the box.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
wangchenglong
2026-09-04 15:43:42 +08:00
co-authored by Claude Opus 5
parent d3b251de04
commit 2981a2025f
2 changed files with 68 additions and 8 deletions
+17 -8
View File
@@ -620,15 +620,16 @@
so the SECOND line would still be pushed right — so `.is-chip-inline` takes
the row out of flow and indents just the first line past it, using the two
custom props HomeHero measures (`--home-hero-chip-w`, and
`--home-hero-chip-text-left`, the chip's border+padding). Also owns the
prompt's scroll (moved off `.composer-input-editor`) so the out-of-flow chip
scrolls together with the text it leads. */
`--home-hero-chip-text-left`, the chip's border+padding).
It does NOT scroll: the attachment band above the prompt is its child, and a
scroller here carried that band away with the text — an attachment the user
staged has to stay in sight while they write about it. The scroll sits on
`.home-hero__prompt-surface` below, which holds the prompt and the lead chip
and nothing else. */
.home-hero__prompt-flow {
position: relative;
display: block;
min-width: 0;
max-height: var(--home-hero-prompt-max-height, 180px);
overflow-y: auto;
}
.home-hero__active {
display: flex;
@@ -653,7 +654,9 @@
display: none;
}
/* Staged files + the remaining context chips keep their own band above the
prompt; only the template chip below leads the text. */
prompt; only the template chip below leads the text. The band sits OUTSIDE
the prompt's scroller (see `.home-hero__prompt-surface`), so a long prompt
scrolls underneath it and what the user attached stays on screen. */
.home-hero__prompt-flow > .home-hero__active-band {
margin-bottom: var(--spacing-8);
}
@@ -727,8 +730,14 @@
/* The template chip's own line, anchored to the prompt surface (NOT the flow —
the attachment row above must be able to push it down). Stays in flow when
the inline treatment is off, so it simply sits above the text. */
/* The prompt's own scroller. `position: relative` is load-bearing twice: it is
the containing block for the out-of-flow lead chip, and because that
containing block IS this scroller, the chip scrolls with the text it leads
instead of pinning itself to the top of the box. */
.home-hero__prompt-surface {
position: relative;
max-height: var(--home-hero-prompt-max-height, 180px);
overflow-y: auto;
}
.home-hero__lead-chip {
display: flex;
@@ -796,8 +805,8 @@
display: block;
min-width: 0;
}
/* Scroll moved to .home-hero__prompt-flow (above) — keeping it here would fence
the text off from the float in its own formatting context. */
/* Scroll moved up to `.home-hero__prompt-surface` — keeping it here would fence
the text off from the out-of-flow chip in its own formatting context. */
.home-hero__prompt-flow .composer-input-editor {
max-height: none;
overflow: visible;
@@ -0,0 +1,51 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const homeHeroCss = readFileSync(
new URL('../../src/styles/home/home-hero.css', import.meta.url),
'utf8',
);
function cssDeclarations(selector: string): string {
const blocks: string[] = [];
const rulePattern = /([^{}]+)\{([^}]*)\}/g;
const cssWithoutComments = homeHeroCss.replace(/\/\*[\s\S]*?\*\//g, '');
let match: RegExpExecArray | null;
while ((match = rulePattern.exec(cssWithoutComments)) !== null) {
const selectors = (match[1] ?? '').split(',').map((item) => item.trim());
if (selectors.includes(selector)) blocks.push(match[2] ?? '');
}
if (blocks.length === 0) throw new Error(`Missing CSS block for ${selector}`);
return blocks.join('\n');
}
/**
* The staged-attachment band and the prompt sit in the same block
* (`.home-hero__prompt-flow`), and only ONE of them may scroll. When the flow
* owned the scroll, a prompt long enough to overflow carried the band up and
* out of the card with it — the user lost sight of the file they had just
* attached exactly while they were writing about it.
*/
describe('HomeHero staged attachment band', () => {
it('scrolls the prompt, never the band above it', () => {
const flow = cssDeclarations('.home-hero__prompt-flow');
expect(flow).not.toMatch(/overflow-y:\s*(auto|scroll)/);
expect(flow).not.toMatch(/max-height:/);
const surface = cssDeclarations('.home-hero__prompt-surface');
expect(surface).toMatch(/(?:^|[;\n])\s*overflow-y:\s*auto\s*;/);
expect(surface).toMatch(/max-height:\s*var\(--home-hero-prompt-max-height/);
});
// The lead chip is taken out of flow to sit beside the prompt's first line.
// Its containing block has to BE the scroller, or it would pin itself to the
// top of the box while the line it leads scrolled away underneath.
it('keeps the out-of-flow lead chip inside the prompt scroller', () => {
expect(cssDeclarations('.home-hero__prompt-surface')).toMatch(
/(?:^|[;\n])\s*position:\s*relative\s*;/,
);
expect(cssDeclarations('.home-hero__prompt-flow.is-chip-inline .home-hero__lead-chip')).toMatch(
/(?:^|[;\n])\s*position:\s*absolute\s*;/,
);
});
});