frameset
v0.10.0
Published
A lightweight viewer for iterating on HTML frames alongside a real codebase. A frame is an HTML fragment that imports real components and styles. A `frameset.json` manifest names those files, and Frameset wraps them into full documents at serve time and s
Readme
Frameset
A lightweight viewer for iterating on HTML frames alongside a real codebase. A frame is an HTML fragment that imports real components and styles. A frameset.json manifest names those files, and Frameset wraps them into full documents at serve time and shows them in a live viewer with hot reload.
Storybook targets isolated component stories. Figma targets visual design. Frameset targets the space between: composing real components into real screens quickly, especially in agent-driven workflows where editing plain HTML is the simplest thing that can work.
Quick start
From your project root:
framesetThis reads ./frameset.json, starts a dev server on http://127.0.0.1:4400/, opens the viewer in a browser, and watches for changes. The manifest is the whole setup — it names which files are frames, what they are called, and how they are grouped in the sidebar.
Run frameset from the directory holding frameset.json. That file names the root to serve, which Vite watches and every path in the manifest is relative to, so frames can import sibling code (e.g. ../components/). A missing frameset.json is a startup error.
To work on the viewer UI itself, run:
npm run devThis runs Frameset against the local example/ project, which is the default sandbox for iterating on the viewer UI and example frames together.
Project setup
A Frameset project is a frameset.json naming *.html frame files that live next to the code they use:
my-project/
frameset.json
frames/
dashboard.html
login.html
components/
auth/
sign-in.html
sign-in.js
nav-bar.js
metric-card.js{
"root": ".",
"sidebar": [
{ "type": "frame", "path": "frames/dashboard.html", "title": "Dashboard" },
{ "type": "folder", "title": "Onboarding", "children": [
{ "type": "frame", "path": "components/auth/sign-in.html", "title": "Sign in" },
{ "type": "frame", "path": "frames/login.html" }
]}
]
}root is the directory Frameset serves, relative to frameset.json — "." is the manifest's own directory. Every path in the file is relative to that root, and so is every frame id.
sidebar is the sidebar, top to bottom: order in the file is order on screen. There are two kinds of entry, told apart by type:
| Entry | Means |
|---|---|
| { "type": "frame", "path": "path.html", "title": "..." } | one frame. Untitled, it takes its filename. |
| { "type": "folder", "title": "...", "children": [ ... ] } | a collapsible group, nested to any depth. title is required. |
| { "type": "section", "title": "...", "children": [ ... ] } | the same, drawn as a muted caret row rather than a folder — for grouping without another level of folder. |
Every frame is listed by hand — nothing is discovered by scanning directories, so the sidebar holds exactly what you put in it, named and ordered how you want. A folder is a sidebar grouping, not a directory: frames anywhere under the root can sit in one together.
Every path is relative to root and must stay under it. A frame it names that doesn't exist yet shows as a broken sidebar entry until you create the file.
Frames
A frame is a body-only HTML fragment — no <!DOCTYPE>, <html>, <head>, or <body> tags. Frameset wraps it into a full document automatically.
<!-- frames/login.html -->
<script type="module" src="../components/login-panel.js"></script>
<style>
body { background: #f8fafc; }
:root[data-theme="dark"] body { background: #020617; }
</style>
<div class="page">
<login-panel title="Welcome back" primary-label="Continue"></login-panel>
</div>- The sidebar name comes from the manifest's
title, or the filename when it gives none. A frame's own<title>element is not read. <script>and<style>tags work normally. Relative paths resolve from the frame file's location.- Frames are served through Vite, so any transforms from your
vite.config.jsapply automatically.
<frameset-frame>
Every frame gets one custom element for free, with nothing to import — it shows another frame at a size you choose, named above its top-left corner the way a Figma frame is labelled, with the embedded frame's declared controls in a strip below:
<frameset-frame src="/frames/login" label="Login" width="420" height="600"></frameset-frame>src is required and takes a path to a frame — relative to the frame you're writing in, or absolute from the project root — and the target needn't be listed in the manifest. Use it for sheets: a frame whose subject is other frames (an overview of screens, one design at two breakpoints). It is transparent until you give it a background, and border adds a 1px grey hairline; beyond the size, the element styles nothing. See specs/frameset-frame.md.
A frame is rendered as a plain webpage: its body fills the iframe edge-to-edge under a minimal margin reset. A frame paints no background unless the design calls for one — unpainted, the viewer's own ground shows behind it. See skill/SKILL.md for the authoring conventions.
Commands
frameset — start dev server
frameset [options]No arguments: the manifest at ./frameset.json says what to serve.
| Option | Description | Default |
|---|---|---|
| --port <number> | Dev server port | 4400 |
| --host <host> | Network interface to bind (0.0.0.0 for all) | 127.0.0.1 |
| --allowed-hosts <hosts> | Comma-separated hostnames to accept, or all | — |
Viewer
The viewer shows the manifest in a sidebar — frames, and folders you can collapse — and an iframe preview of the selected frame. If the frame declares controls, a toolbar with dropdown controls appears below the preview once it loads. Collapsed folders are remembered between sessions; the folders around the selected frame are always opened on load.
A frame stays loaded once you visit it — switching frames shows a different one rather than reloading it, so scroll position, an open menu or half-typed input survive navigating away and back. Editing a file still reloads everything.
The selected frame is stored in the URL hash, so you can bookmark or share links like http://127.0.0.1:4400/#frames/dashboard.
Direct frame routes
Each frame is also available at its own URL — the chromeless, directly-linkable frame document, with no viewer around it:
http://127.0.0.1:4400/frame/frames/dashboard
http://127.0.0.1:4400/frame/components/auth/sign-inA frame's id is its path under the root without .html, so the route carries the slashes through.
Every .html file under the root is served here, whether or not the manifest lists it. The manifest decides the sidebar, not what can be rendered — so a fragment is linkable the moment you write it, and one meant only to be pulled into another page needs no sidebar row at all.
These are full HTML pages, useful for testing with Playwright, visual diffing, or accessibility checks — no special story format needed. Since the controls bar lives in the viewer, controls sit at their declared defaults here.
Vite integration
Frameset creates a Vite dev server rooted at your project. If you have a vite.config.js, Frameset inherits your plugins, aliases, PostCSS config, and framework transforms. Frameset only overrides what it needs to control (server port, host, and its own routes).
Viewer-only dependencies belong to Frameset itself. If the viewer imports a linked local package, Frameset needs an explicit Vite alias for it in src/server.ts so the viewer still resolves correctly when the dev server root points at another project.
Testing
Since frames are plain URLs, standard browser testing tools work directly:
// Playwright example
await page.goto("http://127.0.0.1:4400/frame/frames/dashboard");
await expect(page.locator("metric-card")).toBeVisible();