frameset
v0.17.1
Published
Frameset is a tool for designing and specifying UI using templates. Each frame is one self-contained file — YAML frontmatter over a Liquid template — that takes parameters, renders as its own document, and can be embedded inside other frames. Nest them an
Readme
Frameset
Frameset is a tool for designing and specifying UI using templates. Each frame is one self-contained file — YAML frontmatter over a Liquid template — that takes parameters, renders as its own document, and can be embedded inside other frames. Nest them and you get a digital twin of an application: the same structure and the same states, with none of its data, routing, or machinery.
Storybook targets isolated component stories. Figma targets visual design. Frameset targets the space between: a design surface made of real markup. One file can be a sketch of a screen that has no routes or data yet, a prototype you click through, a gallery of every state it can be in, and the specification an implementation is held to — each state adjusted through controls derived from its parameters, and linked to.
The templates are deliberately dumb — interpolation, conditionals, loops, and composition, with no arbitrary JavaScript — so a frame stays something to read and reason about rather than another program to debug.
A frame at a glance
---
title: Status badge
params:
tone: { enum: [neutral, success], default: neutral }
label: { type: string, default: Ready }
imports:
- ./status.css
---
<span class="status status--{{ tone }}">{{ label }}</span>The frontmatter is optional. params supplies the render interface and
control metadata. imports is an ordered dependency list: strings are
side-effect imports, while a single-key mapping binds a default export into
the Liquid scope. data binds inline values into the scope the same way.
style holds the frame's own CSS and script its own behavior, each delivered
to the document once however many instances render. adoptedStyles is the
frame's own CSS for the declarative shadow roots the body writes, per host
tag, adopted into each such root. The body is Liquid and may
use interpolation, conditionals, loops, and frame composition — but no style,
script, or document-defining elements: the body is a fragment, and styles and
behavior belong in style, script and imports.
Frontmatter is a strict JSON-compatible subset of YAML, and those eight keys —
title, description, params, imports, data, style, script,
adoptedStyles — are the only ones allowed. Duplicate keys are rejected, and param, import, and data names
must be identifiers: makeRoom, not make-room or 10.
Quick start
Install Frameset in the project and add frameset.json:
npm install --save-dev frameset{
"root": ".",
"imports": { "design/**/*.frame": ["design/foundation.ts"] },
"sidebar": [
{ "type": "frame", "path": "design/dashboard.frame", "title": "Dashboard" },
{
"type": "folder",
"title": "Account",
"children": [
{ "type": "frame", "path": "design/account/sign-in.frame" }
]
}
]
}Run the command from the directory containing the manifest — that exact directory, since parent directories are not searched:
npx framesetFrameset starts at http://127.0.0.1:4400/ and opens the viewer. Use
--port, --host, and --allowed-hosts to change the server settings.
Sidebar entries are explicit and retain manifest order. Every .frame under
root is still addressable and composable, whether or not the sidebar lists
it. imports maps a glob to modules every matching frame's document loads
before the frame itself — the place for a foundation stylesheet or the
element definitions frames rely on to draw.
Params and controls
Controls derive from the frame's params; frame code does not register them at
runtime. An enum renders a dropdown, type: number a number input,
type: boolean a checkbox, and type: string a text input. Object and array
params are structured fixtures and do not render controls.
The viewer URL is the selected frame id plus its arguments:
/design/status?tone=success&label=DeployedDefaults seed the render and the controls. Changing a control rewrites the
query and re-renders the document in place, so the URL is also the shareable
state.
The viewer renders controls for the root frame; <frameset-frame> renders them
for an embedded frame.
A frame's id is its path under root without the suffix, so
design/status.frame has the id design/status. That id answers at three
addresses, which is worth knowing when something looks wrong:
/#design/status the viewer, with that frame selected
/design/status the frame document by itself
/design/status.frame the compiled moduleFetching the last one is what proves the frame compiles.
Composition
Liquid render inlines another frame's output into the current document:
{% render './status', tone: 'success', label: 'Deployed' %}<frameset-frame> embeds another frame as its own document and viewport:
<frameset-frame
src="./status?tone=success"
label="Status"
width="320"
height="80"
background="#fff"
border
></frameset-frame>render invokes the child in isolation: only the arguments you name cross the
boundary. Surrounding scope does not — including a loop's forloop.index, which
has to be passed explicitly as an argument.
An embed's label and control strip render outside its declared box, so they
never change its size, and content that overflows the box is clipped
(overflow="visible" to let it escape).
Use render when the child belongs to the same page and style context. Use
<frameset-frame> for document isolation, a fixed viewport, and embedded
controls. Its src may be relative to the current frame or absolute from the
project root.
Importing frames
The viewer is not the only way to use a frame. Add the package's Vite plugin to
a project's own config, and .frame files become importable modules anywhere
that build reaches — application code, scripts, or tests:
import { defineConfig } from "vite";
import frameset from "frameset/vite";
export default defineConfig({
plugins: [frameset()],
});The Frameset server installs this plugin itself, so the config entry is only for your own build.
An import returns a frozen module — render plus the frontmatter:
import status from "./design/status.frame";
status.render({ tone: "success" }); // → markup string, synchronously
status.params; // → the declared schema fragments
status.imports; // → ["./status.css"]Importing also delivers the frame's dependencies to the document, so a specimen's styles are present exactly as they are in the viewer, once however many times it renders.
This is what makes a frame usable as a specification: the frame renders the
intended result, and a test holds the implementation against it. The test
runner needs the plugin above so .frame imports compile, and a real browser
so the frame's styles apply:
import spec from "../design/status.frame"; // delivers the spec's CSS here
import "../src/status-badge"; // defines <status-badge>
// the frame declares which tones exist, so the suite covers whatever it declares
test.each(spec.params.tone.enum as string[])("tone %s matches the spec", (tone) => {
document.body.innerHTML = spec.render({ tone, label: "Ready" });
const reference = document.body.firstElementChild!;
const actual = document.createElement("status-badge");
actual.setAttribute("tone", tone);
document.body.append(actual);
expect(getComputedStyle(actual).backgroundColor).toBe(
getComputedStyle(reference).backgroundColor,
);
});How Frameset itself is specified
The specs/ folder in the repository holds the full specification of the
format, server, viewer, and integration contracts.
