From 7fa0b556a62d1a413e587abf052560f483b7fc91 Mon Sep 17 00:00:00 2001 From: x Date: Tue, 13 Oct 2026 09:12:16 -0400 Subject: [PATCH 1/2] fix(web): stash tab is vessel floor, not a card jammed under the vessel edge The stash badge still drew upstream's inset shoulder-tab card tucked under the prompt box, which inside the fork's vessel reads as a dark slab pressed against the vessel's top edge. Give every shoulder tab the floor treatment the collapsed tasks row already had: vessel width, no tuck, no card. Co-Authored-By: Claude Opus 5.5 (1M context) --- .fork/customizations.yaml | 6 +++--- apps/web/src/theme.custom.css | 35 ++++++++++++++++++----------------- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/.fork/customizations.yaml b/.fork/customizations.yaml index 40c8047fc9563..d12a982bdcb13 100644 --- a/.fork/customizations.yaml +++ b/.fork/customizations.yaml @@ -3994,7 +3994,8 @@ prompt into one bordered box on the composer fill, is the deliberate exception and keeps that construction — including its own top edge under a notice, since the notice above it paints no outline to share. Shoulder tabs (stash, tasks) are - excluded by name and keep riding the card's shoulder. The remaining Figma + excluded by name from the card rules and get their own floor rules: vessel + width, no tuck, no card. The remaining Figma 467:33308 card (branch changed) is stamped data-fork-composer-notice so drawer density stays on Reconnect / Update / tasks. It drops font-medium / leading-7 at the source, stacks title over description, and stamps its @@ -4134,8 +4135,7 @@ keeps one ring), and square the prompt surface's top corners so the two read as one box. Because the card takes the whole dock row, the stash tab cannot sit beside it as it does beside upstream's inset drawer: the dock stacks - and the tab rides above the card's right shoulder, its overlap padding - tucked under the card. + and the tab sits as a floor row above the card, right-aligned. tier: 2 files: - apps/web/src/custom/useComposerPendingUserInputCard.ts diff --git a/apps/web/src/theme.custom.css b/apps/web/src/theme.custom.css index fa4ebda30d0f6..0f76c063b8ef9 100644 --- a/apps/web/src/theme.custom.css +++ b/apps/web/src/theme.custom.css @@ -2317,8 +2317,8 @@ literal value would stop tracking the token the control row reads. Shoulder tabs (the stash and tasks badges) are ComposerBanner.Roots too and - are excluded by name everywhere below: they are tabs on the card's shoulder, - not cards. The collapsed stack's peek cap has no surface attribute but reads + are excluded by name from the card rules below; they get their own floor + rules after them. The collapsed stack's peek cap has no surface attribute but reads the same fill variable, so the colour remap lists its slot beside the surface and gives it the hairline in place of its severity border utility; it takes the composer radius so the cap reads as one more card behind. @@ -2355,10 +2355,8 @@ margin: 0; } -/* A full-width card would squeeze the stash / tasks tab sharing the dock row. - Stack the dock instead: the tab rides above the card's right shoulder and - its own overlap padding tucks it under the card, which paints over it in - order-modified paint order. */ +/* A full-width card would squeeze the stash tab sharing the dock row. Stack + the dock instead: the tab is a floor row above the card, right-aligned. */ :root[data-fork="noahhendrickson-t3code"].dark [data-fork-composer-vessel] [data-slot="composer-banner-attachment"]:has(> [data-composer-shoulder-tab]):has( @@ -2380,7 +2378,6 @@ > [data-composer-shoulder-tab] { order: -1; align-self: flex-end; - margin-bottom: calc(-1 * var(--chat-composer-attachment-overlap) - 4px); } :root[data-fork="noahhendrickson-t3code"].dark @@ -2406,16 +2403,15 @@ --chat-composer-attachment-overlap: 0px; } -/* The collapsed tasks badge is not a shoulder tab here: it is the header row - of the open tasks drawer, so it gets the drawer's floor treatment — vessel - width, no tuck, no card, the drawer's default density — and toggling only - adds or removes the list beneath it. It is only ever shown without a banner - card, so it never needs to ride a card's shoulder the way stash does. */ +/* Shoulder tabs (stash, tasks) are not tabs here either: upstream's inset + card tucked under the prompt box reads as a dark slab jammed against the + vessel's top edge. Like the banners they are vessel floor — vessel width, + no tuck, no card. The collapsed tasks badge is also the header row of the + open tasks drawer, so it takes the drawer's default density and toggling + only adds or removes the list beneath it. */ :root[data-fork="noahhendrickson-t3code"].dark [data-fork-composer-vessel] - [data-slot="composer-banner-attachment"]:has( - [data-composer-shoulder-tab] > [data-composer-tasks-badge] - ) { + [data-slot="composer-banner-attachment"]:has(> [data-composer-shoulder-tab]) { width: 100%; max-width: none; margin: 0; @@ -2423,14 +2419,19 @@ :root[data-fork="noahhendrickson-t3code"].dark [data-fork-composer-vessel] - [data-composer-shoulder-tab]:has(> [data-composer-tasks-badge]) { + [data-composer-shoulder-tab] { --chat-composer-attachment-overlap: 0px; +} + +:root[data-fork="noahhendrickson-t3code"].dark + [data-fork-composer-vessel] + [data-composer-shoulder-tab]:has(> [data-composer-tasks-badge]) { --composer-banner-padding-block: calc(var(--spacing) * 1); } :root[data-fork="noahhendrickson-t3code"].dark [data-fork-composer-vessel] - [data-composer-shoulder-tab]:has(> [data-composer-tasks-badge])::before { + [data-composer-shoulder-tab]::before { display: none; } From 597aff6c7831314f4eaf74738f4284f7b15038d3 Mon Sep 17 00:00:00 2001 From: x Date: Tue, 13 Oct 2026 14:40:22 -0400 Subject: [PATCH 2/2] fix(web): shoulder tabs take the banner floor rules instead of a copy Narrowing the tab wrapper rule to a direct child dropped the dock whenever the stash was empty, so the collapsed tasks row slid back under the prompt box. A shoulder tab is already a banner card, so drop the :not() carve-out from the card rules and delete the parallel tab rules: the descendant match covers the dock in every state. Both tabs now share the drawer density so they sit on one text line, and the guard pins the descendant match. Co-Authored-By: Claude Opus 5.5 (1M context) --- .fork/customizations.yaml | 4 +- .../forkComposerBannerSurface.test.ts | 23 ++++++-- apps/web/src/theme.custom.css | 54 +++++-------------- 3 files changed, 35 insertions(+), 46 deletions(-) diff --git a/.fork/customizations.yaml b/.fork/customizations.yaml index d12a982bdcb13..c9ee3aae5e604 100644 --- a/.fork/customizations.yaml +++ b/.fork/customizations.yaml @@ -3994,8 +3994,8 @@ prompt into one bordered box on the composer fill, is the deliberate exception and keeps that construction — including its own top edge under a notice, since the notice above it paints no outline to share. Shoulder tabs (stash, tasks) are - excluded by name from the card rules and get their own floor rules: vessel - width, no tuck, no card. The remaining Figma + banner Roots and take the same floor treatment; only the dock stacking and + the tabs' shared drawer density name them. The remaining Figma 467:33308 card (branch changed) is stamped data-fork-composer-notice so drawer density stays on Reconnect / Update / tasks. It drops font-medium / leading-7 at the source, stacks title over description, and stamps its diff --git a/apps/web/src/__fork_guards__/forkComposerBannerSurface.test.ts b/apps/web/src/__fork_guards__/forkComposerBannerSurface.test.ts index e45976639aaf5..e9b3a0bf37406 100644 --- a/apps/web/src/__fork_guards__/forkComposerBannerSurface.test.ts +++ b/apps/web/src/__fork_guards__/forkComposerBannerSurface.test.ts @@ -142,7 +142,7 @@ describe("fork guard: fork-composer-banner-surface", () => { const [rule, ...rest] = cardRules(() => true); expect(rest).toHaveLength(0); expect(rule?.selector).toContain(`${MARKER}.dark`); - expect(rule?.selector).toContain("[data-composer-shoulder-tab]"); + expect(rule?.selector).not.toContain("[data-composer-shoulder-tab]"); expect(rule?.body).toMatch(/--chat-composer-attachment-overlap:\s*0px/u); for (const painted of ["background", "border", "box-shadow"]) { expect(rule?.body, painted).not.toContain(painted); @@ -162,13 +162,19 @@ describe("fork guard: fork-composer-banner-surface", () => { }); it("pulls the attachment wrappers out to the vessel's full width", () => { - const rule = rules.find( + const widened = rules.filter( (candidate) => flat(candidate.selector).includes('[data-slot="composer-banner-attachment"]:has(') && - flat(candidate.selector).includes(CARD) && candidate.body.includes("width: 100%"), ); + expect(widened).toHaveLength(1); + const [rule] = widened; expect(rule?.selector).toContain(`${MARKER}.dark`); + // By descendant and with no tab carve-out: the dock reaches the tasks row + // only through its column, so a child match or a `:not` on the shoulder + // tab sends the tasks-only dock back under the prompt box. + expect(flat(rule?.selector ?? "")).toContain(`:has( ${CARD} )`); + expect(rule?.selector).not.toContain(":not("); expect(rule?.body).toMatch(/max-width:\s*none/u); expect(rule?.body).toMatch(/margin:\s*0/u); }); @@ -178,9 +184,18 @@ describe("fork guard: fork-composer-banner-surface", () => { (candidate) => flat(candidate.selector).includes(CARD) && flat(candidate.selector).endsWith("::before"), ); + expect(rule?.selector).not.toContain("[data-composer-shoulder-tab]"); expect(rule?.body).toMatch(/display:\s*none/u); }); + it("gives both shoulder tabs the tasks drawer's density", () => { + const rule = rules.find((candidate) => + flat(candidate.selector).endsWith("[data-fork-composer-vessel] [data-composer-shoulder-tab]"), + ); + expect(rule?.selector).toContain(`${MARKER}.dark`); + expect(rule?.body).toMatch(/--composer-banner-padding-block:\s*calc\(var\(--spacing\) \* 1\)/u); + }); + it("gives notices and the activity strip the design's metrics", () => { const rule = rules.find((candidate) => candidate.body.includes("--composer-banner-icon-column"), @@ -242,7 +257,7 @@ describe("fork guard: fork-composer-banner-surface", () => { expect(componentSource("Root")).toContain('data-slot="composer-banner"'); const attachment = componentSource("Attachment"); expect(attachment).toContain('data-slot="composer-banner-attachment"'); - // The tuck the card rule zeroes, and the shoulder-tab margin still reads. + // The tuck the wrapper rule cancels; the card rule zeroes its overlap. expect(attachment).toContain("-mb-[calc(1rem+1px)]"); expect(banner).toContain("--chat-composer-attachment-overlap"); for (const slot of [ diff --git a/apps/web/src/theme.custom.css b/apps/web/src/theme.custom.css index 0f76c063b8ef9..432549c4d1a14 100644 --- a/apps/web/src/theme.custom.css +++ b/apps/web/src/theme.custom.css @@ -2317,8 +2317,8 @@ literal value would stop tracking the token the control row reads. Shoulder tabs (the stash and tasks badges) are ComposerBanner.Roots too and - are excluded by name from the card rules below; they get their own floor - rules after them. The collapsed stack's peek cap has no surface attribute but reads + take the same floor treatment; only the dock stacking and the tab density + below name them. The collapsed stack's peek cap has no surface attribute but reads the same fill variable, so the colour remap lists its slot beside the surface and gives it the hairline in place of its severity border utility; it takes the composer radius so the cap reads as one more card behind. @@ -2340,15 +2340,15 @@ /* The attachment wrappers carry the inset width and the tuck, and the dock is one of them, so any wrapper around a card goes full width and flush. The - overlap variable is what upstream's tuck is measured in — zeroing it on the - card drops the padding the tuck reserved. It is read off the tab's own - element below, which keeps its value. */ + shoulder tabs (stash, tasks) are banner Roots too, so they count: upstream's + inset tab tucked under the prompt box reads as a dark slab jammed against + the vessel's top edge, and like the banners they are vessel floor instead. + The match is by descendant so the dock also goes flush around the tasks row + nested in its column, whether or not the stash tab is showing. */ :root[data-fork="noahhendrickson-t3code"].dark [data-fork-composer-vessel] [data-slot="composer-banner-attachment"]:has( - [data-slot="composer-banner"][data-composer-banner-surface="attached"]:not( - [data-composer-shoulder-tab] - ) + [data-slot="composer-banner"][data-composer-banner-surface="attached"] ) { width: 100%; max-width: none; @@ -2382,9 +2382,7 @@ :root[data-fork="noahhendrickson-t3code"].dark [data-fork-composer-vessel] - [data-slot="composer-banner"][data-composer-banner-surface="attached"]:not( - [data-composer-shoulder-tab] - )::before { + [data-slot="composer-banner"][data-composer-banner-surface="attached"]::before { display: none; } @@ -2397,44 +2395,20 @@ measures its tuck in, and the Root reserves bottom padding for it. */ :root[data-fork="noahhendrickson-t3code"].dark [data-fork-composer-vessel] - [data-slot="composer-banner"][data-composer-banner-surface="attached"]:not( - [data-composer-shoulder-tab] - ) { + [data-slot="composer-banner"][data-composer-banner-surface="attached"] { --chat-composer-attachment-overlap: 0px; } -/* Shoulder tabs (stash, tasks) are not tabs here either: upstream's inset - card tucked under the prompt box reads as a dark slab jammed against the - vessel's top edge. Like the banners they are vessel floor — vessel width, - no tuck, no card. The collapsed tasks badge is also the header row of the - open tasks drawer, so it takes the drawer's default density and toggling - only adds or removes the list beneath it. */ -:root[data-fork="noahhendrickson-t3code"].dark - [data-fork-composer-vessel] - [data-slot="composer-banner-attachment"]:has(> [data-composer-shoulder-tab]) { - width: 100%; - max-width: none; - margin: 0; -} - +/* The collapsed tasks badge is the header row of the open tasks drawer, so + both shoulder tabs take the drawer's default density: toggling tasks only + adds or removes the list beneath it, and a stash tab sharing the dock row + keeps the same text line. */ :root[data-fork="noahhendrickson-t3code"].dark [data-fork-composer-vessel] [data-composer-shoulder-tab] { - --chat-composer-attachment-overlap: 0px; -} - -:root[data-fork="noahhendrickson-t3code"].dark - [data-fork-composer-vessel] - [data-composer-shoulder-tab]:has(> [data-composer-tasks-badge]) { --composer-banner-padding-block: calc(var(--spacing) * 1); } -:root[data-fork="noahhendrickson-t3code"].dark - [data-fork-composer-vessel] - [data-composer-shoulder-tab]::before { - display: none; -} - :root[data-fork="noahhendrickson-t3code"].dark [data-fork-composer-vessel] [data-slot="composer-banner-peek"] {