diff --git a/apps/web/src/styles/home/home-hero.css b/apps/web/src/styles/home/home-hero.css index 2ebc25a142..06c5a64fca 100644 --- a/apps/web/src/styles/home/home-hero.css +++ b/apps/web/src/styles/home/home-hero.css @@ -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; diff --git a/apps/web/tests/styles/home-hero-attachment-band.test.ts b/apps/web/tests/styles/home-hero-attachment-band.test.ts new file mode 100644 index 0000000000..0f5f853ef6 --- /dev/null +++ b/apps/web/tests/styles/home-hero-attachment-band.test.ts @@ -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*;/, + ); + }); +});