feat(ui): add mobile preview device framing and design QA tools

This commit is contained in:
2026-05-15 11:01:49 -07:00
parent 2993f19a25
commit 8a7897a891
4 changed files with 312 additions and 33 deletions

View File

@@ -32,6 +32,8 @@ export default function PreviewTab() {
const bridge = usePreviewBridge();
const origin = typeof window !== "undefined" ? window.location.origin : "";
const [deviceMode, setDeviceMode] = useState<"desktop" | "mobile">("desktop");
// Auto-select first preview on load
useEffect(() => {
if (!selectedUrl && options.length > 0) {
@@ -50,8 +52,8 @@ export default function PreviewTab() {
return (
<div style={canvas}>
{options.length > 1 && (
<div style={toolbar}>
<div style={toolbar}>
{options.length > 1 && (
<select
value={selectedUrl ?? ""}
onChange={(e) => setSelectedUrl(e.target.value)}
@@ -64,36 +66,96 @@ export default function PreviewTab() {
</option>
))}
</select>
</div>
)}
<div style={previewFrame}>
{loading && !iframeSrc ? (
<div style={loaderWrap}>
<Loader2
className="animate-spin"
style={{ width: 22, height: 22, color: "#9c9590" }}
/>
</div>
) : iframeSrc ? (
<iframe
key={iframeSrc}
src={iframeSrc}
title="Preview"
ref={(el) => {
iframeDomRef.current = el;
bridge?.registerPreviewIframe(el, iframeSrc);
}}
onLoad={() => bridge?.notifyPreviewIframeLoaded()}
style={iframeStyle}
{...(sandboxIframe(iframeSrc, origin)
? { sandbox: SAME_ORIGIN_SANDBOX }
: {})}
/>
) : (
<div style={loaderWrap}>
<p style={emptyText}>No preview available</p>
</div>
)}
<div
style={{
display: "flex",
gap: 4,
background: "rgba(255,255,255,0.85)",
padding: 4,
borderRadius: 8,
border: "1px solid rgba(26,26,26,0.12)",
}}
>
<button
onClick={() => setDeviceMode("desktop")}
style={{
padding: "4px 12px",
borderRadius: 6,
fontSize: "0.8rem",
background:
deviceMode === "desktop" ? "rgba(0,0,0,0.06)" : "transparent",
color: deviceMode === "desktop" ? "#000" : "#666",
}}
>
Desktop
</button>
<button
onClick={() => setDeviceMode("mobile")}
style={{
padding: "4px 12px",
borderRadius: 6,
fontSize: "0.8rem",
background:
deviceMode === "mobile" ? "rgba(0,0,0,0.06)" : "transparent",
color: deviceMode === "mobile" ? "#000" : "#666",
}}
>
Mobile
</button>
</div>
</div>
<div
style={{
flex: 1,
minHeight: 0,
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<div
style={{
...(deviceMode === "desktop" ? desktopFrame : mobileFrame),
transition: "all 0.3s ease",
}}
>
{deviceMode === "mobile" && <MobileChrome />}
{loading && !iframeSrc ? (
<div style={loaderWrap}>
<Loader2
className="animate-spin"
style={{ width: 22, height: 22, color: "#9c9590" }}
/>
</div>
) : iframeSrc ? (
<iframe
key={iframeSrc}
src={iframeSrc}
title="Preview"
ref={(el) => {
iframeDomRef.current = el;
bridge?.registerPreviewIframe(el, iframeSrc);
}}
onLoad={() => bridge?.notifyPreviewIframeLoaded()}
style={{
...iframeStyle,
borderRadius: deviceMode === "mobile" ? 44 : 0,
}}
{...(sandboxIframe(iframeSrc, origin)
? { sandbox: SAME_ORIGIN_SANDBOX }
: {})}
/>
) : (
<div style={loaderWrap}>
<p style={emptyText}>No preview available</p>
</div>
)}
{deviceMode === "mobile" && <div style={homeIndicator} aria-hidden />}
</div>
</div>
</div>
);
@@ -130,9 +192,10 @@ const select: React.CSSProperties = {
color: "#1a1a1a",
};
const previewFrame: React.CSSProperties = {
const desktopFrame: React.CSSProperties = {
flex: 1,
minHeight: 0,
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
borderRadius: 14,
@@ -143,6 +206,121 @@ const previewFrame: React.CSSProperties = {
"0 1px 2px rgba(26, 26, 26, 0.04), 0 12px 40px rgba(26, 26, 26, 0.07), inset 0 1px 0 rgba(255, 255, 255, 0.85)",
};
const mobileFrame: React.CSSProperties = {
position: "relative",
width: 390,
height: 844,
flexShrink: 0,
borderRadius: 56,
padding: 12,
background: "linear-gradient(160deg, #2a2a2c 0%, #1a1a1c 50%, #0e0e10 100%)",
boxShadow:
"0 0 0 1px rgba(255,255,255,0.04) inset, 0 0 0 2px #000 inset, 0 28px 60px -12px rgba(0,0,0,0.45), 0 8px 20px -8px rgba(0,0,0,0.35)",
display: "flex",
flexDirection: "column",
overflow: "hidden",
};
const homeIndicator: React.CSSProperties = {
position: "absolute",
left: "50%",
bottom: 20,
transform: "translateX(-50%)",
width: 134,
height: 5,
background: "#1a1916",
borderRadius: 999,
opacity: 0.85,
pointerEvents: "none",
zIndex: 10,
};
function MobileChrome() {
return (
<>
<div
style={{
position: "absolute",
top: 22,
left: "50%",
transform: "translateX(-50%)",
width: 124,
height: 36,
background: "#000",
borderRadius: 999,
zIndex: 15,
}}
/>
<div
style={{
position: "absolute",
top: 12,
left: 12,
right: 12,
height: 47,
padding: "18px 26px 0",
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
fontSize: 15,
fontWeight: 600,
letterSpacing: "-0.01em",
color: "#1a1916",
pointerEvents: "none",
zIndex: 10,
}}
>
<span>9:41</span>
<span style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
<svg
viewBox="0 0 17 11"
style={{ width: 17, height: 11, fill: "currentColor" }}
aria-hidden
>
<rect x="0" y="7" width="3" height="4" rx="0.6" />
<rect x="4" y="5" width="3" height="6" rx="0.6" />
<rect x="8" y="3" width="3" height="8" rx="0.6" />
<rect x="12" y="0" width="3" height="11" rx="0.6" />
</svg>
<svg
viewBox="0 0 17 11"
style={{ width: 17, height: 11, fill: "currentColor" }}
aria-hidden
>
<path d="M8.5 1.5C5.5 1.5 2.7 2.6 0.5 4.6L2 6.1C3.8 4.5 6.1 3.6 8.5 3.6c2.4 0 4.7 0.9 6.5 2.5l1.5-1.5c-2.2-2-5-3.1-8-3.1zM3.5 7.6L5 9.1c1-0.9 2.2-1.4 3.5-1.4 1.3 0 2.5 0.5 3.5 1.4l1.5-1.5c-1.4-1.3-3.1-2-5-2-1.9 0-3.6 0.7-5 2zM6.5 10.6l2 2 2-2c-0.5-0.5-1.2-0.8-2-0.8s-1.5 0.3-2 0.8z" />
</svg>
<svg
viewBox="0 0 25 11"
style={{ width: 25, height: 11, fill: "currentColor" }}
aria-hidden
>
<rect
x="0.5"
y="0.5"
width="21"
height="10"
rx="2.5"
fill="none"
stroke="currentColor"
strokeOpacity="0.45"
/>
<rect
x="22"
y="3.5"
width="1.5"
height="4"
rx="0.4"
fill="currentColor"
fillOpacity="0.45"
/>
<rect x="2" y="2" width="18" height="7" rx="1.4" />
</svg>
</span>
</div>
</>
);
}
const iframeStyle: React.CSSProperties = {
flex: 1,
width: "100%",

View File

@@ -217,6 +217,12 @@ Each project has a persistent \`vibn-dev\` container. Edit files via \`fs_*\` an
**Testing Auth & Protected Routes:** Do NOT attempt to verify signup flows or authenticated routes by making HTTP requests (e.g. \`curl\` or \`http_fetch\`) to the dev server yourself. The app is protected by NextAuth or similar session cookies which you do not have. Just write the code, start the dev server via \`dev_server_start\`, and provide the user the clickable \`previewUrl\` so they can test it themselves in their browser. If you hit a redirect/401, do NOT assume the server is broken and loop on restarting it.
**Design Critique / Visual QA Tool:**
- \`request_visual_qa { targetPath }\` runs a fast background AI agent to critique a UI file (like \`page.tsx\`, \`layout.tsx\`, or \`.css\`) against a strict 5-dimensional design rubric (Layout, Spacing, Contrast, Hierarchy, Responsiveness).
- You MUST call this tool whenever your turn involves creating or heavily modifying visual User Interface code before you return the \`previewUrl\` to the user.
- If the tool returns a failure with actionable issues (e.g., "missing mobile padding" or "using hardcoded colors instead of CSS variables"), you MUST use \`fs_edit\` to fix those specific issues before ending your turn.
- Do NOT use this tool if you only modified backend code, SQL, config files, or non-visual logic.
**Rules:**
- Stay under \`/workspace\`. \`fs_*\` enforce this; use \`shell_exec\` deliberately for system paths.
- Dev container has no route to internal Vibn services (vibn-postgres, etc.) by design.

View File

@@ -40,6 +40,7 @@ import {
} from "@/lib/workspace-gcs";
import { VIBN_GCS_LOCATION } from "@/lib/gcp/storage";
import { getApplicationRuntimeLogs } from "@/lib/coolify-logs";
import { callVibnChat } from "@/lib/ai/vibn-chat-model";
import { execInCoolifyApp } from "@/lib/coolify-exec";
import { isCoolifySshConfigured, runOnCoolifyHost } from "@/lib/coolify-ssh";
import {
@@ -230,6 +231,7 @@ export async function GET() {
"browser.console",
"browser.navigate",
"ship",
"request_visual_qa",
],
},
},
@@ -414,6 +416,8 @@ export async function POST(request: Request) {
return await toolShellExec(principal, params);
case "fs.read":
return await toolFsRead(principal, params);
case "request_visual_qa":
return await toolRequestVisualQA(principal, params);
case "fs.write":
return await toolFsWrite(principal, params);
case "fs.edit":
@@ -4485,6 +4489,80 @@ async function runFsCmd(
};
}
async function toolRequestVisualQA(
principal: Principal,
params: Record<string, any>,
) {
const guard = await pathBGuard();
if (guard) return guard;
const project = await resolveProjectOr404(principal, params);
if (project instanceof NextResponse) return project;
const targetPath = String(params.targetPath ?? "").trim();
if (!targetPath) {
return NextResponse.json(
{ error: 'Param "targetPath" is required' },
{ status: 400 },
);
}
const absPath = normalizeFsPath(targetPath);
if (absPath instanceof NextResponse) return absPath;
const r = await runFsCmd(
principal,
project,
`test -f ${shq(absPath)} && cat ${shq(absPath)}`,
10000,
);
if (r.code !== 0) {
return NextResponse.json({
error: `Could not read file ${targetPath}. Ensure the path is correct and the file exists.`,
});
}
const fileContent = r.stdout;
const prompt = `You are a strict, world-class Senior Design QA Engineer.
Your job is to evaluate the provided UI code against a strict 5-dimensional rubric and catch "AI slop" before the user sees it.
If the code is flawless and follows the design system perfectly, say "PASS".
If it fails ANY criteria, list the exact issues and how to fix them. DO NOT rewrite the whole file, just give actionable critique.
## The Rubric (5 Dimensions)
1. Layout & Composition: Are there overlapping text elements? Is the grid broken on mobile? Do containers overflow unexpectedly?
2. Spacing & Padding: Is there enough breathing room? Are paddings consistent? (e.g., tight items should have 4-8px gap, sections should have 24-48px).
3. Contrast & Color: Are hardcoded hex colors (e.g. #FF0000) used instead of Design System CSS variables (e.g. var(--accent), var(--fg))? ALL colors MUST use CSS variables if a design system is active.
4. Hierarchy: Is the primary action obvious? Is secondary text properly muted?
5. Responsiveness: Does the UI stack gracefully on mobile (max-width 760px)? Are \`flex-col\` or \`grid-cols-1\` applied at the right breakpoints?
## The Code
\`\`\`
${fileContent.slice(0, 15000)}
\`\`\`
Evaluate the code. Reply with "PASS" or a concise bulleted list of fixes.`;
try {
const aiResponse = await callVibnChat({
systemPrompt: prompt,
messages: [{ role: "user", content: "Perform the Visual QA critique." }],
temperature: 0.1,
});
return NextResponse.json({
result: {
critique: aiResponse.text || "No feedback generated.",
note: "If this returned issues, you MUST fix them using fs.edit before declaring your turn complete.",
},
});
} catch (err: any) {
return NextResponse.json({
error: `QA Agent failed: ${err.message}`,
});
}
}
async function toolFsRead(principal: Principal, params: Record<string, any>) {
const guard = await pathBGuard();
if (guard) return guard;