Concepts
Lerret turns a folder into a canvas. This page explains exactly how β every concept you need to use Lerret confidently, no surprises.
The folder-canvas model
Lerret reads a .lerret/ folder on your disk and turns it into a live in-memory project model: a tree of pages, groups, and assets. The canvas renders that tree directly.
project ββ pages[] βββ¬ββ groups[] (nestable, arbitrary depth)
βββ assets[]
group ββ groups[] (nestable) + assets[]The rules are deliberately tiny:
- A page is a non-underscore folder directly under
.lerret/. Pages are the top-level navigation unit; the studioβs page picker switches between them. - A group is any folder nested inside a page or another group. Groups can nest arbitrarily deep.
- An asset is a recognized file inside a page or group. Today three extensions are recognized:
.jsx,.tsx, and.md. - Folders starting with
_are reserved β_fonts/is the one Lerret uses today; any others are skipped.
The filesystem is the source of truth. Move a file β the artboard moves. Add a folder β a new group or page appears. Delete a file β the artboard goes away. The model is a derived cache of your folder; the watcher keeps it in sync.
The .lerret/ directory
.lerret/
βββ config.json β project-root config (the cascade starts here)
βββ _fonts/ β reserved: custom font files auto-registered
β βββ MyBrand.woff2
βββ social/ β page (top-level non-underscore folder)
β βββ config.json β page-level config (optional)
β βββ twitter.jsx β asset
β βββ twitter.data.jsonβ co-located data for `twitter.jsx`
β βββ thumbnails/ β group (any nested folder)
β βββ og-card.jsx
βββ docs/ β another page
βββ hero.mdEverything outside .lerret/ is yours. Lerret never reads or writes there. You can put .lerret/ in any project β a Next.js app, a TypeScript monorepo, a plain folder β and the rest of the tree is invisible to Lerret.
Assets
An asset is one source file rendered as one or more artboards.
| Extension | Asset kind | Rendered as |
|---|---|---|
.jsx, .tsx | component | One artboard per component-valued export β see Variants. |
.md | markdown | A document card on the canvas with rendered Markdown. |
Asset files live inside pages or groups. The position in the folder tree (<page>/<group>/<...>) determines where the artboard appears on the canvas.
Variants via named exports
One .jsx / .tsx file can declare more than one component, and each component-valued export becomes its own artboard:
// .lerret/social/banner.jsx
export const meta = { dimensions: { width: 1500, height: 500 } };
// Primary variant β exported as `default`
export default function Banner() {
return <div style={{ background: '#1B2A3B', color: 'white' }}>Light</div>;
}
// Named variant β exported as `Dark`
export const Dark = () => (
<div style={{ background: '#0A0A0A', color: '#E0FBFC' }}>Dark</div>
);The canvas renders two artboards from this file β one labelled default, one labelled Dark β both sized 1500Γ500 (the meta is shared by all variants in the file).
Rules:
- A variant is any export whose value is a function β function components and class components both qualify.
- The
defaultexport, when component-valued, is the primary variant. It is the artboard treated as βthe asset itselfβ when nothing selects a specific variant. - Non-function exports (constants, the
metaobject) are skipped; they are not variants. - The reserved
metaexport is always parsed as metadata, never as a variant β even if you accidentally export a function under that name. - A file with no component-valued exports yields zero artboards.
Variant artboards are identified by the path <assetPath>#<variantName> β e.g. social/banner.jsx#Dark.
The meta export
Any asset file may declare metadata about itself:
export const meta = {
dimensions: { width: 1200, height: 630 },
label: 'Open Graph card',
tags: ['og', 'social'],
propsSchema: {
title: { type: 'string', default: 'Untitled', required: true },
},
};| Field | Purpose | Default |
|---|---|---|
dimensions | The artboard size in CSS pixels. Both width and height are optional positive numbers. | Studioβs default size |
label | A human-facing display name. Trimmed, non-empty string. | Derived from the file / export name |
tags | A list of string tags for organization. Non-string entries dropped; empties dropped. | [] |
propsSchema | A typed schema for the in-studio data editor and the props validator. See Authoring β propsSchema. | undefined |
A missing meta, or a meta with some fields absent, falls back to these defaults. A malformed meta (not an object, or a getter that throws) is caught β the asset still renders with defaults, and the error is reported per-asset without affecting any other asset.
The meta object is shared across all variants in the file. Per-variant labels and dimensions are not supported.
Data files
A .data.json or .data.js file co-located with an asset supplies values to its component props at runtime β no code change required.
social/
βββ banner.jsx
βββ banner.data.json β values for banner.jsx// banner.data.json
{
"title": "Hello, world",
"showAccentBar": true
}Two co-location forms exist:
| Form | Use when |
|---|---|
<Name>.data.json | Static values. Plain JSON; parsed once. |
<Name>.data.js | Computed or conditional values. Exports a default value (an object or a function returning one). |
If both files exist for the same asset, .data.js wins and a warning is logged. The dynamic form takes precedence over the static form.
Per-variant data keying
When the data objectβs top-level keys match variant export names, each variant gets its own data:
// banner.data.json β banner.jsx exports `default` and `Dark`
{
"default": { "title": "Light theme" },
"Dark": { "title": "Dark theme" }
}When the top-level keys do not match any export name, the entire object is applied as shared data to every variant:
// Same data flows to default AND Dark
{ "title": "Same title everywhere" }Stray keys (keys with no matching export name) are silently ignored and logged as a warning.
The config cascade
Every page or group folder may contain a config.json. Configs cascade: each folderβs effective config is computed by deep-merging from the project root down to that folder.
.lerret/
βββ config.json β { "vars": { "brand": "#3D5A80" } }
βββ social/
β βββ config.json β { "vars": { "brand": "#B85B33" } }
β βββ banner.jsx β sees brand = "#B85B33"
βββ docs/
βββ hero.md β sees brand = "#3D5A80" (inherited)Cascade rules:
- A
config.jsonis optional at every folder. A missing one means βinherit the parentβs effective configβ β no error, no warning. - Deep merge: at every matching key path, the child wins per leaf. Sibling keys not present in the child are inherited from the parent.
- Arrays replace wholesale. Setting
vars.tags = ['a','b']in a child folder discards the parentβsvars.tagsentirely β arrays are not element-merged. - A
config.jsonthat is not valid JSON, or whose top-level is not a plain object, is skipped with a warning. The cascade falls back to the parentβs effective config for that folder.
The vars block
The most common use of config.json is the vars block β a flat object of named values that the studio exposes as CSS custom properties on every artboard within scope. You reference them with the standard CSS var(--name) syntax:
// In a component
<div style={{ background: 'var(--brandColor, #fallback)' }}>Hello</div>// In config.json
{
"vars": {
"brandColor": "#3D5A80",
"accentColor": "#E0FBFC"
}
}Edit a vars value in config.json, save the file, and every artboard inheriting that value re-renders with the new value. This is how theming works across an entire page or whole project without touching component source.
Four-tier prop resolution
Every artboardβs final props go through one function in @lerret/core. It merges four tiers in fixed precedence β highest first wins:
| Tier | Source | When set |
|---|---|---|
| 1. DATA | Variant data (keyed) or shared data, from .data.json / .data.js | Per-asset, per-variant override |
| 2. VARS | The asset folderβs effective config.json vars block, after cascade | Per-folder or project-wide theming |
| 3. SCHEMA DEFAULT | meta.propsSchema.<prop>.default | Authored default for a prop |
| 4. COMPONENT DEFAULT | Reactβs default parameter (function C({ x = 1 })) | Last-resort fallback |
Each prop is resolved independently β it takes its value from the first tier that supplies it. A single artboardβs props can come from a mix of tiers: some from data, some from vars, some from schema defaults, some from the component itself.
When no tier supplies a prop, the resolver omits the key entirely. React then applies the componentβs own default parameter. The resolver never invents a value.
// propsSchema: { title: { default: 'Untitled' } }
// vars: { title: 'Theme title' }
// data: { title: 'Variant data title' }
// component: function Card({ title = 'Component default' })
// β title resolves to 'Variant data title' (tier 1 wins)Live refresh
A file change on disk re-renders the relevant artboard in under a second.
- Watcher. In CLI mode a chokidarβ-backed watcher emits normalized change events. In hosted mode a directory-handle poll produces the same events.
- Normalized events. Each event has the shape
{ type: 'add' | 'change' | 'remove', path }. - Incremental model patching. One event updates the in-memory project model β adding, changing, or removing one node β without a full directory rescan.
- React Fast Refresh. Component changes hot-swap through Viteβs standard HMR. State is preserved where possible; the studio shell does not reload.
The result: save a file, the affected artboard updates, the rest of the canvas is unaffected.
Error isolation
One broken artboard fails alone.
If a component throws on render, its artboard shows an error card with the message and stack β the other artboards on the canvas keep rendering. Same for a malformed meta, a missing data field, or a propsSchema violation: the failure is reported in place, and nothing cascades.
This is a deliberate architectural choice: the canvas is a fleet of independent artboards rendered through per-artboard error boundaries. You can edit, break, and fix one asset without disturbing the others.
Custom fonts
Drop a font file into .lerret/_fonts/ and Lerret auto-registers it as a CSS @font-face rule. Available extensions:
| Extension | Format |
|---|---|
.woff2 | woff2 |
.woff | woff |
.ttf | truetype |
.otf | opentype |
The fontβs CSS family name is derived from the file name without its extension. So MyBrand.woff2 registers under the family MyBrand:
<div style={{ fontFamily: "'MyBrand', sans-serif" }}>Hello</div>Files in _fonts/ with any other extension are ignored. Subfolders inside _fonts/ are also ignored β fonts must be at the top level.
The separation invariant
Lerret never writes outside .lerret/. Edits made in the studio (data values, config changes) write back to the corresponding .lerret/... file. Export output is written to a separate directory (default ./lerret-export/, configurable with --out) β never inside .lerret/.
The inverse also holds: your components never need to import anything from Lerret. They are plain React. The default export is a component; named exports are extra variants; the meta export is parsed as metadata. That is the entire contract.
Result: you can git rm -rf .lerret/ tomorrow and still have a fully working set of React components. The tool is removable; your work is not.