authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-20 01:24:57+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-20 01:24:57+02:00
log7e1a0d551c750d4a6322e52cfb5e7c866be9a964
treeff06e7877eee0fc0978d7924234b22c10474e6eb
parentb511227b7ad421c422f1ebca65116776020e4799
signature Signed by PGP key B5690EEEBB952194

Fix draft banner stack overlap (#4164)

Co-authored-by: codex <codex@users.noreply.github.com>

3 files changed, 66 insertions(+), 20 deletions(-)

apps/web/src/components/chat/ComposerBannerStack.test.tsx created+37
......@@ -0,0 +1,37 @@
1import { renderToStaticMarkup } from "react-dom/server";
2import { describe, expect, it } from "vite-plus/test";
3
4import { ComposerBannerStack, type ComposerBannerStackItem } from "./ComposerBannerStack";
5
6const banner = (id: string): ComposerBannerStackItem => ({
7 id,
8 variant: "warning",
9 icon: <span aria-hidden="true">!</span>,
10 title: `${id} warning`,
11});
12
13describe("ComposerBannerStack", () => {
14 it("keeps expanded banners in layout flow so surrounding content moves out of their way", () => {
15 const markup = renderToStaticMarkup(
16 <ComposerBannerStack items={[banner("front"), banner("stacked")]} />,
17 );
18
19 const expandedItems = markup.match(
20 /<div data-composer-banner-stack-expanded-items="true" class="([^"]+)">/,
21 );
22
23 expect(expandedItems?.[1]).toContain("grid-rows-[0fr]");
24 expect(expandedItems?.[1]).toContain("group-hover/banner-stack:grid-rows-[1fr]");
25 expect(expandedItems?.[1]).toContain("z-20");
26 expect(expandedItems?.[1]).not.toContain("absolute");
27 expect(markup.indexOf("front warning")).toBeLessThan(markup.indexOf("stacked warning"));
28 expect(markup).toContain("invisible pointer-events-none");
29 expect(markup).toContain("group-focus-within/banner-stack:visible");
30 });
31
32 it("does not render an expandable region for a single banner", () => {
33 const markup = renderToStaticMarkup(<ComposerBannerStack items={[banner("front")]} />);
34
35 expect(markup).not.toContain("data-composer-banner-stack-expanded-items");
36 });
37});
apps/web/src/components/chat/ComposerBannerStack.tsx+28-19
......@@ -85,7 +85,7 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
8585 <div className={cn("group/banner-stack mx-auto mb-2 max-w-3xl", className)}>
8686 <div
8787 className={cn(
88 "relative",
88 "relative flex flex-col-reverse",
8989 hasStack ? "group-hover/banner-stack:z-50 group-focus-within/banner-stack:z-50" : null,
9090 )}
9191 >
......@@ -119,30 +119,39 @@ export function ComposerBannerStack({ className, items }: ComposerBannerStackPro
119119 </div>
120120 {hasStack ? (
121121 <div
122 data-composer-banner-stack-expanded-items="true"
122123 className={cn(
123 "pointer-events-none absolute inset-x-0 bottom-[calc(100%+0.5rem)] z-20 space-y-2 opacity-0",
124 "transition-[opacity,transform] duration-150 ease-out",
125 "translate-y-1 transform-gpu will-change-[opacity,transform]",
126 "group-hover/banner-stack:pointer-events-auto group-hover/banner-stack:translate-y-0 group-hover/banner-stack:opacity-100",
127 "group-focus-within/banner-stack:pointer-events-auto group-focus-within/banner-stack:translate-y-0 group-focus-within/banner-stack:opacity-100",
124 "relative z-20 grid grid-rows-[0fr] transition-[grid-template-rows] duration-150 ease-out",
125 "group-hover/banner-stack:grid-rows-[1fr] group-focus-within/banner-stack:grid-rows-[1fr]",
128126 )}
129127 >
130 {stackedItems.map((item) => (
128 <div className="min-h-0 overflow-hidden">
131129 <div
132 key={item.id}
133 className={cn(exitingItemId === item.id ? "pointer-events-none" : null)}
134 style={{
135 ...exitTransitionStyle,
136 ...(exitingItemId === item.id ? stackedExitStyle : restingStyle),
137 }}
130 className={cn(
131 "invisible pointer-events-none space-y-2 pb-2 opacity-0",
132 "translate-y-1 transform-gpu transition-[opacity,transform] duration-150 ease-out will-change-[opacity,transform]",
133 "group-hover/banner-stack:visible group-hover/banner-stack:pointer-events-auto group-hover/banner-stack:translate-y-0 group-hover/banner-stack:opacity-100",
134 "group-focus-within/banner-stack:visible group-focus-within/banner-stack:pointer-events-auto group-focus-within/banner-stack:translate-y-0 group-focus-within/banner-stack:opacity-100",
135 )}
138136 >
139 <ComposerBannerStackAlert
140 item={item}
141 exiting={exitingItemId === item.id}
142 onDismissRequest={() => requestDismiss(item)}
143 />
137 {stackedItems.map((item) => (
138 <div
139 key={item.id}
140 className={cn(exitingItemId === item.id ? "pointer-events-none" : null)}
141 style={{
142 ...exitTransitionStyle,
143 ...(exitingItemId === item.id ? stackedExitStyle : restingStyle),
144 }}
145 >
146 <ComposerBannerStackAlert
147 item={item}
148 exiting={exitingItemId === item.id}
149 onDismissRequest={() => requestDismiss(item)}
150 />
151 </div>
152 ))}
144153 </div>
145 ))}
154 </div>
146155 </div>
147156 ) : null}
148157 </div>
apps/web/src/components/chat/DraftHeroHeadline.tsx+1-1
......@@ -101,7 +101,7 @@ export function DraftHeroHeadline({
101101 );
102102
103103 return (
104 <h1 className="mx-auto w-full max-w-5xl text-center font-normal text-3xl text-foreground tracking-tight sm:text-5xl">
104 <h1 className="mx-auto w-full max-w-5xl text-center font-normal text-2xl text-foreground tracking-tight sm:text-3xl">
105105 {hasResolvedProject ? (
106106 <>What should we build in {projectSelector}?</>
107107 ) : canChooseProject ? (