Skip to content

Commit 5105833

Browse files
authored
Merge pull request #260 from OpenKnots/okcode/fix-enhance-button-error
Add window opacity settings and tighten UI menu wrappers
2 parents 20771ba + 045cbda commit 5105833

4 files changed

Lines changed: 239 additions & 85 deletions

File tree

apps/web/src/components/PreviewPanel.tsx

Lines changed: 34 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { Button } from "./ui/button";
2929
import { Input } from "./ui/input";
3030
import {
3131
Menu,
32+
MenuGroup,
3233
MenuGroupLabel,
3334
MenuPopup,
3435
MenuRadioGroup,
@@ -372,38 +373,40 @@ export function PreviewPanel({ threadId, onClose }: PreviewPanelProps) {
372373
</span>
373374
</MenuTrigger>
374375
<MenuPopup side="bottom" align="end" sideOffset={6}>
375-
<MenuGroupLabel>Viewport</MenuGroupLabel>
376-
<MenuRadioGroup
377-
value={presetId ?? RESPONSIVE_VALUE}
378-
onValueChange={(value) => {
379-
setThreadPreset(
380-
threadId,
381-
value === RESPONSIVE_VALUE ? null : (value as BrowserPresetId),
382-
);
383-
}}
384-
>
385-
<MenuRadioItem value={RESPONSIVE_VALUE}>
386-
<span className="flex items-center gap-2">
387-
<MaximizeIcon className="size-3.5 opacity-60" />
388-
Responsive
389-
</span>
390-
</MenuRadioItem>
391-
<MenuSeparator />
392-
{BROWSER_PRESETS.map((preset) => {
393-
const Icon = PRESET_ICONS[preset.id];
394-
return (
395-
<MenuRadioItem key={preset.id} value={preset.id}>
396-
<span className="flex items-center gap-2">
397-
<Icon className="size-3.5 opacity-60" />
398-
<span>{preset.label}</span>
399-
<span className="ml-auto text-[10px] tabular-nums text-muted-foreground/60">
400-
{preset.width}&times;{preset.height}
376+
<MenuGroup>
377+
<MenuGroupLabel>Viewport</MenuGroupLabel>
378+
<MenuRadioGroup
379+
value={presetId ?? RESPONSIVE_VALUE}
380+
onValueChange={(value) => {
381+
setThreadPreset(
382+
threadId,
383+
value === RESPONSIVE_VALUE ? null : (value as BrowserPresetId),
384+
);
385+
}}
386+
>
387+
<MenuRadioItem value={RESPONSIVE_VALUE}>
388+
<span className="flex items-center gap-2">
389+
<MaximizeIcon className="size-3.5 opacity-60" />
390+
Responsive
391+
</span>
392+
</MenuRadioItem>
393+
<MenuSeparator />
394+
{BROWSER_PRESETS.map((preset) => {
395+
const Icon = PRESET_ICONS[preset.id];
396+
return (
397+
<MenuRadioItem key={preset.id} value={preset.id}>
398+
<span className="flex items-center gap-2">
399+
<Icon className="size-3.5 opacity-60" />
400+
<span>{preset.label}</span>
401+
<span className="ml-auto text-[10px] tabular-nums text-muted-foreground/60">
402+
{preset.width}&times;{preset.height}
403+
</span>
401404
</span>
402-
</span>
403-
</MenuRadioItem>
404-
);
405-
})}
406-
</MenuRadioGroup>
405+
</MenuRadioItem>
406+
);
407+
})}
408+
</MenuRadioGroup>
409+
</MenuGroup>
407410
</MenuPopup>
408411
</Menu>
409412

apps/web/src/components/PromptEnhancer.tsx

Lines changed: 34 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
import { useCallback, useState } from "react";
22
import { SparklesIcon, CheckIcon } from "lucide-react";
33
import { Button } from "./ui/button";
4-
import { Menu, MenuItem, MenuPopup, MenuTrigger, MenuSeparator, MenuGroupLabel } from "./ui/menu";
4+
import {
5+
Menu,
6+
MenuGroup,
7+
MenuGroupLabel,
8+
MenuItem,
9+
MenuPopup,
10+
MenuSeparator,
11+
MenuTrigger,
12+
} from "./ui/menu";
513

614
// ────────────────────────────────────────────────────────────────────────────
715
// Prompt Enhancement Presets
@@ -104,30 +112,32 @@ export default function PromptEnhancer({ prompt, onEnhance, disabled }: PromptEn
104112
<SparklesIcon className="size-4" />
105113
</MenuTrigger>
106114
<MenuPopup align="end" side="top">
107-
<MenuGroupLabel>Enhance your prompt</MenuGroupLabel>
108-
<MenuSeparator />
109-
{ENHANCEMENTS.map((enhancement) => {
110-
const isApplied = appliedIds.has(enhancement.id);
111-
return (
112-
<MenuItem
113-
key={enhancement.id}
114-
onClick={() => handleEnhance(enhancement)}
115-
disabled={isApplied}
116-
>
117-
<div className="flex items-center gap-2">
118-
{isApplied ? (
119-
<CheckIcon className="size-3.5 text-green-500" />
120-
) : (
121-
<span className="size-3.5" />
122-
)}
123-
<div className="flex flex-col">
124-
<span>{enhancement.label}</span>
125-
<span className="text-muted-foreground text-xs">{enhancement.description}</span>
115+
<MenuGroup>
116+
<MenuGroupLabel>Enhance your prompt</MenuGroupLabel>
117+
<MenuSeparator />
118+
{ENHANCEMENTS.map((enhancement) => {
119+
const isApplied = appliedIds.has(enhancement.id);
120+
return (
121+
<MenuItem
122+
key={enhancement.id}
123+
onClick={() => handleEnhance(enhancement)}
124+
disabled={isApplied}
125+
>
126+
<div className="flex items-center gap-2">
127+
{isApplied ? (
128+
<CheckIcon className="size-3.5 text-green-500" />
129+
) : (
130+
<span className="size-3.5" />
131+
)}
132+
<div className="flex flex-col">
133+
<span>{enhancement.label}</span>
134+
<span className="text-muted-foreground text-xs">{enhancement.description}</span>
135+
</div>
126136
</div>
127-
</div>
128-
</MenuItem>
129-
);
130-
})}
137+
</MenuItem>
138+
);
139+
})}
140+
</MenuGroup>
131141
</MenuPopup>
132142
</Menu>
133143
);

apps/web/src/components/simulation/SimulationViewer.tsx

Lines changed: 33 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { useMediaQuery, useIsMobile } from "~/hooks/useMediaQuery";
2222
import { Button } from "~/components/ui/button";
2323
import {
2424
Menu,
25+
MenuGroup,
2526
MenuGroupLabel,
2627
MenuPopup,
2728
MenuRadioGroup,
@@ -120,37 +121,39 @@ function SimulationToolbar({ onClose }: { onClose: () => void }) {
120121
<span className="max-sm:hidden">{preset ? preset.label : "Responsive"}</span>
121122
</MenuTrigger>
122123
<MenuPopup side="bottom" align="end" sideOffset={6}>
123-
<MenuGroupLabel>Simulation Viewport</MenuGroupLabel>
124-
<MenuRadioGroup
125-
value={viewportPreset ?? RESPONSIVE_VALUE}
126-
onValueChange={(value) => {
127-
setViewportPreset(
128-
value === RESPONSIVE_VALUE ? null : (value as SimulationViewportPreset),
129-
);
130-
}}
131-
>
132-
<MenuRadioItem value={RESPONSIVE_VALUE}>
133-
<span className="flex items-center gap-2">
134-
<MaximizeIcon className="size-3.5 opacity-60" />
135-
Responsive
136-
</span>
137-
</MenuRadioItem>
138-
<MenuSeparator />
139-
{SIMULATION_VIEWPORT_PRESETS.map((p) => {
140-
const Icon = PRESET_ICONS[p.id];
141-
return (
142-
<MenuRadioItem key={p.id} value={p.id}>
143-
<span className="flex items-center gap-2">
144-
<Icon className="size-3.5 opacity-60" />
145-
<span>{p.label}</span>
146-
<span className="ml-auto text-[10px] tabular-nums text-muted-foreground/60">
147-
{p.width}&times;{p.height}
124+
<MenuGroup>
125+
<MenuGroupLabel>Simulation Viewport</MenuGroupLabel>
126+
<MenuRadioGroup
127+
value={viewportPreset ?? RESPONSIVE_VALUE}
128+
onValueChange={(value) => {
129+
setViewportPreset(
130+
value === RESPONSIVE_VALUE ? null : (value as SimulationViewportPreset),
131+
);
132+
}}
133+
>
134+
<MenuRadioItem value={RESPONSIVE_VALUE}>
135+
<span className="flex items-center gap-2">
136+
<MaximizeIcon className="size-3.5 opacity-60" />
137+
Responsive
138+
</span>
139+
</MenuRadioItem>
140+
<MenuSeparator />
141+
{SIMULATION_VIEWPORT_PRESETS.map((p) => {
142+
const Icon = PRESET_ICONS[p.id];
143+
return (
144+
<MenuRadioItem key={p.id} value={p.id}>
145+
<span className="flex items-center gap-2">
146+
<Icon className="size-3.5 opacity-60" />
147+
<span>{p.label}</span>
148+
<span className="ml-auto text-[10px] tabular-nums text-muted-foreground/60">
149+
{p.width}&times;{p.height}
150+
</span>
148151
</span>
149-
</span>
150-
</MenuRadioItem>
151-
);
152-
})}
153-
</MenuRadioGroup>
152+
</MenuRadioItem>
153+
);
154+
})}
155+
</MenuRadioGroup>
156+
</MenuGroup>
154157
</MenuPopup>
155158
</Menu>
156159

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { readdirSync, readFileSync, statSync } from "node:fs";
2+
import path from "node:path";
3+
import { describe, expect, it } from "vitest";
4+
5+
const WEB_SRC_ROOT = path.resolve(import.meta.dirname, "../..");
6+
7+
const REQUIRED_ANCESTORS = {
8+
AutocompleteGroupLabel: "AutocompleteGroup",
9+
ComboboxGroupLabel: "ComboboxGroup",
10+
MenuGroupLabel: "MenuGroup",
11+
MenuRadioItem: "MenuRadioGroup",
12+
MenuSubPopup: "MenuSub",
13+
MenuSubTrigger: "MenuSub",
14+
SelectGroupLabel: "SelectGroup",
15+
} as const;
16+
17+
const TARGET_COMPONENTS = new Set(Object.keys(REQUIRED_ANCESTORS));
18+
const JSX_TAG_PATTERN = /<(?<closing>\/)?(?<name>[A-Z][A-Za-z0-9]*)\b[^>]*?(?<selfClosing>\/)?>/g;
19+
20+
const WRAPPER_SOURCE_ASSERTIONS = [
21+
{
22+
filePath: path.resolve(import.meta.dirname, "./select.tsx"),
23+
message: "Select item wrappers keep item indicator and text inside SelectPrimitive.Item",
24+
patterns: [
25+
/<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>/,
26+
],
27+
},
28+
{
29+
filePath: path.resolve(import.meta.dirname, "./combobox.tsx"),
30+
message: "Combobox item wrappers keep item indicators inside ComboboxPrimitive.Item",
31+
patterns: [
32+
/<ComboboxPrimitive\.Item\b[\s\S]*?<ComboboxPrimitive\.ItemIndicator\b[\s\S]*?<\/ComboboxPrimitive\.ItemIndicator>[\s\S]*?<\/ComboboxPrimitive\.Item>/,
33+
],
34+
},
35+
{
36+
filePath: path.resolve(import.meta.dirname, "./menu.tsx"),
37+
message: "Menu item wrappers keep radio and checkbox indicators inside their item parents",
38+
patterns: [
39+
/<MenuPrimitive\.RadioItem\b[\s\S]*?<MenuPrimitive\.RadioItemIndicator\b[\s\S]*?<\/MenuPrimitive\.RadioItemIndicator>[\s\S]*?<\/MenuPrimitive\.RadioItem>/,
40+
/<MenuPrimitive\.CheckboxItem\b[\s\S]*?<MenuPrimitive\.CheckboxItemIndicator\b[\s\S]*?<\/MenuPrimitive\.CheckboxItemIndicator>[\s\S]*?<\/MenuPrimitive\.CheckboxItem>/,
41+
],
42+
},
43+
{
44+
filePath: path.resolve(import.meta.dirname, "./scroll-area.tsx"),
45+
message: "ScrollArea wrappers keep thumbs inside scrollbars",
46+
patterns: [
47+
/<ScrollAreaPrimitive\.Scrollbar\b[\s\S]*?<ScrollAreaPrimitive\.Thumb\b[\s\S]*?\/>[\s\S]*?<\/ScrollAreaPrimitive\.Scrollbar>/,
48+
],
49+
},
50+
] as const;
51+
52+
function collectTsxFiles(rootDir: string): string[] {
53+
const entries = readdirSync(rootDir);
54+
const files: string[] = [];
55+
56+
for (const entry of entries) {
57+
const absolutePath = path.join(rootDir, entry);
58+
const stats = statSync(absolutePath);
59+
60+
if (stats.isDirectory()) {
61+
files.push(...collectTsxFiles(absolutePath));
62+
continue;
63+
}
64+
65+
if (
66+
absolutePath.endsWith(".tsx") &&
67+
!absolutePath.includes(`${path.sep}components${path.sep}ui${path.sep}`)
68+
) {
69+
files.push(absolutePath);
70+
}
71+
}
72+
73+
return files;
74+
}
75+
76+
function findMissingAncestorViolations(source: string): string[] {
77+
const stack: string[] = [];
78+
const violations: string[] = [];
79+
80+
for (const match of source.matchAll(JSX_TAG_PATTERN)) {
81+
const name = match.groups?.name;
82+
if (!name) continue;
83+
84+
const isClosing = Boolean(match.groups?.closing);
85+
const isSelfClosing = Boolean(match.groups?.selfClosing);
86+
87+
if (isClosing) {
88+
const lastIndex = stack.lastIndexOf(name);
89+
if (lastIndex >= 0) {
90+
stack.splice(lastIndex, 1);
91+
}
92+
continue;
93+
}
94+
95+
if (TARGET_COMPONENTS.has(name)) {
96+
const requiredAncestor = REQUIRED_ANCESTORS[name as keyof typeof REQUIRED_ANCESTORS];
97+
if (!stack.includes(requiredAncestor)) {
98+
violations.push(`${name} must be rendered within ${requiredAncestor}`);
99+
}
100+
}
101+
102+
if (!isSelfClosing) {
103+
stack.push(name);
104+
}
105+
}
106+
107+
return violations;
108+
}
109+
110+
describe("Base UI wrapper invariants", () => {
111+
it("keeps label and item wrapper parts inside their required parent components", () => {
112+
const failures = collectTsxFiles(WEB_SRC_ROOT).flatMap((filePath) => {
113+
const source = readFileSync(filePath, "utf8");
114+
const violations = findMissingAncestorViolations(source);
115+
116+
return violations.map(
117+
(violation) => `${path.relative(WEB_SRC_ROOT, filePath)}: ${violation}`,
118+
);
119+
});
120+
121+
expect(failures).toEqual([]);
122+
});
123+
124+
it("keeps second-tier primitive relationships intact inside shared UI wrappers", () => {
125+
const failures = WRAPPER_SOURCE_ASSERTIONS.flatMap(({ filePath, message, patterns }) => {
126+
const source = readFileSync(filePath, "utf8");
127+
const missingPattern = patterns.some((pattern) => !pattern.test(source));
128+
129+
if (!missingPattern) {
130+
return [];
131+
}
132+
133+
return `${path.relative(WEB_SRC_ROOT, filePath)}: ${message}`;
134+
});
135+
136+
expect(failures).toEqual([]);
137+
});
138+
});

0 commit comments

Comments
 (0)