diff --git a/.fork/customizations.yaml b/.fork/customizations.yaml index 417742ef8faf..b791c9c62d7e 100644 --- a/.fork/customizations.yaml +++ b/.fork/customizations.yaml @@ -938,8 +938,16 @@ 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. + ink. The editor's siblings — Lexical's placeholder and spacer — carry + overflow clip on both axes (clip, not hidden, so the pure-paint + placeholder never becomes a scroll box; both axes, so an unbreakable + approval-detail placeholder can neither eat its own 16px line with a + horizontal scrollbar nor propagate one onto the wrapper), because the + placeholder's overhanging line box otherwise reaches the wrapper's + scrollable overflow through its abspos box and paints the thumb on an + empty prompt only. 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/apps/web/src/__fork_guards__/forkComposerShell.test.ts b/apps/web/src/__fork_guards__/forkComposerShell.test.ts index 6aeeb8d2703c..0ef871e6b806 100644 --- a/apps/web/src/__fork_guards__/forkComposerShell.test.ts +++ b/apps/web/src/__fork_guards__/forkComposerShell.test.ts @@ -210,16 +210,44 @@ describe("fork guard: fork-composer-shell", () => { expect(scrollport!.body).toMatch(/max-height:\s*12\.5rem/u); expect(scrollport!.body).toMatch(/overflow-y:\s*auto/u); + // The `~ div` exclusion is load-bearing: the sibling clip rule below + // satisfies the other three predicates too, so without it this `find` + // resolves to the unclamp rule only because that rule happens to sit + // earlier in the file. Reorder the two blocks and `max-height: none` + // starts interrogating the sibling rule and fails for a reason unrelated + // to the invariant — the same class of wrong-thing-satisfied-it bug the + // @media prefix match already cost this file. const editor = rules.find( (rule) => inDesktopLineBoxMedia(rule) && rule.selector.includes("[data-fork-composer-prompt]") && - rule.selector.includes('[data-testid="composer-editor"]'), + rule.selector.includes('[data-testid="composer-editor"]') && + !/~\s*div/u.test(rule.selector), ); 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); + // The editor's clip does not cover its siblings: the placeholder's line + // box overhangs its inset-0 box by a pixel at 14/16, and abspos overflow + // propagates to the wrapper's scrollable overflow — a thumb that appears + // only while the prompt is empty. Clip specifically, not hidden: hidden + // computes the other axis's visible to auto, and an unbreakable + // approval-detail placeholder would then hand the 16px line to a + // horizontal scrollbar. Both axes, because a visible x would propagate + // that same text sideways into the wrapper's auto overflow-x instead. + // (The editor tolerates hidden only because pre-wrap + wrap-break-word + // forecloses horizontal overflow.) + const siblings = rules.find( + (rule) => + inDesktopLineBoxMedia(rule) && + rule.selector.includes("[data-fork-composer-prompt]") && + /\[data-testid="composer-editor"\]\s*~\s*div/u.test(rule.selector), + ); + expect(siblings, "desktop placeholder clip rule missing or unscoped").toBeDefined(); + expect(siblings, "the unclamp and clip rules must stay distinct").not.toBe(editor); + expect(siblings!.body).toMatch(/overflow:\s*clip/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"); diff --git a/apps/web/src/theme.custom.css b/apps/web/src/theme.custom.css index e81b28a5b81f..8d7bf7853a3d 100644 --- a/apps/web/src/theme.custom.css +++ b/apps/web/src/theme.custom.css @@ -397,6 +397,31 @@ max-height: none; overflow-y: hidden; } + + /* The placeholder (and Lexical's zero-height spacer) are the editor's + siblings, outside its clip. At 14/16 the placeholder's line box is a pixel + taller than its inset-0 box, and that pixel propagates through the + absolutely-positioned box into the wrapper's scrollable overflow — a + phantom thumb that shows only while the prompt is empty, because typing + unmounts the placeholder. Typed text already paints without that pixel. + + `clip`, not `hidden`: setting an axis to hidden computes the other + axis's `visible` to `auto`, which turns this pure-paint element into a + scroll box — and the approval-state placeholder is server-supplied text + (paths, URLs, SHAs) with no break opportunity, so the reserved horizontal + scrollbar would eat the entire 16px line. And both axes, not just y: left + visible, that same unbreakable text propagates sideways overflow into the + wrapper — whose overflow-x computes to auto alongside its overflow-y — + and paints a 16px horizontal scrollbar there instead. clip contains the + element on both axes without creating a scroll container anywhere. The + editor above gets away with hidden only because pre-wrap + + wrap-break-word means it can never overflow horizontally. */ + :root[data-fork="noahhendrickson-t3code"] + [data-fork-composer-prompt] + [data-testid="composer-editor"] + ~ div { + overflow: clip; + } } /* The in-box pills and the control row below it share one 24px ghost treatment: