Skip to main content

Authoring flows

What an author does, step by step, against the model in domain model and the API in API sketch, with the canvas described in studio.md. Each flow is Trigger → Steps → System → Failure modes. Several failure modes are open questions rather than settled behavior, and are marked as such.

1. Create a page

Trigger: author clicks "New page."

Steps: choose a route (a literal path, e.g. /dispatches/tide-tables); pick a layout (a Document with kind: "layout") or none; optionally start from a preset instead of blank. A preset is stored as kind: "preset". Why it is not called a template is in the decisions.

System:

  • Blank start: Document created with head: 1 and no route pointer — that absence, not a stored field, is what "not yet published" means (domain model).
  • From a preset: the preset's tree is cloned and every node id in the subtree is remapped via the shared clone utility in core. Ids are never regenerated for an existing document, but a clone is a new document, so nothing is shared (domain model).

Failure modes:

ModeConsequence
Route collides with another Document's routeCaught at creation: the studio's create endpoint answers a conflict for a route it already edits, and a malformed route with the compiler's own message. Publishing two documents to one route is still unguarded — Artifact.route is never checked against Document.route, so one silently evicts the other (route ownership, flow 5).
Route collides with a coded route in the consumer's appUndetectable by Nubbin — it has no visibility into the app's route tree. Next's file-system routing prefers an explicit route over a catch-all, so the authored page is unreachable with no compile error and no publish error.
Route needs a pattern, not a literalReal content needs this — a single entry often serves a whole family of URLs off a prefix or param rule, with the remaining segment read at render. Whether authors can create pattern routes is open; the pointer model sketches exact, param and prefix matching (route pointer).
Layout reference is stale or wronglayoutId is not validated against an existing layout Document anywhere in the current design.

2. Compose

Trigger: drag a block from the palette, or act on a selected node.

ActionMechanics
PlaceAllocate a new id, write Node { id, block, props: defaults }, insert the id into the target slot's ordered array, or into the document's roots.
ReorderReindex the id within its slot array. No id changes.
Nest into a slotSame as place, targeting a node's slot rather than the document's roots.
Duplicate / pasteClone the subtree, remap every id via the shared clone utility.
DeleteRemove the id from its parent's slot array; the orphaned elements entry is detected the same way compile detects unreachable nodes.

System: every operation is elements[id] = … against the flat { roots, elements } shape, never a deep tree rebuild. Slot legality is a SlotConstraint (allow / min / max) declared once on the block and read by both the compiler and the canvas — the same declaration greys out an invalid drop target during drag (API sketch).

Failure modes:

ModeConsequence
slot.min violated by a deleteThe editor does not block the delete. Compile runs on the next save, so the violation stands in the editor's issue pill, naming the node, until it is fixed; publish refuses it as a NubbinError with a node and a path.
allow names a typo'd or renamed blockcreateRegistry() throws, naming every unresolvable entry with its block and slot, so the slot never reaches an author.
A pasted/cloned subtree creates a cycleNot reachable through normal drag; possible via direct API writes. The flat shape makes it detectable — a cycle can't flatten into a tree, so compile fails rather than looping.
Cross-document composition (copy a node from one open page into another)Undesigned. The canvas is one iframe over one document at a time (studio.md).

3. Edit props

Trigger: select a node; the inspector renders its fields from ui.order / ui.groups / ui.fields, control chosen by ranked tester (API sketch).

Steps: the inspector holds local edit state while the author types; on a debounce the whole document folds back into elements[id].props and saves to the draft store, with the compiler's verdict riding the reply.

System: the canvas — Puck rendering the consumer's components directly — repaints as the author edits. The iframe canvas updates on commit instead, because a server component's code never ships to the browser, so there is nothing on the client to patch (one render path, one preview mode).

Failure modes:

ModeConsequence
Invalid value against the block's schemaThe whole draft compiles on every save and the refusal surfaces in the editor's issue pill — one tier of the three the API sketch describes. A draft may still hold invalid values indefinitely: blocking a save mid-edit is hostile, so publish is the gate, not save.
A field hidden by a discriminated union keeps a stale valueThe value must be dropped at compile, not merely hidden in the editor — so a draft can look wider than what actually publishes.
Repeater rows keyed by indexResolved: rows carry generated keys, stable across a reorder — an index key re-mounts every row on a move and drops focus, which is why the keys ride beside the values instead.
Fields with a data hintEditing props only edits the block's declared parameters — the live-fetched half of its output can't be previewed without a real request round trip.

4. Preview

Trigger: "Preview," or a viewport change.

Steps: draft preview is a real server render through the route the public site already uses (one render path, one preview mode). Viewport switching reads named presets from the consumer's own breakpoints plus free-drag, which works because the canvas is a true iframe (CSS2 §9.1.1, "at most one viewport per canvas"). Preview at content extremes generates values from the schema's own bounds (z.string().max(80) → an 80-char and a 1-char value; z.array(x).max(4) → 4, 1, and 0 if optional) and renders that synthetic document through the same path.

System: schema work — validation, control resolution, extreme-value generation — happens at publish and in the studio only. The production render path never parses a schema; it reads an already-validated artifact.

Failure modes:

ModeConsequence
A node with a data-hinted field previewed at extremesOnly the static-declared props vary; the fetched half renders real, unrelated live data next to a synthetic extreme — no static-only preview mode exists.
Schema shapes the JSON Schema projection can't representbigint, Date, branded types, discriminated unions (which emit oneOf, not if/then/else) — extreme-value generation for these needs the same explicit-control escape hatch editing needs, and it isn't designed for stress-content generation specifically.
Consumer doesn't expose its breakpoint config discoverablyViewport presets have no defined fallback — falls back to inventing sizes, the exact thing this design was meant to avoid.
Preview environment unreachableNo client-only degraded mode — the live postMessage path was removed entirely, so preview fails outright rather than degrading.

5. Publish / unpublish / schedule / rollback

Trigger: publish, unpublish, rollback, or (see below) schedule.

Steps: compile(documentVersion, catalog, registry, route) → on success, store.write(artifact)store.publish(route, hash) swaps that route's pointer, one atomic record (domain model). Unpublish is store.unpublish(route) — the artifact is untouched, so republish and rollback are both pointer moves, never a recompile.

Schedule is not modeled. There is no scheduledAt field, no job runner among the adapters, and store.publish() is synchronous. The artifact model makes scheduling unusually safe to add because the artifact is compiled and validated before the schedule is set, so firing cannot fail on a surprise validation error.

Failure modes:

ModeConsequence
Compile failsNubbinError { code, issues: [{ code, message, at, path }] }; the document stays on its previous artifact.
Rollback target no longer validates against the current registryRollback is a pointer move with no recompile, so frozen props from an older block version could feed a changed component. checkRollback compares the artifact's blockVersions against the registry live now and returns the verdict, so the caller decides whether drift blocks the swap.
A live artifact's block is deleted from the registryA static block is inert — its data is frozen into the resolved tree, no lookup at render. A request-mode field is not: it needs the registry at request time, so deletion breaks the live page with no republish involved. Resolved: checkCompatibility runs over every live pointer as the pnpm guardrail step of CI and fails it, treating a deleted block as an incompatible version bump. Branch protection is what makes a failing check unmergeable, and is set in repository settings.
Route ownershipUnpublish a route, let another Document claim it, republish the first — the second is silently evicted. Nothing enforces uniqueness of route → documentId, so the eviction is silent.
Concurrent publishesResolved: route pointers are independently-writable records, one per route, so two publishes to different routes cannot interfere.
Document.publishedVersion disagreeing with what is liveResolved: publishedVersion is derived on read from the route pointer rather than stored, so there is no second copy to diverge.
Artifact pruningRollback depends on the target artifact still existing. Retention must respect a stated rollback window, and publish() must reject a missing hash rather than wiring a dead pointer — see adapters.md. No policy is set yet.

6. Layouts vs presets

BehaviourLayoutPreset (kind: "preset")
Editing itPropagates to every page that references it — in principleAffects nothing already created; no ongoing link after clone
Used atEvery render of a page that references itOnce, at page creation (flow 1)

Preset cloning has no failure mode beyond what any document already has: a preset's stored prop shapes get validated fresh at the new page's first compile, same as stale content anywhere else.

Layout propagation is unresolved. Artifacts inline fully resolved content and record no layout dependency at all — publishing an edited layout, as currently specified, changes nothing already live: every page compiled against the old layout keeps rendering it, silently, forever, until something explicitly recompiles it. Two candidates, neither chosen:

CandidateMechanismCost
Draft/preview onlyA layout edit shows immediately in the studio; a live page's artifact stays frozen until explicitly republished through a bulk-recompile pinned to each page's publishedVersionSome pages may fail the new slot constraints — a partial rollout with no owner
Resolved at renderThe artifact stores a layoutHash, joined at request timeEvery live page changes immediately, with no page-level compile gate — the propagation itself is the incident

Either way, Artifact needs to record a layout dependency the way it already records blockVersions, so staleness is at least detectable.

7. Collaboration

Trigger: a second author opens a document already open elsewhere.

Every save carries the revision from the last successful read. The host callback performs an atomic compare-and-save. On a stale revision it returns the remote document, and the studio reconciles the shared base, local draft, and remote draft. Non-overlapping edits merge; overlapping edits remain explicit conflicts until the author chooses a value. This protects work without requiring accounts, locks, sockets, or a Nubbin-operated service.

Presence, cursors, selections, and a shared event ledger are optional host integrations. A host may connect emitted editor events to polling, SSE, WebSockets, a synchronization engine, or nothing. Presence improves awareness; the compare-and-save boundary remains responsible for correctness. See The repository ships contracts, not operated infrastructure.

Failure modes:

ModeConsequence
Host save is not atomicTwo accepted writes can overwrite one another. The callback implementation, not presence UI, must enforce the expected revision.
Both authors change the same valueThe studio preserves both candidates and requires an explicit choice; it does not silently use last-write-wins.
Authors change different valuesThe three-way merge accepts both changes and retries against the latest remote revision.
Tab reconnects after being offlineIts next save follows the same stale-revision path; no separate offline protocol is required.
Presence transport failsAwareness becomes stale, but save correctness does not change.