Decisions and tradeoffs
What was chosen, and what it cost.
JSX compiles to nodes rather than to elements
Chosen because it makes one model serve two audiences: a screen can be written in TypeScript with full types, or loaded from JSON, generated, or edited by something that is not a compiler.
Cost. Props at a graph boundary must stay serializable, so a closure works in-process but is dropped by toSerializable. Actions are the seam: { functionCall: { call: 'x' } } and a closure are interchangeable to a component, which is what keeps the cost bearable.
Late binding, so a missing name is a runtime miss
Chosen because a graph that is data cannot reference a module.
Cost. A typo in a component name compiles. The mitigation is that a miss renders visibly - <NotRegistered> in the frame - rather than silently producing nothing, and the playground tests assert no such marker appears.
Only four host primitives
Chosen so the layout engine and the painter reason about three shapes and nothing else. Adding a component costs a function, never a case in the engine.
Cost. Anything that genuinely needs to paint cells - charts, gauges - goes through canvas and gives up participating in layout beyond its own box.
A flexbox subset, in whole cells
Chosen because a terminal has no sub-cell measurement, and the rules people need are grow, shrink, align and absolute positioning. Fractions are distributed by largest remainder, so three flex-1 children of a 10-cell row get 4, 3, 3 rather than losing a cell to rounding.
Cost. No wrapping, no baseline alignment, no grid with spans. Grid is equal columns; anything else is a Row with widths.
Responsive means dropping columns, not shrinking them
Chosen because squeezing every column until none is readable is worse than showing fewer. A column with no stated priority inherits its position, and the first column is never dropped - a row you cannot identify is not a smaller row.
Cost. A table’s priorities have to be chosen by whoever writes it, and a bad choice is invisible until the terminal is narrow. The playground test resizes to 40 columns for exactly this reason.
Meaning never depends on colour alone
Chosen because a 16-colour ssh session, a colourblind reader and a piped log all lose the colour. Every status is a glyph and a colour.
Cost. Every component that shows state needs a glyph decision, and glyphs must come from the theme rather than be typed inline - which is a rule people forget, so the playground tests assert that nothing outside ASCII survives an unicode: 'ascii' downgrade.
Input settles between events
Chosen because a terminal delivers several keystrokes in one read and a handler closes over its last render’s props. Without a render between events, typing quickly drops characters.
Cost. A paste that arrives as N key events (no bracketed paste) costs N frames. Bracketed paste avoids it, and the frame diff makes each one cheap.
Commands are the only way to spell an action
Chosen because a button and a palette entry calling the same API are two implementations that will drift.
Cost. Ceremony for a one-off action. onPress still exists for genuinely local behaviour; the rule is about anything a user could reach two ways.
The store is authoritative, and the two store hooks say which is which
Chosen because two components disagreeing about the same path is the exact failure a single store exists to prevent.
useStore(path, initial) is state: it looks like useState, so it behaves like it and writes the initial value when nothing has filled the path in. useStoreValue(path, fallback) is a view of something someone else owns, and its fallback is a display default that stays local to that reader.
Cost. Two hooks where one would do, and a rule to remember about which argument means what. The earlier design had one hook whose second argument never wrote, which read like useState and was not - the split is what makes the difference visible at the call site.
A component fills what it is given; it never sizes itself from its content
Chosen after the file explorer: opening a four-thousand-line file moved every pane on the screen, because the viewer rendered one row per line and its parent grew to fit.
Three rules together make that impossible:
useMeasurehands a component the rect it was laid out into, and the frame runs the render/layout pass again when a measurement changed - so a viewer can render exactly the rows that fit, in the same frame.- A component only trusts that measurement when the layout actually decided its size, which is when it was given
flex, aheight, amaxHeightor abasis. A box sized by its own content would otherwise freeze at whatever it first drew: its measurement is its content, so a tree could never grow. - The layout engine shrinks elastic children before rigid ones and never places a child outside its container, so a pane that is too big cannot push a status bar off the screen or measure itself at a size the terminal does not have.
Cost. A component in a container with no definite height renders all of its content, which is right but means flex is load-bearing in a way that is easy to forget. Measurement also costs a second render pass in the frame where a size changed.
Resource adapters bundle what a file type needs, and buffers make actions honest
Chosen because “support JSON” is not one registration: it is a kind, a highlighter, two viewers and three transforms, and an application that has to make five calls in the right order will get one of them wrong.
Actions edit a document buffer rather than the provider. Formatting a file from a read-only source still shows you the formatted document, and saving is a separate, explicit act that the provider can refuse.
Cost. A buffer is a second copy of the content, held in the session scope until the process ends. Browsing a directory of large files keeps all of them.
The palette runs commands, and asks when a command needs an argument
Chosen because a palette that reports a choice for the caller to execute is a palette every caller wires slightly differently - and one of them will run the wrong thing, or nothing.
Sub-items follow from the same idea. A command that needs a tone declares the choices; the palette reads them and asks. The alternative was a submenu structure owned by the palette, which would mean every command that wants one has to know the palette exists.
Cost. onRun is now a notification rather than the mechanism, which is a breaking change for anyone who wired it the old way; execute={false} restores the picker behaviour. And a command with an argument cannot be run from the palette without answering the question, even when a default would do.
Selection inverts, and colour is inherited
Chosen after a screen where the focused button was a blue outline and an unfocused one was a filled green block: the eye picks the fill, so the wrong control looked selected. Inverting the tone makes selection the same signal everywhere - a line becomes a fill - and the theme’s on* tokens keep the label readable on whichever tone that is.
Inheritance is the other half. A cell holds one foreground and one background, so a label that did not inherit was drawn in the terminal’s default colours and cut a hole through the fill behind it.
Cost. Inheritance means a container’s colour reaches further than some authors expect, and attributes accumulate with no way to unset one. Selection also owns the fill now, so solid is a weaker signal than it was: it says “this is the primary action”, not “this is selected”.
A component states its state colours where the component is
Chosen after a theme that wanted its lists to have a different selection from its tables, and could not have one. active and selected were global tokens, so restating either repainted every list, tree, table, menu, tab and text field in the application at once - and leaving them alone meant the only way to colour one row differently was to write the colour on the node, where a theme could not reach it.
So components.List.selected = { bg, fg } is the answer, and the component says which states it is in rather than what they look like. The five names are real: selected, hover, active, focus, disabled. Reaching them needed a prop, because a row’s focused was structurally always false - the keyboard belongs to the list, not the row - so focused is a tri-state node prop that stateOf honours. styleAs came with it, since a composite component draws plain box nodes and components.List was otherwise a key nothing read.
Cost. Three things. A theme author now has to know which name a component answers to, and a wrong one is a colour that silently does nothing - so each built-in entry is covered by a test that mounts the component and reads the cell, which is the only way a miss shows up before a person notices it. Text inside a selected row had to give up its own fg, which is the inheritance rule doing its job but reads as an omission at the call site. And Tabs needed the general form, a state qualified by a variant (Tabs.solid.selected), because whether a selected tab paints at all is a property of the variant rather than of the state.
A selection in a list is a colour, not a fill
Chosen for dark, light, paper and paper-dark: a selected row in a list, tree, table, menu or text field keeps the canvas behind it and is drawn in onSelected, bold while the component has the keyboard, and in onActive when it does not. A button still inverts; this is about rows. The editor’s selection is reverse video, because it has to keep each token’s syntax colour and there is no fill to lay it on.
Cost. The live and the remembered selection are now the same colour and differ only by weight, which a terminal that draws bold faintly will not show well. And a theme that extends one of these and brings a fill back has to state its own onSelected and onActive, or it inherits a colour chosen for the canvas and writes it on the fill.
A colour is typed by its channel
Chosen because active was reachable in all three and meant three things. As a background it was the dim end of the selection pair; as a foreground it was the colour written on it; as a border it was neither and read as a mistake. Every one of those was a valid-looking line of code, and none of them said which was which.
ColorToken is now a union of three per-channel lists, and fg, bg, each border side and scrim take their own. The tokens a theme’s colors map is keyed by are still the whole union, so no theme has to be reorganised; the narrowing is on the field, which is where the mistake is actually made.
Cost. A literal colour is still accepted anywhere a colour is, so fg="canvas" is the only thing that stops compiling and fg="#0d1117" still does - the escape hatch stays open deliberately, because the point is to catch the wrong token rather than to make the escape a second thing to argue about. cursor is in two lists on purpose, because an underline caret is a foreground and a block caret is the foreground swapped. And the border list keeps the two quiet foregrounds, muted and subtle, because a dim frame is a line somebody means; what it refuses is a rule in text or in an on* token, where writing is being used as structure.
root is a mount, and the shell always frames it
Chosen after a light theme that stayed dark: createApp({ root }) returned that node instead of the shell, so the application had no canvas, no status surface, no toast host, and setShell silently did nothing. Only the dialogs looked themed, because they paint their own background.
root now opens into main at boot. It is an alternative to screens, not to the shell.
Cost. An application that wants a bare node with no shell has to register no shells at all, which is the fallback path rather than the obvious one.
No dependencies
Chosen because every one of them would have to be audited by whoever ships a terminal application to a production host.
Cost. Grapheme measurement, ANSI encoding, input decoding, colour downsampling and argument parsing are all written here. Each is small; together they are a real amount of surface. Intl is used wherever it can be - number, date, list and plural formatting are the runtime’s job, not ours.
What was deliberately left out
- A router. Screens and a stack. An application that wants URLs maps them.
- A CSS engine. Style objects, tokens, and convenience props.
- Dependency injection. A typed lookup table with a parent chain.
- A job queue. Tasks with a lifecycle and cancellation, and nothing more.
- Fine-grained hot reload. A reliable full remount that preserves the store beats a clever one that sometimes does not.