diff --git a/.fork/customizations.yaml b/.fork/customizations.yaml index 541afa84fc1..db8ab51a35e 100644 --- a/.fork/customizations.yaml +++ b/.fork/customizations.yaml @@ -757,6 +757,13 @@ focus. 14 is a deliberate divergence from the drawn 12, and the pills are 12px where the designs draw 10 — the guard pins both so a later reconciliation against Figma does not quietly "correct" them back. + Desktop moves the max-height scrollport onto data-fork-composer-prompt + rather than the contenteditable: the tight 14/16 line box lets Geist ink + inflate scrollHeight on the editor, so upstream's max-h + overflow-y:auto + there painted a phantom thumb on a single line. The wrapper owns the + 12.5rem cap (upstream max-h-50); the editor stays unclamped and clips its + ink. Scoped to the same >=40rem media as the line box — below that the + prompt stays on leading-relaxed and upstream's editor scroll is left alone. The prompt and its placeholder are set by one rule and must stay that way: they are two elements stacked exactly on top of each other, and any disagreement about size or leading puts the caret beside the text it diff --git a/.fork/notes/FORK-CUSTOMIZATION-DECISIONS.md b/.fork/notes/FORK-CUSTOMIZATION-DECISIONS.md index 30a3ec2dc5c..4d38b5bac10 100644 --- a/.fork/notes/FORK-CUSTOMIZATION-DECISIONS.md +++ b/.fork/notes/FORK-CUSTOMIZATION-DECISIONS.md @@ -175,3 +175,13 @@ Related deep-dives that predate this file and stay where they are: example, upstream adding a second child inside the frame) keeps every guard green. Selecting through `data-fork-composer-surface` rather than a positional child selector closes the known instance, not the class. +- The phantom single-line scrollbar under the tight 14/16 desktop line box first got an imperative + fix: `overflow-y: hidden` on the contenteditable, flipped to `auto` via + `data-composer-prompt-scrollable` when `scrollHeight` cleared `max-h`. That needed a pure + predicate, a sync helper, and three effect sites on the wrap latch — and still stranded a + clamped→clamped draft switch with scrolling off (no resize, no second prompt effect). The + shipped answer deletes that machine: on `width >= 40rem` the existing + `data-fork-composer-prompt` wrapper owns `max-height: 12.5rem` + `overflow-y: auto`, and the + editor stays unclamped with `overflow-y: hidden` so Geist ink cannot inflate a scroll + container. The guard's `not.toContain("data-composer-prompt-scrollable")` is a regression fence + against bringing the toggle back, not an unexplained ban. diff --git a/apps/web/src/__fork_guards__/cssRules.ts b/apps/web/src/__fork_guards__/cssRules.ts index c82568a9d2d..fba73b6bee2 100644 --- a/apps/web/src/__fork_guards__/cssRules.ts +++ b/apps/web/src/__fork_guards__/cssRules.ts @@ -12,11 +12,12 @@ * * So: strip comments, then walk the braces, and return only leaf rules (blocks * whose body contains no further block). Every selector a fork guard cares - * about is a leaf; the at-rules that are not are exactly the ones to skip. + * about is a leaf; the at-rules that wrap them are recorded on `atRules` so a + * guard can pin media scoping without a coincidence-detector regex. * * This is not a CSS parser and does not want to be. It is enough to answer - * "which selector does this declaration sit under", which is the only question - * the guards ask. + * "which selector does this declaration sit under" and "which at-rules wrap + * it", which are the only questions the guards ask. */ export interface CssRule { @@ -24,6 +25,8 @@ export interface CssRule { readonly selector: string; /** The declarations, verbatim. */ readonly body: string; + /** Enclosing at-rule headers, outermost first (e.g. `@media (width >= 40rem)`). */ + readonly atRules: ReadonlyArray; } function stripComments(css: string): string { @@ -32,31 +35,58 @@ function stripComments(css: string): string { return css.replace(/\/\*[\s\S]*?\*\//gu, (match) => " ".repeat(match.length)); } -export function cssRules(css: string): ReadonlyArray { - const source = stripComments(css); - const rules: CssRule[] = []; - - for (let index = 0; index < source.length; index += 1) { - if (source[index] !== "{") continue; +function matchingClose(source: string, open: number, end: number): number { + let depth = 1; + for (let index = open + 1; index < end; index += 1) { + const char = source[index]; + if (char === "{") depth += 1; + else if (char === "}") { + depth -= 1; + if (depth === 0) return index; + } + } + return -1; +} - const close = source.indexOf("}", index); - if (close < 0) break; +function parseBlock( + source: string, + start: number, + end: number, + atRules: ReadonlyArray, +): CssRule[] { + const rules: CssRule[] = []; + let index = start; - const body = source.slice(index + 1, close); - // A nested block: this `{` opens an at-rule, so its "body" is another - // selector. Step inside rather than recording it. - if (body.includes("{")) continue; + while (index < end) { + const open = source.indexOf("{", index); + if (open < 0 || open >= end) break; const previousBoundary = Math.max( - source.lastIndexOf("}", index - 1), - source.lastIndexOf("{", index - 1), + start - 1, + source.lastIndexOf("}", open - 1), + source.lastIndexOf("{", open - 1), ); - rules.push({ - selector: source.slice(previousBoundary + 1, index).trim(), - body, - }); - index = close; + const header = source.slice(previousBoundary + 1, open).trim(); + const close = matchingClose(source, open, end); + if (close < 0) break; + + const body = source.slice(open + 1, close); + if (header.startsWith("@")) { + rules.push(...parseBlock(source, open + 1, close, [...atRules, header])); + } else if (!body.includes("{")) { + rules.push({ selector: header, body, atRules }); + } else { + // Nested non-at block (not used by theme.custom.css today). Step inside + // without recording the outer selector, same as the previous walker. + rules.push(...parseBlock(source, open + 1, close, atRules)); + } + index = close + 1; } return rules; } + +export function cssRules(css: string): ReadonlyArray { + const source = stripComments(css); + return parseBlock(source, 0, source.length, []); +} diff --git a/apps/web/src/__fork_guards__/forkComposerShell.test.ts b/apps/web/src/__fork_guards__/forkComposerShell.test.ts index 2885254eebe..6aeeb8d2703 100644 --- a/apps/web/src/__fork_guards__/forkComposerShell.test.ts +++ b/apps/web/src/__fork_guards__/forkComposerShell.test.ts @@ -37,15 +37,17 @@ const controlRow = readSibling("../custom/ComposerControlRow.tsx"); describe("fork guard: fork-composer-shell", () => { it("keeps the box, send and stop hooks the stylesheet hangs off", () => { - // Every visual decision below is keyed to one of these four attributes. Lose - // one in a rebase and the CSS still compiles, still ships, and matches - // nothing. + // Every visual decision below is keyed to one of these attributes. Lose one + // in a rebase and the CSS still compiles, still ships, and matches nothing. expect(chatComposer, "ChatComposer lost data-fork-composer-box").toContain( 'data-fork-composer-box="true"', ); expect(chatComposer, "ChatComposer lost the density attribute").toContain( "data-fork-composer-density={composerDensity}", ); + expect(chatComposer, "ChatComposer lost the prompt scrollport hook").toContain( + 'data-fork-composer-prompt="true"', + ); expect(primaryActions, "send button lost its fork hook").toContain( 'data-fork-composer-action="send"', ); @@ -182,6 +184,48 @@ describe("fork guard: fork-composer-shell", () => { expect(theme).toMatch(/\[data-testid="composer-editor"\][\s\S]{0,160}min-height:\s*0/u); }); + it("moves the desktop prompt scrollport onto the wrapper, not the editor", () => { + // The 14/16 line box lets Geist ink inflate scrollHeight on the + // contenteditable, so upstream's max-h + overflow-y:auto there paints a + // phantom thumb. The wrapper owns the cap; the editor clips ink and stays + // unclamped. Scoped to the same >=40rem media as the line box — below that + // upstream's editor scroll behaviour is correct and must stay alone. + // + // Routed through cssRules (with atRules) rather than a bare @media…[\s\S] + // regex: theme.custom.css has one media query today, so a prefix match was + // satisfied by any placement within ~1200 chars of it — including after the + // block's closing brace. Hoisting these rules out of the media stayed green. + expect(chatComposer).toContain('data-fork-composer-prompt="true"'); + const rules = cssRules(theme); + const inDesktopLineBoxMedia = (rule: (typeof rules)[number]) => + rule.atRules.some((at) => /@media\s*\(\s*width\s*>=\s*40rem\s*\)/u.test(at)); + + const scrollport = rules.find( + (rule) => + inDesktopLineBoxMedia(rule) && + rule.selector.includes("[data-fork-composer-prompt]") && + !rule.selector.includes('[data-testid="composer-editor"]'), + ); + expect(scrollport, "desktop prompt scrollport rule missing or unscoped").toBeDefined(); + expect(scrollport!.body).toMatch(/max-height:\s*12\.5rem/u); + expect(scrollport!.body).toMatch(/overflow-y:\s*auto/u); + + const editor = rules.find( + (rule) => + inDesktopLineBoxMedia(rule) && + rule.selector.includes("[data-fork-composer-prompt]") && + rule.selector.includes('[data-testid="composer-editor"]'), + ); + expect(editor, "desktop editor unclamp rule missing or unscoped").toBeDefined(); + expect(editor!.body).toMatch(/max-height:\s*none/u); + expect(editor!.body).toMatch(/overflow-y:\s*hidden/u); + + // An imperative attribute toggle is the failure mode this replaces — see + // .fork/notes/FORK-CUSTOMIZATION-DECISIONS.md#fork-composer-shell. + expect(theme).not.toContain("data-composer-prompt-scrollable"); + expect(chatComposer).not.toContain("data-composer-prompt-scrollable"); + }); + it("squares the send and stop buttons, flattens release send, and reddens stop", () => { expect(theme).toMatch(/\[data-fork-composer-action\]\s*\{[\s\S]{0,200}border-radius:\s*6px/u); // Flat (no stage art) is pure white / black icon in dark mode only; light diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 14fec2e0b94..0daacdd18aa 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2732,7 +2732,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {/* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */}
-
+
{/* fork:end fork-composer-shell */} = 40rem) { :root[data-fork="noahhendrickson-t3code"] [data-fork-composer-box] @@ -377,6 +385,18 @@ wrapped prompt; the geometry is the thing being protected. */ line-height: 16px; } + + :root[data-fork="noahhendrickson-t3code"] [data-fork-composer-prompt] { + max-height: 12.5rem; + overflow-y: auto; + } + + :root[data-fork="noahhendrickson-t3code"] + [data-fork-composer-prompt] + [data-testid="composer-editor"] { + max-height: none; + overflow-y: hidden; + } } /* The in-box pills and the control row below it share one 24px ghost treatment: