Skip to content
Port an existing page

Documentation

Velloo documentation

Markdown for LLMs ↗

Port an existing page

Code-to-design. Seed the theme from your app's stylesheet, rebuild the structure on the canvas, and verify fidelity against the live URL.

The reverse direction: your app already has pages, and you want them on the canvas, so redesigns happen in Velloo instead of in scattered JSX edits, and the design becomes the source going forward.

The guiding rule is re-express, don’t clone. A ported screen strips handlers, state, and data fetching, inlines representative copy as literals, and keeps the visual structure. Designs are static by construction; a data-heavy dashboard ports as its structure plus fixture copy.

Set Velloo up for the app first

Once per design, before the first screen, have your agent set the design up for the app. It is what makes everything after it trustworthy, and it is cheapest while nothing is drawn yet. In Claude Code that is /velloo:setup; in any other agent, say “set Velloo up for my app”. Four steps:

  1. Theme and fonts. import_theme the app’s stylesheet and work through its notImported list. velloo init already ran the same import, so on a fresh design the dry run mostly confirms it. Step 1 below has the details.
  2. The preview entry. preview_status says whether your app’s own components have the providers and stylesheets they need. When the state is not valid, set_preview_entry {} writes the suggested entry: the app’s own entry rewritten for a canvas, with an in-memory router in place of the real one.
  3. Every component, in one render. check_components mounts each component the app gives the design at the props its call sites pass, and returns contact sheets to look at. Fix what the problems have in common first: several components whose variants all draw the same is usually one stylesheet the preview entry doesn’t load, or colours the theme lacks.
  4. The design language. set_design_language records what the design is going for and the rules the code visibly holds to. You review and edit them on the canvas. See Theme your design.

An app with no components of its own, only pages and a router, skips steps 2 and 3. The agent finishes by telling you what is exact, what is adapted or stands in, and what it could not verify.

1. Seed the theme

Match the palette before composing anything, or every color decision you make will be against the wrong background. import_theme reads the app’s stylesheet. Note that globals.css lives outside the design folder, so pass an absolute path or one relative to the host app root (css takes the stylesheet text instead):

{ "tool": "import_theme", "args": { "cssPath": "app/globals.css" } }

What it parses:

  • shadcn-convention :root / .dark custom properties: raw HSL triplets or any CSS color
  • Tailwind v4 @theme --color-* variables, with var() indirection resolved, plus --radius and --font-* roles
  • The stylesheets it imports: given a cssPath, relative @imports inside the app are followed, so an entry sheet that only imports ./theme.css yields that file’s tokens. importedStylesheets lists what was read. Packages and URLs are never followed
  • tailwind.config theme.extend (found near the cssPath): brand colors land in the theme’s palette, named spacing becomes spacing tokens (so w-icon-rail resolves), boxShadow becomes shadows, fontFamily becomes font roles, keyframes + animation become --animate-*

Beyond the semantic slots, every other custom color var (numeric scales like --primary-600, extra roles like --success-500, bare brand names like --ink) is captured into the palette.* passthrough namespace, so app classes like bg-primary-600 or bg-ink render literally on the canvas instead of falling back. The result also reports the app’s Tailwind container config as container.suggestedClasses. Centered/padded/capped layout doesn’t transfer as a theme, so wrap page content in a Box with those classes.

It dry-runs by default; review the changes list, then re-run with apply: true. Read coverage as well as the change count: an app whose variables follow its own naming rather than shadcn’s maps zero semantic slots, parks every color in palette.*, and leaves colors.background, primary, and the rest on the starter palette. Map those yourself with set_theme { tokens } before composing. Details in the theme guide.

Then read notImported: what the app states that did not reach the theme, each with its reason. A font the import could not trace, a body font the app never assigns to sans, or a dark palette the stylesheet does not define all appear there. An empty list means the import left nothing it saw behind.

Match the app’s real fonts as well as its colors. Fonts that next/font sets at runtime are in no stylesheet, so the import follows each one to its loader call and sets the role, family, and weights; fonts in the result names them. Set any it could not trace with set_theme { fonts }. When the repo has a design-system DESIGN.md, read its rules and optionally layer its tokens over the stylesheet with import_theme { designMdPath }. Theme guide →

Pages behind a login

When the page sits behind a login, on staging, or on a site you don’t run, capture it first. start_capture_session opens a real browser window that you drive:

{ "tool": "start_capture_session", "args": { "url": "https://staging.example.com/dashboard" } }

It returns a sessionId immediately and does not wait. Log in, press Capture page in the Velloo toolbar on each page you need, then Done; the agent polls list_captures meanwhile rather than calling start_capture_session again. From a terminal, velloo capture https://staging.example.com/dashboard opens the same kind of session (velloo capture list browses stored captures, velloo capture delete <id> removes one).

get_capture { captureId } then returns the evidence: absolute files paths including page.png (the authoritative reference), a structural outline with each node’s rect and HIDDEN / OFFSCREEN markers, fonts, downloaded image assets, and themeCss, the page’s real custom properties. Pass that CSS to import_theme as css before composing. The session cookies never reach the agent, and captures live outside the design folder.

2. Rebuild the structure

Now compose the screen, the same compose-and-verify loop, with the page’s source open next to list_components.

compose reads the JSX your app already holds, so a page does not have to be flattened by hand first. Point it at the file:

{ "tool": "compose", "args": { "screenId": "dashboard", "mode": "replace", "file": "app/dashboard/page.tsx" } }

The file is read as data and nothing in it executes. const data, .map, cond && <X />, components defined in the same file, and data imported from the app’s own files are written out as plain elements. Event handlers are dropped and reported in sourceNotes. A value nothing holds, such as a fetch or a package’s data, is refused by name, with its line and column. A Next app-router page is composed inside the layout files above it, as the route shows it. compose →

A few porting-specific rules:

  • Build into scanned screens. If velloo init scanned the app’s routes, it already created one placeholder screen per route (id = route slug). Rebuild it in place with compose in mode: "replace", which swaps the placeholder tree for yours in one call; add_screen for a scanned route returns ScreenIdConflict.
  • Keep Tailwind classes verbatim on a shadcn app. It shares Velloo’s component vocabulary, so most refs map one-to-one and the classes travel unchanged.
  • Use the app’s own component before rebuilding it. The page you’re porting is built from components Velloo has probably already found: check list_components for the Repo shelves first, and compose the real FeatureCard rather than reassembling it from primitives. A repository component renders from your own installed copy and emits its exact import, which is the shortest path to a faithful port. Run preview_status once before you start so the mount is known-good.
  • Keep the page editable. Compose the app’s sections, cards, and controls rather than placing the entire page as one opaque component. The screen/opaque diagnostic flags this shortcut even when the screenshot matches.
  • Then snippet, then extension. A presentational pattern you want to reuse and edit in one place becomes a snippet with typed params; snippets render for real. A dynamic leaf that can’t render as a tree of nodes — a chart wired to your data layer — becomes an extension registered with its real import path, optionally with live rendering so Velloo bundles your actual component.
  • Replace dark: variant patterns with semantic tokens. Velloo’s dark mode swaps token values, not a class, so dark: variants are inert on the canvas: bg-white dark:bg-background becomes bg-background.

3. Check fidelity against the live URL

compare_to_url renders your screen and captures the running app page at the same viewport, then pixel-diffs. What to diff against goes in source; the render size goes in viewport (default: the folder’s Desktop preset):

{
  "tool": "compare_to_url",
  "args": {
    "screenId": "dashboard",
    "source": { "url": "http://localhost:3000/dashboard" },
    "viewport": { "w": 1440, "h": 900 }
  }
}

The result carries similarity, changedRatio, heightDelta, and contentHeight, plus contentSimilarity when the heights differ. topMismatches ranks the worst diff regions and names the node responsible for each (so “the sidebar is off” arrives as a node reference, not a guess), and styleDiff, when both sides could be measured, lists the computed properties behind those regions (design versus page). A side-by-side PNG comes back too (URL on the left, Velloo on the right; pass image: false for metrics only).

Useful knobs:

  • mode: "dark" renders the Velloo side dark and best-effort drives the target page dark (prefers-color-scheme, .dark / data-theme on the html element, localStorage.theme), so dark fidelity checks against the app’s real dark theme.
  • source.settleTimeoutMs (default 8000) caps the network-quiet wait before capture. Raise it for data-heavy pages that paint a spinner first.
  • source.auth: storageStatePath (a Playwright storage-state JSON), cookies, or localStorage get you past a login wall to the real page.
  • source.cache: freeze: true captures a dynamic page (feed, dashboard, per-user content) once and diffs every later call against that frozen reference, so drifting content doesn’t make similarity jitter. ttlMs bounds staleness (default five minutes); refresh: true re-samples after you change the app.

For a page you captured in a browser session, diff against the stored capture instead of the live URL. It is already past the login and frozen, so it can’t bounce to a login page or drift between calls; the viewport comes from the capture:

{ "tool": "compare_to_url", "args": { "screenId": "dashboard", "source": { "captureId": "<id from list_captures>" } } }

Two cases are not treated as a problem. A sign-in, sign-up, or password-reset page is not called an auth wall, and neither is any page whose design has a password field of its own. A design that is itself a loading state, with an aria-busy region or a “Loading…” label, is compared against the page as it loads instead of waiting for it to finish.

4. Iterate, then flip the source of truth

Fix what topMismatches points at, in order, re-compare, repeat. Calibration:

  • 0.85+ similarity is a faithful structural port.
  • Don’t chase 1.0. Fonts and imagery legitimately differ; the headless render substitutes some emoji glyphs and doesn’t fetch remote images, so a hotlinked avatar shows a fallback box. Use upload_asset plus a local Image or Placeholder for art that needs to be pixel-faithful, and let the rest go.

Once the port is faithful, the screen is the design source going forward: redesign on the canvas, verify with screenshots, and push changes back with emit_code.

Next