The Stack
Orientation for the furniture primitive every Folio kind is drawn with — what it is, where its authority lives, and the few rules that are not visible in the code
Folio is the family word (packages/domain/src/folio); Stack is the
furniture; a Folio is a Stack together with its Substacks.
This page orients; it does not instruct. The Stack is built story-first, so the annotated stories below are the authority for how it looks and behaves — they render the live components and assert what they draw, so they cannot drift from the code the way prose does. Read them before changing anything here.
What this page holds is the part that is not visible in either the code or a story: what the primitive is for, where each piece lives, and the handful of rules a future change has to keep.
In plain terms: every card is the same piece of furniture with different labels on the drawers. This page is the index card on the front of the drawer — it says what is inside and where the real file is.
What a Stack is
One recursive primitive with four regions, in this order:
| Region | Optional? | Holds |
|---|---|---|
| Stack Header | yes | the glyph, the name, and the trailing control cell. A Content Block omits it and starts at its prose. |
| Stack Body | no | the card's content — module faces, a content block's prose, a data block's fields. Fields live here, never in the Portal: a field is Body content, not a Substack. |
| Stack Footer | yes | the attached panel. Rendered before the Portal, so a module's controls sit above its blocks. |
| Substack Portal | yes | where subordinate Stacks render, below the Footer and outside the painted card. |
A Stack inside another Stack's Portal is a Substack — relational, not structural: not a separate component type, just a Stack in a nested relationship. Document, Module and Block each render their subordinates through their own Portal, which is what makes the primitive recursive.
A card is nested from named parts, not configured by handing a shell a list of settings. See the composer rejection for why.
Where the authority lives
| For | Read |
|---|---|
| Every part and region, drawn, named and measured | Shared/Molecules/Stack/v3/Anatomy — the Step 1→6 guide; build a new kind from this |
The spine | gap | mark column | gap | words row primitive | Shared/Molecules/Stack/v3/Row |
| Approved Card and Icon geometry | Shared/Atoms/Card/Geometry and Shared/Atoms/Icon |
| The approved geometry on shipping adapters | Widgets/DraftingEditor/Folio leading region/Construction and Document stack/Leading region holds |
| The parts at their own interface — the control column, the activation guard, the caller-gated body | Shared/Molecules/Stack/v3/Assembly and /SharedParts |
| Selection and the panel | Shared/Molecules/Stack/v3/Selection |
| The card's hairline frame | Shared/Molecules/Stack/v3/Frame |
| The fold, the passive spine, the content surface | Stack/v3/Fold, /Passive spine, /Surface |
| Which kind writes which part, and which declines it | Widgets/DraftingEditor/Folio slots |
| Who may edit what | Widgets/DraftingEditor/Editing access by Folio |
| The drag grip and reorder | Shared/Molecules/Stack/v3/Drag and drop (the geometry is measured there); stack-sortable.tsx for the run clamp |
| The rest/hover/selected colour schedule | stack-palette.ts and its pinned test (shared/ui/molecules/stack/v3); the values are tokens in src/app/app.css |
| The persistent control's cell, and the Footer seam | Shared/Molecules/Stack/v3/Anatomy, Step 5 |
| The concept — kind union, payload names | packages/domain/src/folio |
The rules a change has to keep
These are the reasons behind the shape. A story proves the current behaviour; these say what must stay true of the next change.
- No
kindprop, and noswitch (kind)in the shell. Differences enter as parts a tier writes or omits, so a new kind adds a caller rather than a branch. A switch inside the shell means the extraction has gone wrong. - The two margins carry different channels. Left is structure — the drag handle, the spine, the kind marks. Right is Goldie, alone on her corner. A new control picks the side its meaning belongs to; neither margin holds both.
- The row template is declared once, in
stackRowTracks(shared/ui/molecules/stack/v3/stack-tracks.tsx). A second copy of the leading region anywhere is the drift the primitive exists to prevent — and a row that draws its own cells draws all five of them, or its words land in the mark column. - No product nouns in Shared UI. The shell knows about headers, gutters and
spine segments; it does not know about documents, modules or blocks. Biome's
noRestrictedImportsblocks the domain import; the identifier ban is review-enforced only. - One vocabulary. The component is
Stack, itsdata-slots arestack-*, and the regions are the Stack Header, Body, Footer and Substack Portal — one word for the part, the slot and the region.foliois the one name deliberately notstack: the Folio is the assembly, the Stack is the primitive, and only the assembling element carriesdata-slot="folio". - Paint stays with the tier's palette adapter, which hands the root
spineClassNameandclassName; the header band steps on hover only. A root resolving paint from onetoneprop would be a second decision tree besidestack-palette.ts, which is the one the cards read. - Containment is for code, not presentation. A module contains its blocks in the DOM, but nothing indents and every Stack keeps one left edge. A Substack Portal starts a fresh containment scope, so selecting a parent never tints its children.
Proposal actions
The action row below a proposed card uses the composer's decision controls and row geometry: Reject, Accept, aligned on the right. Labels do not repeat “insertion” or “removal”; the card already shows the proposed change. The final Send-sized slot holds an icon-only Open action in a folio list and remains empty on the canvas (owner ruling, 2026-09-20).
A proposed block exposes its G control for its proposed About information and the conversation that proposed it. Opening that G continues review; moving on or making the next request retains optimistic acceptance. Sending from a proposed block accepts the pending proposal and continues on the created block’s own identity, with its panel kept open; it must never address the old anchor as “this block”. Explicit Accept clears the proposal and Reject declines it.
Numbers that were decided, not derived
A story measures these; none of them explains itself. Do not replace any of them with a formula.
| Value | Why not derived | |
|---|---|---|
| Separation between Stacks | 2px inside one parent, 8px between tiers that hold Substacks, 32px at the outermost boundary | No single multiplier puts 8px between modules without 32px above them, and 4px is invisible at density. The caller states which; SubstackPortal must not know how deep it is, or it becomes a different component at every tier. |
| Leading region | 4px spine | 8px gap | 48px mark column | 8px gap | words — first glyph at 68px, identical at every tier (owner ruling 2026-09-21) | Rank is carried by the size of the mark and the size of the type, never by indentation: a module's name is not indented under its document's. One region means a document header, a module header, a clause, a data chip and a field row all start on one ruler mark, which is what a reader scans. The 8px before the words is a track, not a text inset — see the row below. |
| Spine thickness | 4px, every tier (owner ruling 2026-09-21) | Replaces the 6 / 4 / 2px ramp of 2026-09-18 and the 9 / 6 / 3 before it. The ramp put rank on the card's edge; the ruling moves rank into the mark column, where a reader is already looking, and leaves the edge alone. With one width there is no rank to declare and no thin-rank centring to derive. |
| Card variants | large: minimum 84px — 8px above a 48px chip line, a 16px count line beneath it in the title column, and 12px under that; medium: minimum 64px, 8px vertical padding around a 48px content row; small: minimum 56px, 8px vertical padding around a 40px content row (owner ruling 2026-09-22, the one-chip proto; mark-geometry.ts is the one source). No horizontal padding. | The Card atom owns only these measurements. Stack composes it and owns the spine, columns, text rows, palette and interactions. A large card's rank is carried by its 20px title and its height, never by a bigger chip or an indent. |
| Icon geometry | md: 48px mark area and target, 36px artwork, 24px glyph — the one chip every folio tier draws, document and workspace included (owner ruling 2026-09-22; the 48px lg chip is retired); block: 40px mark area and target, 30px artwork, 20px glyph — 2px proud of the 28px reading line, an even six below the module's 36; sm: 40px mark area and target, 24px artwork, 16px glyph. All square. The target is the whole mark area (owner ruling 2026-09-21, removing the medium 4px and small 2px insets): with all paint on the artwork, an inset target only shrank the hit area and focus ring. The glyph is roughly two thirds of its artwork (same ruling): the artwork paints, the glyph reads, and a glyph that filled its chip read as cramped. Every glyph rests in the muted editor ink; on reach it takes the one blue and the chip the blue wash, a field's bare glyph only the grey reach wash, and a held card keeps the coloured glyph on the held tint (owner ruling 2026-09-22). | Icon owns mark, target, artwork, glyph and their centring. Kind backgrounds and rings paint on artwork only; the target stays transparent. An interactive target uses IconTarget asChild with the existing control and IconArtwork inside it; decorative children retain the concise Icon API. Consumers never reproduce these dimensions. |
| Mark placement | Headers centre left and right mark areas vertically on their row. Headerless content surfaces anchor both the leading mark area and the G at the top of the padded row, including when the content grows tall. Centre small mark areas horizontally inside the 48px column. | Stack owns placement. The leading region after the spine is 8 / 48 / 8px. The mark remains out of flow so a medium Card's 48px text block and padding determine its 64px height while its 48px mark area shares its centre. |
| Text rows | Measured from the painted chip (owner ruling 2026-09-22): title + count equals the artwork, top edge to top edge and base to base, the title's text top-aligned, and the chip's own air in the content row is the inset above and below. Document: 8px inset, 32px title row (20 / 24px medium), 16px count row (12 / 12px), 8px inset. Module: 6px inset, 20px title row (14 / 20px medium), 16px count row (12 / 12px), 6px inset. Field and prose rows centre their first line on the block chip's 20px centre (10 + 20 + 10 and 6 + 28 + 6; field keys and values are 16px on 20px, the clause face's size, owner ruling 2026-09-22), with the mark anchored to the row's top so wrapping text flows past it. | StackHeaderRow owns row splits and alignment; entity adapters supply names and counts. Content Blocks keep sentence rendering; Data Blocks keep their label rendering. |
| Trailing region | Text, 8px gap, 48px G area finishing flush with the Card's right edge. Goldie always uses Icon size="sm": 40px mark area and transparent target, 24px artwork carrying its circular background and ring around a 16px letter. | Stack owns the gap and column; Icon owns the G geometry; the agent adapter owns its palette, panel state and action. There is no right Card padding. |
| The words gap is a track, not an inset | The 8px between the mark column and the words is a grid track of stackRowTracks (owner ruling 2026-09-21) | --space-editable-box-text is 9px because it assumes a bordered editable box — 8px of padding plus its 1px border — and plain text was given the same 9px so the two would agree. Measured on Widgets/DraftingEditor/Document stack (2026-09-20), nothing inside a card draws that border, so they never agreed: names and clauses sat at 56px and count lines at 65px. A track cannot drift that way. Auxiliary chrome keeps its own inset. |
| Heading overflow | Titles and data-block labels stay on one line and truncate; the full name is available on hover. Header editors stay within the text column and scroll horizontally. Version suffixes never wrap or shrink. | Title length cannot change Card height or cover the trailing control column. |
| Name type, by rank | Document 20px / 24px line, 500, in the document accent, with a 12px / 12px muted count line beneath; Module 14px / 20px, 500; Block chip 14px / 20px, 500 (owner ruling 2026-09-22, up from 12px), the chip 20px tall with 4px side padding (owner ruling 2026-09-21) | With one leading region, this and the mark are the whole of hierarchy. The chip's padding is also how far its ground reaches into the gap before the mark: at 8px it crossed the gap and touched the mark. |
| Card frame | 0.5px, one width on every rung | On a 2x page that is one device pixel, half the weight of a 1px line; on a 1x page it snaps to 1px, that page's thinnest. Weight is then a tone decision, not a width one: the three rungs differ by colour, and a frame reading too heavy gets a lighter colour rather than a thinner border. |
Where the pieces live
| Lives in | Holds |
|---|---|
shared/ui/atoms/card/ and shared/ui/atoms/icon/ | neutral size and safe composition contracts; no entity kinds or Stack behaviour. |
shared/ui/molecules/stack/v3/ | the primitive — the shell and painted card, the row template and layered spine cell, the parts a card nests, and SubstackPortal with its containment scope. No product noun anywhere. |
packages/domain/src/folio | the concept — FolioKind and the per-kind payload names. Runtime-free: no React, no DOM, no Convex. |
entities/folio/ | the family's own presentation — FolioAboutFace, FolioTagChips, FolioReview, FolioContentsRow. Product meaning the Stack must not carry. |
block-card/v4, module-card/v4, document-folio | the compositions — each nests the parts at its own call site. |
Imports travel downward through public Interfaces; entity adapters use the
explicit Folio @x doors. A comparison importing several entity adapters
belongs at the widget layer, never in entities/folio.
Known gaps
- A searching block's candidate is a plain dashed
BlockCardwith no decision strip (the strip was dropped 2026-09-25: nothing passed its actions); the Substack Portal is its intended next tenant (BACKLOG).