A board where you drop ideas and work on them. You can drop images, video, audio, 3D models, notes, links and other files, and write a card that draws itself. You can apply visual effects to anything with pixels, run a sound through effects of its own, make a depth map of a photograph and push other pictures around with it, ask for twelve versions of anything and keep the ones worth keeping, move things around, group them into sections and label them, draw connections between them, keep checklists in your notes, put a board inside a board when one canvas is no longer enough, and take any of it out as a full-resolution picture.
Everything is stored in your own browser. There is no server and no account. Two things reach out, and only when you ask them to: drawing a picture from a prompt, which needs your own Google key, and setting text in one of the Google families, which fetches that family's stylesheet the first time you pick it. Everything else — including every board you have, and the two typefaces the app carries — works with nothing to connect to.
You need Node 20.19 or newer, and on the 22 line 22.12 or newer — which is
what the build tools ask for, and what engines in package.json will tell
you if you are on something older. CI runs 22.
npm install
npm run devThen open the address that Vite prints, which is usually
http://localhost:5173.
To build a copy you can host:
npm run buildThe build is written to dist.
The repository already has a vercel.json, so there is nothing to configure.
It sets the framework to Vite, the build command to npm run build, the output
folder to dist, a catch all rewrite to index.html, and long cache headers
for the hashed files under /assets.
The simplest way is to let Vercel build from GitHub:
- Go to https://vercel.com/new.
- Import
ibrahimweng/Ideation-board. - Accept the settings Vercel reads from
vercel.jsonand press Deploy.
Vercel then builds every push. The default branch becomes the production site and other branches get their own preview links.
If you would rather deploy from your own machine, install the Vercel command line tool and run it from a clone of the repository:
npm install -g vercel
vercel login
vercel --prodNothing in the app runs on a server. The build is a set of static files, and all the work happens in the browser, so there are no environment variables or secrets to set.
Two sides. Everything that puts something on the board is a rail down the left: notes, labels, text, sections, files, links, drawing a picture, a board inside this one, and the whole board in and out. Everything that acts on what is already there is on the right of the top bar: undo, the command list, help, and the effects panel. Between them, in the row itself, is where you are — your projects as tabs, flush left, and the search and tag filter in the middle.
Both rails carry icons rather than words. Every one of them says what it is and which key runs it when you hover it, and every one of them is also in the command list, which is the real answer to "where is that thing".
Two of the tools arm instead of firing. Text and Section are both regions rather than cards of a fixed size, so pressing one takes the cursor — a caret for text, a crosshair for a section — and the next drag on the board draws the box. Escape puts the tool down again.
Press Cmd or Ctrl with K, or the ⌘ button in the bar, and type. It searches everything the board can do, including the things there was never room for in the bar: tidy up, line a selection up, space it out, export the selected pictures, open the saved looks, switch the theme. The letters only have to be in order, so "tdy" finds "Tidy up the whole board", and every entry shows the shortcut that runs it, so using the list is how you learn to stop using it.
There is not a gradient anywhere in the interface.
Where a name sits over a photograph, or a bar sits over a board being presented, the thing that makes the words legible is a halftone screen: dots whose coverage falls off, densest where the words are and gone a few pixels above them. A smooth ramp would do the same job and look like every other scrim on the web; a screen does it by the method this app is actually about, which is coverage rather than opacity. A picture that has not loaded yet is a still field of dots rather than a gradient sliding across.
src/board/screen.ts builds them. The radii follow from the coverage they
stand for — area, so the radius goes as its square root, which is what makes a
halftone read as an even ramp instead of a sudden wall of ink.
The dot grid on the board is the same idea and always was.
The button beside the zoom control cycles between following your system, light, and dark. A dot on it means it is following the system.
This is not decoration. What surrounds a photograph changes what you see in it — the same picture reads warmer on a pale ground and flatter on a dark one — and a board is where photographs are judged, so it is worth being able to say which surround you are judging against.
You add things in several ways:
- Drag files from your computer onto the board. A folder of twenty arrives as a block shaped like your window rather than a column marching off the bottom of it, and the board moves to show you what landed — unless all of it landed in front of you already, in which case nothing moves. That is the whole question, and it holds for a single photograph too: a card is capped at 420 across, which is wider than a phone.
- Press the picture button in the top bar.
- Paste an image, a block of text or a link.
- Paste or drop the address of a video file and it becomes a video card you can play and put effects on. A YouTube or Vimeo link becomes an embedded player.
- Drag a picture out of another browser tab, or paste its address, and it becomes a picture rather than a link.
- Press the nested-frame button for a board inside this one, for work that has outgrown a corner of the canvas.
- Drop a
.glbor a.gltfand it arrives as a picture of itself that you can turn, rather than as a grey rectangle with three letters on it. - Drop a sound and it draws its own waveform, plays, and can be run through a chain of effects the way a picture can.
- Press W for a card you write: a dozen lines of drawing code that become a picture like any other.
Once something is on the board:
- Drag a card to move it. It lines itself up with the cards around it and draws a guide where it has, or hold Shift to snap to a grid instead.
- Drag a corner handle to resize it.
- Drag on empty space to select several cards at once. Shift and a click adds one to the selection or takes it out again, and a click on a card that is already one of several keeps just that one — but only once the button comes back up without having moved, so a group can still be dragged by any card in it.
- Hover a card and drag one of the four dots on its sides onto another card to connect them.
- Select several and use the right click menu to line them up, space them evenly, or tidy them onto a grid.
- Hold Alt and drag, or drag with the middle mouse button, to pan the board.
- Hold Ctrl or Cmd and scroll to zoom.
- Press 1 to fit the whole board on screen and 2 to fit what is selected.
- Double click a note, a label or a section to edit its text.
- Double click a picture, a video or a board card to see it big.
The row across the top holds eleven buttons on a laptop and four on a phone: add a file, undo, the command list, and the effects panel. The seven that stand down are all reachable from the command list, from the menu a held finger opens on the board, and from the empty board's own buttons — the row clips rather than wraps, so anything that will not fit has to be taken out deliberately rather than pushed off the edge where nobody can see it went.
Every button grows when the pointer is a finger rather than a mouse. The row was built at 28 by 26 pixels, which is under both of the sizes the platforms ask for, and that was true at every window size and not only on a small one.
A section is a labelled area you can group work into. It behaves the way a section does in Figma.
- Drop an item so its middle is inside a section and it joins that section. The section lights up while you hold an item over it, so you can see which one will take it.
- Move a section by dragging anywhere on it — its title bar, or the space between the things inside it — and everything inside moves with it. Holding shift draws a selection rectangle instead, which is the only thing the body of a section used to do.
- Draw one by pressing Section (or S) and dragging out the region you want. The board takes a crosshair while the tool is armed, and the tool stands down once you have drawn one.
- Drag an item out and it leaves the section.
- Resizing a section never changes what is inside it. Only dragging does. An item can therefore stick out past the edge and still belong to the section.
- Duplicating a section copies its contents too, and the copies belong to the copy rather than the original.
- Deleting a section deletes what is inside it. One undo brings all of it back.
Sections sit behind the items they hold, and one section cannot go inside another.
Type in the search box to narrow the board. Cards that do not match fade out and stop taking clicks, but stay where they are, because where you put something is part of how you recognise it later.
Search looks at the card name, its text, a link's address, its tag, the kind of card it is, and what it is made of — the effects on a picture, the chain on a sound, the code in a sketch, the materials in a model. Every word has to match somewhere, in any order, so "blue note" finds a note tagged blue, and "halftone" finds the picture you put a halftone on and never got round to naming.
Next to the box is a tag filter. Pick a colour to keep only cards carrying that tag, or Untagged to find the ones with none. Each entry shows how many cards it covers. The two controls work together, so a card has to satisfy the text and the tag to stay lit.
Press Enter to move the board to the next result and select it, Shift and Enter for the previous one, and Escape to clear. Press / or Cmd and F to put the cursor in the box.
The search box reaches into the boards nested on this one. A board card holds a whole board and opening one loads only that record, which is what makes nesting free — and what used to make everything you filed invisible to the thing that exists to find it. A count beside the box says how many matches are below; pressing it lists them with the board each is in, and picking one opens that board and puts the view on the card. Nesting is meant to be how you put work away, not how you lose it.
Narrowing a board is only half of it. Cmd or Ctrl with Enter takes the whole
result set as the selection, and so does clicking the count beside the box.
While a search or a tag filter is running, everything that acts on "this
board" acts on what you can see instead: Select all takes the results, Present
shows only them, and exporting exports only them — the command list says which
it is about to do rather than leaving you to find out. Search for kept or
cut and the board narrows to one side of a decision, which is what makes
marking one up worth the trouble.
Each toolbar button shows its shortcut, and hovering one names it in full. The single key shortcuts only fire when no modifier is held and no text field has focus, so they never get in the way of typing.
Right click a card for a menu with edit or rename, duplicate, bring to front, send to back, remove from section, keep or cut, tagging and delete. Right clicking a card that is part of a selection acts on the whole selection.
Right click bare board for a menu that adds a note, label, section, link or files at the point you clicked, and that can paste or select everything.
Keyboard shortcuts:
| Keys | What it does |
|---|---|
| F | Add files |
| N | New note |
| L | New label |
| S | New section |
| B | New board |
| K | New link |
| E | Show or hide the effects panel |
| P | Present the board |
| 1 | Fit the whole board on screen |
| 2 | Fit the selection on screen |
| I | Mark the selection as kept |
| O | Mark the selection as cut |
| G | Put the selection together in one place |
| C | Hold the selection up against each other |
| / | Search |
| Cmd or Ctrl and Enter | Select every search result (in the search box) |
| Cmd or Ctrl and S | Export this board and everything in it |
| Cmd or Ctrl and O | Import a board file |
| Cmd or Ctrl and X | Take the selection off this board |
| Cmd or Ctrl and V | Paste — cards taken off another board, or whatever is on the clipboard |
| Cmd or Ctrl and E | Export the selected pictures as PNG |
| Cmd or Ctrl and Z | Undo |
| Shift and Cmd or Ctrl and Z | Redo |
| Cmd or Ctrl and A | Select everything |
| Cmd or Ctrl and D | Duplicate the selection |
| Delete or Backspace | Remove the selection |
| Arrow keys | Nudge the selection, and hold Shift to nudge further |
| Escape | Clear the selection |
Select an image or a video and open the Effects panel. There are 69 effects, grouped by the kind of look they give. Each preview in the panel is a real render of your own picture through that effect, not a stock sample. Seventy tiles — the sixty-nine and the picture untouched — is more than anyone can scan, so there is a field at the top to find one by name.
Anything with pixels of its own takes them, which is wider than it sounds: a page of a PDF, a Photoshop document, a rendered view of a model, a sketch you wrote.
With nothing selected the panel stands down to a narrow rail and gives the board back the width, since everything it has to say needs a picture to say it about.
The panel has three tabs:
- Effect. Pick one of the 69 effects and adjust its own settings.
- Adjust. Change exposure, contrast, saturation, warmth, blur and grain. You can also zoom, move, rotate and flip the picture inside its card, and say how it sits with the cards under it.
- Looks. Save what you have arrived at and put it on other pictures.
Every figure in the panel can be typed into as well as dragged, because a setting you cannot enter exactly is a setting you cannot repeat on a second card. Hold \ to see the picture without any of it — held rather than toggled, since a mode that hides your work is the worst kind to be left in by accident.
Framing is done by hand as well as by number: Alt and drag pushes the picture around inside its card and Alt and the wheel scales it, writing the same two numbers the sliders write. Nobody frames a photograph by typing coordinates into two boxes. On a model the same gesture turns the model instead, because there is no picture to push about there — there is a thing, and the other side of it.
You can select several images and apply the same effect to all of them at once.
An effect never changes the shape of what it is applied to. A card that is not the shape of its picture crops it to fill, exactly as it does with no effect on it, so turning one on changes the look and nothing else.
Effects work on playing video as well as on stills. When you put an effect on a video, the card shows the effected picture and gets its own play button, scrub bar and mute button, because the browser's own controls sit behind the effect and cannot be reached. Pause anywhere and the card keeps showing the effected frame you stopped on.
A board of photographs is rarely a dozen separate decisions. It is usually one decision made a dozen times, and the Looks tab is where that decision is kept.
Set an effect and a tone on one picture, open Looks and press "Save this look". It arrives in the grid below under a name taken from what it actually is — "Halftone mono grain" — which you can type over then or rename later by double clicking it. Every tile in the grid is a live render of the picture you have selected through that look, so you are choosing by looking at your own photograph rather than at a stock sample.
Click a tile to put that look on everything selected, in one step of undo.
A look carries the treatment and not the composition: the effect, its own settings, and exposure, contrast, saturation, warmth, blur and grain. It leaves zoom, offset, rotation and flip alone, because those are how one particular picture is cropped and carrying them across would wreck eleven framings to copy one.
For a look you want once rather than forever, right click a graded card and choose "Copy look", then right click another and choose "Paste look". The clipboard holds one look and survives a reload.
Saved looks are kept in the browser rather than in the board, because a look is
how you work rather than what is on this board. The one you saved last week is
waiting on the board you start today, and it does not travel inside an exported
.board.zip.
Every effect here treated one picture. The ones that matter most on a moodboard treat two: a texture pushed through a photograph, a wordmark knocked out of a shape, a palette taken off one image and put onto another. Doing any of it meant leaving, doing it somewhere else, and bringing the answer back flat.
The wire is the wiring. Draw a line from one card to another — the same line you would draw to say these two belong together — and when the card it points at is running an effect that wants a second picture, the card at the other end is that picture. There is no picker and no second selection mode, because the board already had a way to say "this one, with that one". Where two wires arrive at the same card the newest wins, which is how you change your mind.
Seven effects read a partner. Displace pushes the picture around by the other one's brightness. Stencil lets the other picture decide where this one shows — a shape knocked out of a photograph, a photograph poured into a letterform. Through takes this picture's range of light and colours it with a line read across the other, which is how a palette pulled off one image gets put onto another without anybody naming a single colour. The remaining four — Parallax, Depth of field, Fog and Relight — read the partner as distance, which is what the next section is about.
An effect that wants a partner and has none is still an effect rather than an error. It has to be, or it could not sit in the list beside the others.
Select a photograph and ⌘K → "Make a depth map of the picture". A card appears beside it holding a map of how far away everything in it is, wired into the picture it came from — because that is what anybody making one is about to do by hand anyway.
A card rather than a hidden buffer, and that decision is most of the feature. You can look at it, which is the only way to know whether the guess is any good before spending an hour on top of it. It takes every effect on the list, so a map that reads a bright window as near is corrected with the tools already here rather than with a slider somebody has to write first. It can be drawn from nothing as a sketch, or brought in from a renderer that exported a real one. It wires into anything, so one map can drive four pictures. And it exports, varies twelve ways and comes back after a reload, because everything that works on a picture already works on it.
Four effects read a map as distance:
- Parallax moves the camera. Near things move further across the frame than far ones, and Towards makes it a step forward rather than a step sideways.
- Depth of field holds one distance sharp and blurs away from it in both directions, the way a lens does rather than the way a blur slider does.
- Fog puts air between you and the far things.
- Relight reads the map as the shape of the surface and lights the picture again from a direction you choose.
The map the engine makes is three cues and a blur: what carries detail is near, what is washed out is far, and the bottom of the frame is the floor. It costs nothing, it is instant, and it is wrong in one particular way that no weighting fixes — it is reading brightness and texture, so a dark near thing against a bright far one comes out backwards.
⌘K → "Work out the depth map properly" runs a real monocular depth model on the picture, in your browser. It is not reading brightness; it has seen a few million photographs and knows what a face, a doorway and a horizon are.
A first use fetches a runtime and about twenty-five megabytes of weights, which is a real download on a board that has never asked the network for anything except a picture you paid for. So it happens when it is asked for and never before, and the quick map stands in the whole time — the board is never waiting on it. Both are pinned to a version, because a runtime that changed under the app would change what every board's depth map looks like, and that is not a thing to find out about from a bug report.
The weights are kept in the same store the pictures are in, so the second use is offline and instant, and ⌘K → "Let go of the downloaded depth model" gives the room back and says how much it was.
Each effect has a Repeat count, and each pass reads what the pass before it drew. One is an effect. More is feedback, which is the whole reason a live coded video synth is worth using: a kaleidoscope that recurses, a warp that spirals, a blur that blooms. None of it can be reached by moving a slider, because what it needs is not a different setting — it is the same setting applied to its own output.
A count rather than a running loop, because these are still pictures. A card has to look the same next time it is opened, and an effect that drifts while nobody is watching cannot be exported, reloaded or compared against anything.
This is not the same as stacking. Stacking is different effects one after another and has a section of its own below; repeating is one effect reading what it just drew.
Two settings on the Adjust tab are about the card and the board rather than about the picture: how strong it is, and how it blends with what is beneath it. Eight modes rather than the sixteen a graphics program offers, because sixteen is a menu nobody reads — Normal, Multiply, Screen, Overlay, Darken, Lighten, Difference and Luminosity, which read as nothing, the two that darken and lighten by multiplying, the two that do it by picking, and the three that are their own thing.
A texture laid over a photograph, a wordmark knocked out of a colour field, a scan of a print held at a quarter strength over the thing it is being compared with. Both settings are part of the treatment, so a saved look carries them the way it carries the tone.
The panel is a thing you operate: pick an effect, push six sliders, look, push them again. That is fine when you know what you are after, and useless when you do not — which, on a board whose job is working out what you are after, is most of the time.
So select a card and press V. Twelve versions of it appear in a grid underneath, the card itself untouched. Mark the ones worth keeping with I and press V again: the ones you did not keep are replaced, in the holes they left, by twelve bred from the ones you did. Press V on a single card out of the batch and that one has won — the rest were the alternatives it was chosen over, and they go. Every round is one press of undo, because a grid you cannot cheaply throw away is a grid nobody will risk making.
What varies is whatever makes that card what it is. A picture varies by what is
drawn on it, a sound by what it is run through, a sketch by the throw its code
was drawn on, and a model by where the camera stands — because twelve
treatments of one drawing is not what you asked for when the thing worth having
twelve of was drawings. src/state/varyGrid.ts is the grid, once, for every
medium; src/state/varying.ts decides which dice. A medium supplies only what
makes its twelve different from each other.
R throws one random treatment at the selection instead, for when twelve is more than the question deserves.
A note holds more than a block of text. Headings, bold, italic, code, quotes, bullet and numbered lists, links and checkboxes all work, and the buttons above the editor write them for you — or you can type them, since they are the same marks people already use in plain text:
# A heading
**bold** *italic* `code`
- a list
1. a numbered list
> something borrowed
- [ ] still to do
- [x] done
https://example.com
Checkboxes can be ticked on the card itself, without opening anything, and the card's title bar counts how many are done. Cmd or Ctrl with B and I work in the editor.
The note stays one string. That is what keeps search working on it, keeps the saved board readable, lets a note pasted in from somewhere else arrive with its shape intact, and means none of this needed a migration.
Select a note or a label and the panel on the right becomes a Text panel.
It opens with six roles — Title, Heading, Subheading, Small heading, Body, Caption — because "make this a heading" is one decision rather than four sliders. Each sets size, weight, line height and tracking together, the way they go together. Underneath are the sliders themselves, plus the family, the weights that family actually has, alignment, italic, underline, caps, and the ink.
The ink matters more than it looks. A label used to be made with a near-black written into its record, which is right on a pale board and invisible on a dark one — so text imported onto a dark board could not be found at all. A colour you pick is now kept exactly and nothing else is written down, which means the theme's own ink answers, and a label reads on either ground. Labels already saved with that near-black are read as "nobody chose this" and heal the moment the board is opened.
Two typefaces come with the app — Instrument Sans and JetBrains Mono — along with the ones your machine already has. Twelve Google families are on the shelf as well, with a box for typing the name of any other, and one of those is fetched the first time you set something in it and never again. A board that stays on the families the app carries never asks Google for anything. If the fetch cannot happen — a plane, a firewall — every family falls back through its own stack to something already installed, so you are never left with a board you cannot read.
T, or the Text tool in the rail, draws a text box wherever you drag it and puts the caret straight in it. It is the same kind of card as a label — type lying on the board with nothing under it — and it opens empty, at the measure you drew.
Double-clicking a label writes on it in place rather than opening a dialogue, set in exactly the type it draws in: the family, the size, the weight, the alignment, the colour. Enter finishes, shift and Enter starts a line, and Escape leaves it as it was. A note still opens the sheet, because a note has headings and lists and a row of buttons for them.
Anything you add arrives selected, which is the other half of the same problem: a card that lands with nothing marking it is a card you have to go looking for, and on text you could not see it was a card that appeared not to have arrived.
Cards line themselves up as you drag them: edges with edges, middles with middles. A guide is drawn between the two cards that agree, so it is clear what has just happened, and Shift asks for the plain grid instead.
Select two or more and the right click menu can line them up by any edge or through their middles. Three or more can be spaced evenly across or down, with the outermost two left where they are. "Tidy up" lays the selection out on a grid, in the order it reads now, keeping roughly the shape it already had: a row stays a row and a block stays a block. Each is one step of undo.
One finger pans the board and drags a card. Two pinch to zoom and pan together. A tap on empty board clears the selection.
Hold one finger still on a card for half a second and its menu opens, which is where export, pull the colours out, copy a look, tag, and send to back all live. Hold on empty board and the add menu opens. Without that, half the app was unreachable on a tablet: iOS fires no context menu event for a long press and Android fires one at a moment of its own choosing, so the press is timed by the board itself and behaves the same on both.
Tab moves through the cards in the order the board reads — across each row, then down — and brings whatever it lands on into view. Shift with it goes back. Everything the board can do to a card is behind a selection, so before this none of it could be reached without a pointer.
- Tab / Shift Tab — the next card, the previous one
- Enter — open what is selected
- Arrows — move the selection, Shift for a bigger step
- Escape — select nothing
- ⌘K — everything else, searchable, with the key for each
The selection is read out as it moves, with its position: "Autumn light.jpg, image, 3 of 12". A card is a box on a canvas, and nothing about its border changing colour would otherwise be announced.
Hover a card and four dots appear on its sides. Drag one onto another card and the two are joined by an arrow. The card you are about to land on is outlined, so it is clear what will be connected before letting go. Selecting exactly two cards and choosing "Connect" from the right click menu does the same thing without the drag.
A connection is stored as the two card ids and nothing else. It is drawn from wherever those cards happen to be, leaving each one from the side that faces the other, so moving a card takes its connections with it and nothing has to be tidied up afterwards. Each connection watches only the two cards it joins, so dragging a card redraws its own arrows and no others.
Click an arrow to select it and Delete to remove it. Deleting a card removes the connections that reached it, and one undo brings back the card and its connections together.
A connection is also how one card is handed to another: an effect that wants a second picture reads the card at the other end of the wire, and a model wears what is wired into it. Both are above, under "One card read through another" and "Models".
A row of tabs along the top, one per project. There was one board with everything nested inside it, which is a fine way to keep one project and a poor way to keep four: opening last month's work meant walking down into it from the same root, and there was no way to have two of them on screen at once.
Which boards are top level is worked out rather than written down — a board is a project when no board card anywhere points at it. Nothing to keep in step and no second list that can disagree with the boards themselves, and it has a property worth having on purpose: delete the card that stood for a nested board and that board stops being nested, so instead of becoming a record nothing can reach, it turns up in the row. Work cannot go missing quietly.
Switching does not reload. The board is loaded in place and the address is
rewritten to match, so ?board=<id> always names what is on screen — which is
what makes the address worth copying, and what lets a reload, a bookmark or the
browser's own "open in a new tab" land back where you were.
The tabs are not the breadcrumbs. The crumbs say where you are inside a project and the tabs say which project, and folding them into one row would make a board four levels down look like a fifth project.
Closing a tab deletes the project, which is the one thing here that destroys work outright: no file behind a board, no undo that reaches across boards, and this browser holding the only copy. It asks first and says what it is about to take. Then, for ten seconds, it offers to put it back — under the same ids, in the same place in the row — because a confirm is a thin safety net when a focused tab answers the Delete key. The project is properly gone in the meantime, from the row and from search both, and the sweep that would have collected its pictures is the one thing in the app that waits.
A board card opens a board of its own. Double click it to go in, and the trail at the top left is the way back out: each step is a button that takes you straight to that level. Boards nest as deep as you like, and the card shows what is inside — a count and the first few pictures — so a closed board is not a closed box.
Each board is stored on its own and only the one you are looking at is loaded. A board holding a thousand cards therefore costs nothing to the board it sits on, which is the point of putting it in a board rather than a section. The board you were on is the one you come back to when you reopen the page.
Renaming works from either end: the name field at the top left names the board you are in, and the card that opens it takes the same name. Duplicating a board card copies what is inside it, including any boards in there, so the copy is a copy rather than a second door onto the same room.
Deleting a board card takes the card off this board but leaves what was inside it alone, so undo brings back the card and everything in it.
One thing to know: search and the tag filter look at the board you are on, not the ones inside it. Export does take the whole tree.
Paste the address of a video file and the board loads it, works out its shape and gives you a video card with everything a dropped file gets.
Whether it can also take an effect is not ours to decide. Running a shader over a video means reading its picture back out of the player, and a browser only allows that when the site hosting the file says it may. Many do; some do not. The board finds out by loading the file both ways when you paste it, so you are told rather than left guessing:
- If the host allows it, the card behaves exactly like a dropped file.
- If it does not, the video still plays and the card says "Effects unavailable for this source". Exposure, contrast, saturation, warmth, blur, grain, zoom, rotation and flipping all still work on it, because those are applied to the card rather than to the picture's pixels.
- A YouTube or Vimeo link becomes an embedded player. Nothing outside an embedded player can read its picture, so those cards take the same adjustments but no effects. Click once to select the card, and again to reach the player's own controls.
An address that turns out not to be a video stays an ordinary link card.
An embedded player is an iframe, and an iframe's pointer events never reach the page around it. That is fine until something out here is mid-drag: the pointerup that should end the drag lands in the player and is lost, and the card goes on following the mouse until it is clicked again.
It was worst on the card that caused it. Pressing an unselected player selects it, and selecting it takes away the shield covering it — so the act of starting the drag exposed the very thing that would swallow the end of it.
Pointer capture is the textbook answer and it does not work, twice over. A
cross-origin frame runs in its own process, and Chromium routes input into it
before the parent document's capture is consulted: the capture is held,
hasPointerCapture agrees, and not one pointermove arrives. And holding a
pointer on an ancestor retargets the release, so a click is no longer dispatched
to a button inside it — which quietly stopped the zoom buttons working the
moment it was tried.
So the frame is made untargetable instead, for as long as a press that reached the board is still down. Hit testing then walks straight past it. Pressing a selected player to play it lands in the frame, is never seen out here, and so never sets the flag. The flag is released by a listener on the window rather than by each path, because a path that forgot would leave every player on the board dead to the touch until the page was reloaded.
There are five ways to start a drag — moving a card, resizing one, drawing a
wire, panning, and pulling a marquee — and every one of them needs this. Three
were written before there were players on the board, and the card's own handler
returns early for a resize handle, so resizing a player by dragging its corner
across itself failed in exactly the same way and for exactly the same reason.
That is why holdPress is a file of its own rather than a line in whichever
handler happened to need it first.
A selected player also keeps a strip along the top to be taken hold of by. The name plate was meant to be that, and never could be: it is drawn with pointer events off, because it is a label rather than a control, so every point on a selected player belonged to the player and the card could only be moved by clicking away to deselect it first.
A sound card played and drew its own waveform, and that was the whole of it. On a board where a photograph can be taken through sixty-nine treatments and varied twelve ways, sound was the one medium you could only look at.
So a sound gets the shape a picture has. Select one, open the panel, and build a chain of up to four: Speed, Reverse, Trim and Wow and flutter under Tape; Filter, Drive, Bit crush and Ring mod under Tone; Delay, Reverb and Chorus under Space; Tremolo and Gate under Shape. Four is the cap for the same reason it is the cap on effects — past four nobody can hear which one is doing what.
The chain is rendered rather than played live, and that decision is the rest of
it. A graph of nodes between the file and the speakers is gone the moment the
tab is, cannot be measured, and cannot be exported. A rendered buffer is saved
beside the original: the card sounds the same next time the board is opened,
the waveform is redrawn from what you actually made — a gate that chops a track
to pieces looks like a track in pieces, from across the room, without pressing
play — and ⌘K → Export the selected sound writes a WAV of the treated
version, while an untreated one comes back out as the file that arrived,
because renaming an mp3 to .wav would be a lie about its contents. Offline
rendering runs far faster than the sound is long, so the cost is a pause rather
than a wait.
The file you dropped is never touched. media stays what arrived and heard
is what the card plays, so taking the chain off is not an undo — it is the card
pointing back at the thing it always had. Hold \ on a treated sound to
hear the original for as long as you hold it.
Drop a .glb or a .gltf and it arrives as a photograph of itself: turned a
little off square and looked slightly down on, which is how a thing is
photographed when the photograph is meant to show what it is. It used to arrive
as a grey rectangle with three letters on it, which on a board about looking at
things is the wrong answer for the file a product designer works in all day.
Turning it is not a transform on the card, it is a new picture — the same job as turning to another page of a PDF, and written in the same place for the same reason. Alt-drag the model to turn it, Alt-scroll to move in and out, or use Turn, Tilt and Distance in the panel. A drag asks for a new angle faster than a render can answer, so the last angle asked for is kept and everything before it is thrown away: the model comes to rest showing what your hand actually asked for last, rather than going on turning after it stopped.
Because the card is a picture with the file kept beside it, everything downstream works with no special case at all — the effects, the export, the palette, being read through by another card, and twelve versions, which for a model means twelve places to stand rather than twelve treatments of one view.
The file says what it is made of, so the panel lists the model's own materials rather than guessing at them. Wire a card into the model, pick a material, and press Wear: that card becomes the material's Colour, Roughness, Glow, Relief or Cut-out — as it looks now, effects and all. One material can wear several at once.
Five slots, and only five: the ones where a photograph means something. Metalness is left out on purpose, because its map multiplies a number the file sets, so wearing one does nothing at all unless that number is forced to one — and a material forced to metal is a chrome material whatever picture is on it, which is an answer that looks like a bug.
Relief is the odd one out. The other four are slots a glTF can arrive with something already in, so they can be worn and they can be treated. glTF carries normal maps, and a normal map is three channels of direction rather than a picture of a height, so relief is something you put on and never something the file came with.
Treat is the other half, and it works on what the model already had: it runs the effect this card is set to over the texture a material arrived with. A material with no texture of its own has nothing for Treat to run over, and the button says so rather than doing nothing.
⌘K → Export the selected model as a .glb writes it back out wearing what it was given.
Press W. A card appears with code on it and the picture that code drew, and from that moment it is a picture like any other: it takes the effects, exports, gives up its colours, and varies twelve ways.
Every other picture here arrived from somewhere — dropped, pasted, fetched, rendered out of a file. This one is written, which makes it the only material on the board that can be asked for a hundred nearly-right things and edited between each one. Generative work has always been a design practice rather than a programming one: a grid you can shake, a pattern with a dial on it, a hundred versions of a mark. What was missing was somewhere to put the output next to the photographs.
The editor opens with something already on it and a shelf of seven more, each short enough to read in one go and built so that the first thing you would change is obvious — a count, a colour, a power. A blank editor is a worse offer than no editor: nobody opens one and writes a flow field. The shelf is curated rather than open, so growing it is a commit, which is the same arrangement the effects have.
Your code is handed a 2D context, the size, a seeded random, and — if a card is wired into it — that card's picture. The last one is what makes this part of the board rather than a canvas beside it: a sketch can read a photograph, so code becomes another way to treat a picture, alongside the sixty-nine effects.
It runs in a worker made for the run and thrown away after it, with four seconds to finish. That is not politeness about speed. A loop that never ends can only be stopped by killing the thing running it, and on the main thread a missing increment locks the tab and takes the board with it; in a worker it is a card that says it took too long, and the board never notices.
Everything that reaches out of the worker — fetch, sockets, storage, other workers — is taken off the global before a line of your code is read. You own the board, so this is not a wall against an attacker. It is the same courtesy the rest of the app extends, which is that your work does not go anywhere, and code pasted in from a forum should not be the exception that quietly does.
Press D, or the frame button in the top bar, and describe something. A card goes down where you are looking, at the shape you asked for, and fills in when the picture arrives. After that it is an ordinary picture card: effects run on it, it exports with the board, it goes into the poster, it can be compared against the others.
Ask for two or four at once and they arrive side by side, all selected, so C holds them up against each other straight away. That is usually the point of asking more than once.
The whole prompt stays on the card, so a board of generated pictures still says what each one was asked for six months later, and the search box finds one by a word from its prompt.
Pick out a picture and press D. The sheet shows it — shown, not named, because it is a picture and a row of filenames would be the one thing on this board you cannot look at — and the prompt becomes what to do to it rather than what to make from nothing. "The same pot, at night." Ask for four and press C to hold them up against each other.
What is sent is the card's own file, not what is on screen: the card has an effect and a framing on it and is a smaller thing than the file behind it. It is scaled to a thousand pixels on its long edge and re-encoded before it goes, because the file may be a twelve megapixel photograph and what is wanted is a look at it rather than a copy of it.
The pictures go before the words, which is the order the model reads them in: here is the thing, now here is what to do with it.
Only Gemini models take a picture to work from. Imagen answers to predict,
and how that takes a reference is not in Google's discovery document —
instances is typed as any there, so there is nothing to build the request
from but memory, and a request built from memory fails in a way nobody can
debug. So it is refused with a sentence saying which model to pick instead,
before anything is sent.
Claude can do the same thing through the relay: draw_image takes from with
card ids.
This is a static site with no server, so it has no key of its own and no way to pay for anything. You bring a Google AI Studio key, and it is kept in this browser's local storage.
That is a deliberate choice rather than a shortcut. A key held on a server would mean one key paying for everybody who finds the address — a free image generator for strangers, billed to whoever deployed it — and it would need a server to hold it, which this does not have. A key held here can only ever spend your own quota.
What follows from that is worth knowing:
- It is per browser. A second machine needs the key entered again.
- Anything with a debugger open on this page can read it, so use a machine you trust and a key you can revoke.
- It never touches a board. Not the record in IndexedDB, not the
.board.zipexport, not the folder mirror, not what travels to another tab. The board is data about pictures; the key is a credential, and the two do not mix.test/draw.mjsproves it against real storage and a real exported file. - The request goes straight from your browser to Google, with the key in the
x-goog-api-keyheader rather than in the address, because a URL is the one part of a request that gets written down — in a referrer, in a devtools list, in the text of an error somebody pastes into a chat.
No model name is written into this app. Open the key and model line, and the list is fetched with your key: whatever your key can see, filtered down to the ones that make pictures. Pick one, or type an id the list has not heard of.
Two families live behind the same address and take different requests. Imagen
answers to predict; Gemini answers to generateContent. Which one a model is
is not guessed — the listing says, and the request is built from that.
The reply is read by looking for a picture in it rather than by walking a path.
Both families bury the bytes at a different depth under a different name, the
shapes have moved before, and a search that recognises an image by its own first
bytes cannot be broken by a rename. It is careful about the opposite mistake
too: several fields in a Gemini reply are base64 and are not pictures — a
thought signature most of all — so a string has to either sit beside an image/*
mime type or begin with the first bytes of a real image format before it is
believed.
Some request fields are an error on models that do not support them, and there is nothing in the listing that says which. So the adapter asks, and when the answer is that the request was malformed it asks again with less: the aspect ratio config first, then the combination of response types. A refused key or an exhausted quota is not retried — it would say the same thing four times.
When nothing comes back you are told what happened rather than that something went wrong. A model that writes rather than draws hands back its own sentence. A refused prompt says it was refused and why. A key that is not accepted says so. The empty card removes itself either way.
The address is a setting too, so a proxy of your own can stand in for Google's — which is also how the browser test runs the whole path against a fake endpoint on this machine, with no key and no internet.
The board has no server, so an agent cannot be handed a database to read. Everything it knows lives in one browser's IndexedDB. What it can be handed is the tab: a small relay runs on your machine, Claude talks to it, and it asks the tab — which is the only thing that has ever known what is on your board. That is the same shape Figma uses, where the local process is a relay and the application is the thing that actually holds the document.
claude mcp add ideation -- node /path/to/ideation-board/mcp/server.mjsThen, in the board: ⌘K → Connect to Claude. The corner says "Claude" for as long as something other than you can move the cards.
Running Claude Code from inside this repo needs none of that — .mcp.json is
already here.
If you are using the deployed site rather than a local one, the relay has to be told about it, once:
claude mcp add ideation -- node .../mcp/server.mjs --origin https://your.vercel.appThe sheet fills that address in for you.
get_board first — card ids come from there and from nowhere else. Then
list_boards, add_card, draw_image, update_card, move_card,
delete_cards, connect_cards, arrange, select_cards, make_versions,
fit_view.
Two of those are worth saying more about. add_card will put down a sketch
— a body of plain JavaScript handed (ctx, w, h, rand, img, seed) — and for
anything a program can make, a pattern, a grid, a field, a type specimen, that
is the better verb than draw_image: it costs nothing, it appears at once, and
the person can throw it again or ask for twelve of it. draw_image is for
photographs and things only a model knows. make_versions is the grid from
here: name one card and twelve alternatives appear under it, varied by whatever
that medium varies by.
Everything goes through the same store the interface goes through. An arrow
drawn by Claude and an arrow drawn by hand are the same arrow, one press of undo
takes either back, and the board is saved by the machinery that was already
saving it. select_cards is worth more than it looks: you are watching the
board while Claude works, and pointing beats describing.
draw_image spends your own key, from your own browser. Nothing about that
changes because an agent asked.
The relay listens on the loopback address, and every page in your browser can reach loopback. Without a check, any site you happened to be visiting could open the stream and read — or rewrite — your board.
The check is the Origin header. A browser sets it on every cross-origin
request and a page cannot forge it, so a list of allowed origins is a real
boundary rather than a polite one. Loopback origins are allowed by default,
because that is what you develop against; the address you deployed to has to be
named with --origin. A missing or null origin is refused outright — that
means a sandboxed frame, or something that is not a browser at all.
--token adds a shared secret on top. It is off by default and it is not the
boundary; it is there for a machine where something else untrusted is already
running.
Worth being plain about what "loopback origins are allowed by default" means,
because it is a trade rather than a free win. Any port counts. So while the
relay is running, a page served from any local development server you happen to
open in the same browser can read and rewrite every board, not only the one you
are looking at. On a machine where you wrote or trust everything that listens on
a port, that is the convenience it is meant to be. If that is not your machine,
name your real origin with --origin and set a --token. One caveat on the
token: an event stream cannot carry a header, so it travels in the query string,
where it will show up in server logs and in a process list.
test/mcp.mjs proves this rather than asserting it: it conjures a second origin
with Chrome's host resolver and, from inside a real browser, tries to open the
stream and tries to write to it. Both are refused, the relay records who it
turned away, and the board carries on untouched.
mcp/server.mjs has no dependencies. MCP over stdio is JSON-RPC in
newline-delimited JSON, and the bridge is an event stream out and a POST back,
so the whole relay is Node's own http and nothing else. A tool for looking at
pictures should not drag a dependency tree behind it.
It knows the names of the tools and the shape of their arguments, and nothing
whatever about what a card is. src/mcp/tools.ts, in the app, is the half that
knows — so adding a kind of card means editing one program, not two.
A GIF plays on the board, and it keeps playing with an effect on it. So does a video: the video element still decodes and plays, invisible behind a canvas the renderer feeds, and the controls under the picture stand in for the native ones.
The GIF took more doing, and the reason is worth writing down so nobody tries
the obvious thing again. With an effect on it a card stops being an <img> and
becomes a canvas fed by the renderer, and the renderer had been handed one
still — so the picture stopped. The obvious fix is to keep the <img> around
and grab its current frame each tick. That does not work: drawImage and
createImageBitmap on an animating <img> both hand back the first frame,
whether or not the element is in the document and whether or not it is visible.
The animation lives where a canvas readback cannot reach it. That is measured,
not assumed, and test/moving.mjs asserts it so the finding does not get lost.
So the frames are decoded properly, with WebCodecs ImageDecoder: a frame
count, each frame as something the render pipeline already swallows, and each
frame's own duration — which matters, because a GIF is free to hold one frame
for a second and flick through the next four in a tenth of that. Frames are
decoded as they are asked for rather than all at once, since a long GIF is a
great deal of memory to hold for a card that may be off screen.
Whether a file moves is worked out once, when it arrives, and written on the
card. Only the types that can move are asked — a folder of two hundred
photographs does not open two hundred decoders to be told what the mime type
already said. A browser with no ImageDecoder shows the still it always showed.
The check that keeps all of this honest is the last one in the suite: a picture that does not move must still be drawn once and left alone. Feeding every effected card a reel of frames would make every photograph on the board cost what a video costs, and nothing else would have noticed.
The boards were always local. The app was not.
The manifest says display: standalone, and there are icons at every size, so
it installs as an app on a phone or a desktop. People install it. And then it
opens on a plane to the browser's offline page, over a machine that has every
board on it — the pictures in IndexedDB, two inches away and unreachable. The
data was local; the app was a website.
sw/sw.js fixes that, and it has no dependencies either. What is cached is the
shell and only the shell: the document, the built scripts and styles, the worker
that runs the effects, the icons. Not your pictures — those were never fetched,
they are already here.
The list is written into the worker at build time by scripts/build-sw.mjs, so
it names exactly what that build produced. The alternative — caching whatever
happens to be fetched — works right up until the first offline start needs a
file nobody happened to ask for while online, which for this app is the effects
worker: nothing fetches it until the first effect is applied. If the script does
not run there is no sw.js at all, and the app behaves exactly as it did
before, which is the right way for this to fail.
Fonts come from Google and are kept in a cache of their own, so a new build does not throw them away and send everyone back for them.
Anything that is not this app is left alone entirely: Google being asked for a picture, the relay on the loopback address, a photograph dragged in from another site. An event stream that went through a fetch handler would be buffered into uselessness, and a picture bought once would be handed back for ever.
A service worker that takes over the moment it installs swaps the scripts under whoever is using the page, and this is an app people leave open for days with unsaved thinking on the screen. So a new version installs, waits, and says so in the corner. Nothing changes until Reload is pressed. It is the same bargain the app makes everywhere else it could decide something on your behalf and does not.
The document is fetched from the network first and falls back to the cache, so a deploy is picked up the moment there is a network to pick it up from. Built files carry a hash in the name, so a given address's contents never change and the cache can be trusted without asking.
sw/sw.js is the one file here that nothing else checks — plain JavaScript,
outside the TypeScript build and outside the bundle, registered inside a catch.
A syntax error in it would ship, fail to register, and say nothing at all; the
app would go on working and simply never be available offline again, which is
the kind of breakage only ever noticed on a train. test/unit/offline.test.ts
parses it.
A card had one effect. Halftone or grain, never both, and no way to say what a picture should look like when the answer was two things.
The effects panel shows a card's effects as a row of numbered chips, in the order they are applied. Click one to work on it — the grid and the sliders act on whichever is chosen — and + Add puts another on top. Four is the cap.
Each layer draws into a buffer the next one reads, and the last draws to the canvas. Two buffers are enough however deep it goes, because a pass only ever needs what the pass before it wrote.
A card with one effect costs exactly what it cost before. It does not go
through that loop at all: one pass, straight to the canvas, no buffer in the
way, which is every card on the board unless somebody has asked otherwise. The
full-size buffers are made on first use, so a board that never stacks anything
never allocates them. test/stacked.mjs measures the frame rate rather than
reading the code and hoping.
fx.more holds what comes after fx.fxid, which stays the first effect. An
addition rather than a replacement: every board ever saved reads back exactly as
it was, and a card with one effect has no more at all.
Two places would have silently flattened a stack, and each looks like working software while doing it. A saved look stored the first effect only, so applying one would have quietly dropped the rest. Export and the poster render the card again at full size, and would have written out a picture that is not the one on the board. Both carry the stack now.
Two renders of the same card on the same machine are identical to the pixel,
and that is checked rather than hoped for: test/ascii.mjs renders a board,
reloads the page, and compares a hash of the painted card — twice over.
It is worth saying because it used to be false, and this section used to say
so. The ASCII effect built its glyph atlas from ui-monospace, "JetBrains Mono", … when the engine started, so which of those faces had resolved by that
moment was baked into the picture: the same board came back looking different
after a reload and two machines never agreed. The glyphs are drawn as shapes
now, and the same suite renders the board again in a page that cannot draw text
at all to prove nothing is reading a font. Everything random is seeded from the
card's own id, so grain and dithering do not crawl between renders either.
What is still not promised is the same picture on somebody else's machine. A different driver and a different rasteriser will not agree to the last bit, which is why the check compares a render against its own reload rather than against a picture kept in the repository.
Deleting a card never deleted its picture. Nothing ever deleted a board at all. So the store only ever grew — while the corner of the screen warned that the disk was filling up and offered three buttons, not one of which freed a byte. It told you to remove what you did not need and gave you no way to do it.
⌘K → "Clear up files nothing uses any more", or the button on the warning itself.
It is a sweep rather than a delete, and the reason is the whole design. A file is keyed by its own name and any number of cards may point at it: duplicating a card, pasting one onto another board, importing a board twice. Deleting the file when one of those cards goes would blank the others, on boards that are not even open. So nothing is deleted when a card is. Instead every board is read, every name still pointed at is collected, and what nothing points at goes.
Three things are not garbage and look exactly like it:
- The board on screen, which may hold cards not yet written to disk.
- Cards taken away with Cut, which are on no board at all until they are put down somewhere. That is what cutting means.
- A file written moments ago, because a drop writes the file first and puts the card down after. The gap is milliseconds; ten seconds is the margin, which is a thousand times over.
A fourth is not a card's at all. The depth model's weights live in the same
store as the pictures, and no card will ever point at them, so a sweep would
collect twenty-five megabytes that the next depth map would immediately
download again. src/store/kept.ts is the list of what the app itself keeps,
and the sweep asks it before it collects anything.
Each of those is a way to delete a picture somebody is still using, so each is
asked about before anything is removed. test/unit/reclaim.test.ts gives every
one of them a test of its own rather than letting one test happen to cover
them, because every mistake this can make is the same mistake: a name left out
is a photograph gone, from a board that may not be open, with no undo and no
second copy.
A video's first frame is kept too. It is stored under a name derived from the video's own, so the name is worked out rather than read — a board saved before posters were written onto the item would otherwise lose the still that every effected video card falls back to.
⌘K → "Let go of the downloaded depth model" deletes the weights and says how many megabytes came back. Nothing else is touched, and the next map that asks for the model downloads it again.
⌘K → "Delete a board and everything in it", with a board card picked out. It says what it is about to destroy — how many cards, and how many boards inside — and then does it. There is no undo, so it asks first.
Deleting the card that stands for a board is a different thing and still only deletes the card: a board card can be cut from one board and put down on another, and the board has to survive that.
Everything lives in this browser and nowhere else: the boards in IndexedDB, and beside them as blobs the pictures, sounds, documents, models, and — once you have asked for one — the weights of the depth model. Nothing is uploaded, and there is no account.
That means the only copy of your work is on this machine, in this browser, so the app takes it seriously:
- It asks the browser to keep the data rather than treat it as a cache it may clear when the disk gets tight. Chrome decides on its own, Firefox asks you, Safari grants it once you have used the site a few times. Whether it was granted is on the tooltip of the counter in the corner.
- It watches how full the storage is and says so in that corner once it is past eighty per cent.
- It checks that a drop will fit before starting it, rather than finding out half way through.
- If a write does fail, it says so and does not go away until you answer. Every write used to swallow its own error, which meant a board that would not save looked exactly like one that had — the pictures were still on screen because they were still in memory — and you found out on the next reload, when they were gone.
Everything is kept in one browser, and a browser has tabs. Open the app twice and both copies used to load the same record and both write it back, neither knowing the other was there: whichever saved last won, and whatever the other had added in the meantime was gone, with no warning and no undo.
The tabs tell each other when they write. A tab that is only looking picks the change up and is no longer holding a stale copy to overwrite with, so the ordinary case — you edit in one tab, then the other — keeps everything. And every write checks the record it is about to replace, in case that message was missed or the browser has no way to send one, so a save that would clobber a newer version does not happen.
When both tabs really have been edited before either wrote, there is no answer that is not somebody's loss, so the app does not pick one. Saving stops, both versions still exist — theirs on disk, yours on screen — and a message that does not go away asks which. It also offers the third way out: export this version first, and lose neither.
This is one browser on one machine, which is the scope of the problem. Two machines on one folder is a different question, and the folder copy above already says it does not answer it.
The stronger answer to all of that: point the board at a folder on disk and it
writes itself there and keeps writing. ⌘K → "Keep a copy in a folder".
It writes a board.json and a media folder — the same shape the exported zip
has, so the folder can be zipped by hand and imported back. Put that folder in
Dropbox, iCloud, a network drive or a git repository and the work is on more
than one machine, backed up, and outside the browser that made it. Nothing here
talks to a server.
It is a copy, not a synchronisation. Nothing is ever read back out of the
folder, and two browsers pointed at one folder will overwrite each other. test/mirror.mjs
drives all of it against a folder that lives in memory, including the part
that matters most: a permission that lapses has to stop the copying and say
so, rather than leaving you believing there is a backup.
Conflict resolution is the hard part of syncing and this deliberately does not
attempt it — saying so is better than pretending otherwise.
Chrome and Edge can do this. Safari and Firefox cannot yet, and say so rather than failing quietly. The folder is remembered between sessions; a browser will not hand the permission back without a click, so it is offered rather than silently reopened.
The other way out of a full disk is the way onto another machine: "Export" writes the board and everything in it to a single file.
Press P, or pick "Present this board" from the command list, and the board fills the screen with one thing at a time and nothing else: no toolbar, no panel, no grid of dots, no other cards around the one being talked about. The arrow keys and the space bar move through it, clicking the left third goes back and clicking anywhere else goes on, and Escape leaves.
The order is the one on the board, not the order things were made in. Somebody who arranged twelve photographs into three rows meant those rows, so it reads top to bottom in bands and left to right inside each band, the way an eye crosses a wall of pictures.
Select more than one thing first and it shows only those. Double clicking a picture, a video or a board card opens the same view at that card, which is the short way to look at one thing properly without meaning to run a slideshow.
The ground is near black whatever theme you are in, which is what a screening room and a gallery both arrange for, and what a board cannot be because you have to work on the board.
Right click a picture or a video and choose "Pull the colours out", and the five colours it is made of arrive as swatches in a row underneath it. All five are one step of undo.
Half of what a moodboard is about is colour, and until now the colour was locked inside the photographs: you could look at it, but you could not write it down, hand it to anyone, or hold it beside the colour out of another picture.
A swatch is a note whose paper is the colour and whose text is the hex. That is not a shortcut. It means a swatch is something the board already knows how to do everything with — move it, tag it, search it, group it in a section, carry it into an exported file and back out again — where a tenth kind of card would have had to learn all of that over again. The writing on a note takes its colour from the paper, so the hex is readable on a black swatch and on a pale one.
The colours are counted rather than guessed at: the picture is sampled small, its colours are bucketed, and the buckets are weighted so that a photograph which is two thirds pale sky does not spend three of its five swatches on the sky, and the one red thing in a grey picture still makes the list. No two swatches are allowed to be the same colour under two names.
Deciding is only half of it. Mark six of forty as kept and they are still exactly where you dropped them, scattered over four screens among the thirty-four you did not keep — and a shortlist that is only a mark on a card is not somewhere you can look at, present or hand over.
Select what you picked and press G, or use "Put the selection together in one place" from the command list. It makes a section named Shortlist on clear ground below the board, lays what you chose out as a block and moves it in, in one step of undo. The name is why it is a section and not a heap: what comes out is a group you can select, present and export as one thing.
The usual way in is through the search box: type kept, press Cmd or Ctrl with
Enter to take the whole result set, then G.
A board card holds a whole board, and for a long time nothing could travel between them: you could nest boards, and search inside them, and never bring anything up or send anything down. The only way to move a photograph one level was to find the original file and drop it again.
Cmd or Ctrl with X takes the selection off the board and holds it. Open the board where it belongs and paste. There is no list of boards to pick from, because the boards are a tree you can already walk — cut here, walk there, paste.
The pictures themselves never move. A card names a file in a store every board in this browser shares, so what travels is the record. Arrows travel when both of the cards they join are travelling; a section the card used to sit in stays behind, because it is not on the board it is going to. Taking away and putting down is a move, so it happens once. A board will not be put inside itself, and says so rather than making a loop.
Deciding is nearly always between two things — this one or that one — and the show could not help with it, because it puts one thing on the screen at a time and the question when you are choosing is what the other one looked like. On the board they are cards among thirty. Neither is a comparison.
Select two, three or four and press C. They go up together on the same near black ground the show uses, each as large as the room allows, with nothing else on screen. The arrangement is worked out rather than fixed: four wide photographs want two rows and four tall ones want four columns, and which is which depends on the shape of the window as much as on the pictures.
The decision is made from inside it, because that is the moment you have made up your mind. I keeps whichever one the keys are on and O cuts it, the same two keys the board uses, and the mark appears on the picture rather than only after you have left. The arrows move between them, and so do their numbers, which are written on them. Escape leaves, and what you decided is on the board.
Four is the most it will hold up at once, because past that they are too small to be honestly compared — at that point what you want is the board. Select more and it takes the first four in the board's own reading order and says how many it left out.
A board only ever grew. Everything that went on it stayed on it, and there was nothing to record that three of the thirty were the ones and the rest were not.
Select anything and press I to keep it or O to cut it. A kept card wears a green tick in its top left corner and a cut one wears a red cross and steps back to a fifth of its weight — it stays exactly where it is, because where something sits on a board is part of how you recognise it, and a decision whose consequences you cannot see is not much of a decision. Pressing the same key again takes the mark off; a decision you can only make and never unmake is a trap. The same two are in the right click menu and in the command list.
This is not a tag. A tag says what something is, and the five of them are colours with no meaning attached. This says what you decided about it, so the two are on separate corners of the card and neither one disturbs the other. Searching for "kept" or "cut" narrows the board to one side of the decision.
Right click a picture or a video and choose "Export as PNG", or press Cmd or Ctrl with E. Select several and they come out together as a zip of PNGs.
The file is the picture at its own resolution rather than at the size of the card, cropped the way the card crops it. A card showing a narrow slice of a wide photograph exports the pixels of that slice, not a stretched copy of the whole thing.
What comes out is the card, not just the shader. The effect, the tone, the framing and the grain are all in the file, in the order the card applies them, because an export carrying only the effect would hand back something you were never looking at. Exporting a video card exports the frame you are looking at.
Everything else here exports a piece of a board: one card as a PNG, the whole board as a zip only this app can open. Neither of those is the thing you are asked for at the end of a week of collecting, which is the board itself — flat, in one file, that opens anywhere and can go in an email.
Pick "Export this board as one picture" from the command list for a PNG, or "Export this board as a PDF" for a page. Select more than one card first, or narrow the board with the search box, and it exports only those — the command says which before you run it.
Two lines at the top say what the sheet is: the board's name, and then how many things are on it, how many were kept and cut, and the date. Each card carries its own name along the bottom, drawn the way the card draws it when you hover one — over a screen on a photograph, on a plate on anything paler. The board shows that only on hover, which is right for a board: a wall of photographs should look like a wall of photographs and not like a list of filenames. A sheet is not a board. It is the thing you send somebody, usually of the few you chose out of the many, and six pictures with nothing written on them are six pictures and not an argument. A picture of a board with nothing on it to say whose board it is becomes an anonymous file in somebody's downloads a week later, and a sheet that is only part of a board says "5 of 20" rather than passing itself off as the whole thing.
It is painted rather than photographed. The board is bigger than the window, it is spread across a WebGL canvas per card and a hundred DOM nodes, and half of it is scrolled out of sight, so there is nothing to take a picture of. The sheet is drawn card by card in the order the board stacks them, using the same geometry, the same type and the same colours the stylesheet uses — read out of the live custom properties, so a board worked on in the dark theme comes out dark. Photographs go through the same path a single-card export takes, so a picture that is halftoned and cropped and warmed on the board is halftoned and cropped and warmed on the sheet. A cut card comes out faded, exactly as far as it fades on the board.
The PNG is the sheet at its own size, however large that is: a board four screens wide comes out four screens wide, which is what you want on a monitor.
The PDF is that sheet on paper somebody owns. It picks A4 or Letter from your locale, turns the page to whichever way round suits the board, and centres the board inside a margin. The first version of this wrote a page 1386 points by 972 — fine to email, impossible to print, and nobody owns that paper.
It is written by hand: one page, one image on it, and five objects to say so. The picture goes in as JPEG because a PDF understands JPEG bytes directly, so nothing here has to carry a compressor around. The only fiddly part is the cross-reference table, which is a list of byte offsets into the file itself, each entry exactly twenty bytes long — wrong in a way nothing on screen would show, which is why it is unit tested.
⌘K → "Save as a page anyone can open" writes the board as one HTML file. Every picture is inside it as data, every board in the tree is in there too, and it opens in any browser with no network, no server and nothing installed. Effects are baked in, because the person opening it has no engine of ours to run them through — what they get is what was on your screen. Sounds come with it, treated, with a waveform they can play and step through from the keyboard.
It is not the board coming back. An exported page has thrown away the original
files and kept a picture of each card at the size it was being shown, which is
the trade that makes it small enough to send. Two exports for two jobs: the
page is the thing to hand over, the .board.zip below is the thing to keep,
and the names say which is which.
test/sendable.mjs is the only test that could settle a claim like this: it
takes the file the app produces, opens it in a browser that has never seen this
app with the network switched off, and checks the board is there — the cards,
the pictures, the note text, the boards inside, the panning, all of it in that
second page.
"Export" writes the board you are on, every board nested inside it, and every
picture, video and file any of them use, into one .board.zip. It is an
ordinary zip: a board.json describing the tree and a media folder beside
it, so anything can open it and a person can read it.
"Import" reads one back. Drop the file on the board, use the button, press Cmd or Ctrl with O, or pick "Board file…" from the right click menu on empty board.
An imported board arrives as a board card on the board you are on. Nothing is replaced, everything inside it is renamed on the way in, and so the same file can be brought in twice as two separate boards, and a board someone sends you cannot land on top of anything you already have.
Because the storage is local to one browser, your boards do not follow you to
another machine. The storage code sits behind a small set of functions in
src/store, so you can add another way to store boards later without changing
the board itself.
This version was rebuilt to stay fast when a board holds many images with effects on them. The short version is that all the graphics work happens on a separate thread, each picture is sent to the graphics card only once, and finished frames are handed to the page without being copied.
Every number in docs/PERFORMANCE.md was measured on SwiftShader, which
draws with the processor instead of a graphics card, because that is what the
machine the work was done on had. The shape of the result should hold on real
hardware and the gap should be wider — the main fault in the old method was
reading pixels back off the graphics card, which costs almost nothing when the
"graphics card" is the processor — but that is a reasoned expectation and not a
measurement, and it should not be repeated as though it were one.
Panning a large board costs what it costs to draw the pixels, not what it costs to keep track of the cards. On SwiftShader, panning 150 photographs runs at about 34 frames a second and 40 photographs at about 47; the same 150 with an effect on every one of them runs at about 11, because a shaded card is a canvas rather than an image and a software rasteriser has no fast path for one. On a real graphics card both are texture blits and the gap should close.
Until recently every card also asked for a compositor layer of its own, all the time, whether or not anything was moving — three hundred layers on a board of a hundred and fifty, reserved against the moment one of them might be dragged. The hint now goes only to the cards actually being dragged, for as long as the drag lasts. It did not move the frame rate on a software renderer, where fill is the whole cost; it gives back a large amount of graphics memory on a machine that has any.
Measure it on your own machine rather than taking the table on trust:
npm run dev &
N=40 SRC=1400 npm run bench # cold and warm redraws
npm run test:load -- http://localhost:5173 60 # whether it stays responsivedocs/PERFORMANCE.md explains what was slow before, what changed and what the
measurements show. docs/ARCHITECTURE.md explains how the code is laid out.
Two kinds. The fast ones are arithmetic and run in a few seconds; the slow ones are sixty-one suites driving a real browser, and take about half an hour.
npm test # types, then 617 unit tests — a few seconds
npm run test:all # the above, then a build, then every browser suiteBoth run on every push through .github/workflows/ci.yml, as two jobs, so a
typo is reported while you are still looking at the tab.
npm run test:unit # once
npm run test:watch # and again on every saveThey cover the arithmetic underneath the interface: how a picture is cropped to a card and how big the file that comes out of it is, what a note's markup parses to, which colours come out of an image and whether the hex is legible on them, where a dragged card snaps to, what a pasted address turns into, what a saved look carries and what it leaves behind, how the store lines cards up and spaces them out and joins them, what each kind of card can do, which fields on a card name another card, which boards are projects, what twelve variations of a picture, a sound, a sketch and a model come out as, what a sound chain does to a buffer, where a model's camera ends up, what a material on it is wearing, what the folder copy says about itself, how a download arriving in pieces is put back together, and what happens when the disk runs out.
The variation tests are the ones worth copying the shape of. A dice roll can only be judged over thousands of draws, so they assert rates rather than outcomes — twelve samples in a browser would flake, and did.
They found two real bugs on the day they were written: a palette could offer the same colour twice under one name, and duplicating two connected cards dropped the arrow between them, because the code that copies a wire was looking at a list the wire had already been filtered out of.
npm run build
npm run test:browser # all of them, one after another
npm run test:browser -- ui menu # or just these
npm run test:browser -- --network # and the one that reaches the internettest:browser starts the preview server, runs the suites against it, and puts
it away afterwards. To run one by hand instead, start a server and point a
suite at it:
npm run dev &
npm run test:ui -- http://localhost:5173
npm run test:sections -- http://localhost:5173
npm run test:boards -- http://localhost:5173
npm run test:wires -- http://localhost:5173
npm run test:notes -- http://localhost:5173
npm run test:transfer -- http://localhost:5173
npm run test:arrange -- http://localhost:5173
npm run test:touch -- http://localhost:5173
npm run test:menu -- http://localhost:5173
npm run test:search -- http://localhost:5173
npm run test:looks -- http://localhost:5173
npm run test:palette -- http://localhost:5173
npm run test:present -- http://localhost:5173
npm run test:decide -- http://localhost:5173
npm run test:fit -- http://localhost:5173
npm run test:drop -- http://localhost:5173
npm run test:tabs -- http://localhost:5173
npm run test:curate -- http://localhost:5173
npm run test:compare -- http://localhost:5173
npm run test:draw -- http://localhost:5173
npm run test:mcp -- http://localhost:5173
npm run test:moving -- http://localhost:5173
npm run test:embed -- http://localhost:5173
npm run test:offline -- http://localhost:5173
npm run test:reclaim -- http://localhost:5173
npm run test:manyboards -- http://localhost:5173
npm run test:stacked -- http://localhost:5173
npm run test:big -- http://localhost:5173
npm run test:pdf -- http://localhost:5173
npm run test:audio -- http://localhost:5173
npm run test:design -- http://localhost:5173
npm run test:access -- http://localhost:5173
npm run test:smoke -- http://localhost:5173
npm run test:effects -- http://localhost:5173
npm run test:png -- http://localhost:5173
npm run test:poster -- http://localhost:5173
npm run test:urlimage -- http://localhost:5173
npm run test:aspect -- http://localhost:5173
npm run test:video -- http://localhost:5173
npm run test:urlvideo -- http://localhost:5173
npm run test:vary -- http://localhost:5173
npm run test:pair -- http://localhost:5173
npm run test:repeat -- http://localhost:5173
npm run test:layer -- http://localhost:5173
npm run test:framing -- http://localhost:5173
npm run test:finish -- http://localhost:5173
npm run test:dialogs -- http://localhost:5173
npm run test:plate -- http://localhost:5173
npm run test:sound -- http://localhost:5173
npm run test:model -- http://localhost:5173
npm run test:sketch -- http://localhost:5173
npm run test:depth -- http://localhost:5173
npm run test:help -- http://localhost:5173
npm run test:findall -- http://localhost:5173
npm run test:sendable -- http://localhost:5173
npm run test:undoboards -- http://localhost:5173
npm run test:undelete -- http://localhost:5173
npm run test:nospace -- http://localhost:5173
npm run test:mirror -- http://localhost:5173
npm run test:context -- http://localhost:5173
npm run test:ahead -- http://localhost:5173
npm run test:ascii -- http://localhost:5173
npm run test:load -- http://localhost:5173 60
npm run benchEvery suite the runner knows has a script of its own, and the runner takes any
of them by name too — node scripts/browser-tests.mjs vary depth runs those
two against a server it starts itself.
test:uidrives every control on the board, which is 43 checks covering the toolbar, selection, dragging, resizing, undo, the effects panel, the editor and saving. It clears the board's stored data first, so do not point it at a browser holding work you want to keep.test:arrangedrags a card onto a neighbour's edge and checks it is pulled exactly onto it with a guide to say so, then lines up, spaces and tidies a selection from the menu and checks each is a single step of undo.test:accesschecks the three ways in that were missing: that an empty board says what to do with it, that Tab walks every card once in the board's own order and brings each into view, that Enter opens what is selected and the selection is spoken with its position, and that holding a finger on a card opens its menu while a tap and a drag do not.test:presentputs a picture with five known colours in it on the board, pulls the colours out and checks that the five it finds are the five that went in, that none of them is the same colour twice, that the hex is legible on every swatch, and that all five are one step of undo. Then it shows the board and checks the order is the board's reading order, that the arrows move through it, and that a selection of two out of three shows two.test:decidemarks cards kept and cut from the keyboard, the card menu and the command list, checks that the same key takes a mark off again, that a whole selection is decided together, that a cut card fades but stays put and a marked card gains no tag, that "kept" and "cut" can be searched for, and that a decision survives a reload and can be undone.test:fitscrolls a board of four pictures right off the screen, presses 1 and checks every card is back on it and that nothing has been blown up past life size, presses 2 on a selection and checks it comes closer, checks that fitting nothing says so rather than appearing to do nothing, and that double clicking a picture opens it big at that picture while a note still opens its editor.test:compareholds two wide-and-tall photographs up against each other and checks they are laid out side by side and far larger than a card, that I and O decide from inside without going back to the board, that the arrows and the numbers move between them, that four is the most it shows and it says what it left out, and that the board underneath is not moved or nudged by any of it.test:drawstands up a fake Gemini on its own origin, speaking the shapes Google's discovery document describes, and drives the whole path through a real browser: the sheet with no key, a key that is refused, a key that works and the model list it can see filtered to the ones that draw, a card on the board before the picture arrives and the picture in it afterwards, both families of model, four at once laid out in a row and all four selected, a model that writes rather than draws handing back its own words, a refused prompt, and everything still there after a reload. Then the part that matters most: that the key is in local storage under its own name and nowhere else, in none of the boards this browser holds, and in no part of an exported.board.zip— read back out of the file with Python'szipfile. No key and no internet are needed to run it.test:stackedputs one effect on a picture, adds a second on top, and checks the picture is different from either alone, that both are recorded in order, that it comes back stacked after a reload, and that taking the top one off returns it exactly to what one effect looked like. Then the part that is really being asked for: a card with one effect still runs at a sensible frame rate, measured rather than assumed.test:manyboardsmakes a second board, checks it is really separate, and then does the thing an in-app switcher could never do — opens both at once in two browser tabs and checks that working in one does not reach into the other.test:pdfdrops a document written by hand intest/fixtures/pdf.mjs, three pages each a different colour with its own number on it, and checks the whole path: that the page really renders rather than the card falling back to a filename, that it is page one by its colour rather than by a counter, that turning to page two changes the picture, that one undo goes back a page, that an effect runs on a page the way it does on any picture, that the document itself is still in storage afterwards with%PDFat the front of it, and that all of it comes back after a reload on the page it was left on.test:audiodrops a clip written by hand intest/fixtures/wav.mjsthat is loud, then quiet, then loud again — so "the waveform has a shape" is a thing that can be asserted rather than assumed, and a test that only counted peaks would pass against a flat line. It checks that the track was really decoded, that the shape is that clip's and not any clip's, that the card says how long it is before anybody presses anything, that play and pause work, that pressing three quarters along the waveform goes three quarters into the track, that an arrow key moves through it, and that it all comes back after a reload without decoding again.test:designdrops a Photoshop document, a Sketch file, an Illustrator file and a file only named like one, all written by hand intest/fixtures. Each fixture has a shape chosen to catch the two ways this goes wrong quietly: the Photoshop one is orange on the left and blue on the right with a dark band along the bottom, so a red and blue channel swap is obvious rather than nearly right and a picture read upside down is obvious rather than symmetrical. Both Photoshop compressions are checked, because real documents are almost always the packed one and the packing is where the arithmetic is. The Illustrator file has to come out as a document with three pages, because it really is a PDF; the file that is only named.psdhas to come out as a plain file card rather than as an empty one pretending to be artwork.test:bigis the only suite that works at scale. Two thousand cards, written straight into IndexedDB because that is how a board that size really arrives, then the questions that only have an answer at that size: does it open, is only what is on screen in the page, does picking a card up block the main thread, does one undo put the whole drag back and leave the other 1,999 alone, and does panning across all of it stall. Undo used to keep a copy of the whole board per step, so this is the suite that would have noticed.test:reclaimdrops four pictures, deletes every card, and checks that the files are all still there — the bug, stated as a test — then clears up and checks the store is really smaller. Then the half that matters more: a picture still on a board, one written moments ago, and one taken away with Cut must all survive a sweep, and the Cut one has to come back with its picture intact.test:mirroris about the folder copy, which is the app's whole answer to "the only copy of my work is in one browser" and had no test at all. A folder picker cannot be opened headlessly, soshowDirectoryPickeris replaced with a folder that lives in memory and counts what is written to it — everything on this side of that is the real thing. It checks that the folder ends up holding a board.json that really parses, the media beside it and a note saying what it is; that a picture already written is never written again, which is what stops a board of two hundred photographs rewriting all of them every time a card moves; that a change waits for the board to settle rather than being written the moment it is made; and that a permission which lapses mid-copy stops the copying, says so, and says what to do about it. That last one is why it exists: the message written for it could not be reached, so a backup that had just stopped working reported itself as one that was never set up.test:contexttakes the graphics context away from under a working board — a GPU process crashing, a driver updating under a running tab — and checks the board carries on. It matters because of how that fails rather than how often it happens: every call on a lost context is a silent no-op that neither throws nor returns an error, so a board that has stopped rendering goes on looking exactly like one that has not, until somebody reloads. The worker puts its context on its own global — the same reason the engine is on the window — and the suite loses it with the platform's ownWEBGL_lose_context, a real loss rather than a simulated one. The check that matters is that a different effect afterwards paints a different picture; before this, it painted the same bytes exactly, twice over. If the context cannot be reached the suite stops rather than reporting the rest, which would otherwise be eight passes about a board that was never hurt — which is exactly what the first version of it did on a machine where its setup lost a race.test:aheadopens a board holding a card this build has never heard of, which is what a second tab running a newer deploy writes, and what a.board.zipfrom a later version brings in. It used to take the whole app down — not the card, everything, a blank page with one line in a console nobody has open — because every question about a card went through a table indexed by its kind and a row that is not there is undefined. Now the card is drawn as one this version does not know, can still be moved out of the way or deleted, and its file survives a sweep. The second half is the other layer: a record no build ever wrote is put on the board to make a render really throw, and the page has to say what happened and say the work is still there rather than going white.test:offlinemakes a board, cuts the network at the browser, and opens the app again: it has to start, and the work has to be there — in a reloaded tab and in a fresh one, which is what an installed app is. Then the two things that would make that a bad bargain: nothing from outside this app is ever put by, and a second version really is put on the server to check that it says so and changes nothing until it is answered.test:embedserves a player from the test itself, so the iframe is a real cross-origin document with no internet involved, then drags the card by the picture and lets go over the player. It has to stay where it was let go of rather than follow the mouse, the board has to take a press again afterwards, a selected player has to still be movable by its strip while its picture belongs to the player, the strip has to give way on a card too short to spare it, and neither a click nor a tap may leave the players unclickable afterwards. Resizing the card by its corner is in there because that path starts a drag without going through the card's own handler, and so had the same bug all of its own.test:movingdrops a hand-written animated GIF and a recorded video on the board, puts an effect on each, and screenshots the painted card to count how many different pictures it draws. Both must keep moving, the GIF must still move after a reload, and a still picture must still be drawn exactly once. It also records why the easy fix was not available: reading an animating<img>back only ever gives the first frame.test:mcpstarts the real relay as a real subprocess and speaks to it the way Claude does — JSON-RPC on its stdin and stdout — while a real browser holds the board at the other end. Nothing stands in for anything: a note put down over the wire is really on the board, its words are on screen, undo takes it back, anddraw_imageproduces real pictures. Then the half that matters most: a second origin is conjured with Chrome's host resolver and made to try, from inside a real browser, exactly what a hostile page would.test:curateruns the job the whole app is for, end to end: gather twelve references, keep five, put those five in a place of their own, move them to a board where they belong, and check they arrive with their pictures and their marks intact. It also checks that gathering is one step of undo, that putting cards down happens once rather than making copies, and that a board refuses to be put inside itself.test:tabsopens the app twice in one browser, which is what two tabs are, and checks that a tab picks up what the other one wrote without being touched, that a later write keeps both tabs' work, and that a write which would replace a newer record stops and asks instead — with that tab's own work still on screen and the other version still on disk while it does.test:dropdrops a folder of twenty on the board and checks that all twenty arrive, that they are laid out as a block rather than a column, and that the board moves to show them. Then it checks the other half: a single picture dropped in front of you moves nothing, and a drop made while zoomed in never zooms you in further than you were.test:posterexports a board wider than the window as one picture and reads the file back: both far-apart photographs are on it and in different places, the writing on a note is really painted, a section and a wire are drawn, a cut card comes out faded, the two lines at the top are there, and a board exported in the dark theme comes out dark. It checks that a narrowed board exports only what it narrowed to. Then it exports the same board as a PDF and checks the page is a paper size somebody owns, turned to suit the board, with the board inside a margin and its own shape kept.test:urlimageserves a PNG from a second origin on your machine, once with the cross-origin header and once without, and checks that dragging the first in makes a picture this board holds and can shade, that pasting the second makes a picture shown from its own address, that a page is still a link, and that all three survive a reload.test:palettechecks that every button in the top bar is an icon that still says what it is, that the command list opens, narrows as you type, runs what you pick and refuses what has nothing to act on, that a command the toolbar never had room for works from it, that the panel collapses to a rail with nothing selected, and that an effect can be found by name.test:looksgrades a card, saves the look, puts it on a second card and on a whole selection, and checks the treatment carries while the framing does not — the second card is zoomed first, and stays zoomed. It also copies and pastes a look through the card menu, renames one, deletes one, and reloads the page twice to check that a saved look outlives the browser session and that a deleted one stays deleted.test:touchpans with one finger, pinches with two, drags a card with one, and taps to clear the selection. It also checks that every button in the top row and the zoom bar is big enough to hit with a finger.test:transferbuilds a board with a picture, an arrow and a board nested inside it, exports the lot, wipes the browser and drops the file back on an empty board, then checks every piece came back — including the picture, which is the part the old export could not carry. It also opens the file with Python's own zip reader, so "openable by anything" is checked by something that is not the reader that wrote it.test:noteswrites a note with every mark in it and checks each one is drawn as what it means, that ticking a box on the card writes the tick back into the text and survives a reload, and that the editor's buttons put marks where the cursor is and take them off again.test:wiresconnects two cards by dragging from a port, checks the arrow follows them when they move, that the same pair cannot be joined twice, that an arrow can be selected and removed on its own, that deleting a card takes its arrows with it, and that undo brings both back.test:boardsmakes a board inside a board inside a board and checks that what goes in each one stays there, that the trail back out works at depth, that a rename reaches the card that opens it, that a reload lands you where you were, and that duplicating a board card copies its contents rather than pointing at the same board. It also measures the top bar at five widths, since the trail has to share a row that was already full. It also puts a note two boards down, comes back to the top, and checks that searching for a word in it finds it, says which board it is in, and that picking the result opens that board and brings the card into view.test:sectionschecks that items join a section when dropped in, move with it, leave when dragged out, survive a reload, and that resizing does not change membership while deleting removes the contents. It also draws one with the tool rather than dropping it, and drags one from the middle of its own body — which used to draw a selection rectangle and pick up everything inside it instead.test:typeis the suite for text you can see and text you can set. On a dark board it measures the ink a new label is drawn in against the ground behind it, does the same for a board saved with the old baked-in near-black, checks that what a drop brings arrives selected, that the roles in the Text panel each set something different, that choosing a Google family asks Google exactly once and that nothing else ever does, that the text tool draws a box at the size of the drag with the caret already in it, and that all of it survives a reload.test:menuopens both right click menus and checks each action, including ordering, tagging, acting on a multiple selection, and that the canvas menu adds things under the pointer.test:searchchecks what search matches on, that non-matches fade and stop taking clicks, that Enter steps through results, and that the tag filter narrows the board and combines with the text.test:smokedrops a picture, applies five effects and checks that each one actually painted something different.test:effectsapplies all 69 to one picture and checks that each paints something of its own, that no two paint the same thing, and that none paints a flat colour. A shader that fails to compile falls back to Original without saying so, which is exactly what that catches. It also checks that ASCII finds something to say in the picture's shadows, which is where it used to give up. It leaves a contact sheet of every effect in.smoketo look at.test:pngexports a card and checks the file is the picture's own resolution rather than the card's, that it is the shape of the card, and that the effect, the tone, the framing and the grain are each really in it. It also exports two cards at once and opens the zip with Python's own reader.test:aspectputs a circle on a card that is not the shape of its picture and checks it is still a circle once an effect is on it, measured on a picture of the card rather than on the bitmap behind it.test:videorecords a short clip in the page, drops it on the board, applies an effect and checks that the picture keeps changing while the video plays. It also reloads the page to check the card comes back.test:urlvideoserves that same clip from a second origin on your machine, one path with the cross-origin header and one without, and checks all four outcomes: effects on the readable one, a playing card that says so on the other, an embedded player for a YouTube link, and a plain link for an address that is not a video.test:varypresses the key that puts twelve versions under a card and checks the three things that make the loop worth having: that the twelve are genuinely different from each other rather than near-copies, that the card they came from is untouched, and that the whole round is one press of undo. It marks keepers and presses again to check the next batch is bred from those and fills the holes the rest left.test:pairdraws a wire and checks the card at the other end is really what the effect reads — measured on the pixels, since that is the only place the question is answered — and that an effect wanting a partner it has not got is still an effect rather than an error.test:repeatchecks that a second pass changes the picture, that each further pass changes it again, that a card costs nothing until it is asked for, and that the count survives everything else a card survives. It also holds the line on a bug that shipped: a card with two passes on it used to come out upside down, in the card and in every export of it.test:layerputs a blue card over a yellow one and reads the composited pixels. Multiplied they make a near-black green that neither of them is, so an overlap still flat blue means the blending never happened and an overlap flat anything else means it happened wrong. The one that is easy to get wrong and hard to notice is last: a card set to multiply over nothing at all must still look like itself.test:framingchecks that Alt and a drag reframes the picture inside its card while a plain drag still moves the card, that Alt and the wheel scales it, that a card with no picture behaves as it always did, and that a whole selection is not reframed because one of them was.test:finishholds the key that shows the picture without its grade and checks the edges rather than the happy path: that the hold ends when the window loses focus, that it files nothing in the undo history, and that a typed setting out of range is brought into range rather than accepted or dropped.test:dialogsputs all seven things that cover the board through the same four questions — does it say what it is, does the focus go in, does the focus stay in, and does the focus come back out to where it started — which is the point of doing it with a table rather than seven times by hand.test:platemeasures the halftone screen a card's name sits on over a photograph: the words have to stay readable over any picture and the picture has to stay visible between the dots. It was drawn at a coverage that put the dots half a width into each other, which came out as a black slab.test:sounddrops a clip, builds a chain on it, and measures the peaks the card draws from — the one number a browser gives you about a sound without playing it, and exactly what a person sees. It checks that the treatment is rendered and saved so it survives a reload, that the waveform is the treated sound and not a picture of what it used to be, and that the file that was dropped is never written over, so taking it all off puts the original back.test:modeldrops a.glbof two boxes in two named materials, one warm and one cool, and checks four things that cannot be proved by counting cards: that it really rendered and rendered both materials, that the camera really moved rather than a CSS rotation of one picture, that the materials and UV sets were read off the file rather than guessed at, and that it goes back out as a model wearing what it was given.test:sketchchecks that the code really drew the card, that the dice can be thrown again without touching the code, that a loop with no end in it costs a card rather than the tab, and that what comes out is a picture like any other. The quietest check matters most: a sketch runs with nothing it could use to reach out of the browser, asked from the inside.test:depthcannot check that a guessed depth map is correct, so it checks three things that are true of a depth map and false of the picture it came from: that it is grey, because distance has no colour; that the near half of a picture built to be near at the bottom comes out lighter than the far half; and that the four effects which read it read it, by handing them a map with a known shape and measuring what they did. It also runs the real model's whole path, which two addresses on the open internet would otherwise leave unexecuted: the test serves the runtime and the weights itself, and everything between them is the app's own code. What that buys is the two mistakes a wrong version would make plausibly rather than obviously — a picture handed over interleaved or normalised wrongly, and a map that comes back inside out, which looks exactly like a map.test:helpsays the handful of things you cannot deduce from a button, and its suite is about the page being reachable, readable and escapable. The quietest check again matters most: while it is open the board's own keys must do nothing, because a help page that adds a note to the board behind it is worse than no help page.test:findallputs a note in another project and checks it turns up, that the row says which project before you commit to it, and that picking it lands you on the card with the tabs and the address following.test:sendabletakes the page the app exports, opens it in a browser that has never seen this app with the network off, and checks the board is there.test:undoboardschecks that stepping into a nested board and coming back leaves the undo history where it was, that redo survives the same trip, and that a board's undo only ever undoes that board.test:undeletedeletes a project and puts it back, checking it is properly gone from the row and from search in the meantime, that it comes back under its own ids in its own place, and that its pictures are still there because the sweep waited.test:nospacemakes every write fail on purpose, from before the app has started, and checks the whole chain says so — the highest-consequence path in the app and the one where a regression is invisible in ordinary use, since with a disk that is not full none of it ever runs.test:asciichecks the three things drawing the glyphs as shapes had to buy, after a font stack meant the same board came back looking different from one reload to the next and two machines never agreed.test:loadfills a board with images, applies an effect to all of them and reports whether the main thread ever blocked.benchcompares the old drawing method with the new one.
public/ holds the icon at every size something asks for it in — an SVG for
modern browsers, a favicon.ico carrying 16, 32 and 48 for the ones that still
ask for that file by name, a square full-bleed touch icon because iOS puts its
own mask on it, and a maskable one for Android.
og.png is what appears beside the link wherever it is pasted. It is a real
screenshot of the app with the name over it, not a logo on a colour: a card
that is only a logo tells you nothing the URL did not. It is made by driving
the app — dropping the pictures in, putting ASCII on them, pulling the colours
out of one — so it cannot show a feature that is broken.
Both fail silently in the wild — a missing icon is a blank page glyph and a
missing card is a link with nothing beside it, and neither appears in any log —
so test/unit/brand.test.ts checks that everything index.html and the
manifest point at is actually there and the right size.
The absolute address the card needs is filled in at build time from the host's
environment, so it is right on production and right in a preview deploy without
anyone typing a domain. SITE_URL=https://your.domain npm run build pins it.
brand/ holds the generator and explains the mark.
| Path | What is in it |
|---|---|
src/engine |
The effects engine, the worker and the job scheduler |
src/board |
The board surface, the cards and the pan and zoom code |
src/state |
The board contents, undo and redo, the board tree and the projects above it, what each kind of card can do, sounds, sketches, models and depth maps, the variation grid, where things go when they are lined up, and reading what is dropped in |
src/store |
Saving to IndexedDB, decoding what was dropped — documents, design files, models, sound — keeping a copy in a folder on disk, and watching how much room is left |
src/ui |
The top bar, the panels, the command list and the small dialogs |
src/ai |
Your key, and asking Google for a picture with it |
src/mcp |
The wire to the relay, and what an agent may do to the board |
src/app |
The keyboard, in one place |
test |
Browser suites and the benchmark |
test/unit |
The fast tests, on the arithmetic underneath |
mcp |
The relay Claude talks to. No dependencies |
sw |
The service worker, so the app opens without a network |
scripts |
The browser-suite runner, and what writes the service worker |
public |
The icon at every size, the social card, the manifest |
brand |
What generates all of that, and why the mark is what it is |