Skip to Content
Concepts

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.md

Everything 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.

ExtensionAsset kindRendered as
.jsx, .tsxcomponentOne artboard per component-valued export β€” see Variants.
.mdmarkdownA 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 default export, 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 meta object) are skipped; they are not variants.
  • The reserved meta export 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 }, }, };
FieldPurposeDefault
dimensionsThe artboard size in CSS pixels. Both width and height are optional positive numbers.Studio’s default size
labelA human-facing display name. Trimmed, non-empty string.Derived from the file / export name
tagsA list of string tags for organization. Non-string entries dropped; empties dropped.[]
propsSchemaA 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:

FormUse when
<Name>.data.jsonStatic values. Plain JSON; parsed once.
<Name>.data.jsComputed 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.json is 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’s vars.tags entirely β€” arrays are not element-merged.
  • A config.json that 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:

TierSourceWhen set
1. DATAVariant data (keyed) or shared data, from .data.json / .data.jsPer-asset, per-variant override
2. VARSThe asset folder’s effective config.json vars block, after cascadePer-folder or project-wide theming
3. SCHEMA DEFAULTmeta.propsSchema.<prop>.defaultAuthored default for a prop
4. COMPONENT DEFAULTReact’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:

ExtensionFormat
.woff2woff2
.woffwoff
.ttftruetype
.otfopentype

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.

Last updated on