Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .fork/customizations.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions .fork/notes/FORK-CUSTOMIZATION-DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
74 changes: 52 additions & 22 deletions apps/web/src/__fork_guards__/cssRules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,21 @@
*
* 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 {
/** Everything between the previous block and this one's `{`, trimmed. */
readonly selector: string;
/** The declarations, verbatim. */
readonly body: string;
/** Enclosing at-rule headers, outermost first (e.g. `@media (width >= 40rem)`). */
readonly atRules: ReadonlyArray<string>;
}

function stripComments(css: string): string {
Expand All @@ -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<CssRule> {
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<string>,
): 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<CssRule> {
const source = stripComments(css);
return parseBlock(source, 0, source.length, []);
}
50 changes: 47 additions & 3 deletions apps/web/src/__fork_guards__/forkComposerShell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"',
);
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2732,7 +2732,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)

{/* fork:begin fork-composer-shell — see .fork/customizations.yaml#fork-composer-shell */}
<div className={cn("flex min-w-0", isComposerSlim ? "items-center gap-6" : "flex-col")}>
<div ref={attachPromptElement} className="relative min-w-0 flex-1">
<div
ref={attachPromptElement}
data-fork-composer-prompt="true"
className="relative min-w-0 flex-1"
>
{/* fork:end fork-composer-shell */}
<ComposerPromptEditor
editorRef={composerEditorRef}
Expand Down
22 changes: 21 additions & 1 deletion apps/web/src/theme.custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,15 @@
A general sibling combinator, not an adjacent one: Lexical renders the
placeholder as the editor's *third* child, with an empty zero-height div
between them, so `+ div` matches the spacer and never reaches the
placeholder. `~ div` catches both; the spacer has no text to resize. */
placeholder. `~ div` catches both; the spacer has no text to resize.

Desktop also moves the max-height scrollport off the contenteditable. The
14/16 line box lets Geist ink inflate scrollHeight on the editor itself, so
upstream's max-h + overflow-y:auto paints a phantom thumb on a single line.
The wrapper owns the cap; the editor stays unclamped and clips its ink so
the parent does not inherit the same false overflow. Below 40rem the prompt
stays on leading-relaxed and upstream's editor scroll behaviour is left
alone. 12.5rem matches upstream's max-h-50. */
@media (width >= 40rem) {
:root[data-fork="noahhendrickson-t3code"]
[data-fork-composer-box]
Expand All @@ -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:
Expand Down
Loading