picnic-kit
v0.3.0
Published
The Picnic prototype chrome: a canvas, a click-through viewer, a vite plugin, a CLI and a static build, from a typed manifest.
Maintainers
Readme
picnic-kit
Turn a folder of React screens into a pan/zoom canvas, a click-through prototype and a static handoff bundle — from one typed manifest, with no server and no account.
npm install picnic-kit
npx picnic init
npx picnic devpicnic init writes a starter picnic.config.ts and a delimited Picnic section in your AGENTS.md.
picnic dev serves the canvas at / and every screen at its own route. picnic build emits a fully
static dist/ with picnic.manifest.json, handoff.md and llms.txt in it.
Why it exists
A design prototype should be readable by the people who have to build it, and by the agents helping them. So a Picnic prototype is an ordinary repo: screens as real React, plus a typed manifest naming the flows, the screens, the states, and — required — what each one is arguing for.
This package is the chrome around that. It has no server, no persistence and no accounts, and it
must build, run and be tested with no network: a cloned prototype has to work on a developer's laptop
with npm run dev, or "download the repo, you own it" is a slogan rather than a fact.
The commercial half — comments, share links, approvals, versioned rounds, agent editing — is Picnic, and none of it is needed to use this.
The build is prefix-agnostic
picnic build produces an artefact that works served from a domain root, a subdirectory, or an
object-storage prefix: the base is relative, each route's HTML is depth-corrected, and each file
carries the route it was written for. Drop dist/ anywhere and it renders.
Status
0.1.x, and the API is still moving. The manifest types are the public surface and changing them is a breaking change for every prototype pinned to a version, so they are treated carefully — but this is a young package and the minor version will move under you until it settles. Pin it.
picnic-kit is a working name. The framework was meant to get a name of its own, distinct from the
product; a three-round search found no candidate worth the rename, so this one stands.
The source is not public yet. It will be — that has been the plan since the first architecture decision — once the API stops moving. Compiled builds are published here in the meantime, MIT licensed.
Everything below is the design rationale, kept with the package because it is the reasoning a contributor or a curious reader needs.
The rule
Never import from apps/. The dependency arrow is apps → packages, one way. CI should enforce
this rather than trusting convention.
What's here
| Module | What it does |
|---|---|
| manifest.ts | The typed manifest — defineConfig, PicnicConfig, and the helpers that read it |
| validate.ts | The checks a type cannot make: duplicate slugs, route clashes, dangling annotations |
| routes.ts | Manifest to routes, and a location back to a place in the manifest |
| components/PicnicApp.tsx | The whole chrome. A screen route renders bare; anything else is the canvas |
| components/Canvas.tsx | Pinned screen list, the board, drag-pan, reveal-on-select, zoom control |
| components/ScreenCard.tsx | Lazy mount: a frame-size placeholder, then a live iframe |
| components/Viewer.tsx | Click-through: back, prev/next, viewport switch, H to hide chrome |
| components/Toolbar.tsx | The floating pill, and the buttons that go in it. Draggable by its handle, position remembered |
| components/ToolbarMenu.tsx | Menus that hang off a toolbar control: outside-click, Escape, anchoring |
| components/Breadcrumb.tsx | Where a frame sits, as a trail. Collapses in the middle when it is long |
| components/theme.ts | Both palettes, the type ramp, and the recipes. The only file with a raw value in it |
| components/ScreenList.tsx | The list of flows, screens and states. Pinned on the canvas, popover in the viewer |
| components/ScreenNav.tsx | The viewer toolbar's list button, wrapping ScreenList in a popover |
| components/icons.tsx | Nine inline SVGs, so nine glyphs do not cost a dependency |
| components/Comments.tsx | Pins. No source, no render — the product boundary, not a gap |
| components/Frame.tsx | Render at design size, CSS-transform to fit. The canvas primitive |
| canvas/usePanZoom.ts | Pan and zoom, hand-rolled. No graph library — no edges, no ports, no graph semantics |
| canvas/layout.ts | Grid layout: one band per flow, every frame flat and left to right in click-through order |
| handoff.ts | Renders handoff.md and llms.txt from the manifest |
| agents.ts | The delimited section picnic init writes into a prototype's AGENTS.md |
| vite/plugin.ts | Loads and validates the manifest, generates routes, emits the build artefacts |
| cli/index.ts | picnic init / dev / build / check |
Two surfaces, two shapes
The canvas is shaped like Figma. A pinned list down the side, the board in the rest, a zoom control in the corner. The list is always there rather than behind a button, because it is how you find anything once the board is bigger than a screenful, and a control you have to open is one people forget exists.
Selecting and opening are different things, which is the difference between a board you can work on
and a board that is really a menu. Hovering a frame rings it, clicking it selects it, and neither takes
you anywhere. To enter the prototype you press the play button that appears outside the frame's
top-left corner on hover — and it starts the click-through from that frame, not from the beginning.
The side list works the same way: click reveals, double-click opens. Enter opens the selection.
The play button is sized in board units by uiScale (1/zoom) so it stays the same size on screen
however far out the board is zoomed. A control that shrinks with the canvas becomes unclickable exactly
when the board is too big to navigate any other way. It sits in a slot whose right edge touches the
frame, with the visual gap as padding inside the slot — a real gap between two elements is a strip
belonging to neither, and the pointer crossing it fires mouseleave and takes the button away on the
way to clicking it.
Where a frame sits is a breadcrumb, not a glyph
An elbow says "this hangs off something" without saying off what. On a board the parent is somewhere off
to the left, so a frame's title is a trail — Enter your domain / Invalid domain — with the parents
faint so the current frame still reads first. The viewer toolbar uses the same component with the flow
at the root.
In a list, indentation already says it, so there is no elbow there either. One ScreenList, pinned
beside the board and inside the viewer's screen menu, identical in both.
Nothing in the chrome is numbered. The board reads left to right and the list reads top to bottom;
a number on every row was a third ordering saying what two already said. step survives in the manifest
only because handoff.md headings its sections with it, which is the one place a reader has no layout
to infer order from.
Trails longer than two collapse in the middle: Onboarding first cut / … / Invalid domain. The top of
the tree and where you are now are the two things worth the space; the middle is the part you can
infer.
The viewer gets the floating toolbar, because there it is covering the client's work and has to be moveable. There is no floating toolbar on the canvas — nothing is in the way there, and having one on both surfaces meant the shared controls sat in different places depending on which one you were looking at.
Its row is: back to the board, then prev / the screen title / next, then the viewport. The title is the control that opens the screen list, rather than a separate button sitting next to a label saying the same thing. The viewport shows only where you are, and the alternatives are behind it with their names and the width each one applies — three near-identical device glyphs in a row told you nothing about what pressing one would do.
Chrome should feel native
cursor: default everywhere, never pointer — a native application does not put a hand cursor on its
own toolbar. The only exceptions are real direct manipulation: grab on the canvas and on the toolbar
handle. Dragging the canvas pans it rather than selecting the labels it passes over.
A frame's title goes above it and its description below it. The title names the thing you are about
to look at; the description is what to make of it once you have. A screen's description is its intent
and a variant's is its trigger — there is no second, shorter subtitle field, because two fields saying
nearly the same thing is one field that goes stale.
Dark mode
The chrome follows the operating system. There is no toggle, because the chrome is not a place anyone came to configure.
It applies to the chrome only. The prototype inside a frame is the client's work, rendered by its own route with its own CSS, and it looks exactly as its author built it whatever the person viewing it has their laptop set to. A prototype that wants a dark mode ships one itself.
Inline styles cannot carry a media query, so the palette is reactive instead: useTheme() reads
prefers-color-scheme through useSyncExternalStore. An external store rather than a context, so any
component can ask without the tree being wrapped in a provider — <Canvas /> and <Viewer /> work on
their own. Both palettes are built once at module load; switching is picking one, not recomputing.
Two things are fixed across both: the selection blue, and the colour of text drawn on it. Anything on
top of the selection colour uses theme.onSelect, never theme.surface — they are the same in light
and opposites in dark, so text written against surface looks right until the row is selected in dark
mode, and then vanishes. onSelectMuted is the same idea for secondary text.
Floating controls are outlined with a ring rather than a border — a 0 0 0 1px shadow, so the
outline sits over what is behind the pill instead of occupying a pixel of its own. It keeps the edge
crisp and stops the pill measuring two pixels wider than its content.
One file owns every style value
theme.ts holds both palettes and a named recipe for each thing that
exists more than once — styles.pill, styles.control, styles.menu, styles.row, styles.frame,
styles.panelRight. A component never writes a hex, an rgba, a pixel radius or a shadow. It
composes.
This is enforced, not remembered: styles.test.ts scans every
component and fails the build on a raw value, on a cursor: pointer, or on a second colour with a hue
in it. A linter would be the usual answer; this package has no linter and is not taking a dependency on
one to check six regexes.
Dark mode is the payoff: because no component had a colour of its own, adding a second palette was a change to one file and nothing else. The guard test is what made that true.
It exists because the same bug kept arriving in different clothes: the zoom control and the viewer toolbar drifted apart by a border radius and a background, the two surfaces disagreed about cursors, and swapping the palette meant editing three files. Making the zoom control look like the toolbar is now not a styling job — they are the same recipe.
The chrome is greyscale with exactly one exception. Selection is #1777f2. It is a state rather
than decoration and has to stay visible on top of whatever the client's palette is; a grey ring on a
grey screen is not. It is spent on the selected frame's hairline, the matching list row, and the current item in a menu —
one state, one colour, wherever it appears. Nothing else uses it, and nothing selected is indicated by
a tick: a highlight already says which one it is, and a tick beside it says it twice.
Chrome is composable on purpose
Toolbar is a shell — a draggable pill that positions itself and remembers where it was put. What
goes in it is ToolbarButton, Divider, ToolbarLabel and ScreenNav, and both the canvas and the
viewer compose their own row from those. Adding a control to one surface, or a new surface entirely,
is a matter of composing the same pieces rather than editing a bar.
If something takes up space on the canvas, its size belongs in layout.ts. Not in a margin in
Canvas.tsx. This rule has now been learned three times:
- Bands measured from the frames alone, so state chips and annotations under the last screen spilled
into the band below →
footerY - A screen's own title strip is drawn above its frame and was in no one's maths, so it landed on the
band header →
SCREEN_LABEL_HEIGHT - A state stack grew right as well as down, but the row advanced by the frame width alone, leaving a two-state stack sixteen pixels from its neighbour → the row advanced by the stack width
- A variant is dropped below the screen it hangs off, so the board shows the hierarchy without hiding
anything →
VARIANT_OFFSET_Y
Each has a test named after the thing it prevents. Add the number before you add the element.
The third one is now moot, because the stack is gone: variants are laid out flat, each with a frame of its own (ADR 0008). Two rounds of geometry went into making the stack sit correctly before it became clear the stack was the problem — correct and still bad. Nothing on the board is drawn behind anything else, and the test says so exactly rather than allowing for intentional overlaps.
Seeing it work
examples/demo is a real prototype built on this package, and exercises every
feature above.
pnpm -C examples/demo devStill to build
The prototype-ad83 port, which is the formal acceptance test: take a real 16-screen prototype, delete
its hand-rolled chrome, write a manifest, and see whether the repo comes out smaller and the
experience better. That needed a package on a registry a clone could reach, which 0.1.0 is, so it is
unblocked and is the next thing.
Usage
// picnic.config.ts, in a prototype repo
import { defineConfig } from "picnic-kit";
export default defineConfig({
name: "BrandViz onboarding",
frame: { width: 1600, height: 900 },
designSystem: { tokens: "src/index.css", primitives: "src/components/ui" },
flows: [
{
slug: "onboarding",
name: "Onboarding first cut",
note: "Ten screens in three bands. The gate falls at screen four.",
screens: [
{
slug: "landing",
step: "01",
title: "Enter your domain",
intent: "Give the whole diagnosis away. The gate moves to the first fix, not the front door.",
path: "/onboarding/landing",
source: ["src/screens/onboarding/Landing.tsx"],
},
],
},
],
});intent on a screen and trigger on a state are required. They are what handoff mode needs, and
nothing else would make anyone write them down.
Commands
pnpm -C packages/kit build # tsc → dist
pnpm -C packages/kit test # node --test, no runner dependency
pnpm -C packages/kit typecheck