From 045cbda39bccdf0b62bd7a120e9f334d6c98cda4 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sun, 5 Apr 2026 01:56:09 -0500 Subject: [PATCH] Wrap menu sections in MenuGroup - Fix enhance button and related menu popups by restoring required menu group wrappers - Add invariant tests to catch broken shared UI wrapper nesting --- apps/web/src/components/GitActionsControl.tsx | 1 + apps/web/src/components/PreviewPanel.tsx | 65 +++++---- apps/web/src/components/PromptEnhancer.tsx | 58 +++++--- .../simulation/SimulationViewer.tsx | 63 ++++---- .../components/ui/baseUiInvariants.test.ts | 138 ++++++++++++++++++ 5 files changed, 240 insertions(+), 85 deletions(-) create mode 100644 apps/web/src/components/ui/baseUiInvariants.test.ts diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 491bd07ac..a3c877526 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -56,6 +56,7 @@ import { import { Group, GroupSeparator } from "~/components/ui/group"; import { Menu, + MenuGroup, MenuGroupLabel, MenuItem, MenuPopup, diff --git a/apps/web/src/components/PreviewPanel.tsx b/apps/web/src/components/PreviewPanel.tsx index 51f7ddae0..20ca08592 100644 --- a/apps/web/src/components/PreviewPanel.tsx +++ b/apps/web/src/components/PreviewPanel.tsx @@ -29,6 +29,7 @@ import { Button } from "./ui/button"; import { Input } from "./ui/input"; import { Menu, + MenuGroup, MenuGroupLabel, MenuPopup, MenuRadioGroup, @@ -372,38 +373,40 @@ export function PreviewPanel({ threadId, onClose }: PreviewPanelProps) { - Viewport - { - setThreadPreset( - threadId, - value === RESPONSIVE_VALUE ? null : (value as BrowserPresetId), - ); - }} - > - - - - Responsive - - - - {BROWSER_PRESETS.map((preset) => { - const Icon = PRESET_ICONS[preset.id]; - return ( - - - - {preset.label} - - {preset.width}×{preset.height} + + Viewport + { + setThreadPreset( + threadId, + value === RESPONSIVE_VALUE ? null : (value as BrowserPresetId), + ); + }} + > + + + + Responsive + + + + {BROWSER_PRESETS.map((preset) => { + const Icon = PRESET_ICONS[preset.id]; + return ( + + + + {preset.label} + + {preset.width}×{preset.height} + - - - ); - })} - + + ); + })} + + diff --git a/apps/web/src/components/PromptEnhancer.tsx b/apps/web/src/components/PromptEnhancer.tsx index 77ea6a7d7..b10da5acb 100644 --- a/apps/web/src/components/PromptEnhancer.tsx +++ b/apps/web/src/components/PromptEnhancer.tsx @@ -1,7 +1,15 @@ import { useCallback, useState } from "react"; import { SparklesIcon, CheckIcon } from "lucide-react"; import { Button } from "./ui/button"; -import { Menu, MenuItem, MenuPopup, MenuTrigger, MenuSeparator, MenuGroupLabel } from "./ui/menu"; +import { + Menu, + MenuGroup, + MenuGroupLabel, + MenuItem, + MenuPopup, + MenuSeparator, + MenuTrigger, +} from "./ui/menu"; // ──────────────────────────────────────────────────────────────────────────── // Prompt Enhancement Presets @@ -104,30 +112,32 @@ export default function PromptEnhancer({ prompt, onEnhance, disabled }: PromptEn - Enhance your prompt - - {ENHANCEMENTS.map((enhancement) => { - const isApplied = appliedIds.has(enhancement.id); - return ( - handleEnhance(enhancement)} - disabled={isApplied} - > -
- {isApplied ? ( - - ) : ( - - )} -
- {enhancement.label} - {enhancement.description} + + Enhance your prompt + + {ENHANCEMENTS.map((enhancement) => { + const isApplied = appliedIds.has(enhancement.id); + return ( + handleEnhance(enhancement)} + disabled={isApplied} + > +
+ {isApplied ? ( + + ) : ( + + )} +
+ {enhancement.label} + {enhancement.description} +
-
- - ); - })} + + ); + })} + ); diff --git a/apps/web/src/components/simulation/SimulationViewer.tsx b/apps/web/src/components/simulation/SimulationViewer.tsx index 24393b629..c4a1acea4 100644 --- a/apps/web/src/components/simulation/SimulationViewer.tsx +++ b/apps/web/src/components/simulation/SimulationViewer.tsx @@ -22,6 +22,7 @@ import { useMediaQuery, useIsMobile } from "~/hooks/useMediaQuery"; import { Button } from "~/components/ui/button"; import { Menu, + MenuGroup, MenuGroupLabel, MenuPopup, MenuRadioGroup, @@ -120,37 +121,39 @@ function SimulationToolbar({ onClose }: { onClose: () => void }) { {preset ? preset.label : "Responsive"} - Simulation Viewport - { - setViewportPreset( - value === RESPONSIVE_VALUE ? null : (value as SimulationViewportPreset), - ); - }} - > - - - - Responsive - - - - {SIMULATION_VIEWPORT_PRESETS.map((p) => { - const Icon = PRESET_ICONS[p.id]; - return ( - - - - {p.label} - - {p.width}×{p.height} + + Simulation Viewport + { + setViewportPreset( + value === RESPONSIVE_VALUE ? null : (value as SimulationViewportPreset), + ); + }} + > + + + + Responsive + + + + {SIMULATION_VIEWPORT_PRESETS.map((p) => { + const Icon = PRESET_ICONS[p.id]; + return ( + + + + {p.label} + + {p.width}×{p.height} + - - - ); - })} - + + ); + })} + + diff --git a/apps/web/src/components/ui/baseUiInvariants.test.ts b/apps/web/src/components/ui/baseUiInvariants.test.ts new file mode 100644 index 000000000..55602ada8 --- /dev/null +++ b/apps/web/src/components/ui/baseUiInvariants.test.ts @@ -0,0 +1,138 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const WEB_SRC_ROOT = path.resolve(import.meta.dirname, "../.."); + +const REQUIRED_ANCESTORS = { + AutocompleteGroupLabel: "AutocompleteGroup", + ComboboxGroupLabel: "ComboboxGroup", + MenuGroupLabel: "MenuGroup", + MenuRadioItem: "MenuRadioGroup", + MenuSubPopup: "MenuSub", + MenuSubTrigger: "MenuSub", + SelectGroupLabel: "SelectGroup", +} as const; + +const TARGET_COMPONENTS = new Set(Object.keys(REQUIRED_ANCESTORS)); +const JSX_TAG_PATTERN = /<(?\/)?(?[A-Z][A-Za-z0-9]*)\b[^>]*?(?\/)?>/g; + +const WRAPPER_SOURCE_ASSERTIONS = [ + { + filePath: path.resolve(import.meta.dirname, "./select.tsx"), + message: "Select item wrappers keep item indicator and text inside SelectPrimitive.Item", + patterns: [ + /[\s\S]*?[\s\S]*?<\/SelectPrimitive\.Item>/, + ], + }, + { + filePath: path.resolve(import.meta.dirname, "./combobox.tsx"), + message: "Combobox item wrappers keep item indicators inside ComboboxPrimitive.Item", + patterns: [ + /[\s\S]*?<\/ComboboxPrimitive\.Item>/, + ], + }, + { + filePath: path.resolve(import.meta.dirname, "./menu.tsx"), + message: "Menu item wrappers keep radio and checkbox indicators inside their item parents", + patterns: [ + /[\s\S]*?<\/MenuPrimitive\.RadioItem>/, + /[\s\S]*?<\/MenuPrimitive\.CheckboxItem>/, + ], + }, + { + filePath: path.resolve(import.meta.dirname, "./scroll-area.tsx"), + message: "ScrollArea wrappers keep thumbs inside scrollbars", + patterns: [ + /[\s\S]*?<\/ScrollAreaPrimitive\.Scrollbar>/, + ], + }, +] as const; + +function collectTsxFiles(rootDir: string): string[] { + const entries = readdirSync(rootDir); + const files: string[] = []; + + for (const entry of entries) { + const absolutePath = path.join(rootDir, entry); + const stats = statSync(absolutePath); + + if (stats.isDirectory()) { + files.push(...collectTsxFiles(absolutePath)); + continue; + } + + if ( + absolutePath.endsWith(".tsx") && + !absolutePath.includes(`${path.sep}components${path.sep}ui${path.sep}`) + ) { + files.push(absolutePath); + } + } + + return files; +} + +function findMissingAncestorViolations(source: string): string[] { + const stack: string[] = []; + const violations: string[] = []; + + for (const match of source.matchAll(JSX_TAG_PATTERN)) { + const name = match.groups?.name; + if (!name) continue; + + const isClosing = Boolean(match.groups?.closing); + const isSelfClosing = Boolean(match.groups?.selfClosing); + + if (isClosing) { + const lastIndex = stack.lastIndexOf(name); + if (lastIndex >= 0) { + stack.splice(lastIndex, 1); + } + continue; + } + + if (TARGET_COMPONENTS.has(name)) { + const requiredAncestor = REQUIRED_ANCESTORS[name as keyof typeof REQUIRED_ANCESTORS]; + if (!stack.includes(requiredAncestor)) { + violations.push(`${name} must be rendered within ${requiredAncestor}`); + } + } + + if (!isSelfClosing) { + stack.push(name); + } + } + + return violations; +} + +describe("Base UI wrapper invariants", () => { + it("keeps label and item wrapper parts inside their required parent components", () => { + const failures = collectTsxFiles(WEB_SRC_ROOT).flatMap((filePath) => { + const source = readFileSync(filePath, "utf8"); + const violations = findMissingAncestorViolations(source); + + return violations.map( + (violation) => `${path.relative(WEB_SRC_ROOT, filePath)}: ${violation}`, + ); + }); + + expect(failures).toEqual([]); + }); + + it("keeps second-tier primitive relationships intact inside shared UI wrappers", () => { + const failures = WRAPPER_SOURCE_ASSERTIONS.flatMap(({ filePath, message, patterns }) => { + const source = readFileSync(filePath, "utf8"); + const missingPattern = patterns.some((pattern) => !pattern.test(source)); + + if (!missingPattern) { + return []; + } + + return `${path.relative(WEB_SRC_ROOT, filePath)}: ${message}`; + }); + + expect(failures).toEqual([]); + }); +});