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
1 change: 1 addition & 0 deletions apps/web/src/components/GitActionsControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import {
import { Group, GroupSeparator } from "~/components/ui/group";
import {
Menu,
MenuGroup,
MenuGroupLabel,
MenuItem,
MenuPopup,
Expand Down
65 changes: 34 additions & 31 deletions apps/web/src/components/PreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { Button } from "./ui/button";
import { Input } from "./ui/input";
import {
Menu,
MenuGroup,
MenuGroupLabel,
MenuPopup,
MenuRadioGroup,
Expand Down Expand Up @@ -372,38 +373,40 @@ export function PreviewPanel({ threadId, onClose }: PreviewPanelProps) {
</span>
</MenuTrigger>
<MenuPopup side="bottom" align="end" sideOffset={6}>
<MenuGroupLabel>Viewport</MenuGroupLabel>
<MenuRadioGroup
value={presetId ?? RESPONSIVE_VALUE}
onValueChange={(value) => {
setThreadPreset(
threadId,
value === RESPONSIVE_VALUE ? null : (value as BrowserPresetId),
);
}}
>
<MenuRadioItem value={RESPONSIVE_VALUE}>
<span className="flex items-center gap-2">
<MaximizeIcon className="size-3.5 opacity-60" />
Responsive
</span>
</MenuRadioItem>
<MenuSeparator />
{BROWSER_PRESETS.map((preset) => {
const Icon = PRESET_ICONS[preset.id];
return (
<MenuRadioItem key={preset.id} value={preset.id}>
<span className="flex items-center gap-2">
<Icon className="size-3.5 opacity-60" />
<span>{preset.label}</span>
<span className="ml-auto text-[10px] tabular-nums text-muted-foreground/60">
{preset.width}&times;{preset.height}
<MenuGroup>
<MenuGroupLabel>Viewport</MenuGroupLabel>
<MenuRadioGroup
value={presetId ?? RESPONSIVE_VALUE}
onValueChange={(value) => {
setThreadPreset(
threadId,
value === RESPONSIVE_VALUE ? null : (value as BrowserPresetId),
);
}}
>
<MenuRadioItem value={RESPONSIVE_VALUE}>
<span className="flex items-center gap-2">
<MaximizeIcon className="size-3.5 opacity-60" />
Responsive
</span>
</MenuRadioItem>
<MenuSeparator />
{BROWSER_PRESETS.map((preset) => {
const Icon = PRESET_ICONS[preset.id];
return (
<MenuRadioItem key={preset.id} value={preset.id}>
<span className="flex items-center gap-2">
<Icon className="size-3.5 opacity-60" />
<span>{preset.label}</span>
<span className="ml-auto text-[10px] tabular-nums text-muted-foreground/60">
{preset.width}&times;{preset.height}
</span>
</span>
</span>
</MenuRadioItem>
);
})}
</MenuRadioGroup>
</MenuRadioItem>
);
})}
</MenuRadioGroup>
</MenuGroup>
</MenuPopup>
</Menu>

Expand Down
58 changes: 34 additions & 24 deletions apps/web/src/components/PromptEnhancer.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -104,30 +112,32 @@ export default function PromptEnhancer({ prompt, onEnhance, disabled }: PromptEn
<SparklesIcon className="size-4" />
</MenuTrigger>
<MenuPopup align="end" side="top">
<MenuGroupLabel>Enhance your prompt</MenuGroupLabel>
<MenuSeparator />
{ENHANCEMENTS.map((enhancement) => {
const isApplied = appliedIds.has(enhancement.id);
return (
<MenuItem
key={enhancement.id}
onClick={() => handleEnhance(enhancement)}
disabled={isApplied}
>
<div className="flex items-center gap-2">
{isApplied ? (
<CheckIcon className="size-3.5 text-green-500" />
) : (
<span className="size-3.5" />
)}
<div className="flex flex-col">
<span>{enhancement.label}</span>
<span className="text-muted-foreground text-xs">{enhancement.description}</span>
<MenuGroup>
<MenuGroupLabel>Enhance your prompt</MenuGroupLabel>
<MenuSeparator />
{ENHANCEMENTS.map((enhancement) => {
const isApplied = appliedIds.has(enhancement.id);
return (
<MenuItem
key={enhancement.id}
onClick={() => handleEnhance(enhancement)}
disabled={isApplied}
>
<div className="flex items-center gap-2">
{isApplied ? (
<CheckIcon className="size-3.5 text-green-500" />
) : (
<span className="size-3.5" />
)}
<div className="flex flex-col">
<span>{enhancement.label}</span>
<span className="text-muted-foreground text-xs">{enhancement.description}</span>
</div>
</div>
</div>
</MenuItem>
);
})}
</MenuItem>
);
})}
</MenuGroup>
</MenuPopup>
</Menu>
);
Expand Down
63 changes: 33 additions & 30 deletions apps/web/src/components/simulation/SimulationViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { useMediaQuery, useIsMobile } from "~/hooks/useMediaQuery";
import { Button } from "~/components/ui/button";
import {
Menu,
MenuGroup,
MenuGroupLabel,
MenuPopup,
MenuRadioGroup,
Expand Down Expand Up @@ -120,37 +121,39 @@ function SimulationToolbar({ onClose }: { onClose: () => void }) {
<span className="max-sm:hidden">{preset ? preset.label : "Responsive"}</span>
</MenuTrigger>
<MenuPopup side="bottom" align="end" sideOffset={6}>
<MenuGroupLabel>Simulation Viewport</MenuGroupLabel>
<MenuRadioGroup
value={viewportPreset ?? RESPONSIVE_VALUE}
onValueChange={(value) => {
setViewportPreset(
value === RESPONSIVE_VALUE ? null : (value as SimulationViewportPreset),
);
}}
>
<MenuRadioItem value={RESPONSIVE_VALUE}>
<span className="flex items-center gap-2">
<MaximizeIcon className="size-3.5 opacity-60" />
Responsive
</span>
</MenuRadioItem>
<MenuSeparator />
{SIMULATION_VIEWPORT_PRESETS.map((p) => {
const Icon = PRESET_ICONS[p.id];
return (
<MenuRadioItem key={p.id} value={p.id}>
<span className="flex items-center gap-2">
<Icon className="size-3.5 opacity-60" />
<span>{p.label}</span>
<span className="ml-auto text-[10px] tabular-nums text-muted-foreground/60">
{p.width}&times;{p.height}
<MenuGroup>
<MenuGroupLabel>Simulation Viewport</MenuGroupLabel>
<MenuRadioGroup
value={viewportPreset ?? RESPONSIVE_VALUE}
onValueChange={(value) => {
setViewportPreset(
value === RESPONSIVE_VALUE ? null : (value as SimulationViewportPreset),
);
}}
>
<MenuRadioItem value={RESPONSIVE_VALUE}>
<span className="flex items-center gap-2">
<MaximizeIcon className="size-3.5 opacity-60" />
Responsive
</span>
</MenuRadioItem>
<MenuSeparator />
{SIMULATION_VIEWPORT_PRESETS.map((p) => {
const Icon = PRESET_ICONS[p.id];
return (
<MenuRadioItem key={p.id} value={p.id}>
<span className="flex items-center gap-2">
<Icon className="size-3.5 opacity-60" />
<span>{p.label}</span>
<span className="ml-auto text-[10px] tabular-nums text-muted-foreground/60">
{p.width}&times;{p.height}
</span>
</span>
</span>
</MenuRadioItem>
);
})}
</MenuRadioGroup>
</MenuRadioItem>
);
})}
</MenuRadioGroup>
</MenuGroup>
</MenuPopup>
</Menu>

Expand Down
138 changes: 138 additions & 0 deletions apps/web/src/components/ui/baseUiInvariants.test.ts
Original file line number Diff line number Diff line change
@@ -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 = /<(?<closing>\/)?(?<name>[A-Z][A-Za-z0-9]*)\b[^>]*?(?<selfClosing>\/)?>/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: [
/<SelectPrimitive\.Item\b[\s\S]*?<SelectPrimitive\.ItemIndicator\b[\s\S]*?<\/SelectPrimitive\.ItemIndicator>[\s\S]*?<SelectPrimitive\.ItemText\b[\s\S]*?<\/SelectPrimitive\.ItemText>[\s\S]*?<\/SelectPrimitive\.Item>/,
],
},
{
filePath: path.resolve(import.meta.dirname, "./combobox.tsx"),
message: "Combobox item wrappers keep item indicators inside ComboboxPrimitive.Item",
patterns: [
/<ComboboxPrimitive\.Item\b[\s\S]*?<ComboboxPrimitive\.ItemIndicator\b[\s\S]*?<\/ComboboxPrimitive\.ItemIndicator>[\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: [
/<MenuPrimitive\.RadioItem\b[\s\S]*?<MenuPrimitive\.RadioItemIndicator\b[\s\S]*?<\/MenuPrimitive\.RadioItemIndicator>[\s\S]*?<\/MenuPrimitive\.RadioItem>/,
/<MenuPrimitive\.CheckboxItem\b[\s\S]*?<MenuPrimitive\.CheckboxItemIndicator\b[\s\S]*?<\/MenuPrimitive\.CheckboxItemIndicator>[\s\S]*?<\/MenuPrimitive\.CheckboxItem>/,
],
},
{
filePath: path.resolve(import.meta.dirname, "./scroll-area.tsx"),
message: "ScrollArea wrappers keep thumbs inside scrollbars",
patterns: [
/<ScrollAreaPrimitive\.Scrollbar\b[\s\S]*?<ScrollAreaPrimitive\.Thumb\b[\s\S]*?\/>[\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([]);
});
});
Loading