feat(ui): add mobile preview device framing and design QA tools
This commit is contained in:
@@ -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%",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1449,6 +1449,23 @@ After this returns, ALWAYS call apps_deploy { uuid } to regenerate the live Trae
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "request_visual_qa",
|
||||
description:
|
||||
"Runs a fast background AI agent to critique a UI file (like page.tsx or .css) against a strict 5-dimensional design rubric. Use this before finishing any turn that involves visual changes.",
|
||||
parameters: {
|
||||
type: "OBJECT",
|
||||
properties: {
|
||||
targetPath: {
|
||||
type: "STRING",
|
||||
description:
|
||||
"The path of the file to critique, e.g. apps/web/app/page.tsx",
|
||||
},
|
||||
},
|
||||
required: ["targetPath"],
|
||||
},
|
||||
},
|
||||
|
||||
// ── Path B: ship to production ─────────────────────────────────────────────
|
||||
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user