npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@cat-factory/app

v0.315.8

Published

Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an

Readme

@cat-factory/app: Frontend (Nuxt layer)

The user-facing app, packaged as a reusable Nuxt 4 layer: a single-page app that runs entirely in the browser and renders the architecture board, drives agent pipelines, and reflects live execution. A deployment consumes it via extends: ['@cat-factory/app'] (see deploy/frontend). It talks to the backend Worker over REST and a single WebSocket, sharing wire types from @cat-factory/contracts.

The SPA source lives under app/ (the Nuxt srcDir).

Table of contents

What it is

A spatial planning surface. You lay out a system as a board of frames (services), modules and tasks on a Vue Flow canvas, wire up dependencies, attach requirements, and apply agent pipelines to blocks. Execution streams back in real time (step/subtask progress bars, decision prompts, failures with retry) so the canvas doubles as a live dashboard. The board's second view, the queue, lists the same tasks by what each one needs.

It is a thin client: there is no business logic here. Every mutation calls the Worker API and the stores hydrate from server snapshots and live updates pushed over the WebSocket. How that sync works is written up in app/docs/architecture.md.

Tech stack

  • Nuxt 4 / Vue 3 SPA: the board and its queue view are pages/index.vue, beside three standalone routes (mcp-authorize.vue, mcp-oauth-callback.vue, reset-password.vue).
  • Pinia (+ pinia-plugin-persistedstate): feature stores.
  • Vue Flow (core, background, controls, node-resizer): the canvas.
  • Nuxt UI + Tailwind: components and styling.
  • VueUse: composable utilities.
  • Lint/format via oxlint + oxfmt; tests via vitest + happy-dom.

Layout

| Path | Contents | | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | app.vue | Root; wraps the page in AuthGate. | | pages/ | The board (index.vue: sidebar, canvas, toolbar, inspector, focus view, all modals), plus the standalone mcp-authorize, mcp-oauth-callback and reset-password routes. | | components/ | UI grouped by area (see Key UI surfaces). | | composables/ | useApi (typed client), useWorkspaceStream (WebSocket sync), useBlockDrag, useBlockQueries, useBoardFlow, useSemanticZoom, useDepLabels. | | stores/ | Pinia stores, one per feature domain. | | types/ | TypeScript domain unions (domain.ts) and wire types mirroring the contracts. | | utils/ | Small pure helpers. |

The board's top overlay region has ONE owner

A surface that appears at the top of the board renders as a member of BoardTopOverlays, and places nothing itself. No absolute/fixed, no top-*, no z-index of its own: the band is a flex column, and it owns placement and stacking for everything in it. A member contributes only its card plus pointer-events-auto (the column is click-through, so its empty strip never intercepts clicks on the board underneath).

The reason this is a rule is that the alternative failed exactly once per surface. Each of the toolbar, the spend/connection/PAT banners and the four advisory banners used to anchor itself at top-0 with its own z-index, so which one you could see came down to who picked the higher number: a standing advisory covered the zoom and fit controls outright, and the board-basics tour then ringed a control nobody could see. Tuning an offset onto one of them (top-16, sized against the toolbar pill) fixes the pair that was noticed and leaves the rest, and it goes stale the first time the pill wraps or grows a scrollbar. In one column the overlap is not tuned, it is unrepresentable, and a toolbar that grows pushes the banners down by exactly what it grew.

Order within the column is by what the user loses by not reading it now; the toolbar stays first so a tour anchor and the everyday zoom controls do not move as advisories come and go. Full-width page chrome (the translation-warning strip) is a different surface: it sits in NORMAL FLOW at the top of the shell, so it takes its own height rather than covering the row beneath it.

app/components/layout/BoardTopOverlays.spec.ts enforces the no-self-placement half, reading the member list from the component's own imports.

A board driver that MEASURES the DOM runs off the activity pulse, never a bare RAF

Two board features cannot be derived from the stores alone: the dependency-edge overlay needs each card's on-screen rectangle, and the task-expansion driver needs the topmost card under the pointer. Both used useRafFn, so an open board paid O(edges) querySelector plus forced layout reads sixty times a second with nothing moving, and the edge overlay reassigned an equal-but-new segment array every frame on top of that.

A new driver of that kind pairs useSettlingRaf(compute) with the canvas pulse (useBoardActivity), and compute reports honestly whether it changed anything. The pulse answers "something may have started moving" (DOM mutations under the canvas, its resize, the Vue Flow camera, pointer/wheel/scroll gestures) and the settling loop carries that wake through the animation that follows, parking once the output has held still for a few frames. Neither half works alone: a signal fires one frame BEFORE the transition it starts has any geometry, and a bare frame loop never stops.

The pulse does not treat its signals alike, and a driver must not assume it does. What the user is moving (pointer, wheel, scroll, resize, the camera's own pulse()) wakes the loops immediately, because a lagging arrow under a drag is the bug this whole design exists to fix. RENDERS do not: a live board re-renders its cards on every execution event, and admitting each one kept the loops awake forever on exactly the board where measuring costs most, so mutations go through a rate limit (utils/boardWakeGate.ts, one wake led in immediately and then at most one per 250ms while the stream lasts). The cost is stated rather than hidden: a geometry change caused purely by a re-render, a badge appearing and growing a card, can take up to that interval to be followed. A driver that needs a signal the DOM cannot show, a link set changing with no card moving, watches its own reactive source and pokes, the way TaskDependencyEdges watches its four link lists.

The gesture listeners are on the WINDOW, not on the canvas element. A drag does not stop at the canvas's edge (useBlockDrag tracks the pointer on the window for exactly that reason) and the top overlay region and the inspector are siblings painted OVER the canvas, so a canvas-bound listener went quiet for as long as the cursor crossed one of them.

Measure through utils/blockRects.ts, never a querySelector per card. measureBlocks() hands a pass one snapshot: the cards resolved in one query, first-in-document-order per id, and each rect read at most once. It is what makes a wake cheap enough for the rate limit above to be a saving rather than a way of hiding an expensive pass, and it is lazy, so a pass that resolves nothing (a board with no links at all) touches no DOM.

Two things this cost, both worth knowing before adding a third driver. compute returning true unconditionally silently restores the old behaviour, which is why the loop's contract is stated in terms of what the user can see rather than what the function did. And the pulse watches style/class attributes but not the geometry attributes the overlay itself writes, because a driver whose own output pulsed it awake would never settle.

A compute that THROWS parks the loop and lets the error reach the frame callback, so the next pulse of any kind is what restarts it. Retrying the frame instead would turn one bad measurement into a 60Hz error storm, and staying awake with no frame scheduled would make every later poke a no-op and freeze the board for the session.

What the pulse cannot see is a reflow with no mutation and no gesture, a late-loading image or font resizing a card. That leaves an arrow stale until the next pulse of any kind, which is the deliberate trade: firing too often costs a handful of frames, and the alternative is the loop that never sleeps.

app/utils/settlingLoop.spec.ts pins the loop against a hand-driven frame clock; boardWakeGate.spec.ts pins that the rate limit delivers every suppressed wake rather than dropping it, and blockRects.spec.ts that a snapshot resolves and measures each card once.

A store must be instantiable outside a component setup

A Pinia setup store runs its body on the FIRST useStore() anywhere in the app, and that caller is not always a component: plugins/modular.client.ts builds the nav gates (createNavGates) during plugin setup, which instantiates a handful of stores before any component exists. So nothing a store reaches for at setup time may require an active component instance.

The one that bites is useI18n(), which throws MUST_BE_CALL_SETUP_TOP outside a component, and because it happens inside a plugin, Nuxt's error boundary replaces the whole app with its 500 page rather than surfacing a broken feature. Resolve translations through the Nuxt app's global i18n instance instead (useNuxtApp().$i18n, typed as ReturnType<typeof useI18n>), as stores/board.ts, stores/recurringPipelines.ts and composables/usePipelineErrorToast.ts do. This costs no typed-message-key coverage: tier 1 only sees literal keys written in a <script setup>, never in a .ts store or composable.

The blast radius is why this is a rule rather than a preference: a store reached one call earlier than before takes the entire SPA down at boot, and the unit suite cannot see it (nothing there installs the plugin). Every e2e spec does, because every one of them boots the app.

The persisted board pin is UNVALIDATED until init() resolves it

workspace.workspaceId is restored from persisted state SYNCHRONOUSLY, before any request fires, so every immediate: true watcher on it (pages/index.vue) runs against an id nothing has checked. The pin can name a board that was deleted, or one whose access was revoked while the browser held it, and the RBAC gate answers both with a 404 (it hides a denial as a not-found, so existence never leaks). init() then validates the pin against GET /workspaces and re-points it at a board the user can actually reach.

Firing the per-board reads on the pin anyway is deliberate: it overlaps them with the workspace list instead of queueing them behind it, which is why init() fetches the pinned SNAPSHOT speculatively too. What travels with that is the miss. Each of those boot reads states its own tolerance at its own seam (init's .catch(() => null), github.ensureProbed's internal catch, models.prefetchForBoard), because a 404 there is an expected outcome and not a fault: the watcher fires again for the board init resolved, which is the read that counts. A bare void store.load(workspace.workspaceId) in that chain is an uncaught rejection in a real user's browser, and the e2e suite's pageErrors fixture fails the spec that boots a session whose access was just revoked.

Tolerating the miss is not the same as pretending it succeeded: a dropped load leaves its store UNLOADED (models.loaded stays false, so useAiReadiness().ready is false), which reads as unresolved rather than as a board with nothing configured, and leaves the next caller free to retry. Pin new boot reads with a store-level unit test (stores/models.spec.ts).

A backend-DECLARED form renders through DescriptorFields.vue

When the backend declares the fields and the SPA only collects them, render them with the shared components/common/DescriptorFields.vue over the contracts vocabulary (contracts/src/form-fields.ts), and never hand-roll a second renderer for the same shapes. Two surfaces use it: an initiative preset's create form and a reusable operation's per-case form on a custom task type (AddTaskModal). Adding a third is a :fields binding, not a component.

Grouping is the descriptor's own, through descriptorFieldSections, not a wrapper each surface builds: consecutive fields sharing a section render under one caption, and the reduction applies showWhen first, so a section whose every field is hidden renders no caption. Never re-group or re-order the fields at a call site, or a form renders in an order its author never wrote.

A captioned run is rendered FLAT, never as a per-run wrapper element (descriptorFormRows carries each run's caption on the field that opens it). Run membership is derived state that shifts as showWhen reveals fields, while a field's identity does not: nesting the fields inside a wrapper re-parents them when a boundary moves, and Vue can only do that by unmounting and remounting. The remounted input is typically the one being TYPED INTO, because typing into the trigger is what moved the boundary, so it loses focus, caret and IME composition mid-keystroke. Keep every field a sibling keyed by field.key and the diff MOVES it instead. The same trap as keying any list by index, with a worse symptom: descriptorFields.spec.ts pins that a reveal preserves every field key.

Four rules travel with it. Validate with the shared validateDescriptorFields so the submit button reflects exactly what the server will refuse, and submit the shared sanitizeDescriptorFields result so a stale answer on a since-hidden showWhen field never reaches the wire. Every string a descriptor carries is deployment-authored English rendered verbatim, labels, help, option captions and the section grouping captions alike: only the platform's own chrome around them (the path-invalid message) is i18n, so no descriptor string enters a locale catalog. And the value-bag rules live in utils/descriptorFields.ts, not in the SFC (setDescriptorValue / setDescriptorCheckbox / toggleDescriptorGroupValue for one edit; the initial values come straight from contracts' descriptorFieldDefaults, the same helper the server folds in at the creation door): what an edit freezes on an entity is what a unit test must be able to reach, and a rule inside a component is only reachable by mounting one.

Mirroring the server's check leaves one refusal still reachable, deliberately: the deployment can re-register the descriptor while the dialog sits open, so a create can come back 422 with details.reason: 'task_type_fields_invalid'. Map it to translated copy like any other reason (AddTaskModal's createRefusalMessage) rather than showing the server's field-key prose.

Always import a layer component explicitly

Import a component under components/ by path before using it in a template. Do not lean on Nuxt's auto-registration. This layer sets no components config, so the default pathPrefix: true applies and a component is registered under its path-prefixed name: components/panels/StepEffortReport.vue becomes PanelsStepEffortReport, and a bare <StepEffortReport> matches nothing.

Some bare tags do work, which is exactly what makes this worth writing down. Nuxt drops a directory segment the filename already repeats, so pipeline/PipelinePicker.vue registers as PipelinePicker and resolves bare, while pipeline/AgentKindIcon.vue in the same folder registers as PipelineAgentKindIcon and does not. Whether a tag resolves therefore depends on a coincidence between a folder name and a filename, and renaming either end breaks the tag with no error. An explicit import does not care.

The failure is silent, which is why this is a rule rather than a preference. An unresolved tag warns in dev and then renders nothing, so a built SPA has a hole where the component should be. Nothing catches it: not typecheck, not the unit tests, not the e2e suite, and not the user, who reads it as a backend returning no data. Seven components had shipped this way.

scripts/check-component-imports.mjs enforces it (CI's repo-guards job). If a panel section is missing and the data looks right, check the import first.

Every failure toast goes through ONE funnel

A failed call is reported with usePipelineErrorToast().present(error, titleKey). Never build toast.add({ title, description: e instanceof Error ? e.message : String(e) }), and never wrap that shape in a per-component notifyError(title, e) helper (29 components had a copy of the same six lines, plus four more spellings of it).

The funnel is not a formatting convenience. Four properties are what it exists for, and a hand-built toast has none of them:

  • Translated copy. The description is resolved from the envelope's status class or its details.reason; the backend's untranslated prose is DETAIL, never the headline (see the i18n section). A hand-built toast shows English to every locale.
  • It does not auto-dismiss. An error is the one toast a reader has to finish, quote, or act on, and a ~5s dismissal took it away mid-sentence. It keeps its close button, so leaving is a choice.
  • One-click copy of the whole thing, through useCopyToClipboard (so the copy's own success/failure is reported rather than silently no-op'ing in an insecure context). Selecting text in a toast is fiddly and impossible once it is gone.
  • The requestId travels with it. mountRequestLogging puts that id on every error envelope, and it is the ONLY join between what the user saw and the one server log line that explains it. A report that arrives without it costs whoever reads it the entire diagnosis.

present takes a KEY, not a resolved title (plus optional interpolation params), so it can resolve its own copy. A store passes it through its context alongside api/toast (see stores/board/context.ts) rather than calling the composable per write. A site with BESPOKE copy for a recognised refusal keeps that branch and drains only its fallback into the funnel (stores/board/placement.ts, components/board/AddTaskModal.vue).

A FAILED CALL, though, not every refusal. The funnel's whole job is to classify what the backend answered, so a local check that never left the browser must not be dressed up as one: a synthesized new Error(t('...')) has no envelope and no status, which is precisely the input describeGenericFailure reads as a network fault. A blank required field then renders as "The server could not be reached", with the real sentence hidden behind a disclosure. Client-side validation is useActionToast().error(titleKey, { description }) instead (see below). A failure the backend reports as DATA (a job row with status: 'failed' and its error, a probe verdict's message) is presentReported(detail, titleKey, { descriptionKey }): translated copy as the headline, the backend's prose behind "Show details" and in the copied report, the same as a failed call.

The still-open remainder is the INLINE family: error.value = e.message rendered in a panel, and testResult = { ok: false, message } rendered by ConnectionTestVerdict. Those need a render surface rather than a toast, and are tracked as G4 in error-message-coverage.md.

Every other toast goes through the second funnel, useActionToast

toast.add is called from exactly two composables: usePipelineErrorToast (with its pipelineErrorToast/ factory) for a failed call, and useActionToast for everything else. A call site picks a TONE and supplies a title KEY; the tone fixes the colour, the icon and the duration, so two toasts of one tone render identically wherever they come from. Before this, some success toasts were green with a check and the next was grey with no icon, depending on who wrote the site.

| Tone | For | Dismisses | | --------- | -------------------------------------------------------------------------------------------------------- | ----------- | | success | The action the user asked for completed (saved, connected, removed, copied, moved). | After 5s | | info | Progress or a state report that asks nothing ("drafting", "up to date"). | After 5s | | warning | The action ran, but the outcome is partial or needs a look. | When closed | | error | A refusal in translated copy that is NOT a failed call: client-side validation, a mapped refusal reason. | When closed |

warning and error stay for the same reason the failure funnel's toast does: they ask the reader to do something. Options are content, never styling: params and plural for the title key, a resolved description, actions for a call-to-action, and undo: { run, windowMs } for a write held open behind an Undo (the board's delete and move), which keeps the toast exactly as long as the window. A toast is action feedback. Something the reader must act on later belongs in an inline UAlert or the notification inbox, not a toast.

Loading: the component's loading prop, USkeleton for content, Spinner for a glyph

  • A Nuxt UI component with a loading prop takes :loading. Never spin its icon by hand (:ui="{ leadingIcon: 'animate-spin' }", or an :icon swapped to a loader while busy). One exception: UButton's loading also DISABLES it (disabled || isLoading). A button that must stay clickable while its work runs (a tab whose thread is mid-reply, a "planning in progress" button that opens the planning window) puts <Spinner> in its #leading slot instead, with a comment saying why. UInput and the other inputs do not disable on loading.
  • A panel or list waiting on its first fetch shows USkeleton rows sized like the content they replace, so it does not show nothing and then everything. Its fill is bg-accented, set once in app.config.ts: Nuxt UI's default skeleton is bg-elevated, which disappears on the bg-elevated and app-950 window bodies and cards many panels paint. An ACTION in progress (a button press, a sync) is not a content load and keeps :loading or a spinner.
  • A bare glyph showing work in progress is <Spinner> (components/common/Spinner.vue), the one file that writes animate-spin. :spinning covers a status icon that spins in one state only (a running step), and name defaults to Nuxt UI's own loading icon so it matches the one :loading draws. It spins under motion-safe: only.

Empty states: EmptyState, always

A list, panel or section with nothing to show renders common/EmptyState.vue, with its copy as title / description and any button that fills the list in its default slot. compact is the inline size for an inspector section or a picker. It wraps Nuxt UI's UEmpty (the structure, the action row and the tokens) and decides once what each UEmpty caller would otherwise pick: the naked variant, because an empty state sits inside a panel that already draws its border; the spacing, because UEmpty pads p-4 sm:p-6 lg:p-8 for a page body; and plain text for the title, because UEmpty renders it as an <h2>, which would put a heading into every picker. An inline value placeholder ("None", a dash in a table cell) is not an empty state.

scripts/check-frontend-feedback.mjs (CI's repo-guards job) bans useToast( and toast.add( outside the two funnels and animate-spin outside Spinner.vue. Its PENDING list names files another open change is rewriting, and fails once one of them is clean so the entry cannot outlive the reason.

A panel that seeds state on open uses onModalOpen, never a bare watch(open)

Work done on the way IN to a modal goes through onModalOpen(open, fn) (composables/useModalOpen.ts). A hand-written watch(open, (isOpen) => { if (isOpen) … }) is wrong in this SPA and reads as right.

pages/index.vue mounts most panels only WHILE their flag is set (<AssistantModal v-if="ui.assistantOpen" />), so open is already true when the component sets up and a change-only watcher never fires at all. Whatever the body does never happens: the picker keeps its unselected default, the read that fills a catalog is never issued, and the panel opens with a confirm button that cannot be pressed. Nothing throws and nothing warns, which is why four modals carried it at once and the assistant shipped with a Run button that could never submit.

The helper is { immediate: true } plus the open check, and it is correct for an always-mounted panel too: that one passes a false on the first run and the body is skipped. So nobody has to know which kind of panel they are writing, which is the point of it being shared rather than a flag remembered per site.

A watcher acting on the CLOSE edge (emitting close, revoking object URLs) stays a plain watch: it has nothing to do at mount, and running it there would announce a close that never happened.

Type a chip map with BadgeColor, never string

A status → chip map feeding a <UBadge :color="…"> types its values as BadgeColor (utils/badge.ts), which is derived from UBadge's own prop type rather than restated as a literal union. Typed string, the binding does not compile and the reflex is as any at each call site: seven of them had accumulated. That cast also accepts a colour Nuxt UI does not define, which renders as an unstyled badge with nothing failing.

Colour through theme tokens, never a fixed palette shade

Every colour in the SPA is a token that follows the THEME and the colour MODE. Three kinds exist:

  • Nuxt UI role tokens for greys (bg-default, bg-elevated, bg-accented, text-default, text-toned, text-muted, text-dimmed, text-highlighted, border-default, border-muted) and the bare aliases for an accent at Nuxt UI's own shade (text-primary, bg-warning/10, ring-error/25).
  • The app's mirrored numbered tokens (app/assets/css/tokens.css) for a STATUS surface painted in a specific ramp shade: bg-app-950 / text-app-100 / text-app-600 / bg-app-500 / ring-app-400 for the five grey shades with no role token, bg-app-canvas for the board canvas (the one surface below the page background; in light the page surface app-950 sits at neutral-100 rather than the mirror's 50 and the canvas at 200, so the ~350 translucent bg-default/NN panels keep an edge), and text-app-warning-300 / bg-app-error-950/40 / border-app-success-800 for the five status ramps (secondary, success, info, warning, error). Each token's dark value is the exact ramp shade and its light value is the MIRRORED shade (50↔950, 100↔900, 200↔800, 300↔700, 400↔600, 500 its own midpoint), so a 300 text on a 950/40 fill reads on both surfaces from one class list. Every value is a reference into the --ui-color-<alias>-<n> ramps, so a theme that remaps warning recolours every warning surface. primary has NO numbered token: brand accents use the bare alias, which is what the editor's primary slider and blackAsPrimary address.
  • Category hues (text-app-hue-pink, var(--app-hue-teal) in an inline style) for an IDENTITY colour: an agent kind, a task type, a pipeline step kind (utils/catalog.ts). One token per Tailwind hue, 400 in dark and 600 in light (Nuxt UI's own alias convention), referencing Tailwind's ramps because a category is not a status. A STATUS (task status, step state, test verdict, finding severity) is not a category and takes the alias directly (var(--ui-warning), var(--ui-success), var(--ui-text-muted) for planned / done / skipped), so the halo, the badge and the dot recolour together. A translucent tile behind a category glyph goes through tint() (utils/colorTint.ts), which also accepts the hex a deployment-registered kind may send. A theme recolours a hue through its tokens block.
  • Never a raw Tailwind hue (bg-slate-900, text-amber-300, text-pink-300) nor a fixed numbered alias (text-primary-400, bg-warning-950/40): the first is welded to one palette and one mode, the second follows the palette but not the mode. scripts/check-frontend-palette.mjs (CI) bans both. red and rose both mean error.
  • Never a fixed white / black utility (bg-white, text-black, border-white/5, bg-white/[0.02]): invisible or wrong in light mode. Use border-inverted (a selection ring), border-default / bg-muted (faint chrome) or text-inverted (text over a filled surface). A line that genuinely needs a fixed value says why with a fixed-colour-ok: comment, on that line or the one above it.

Type through named steps, never a pixel literal

Every font size in the SPA is a NAMED step, so it scales with the theme and lines up with the sizes Nuxt UI gives its own components. Two sources of steps:

  • Tailwind's own from text-xs up (text-xs, text-sm, text-base, ...), each paired with a line height.
  • The app's two steps below them (app/assets/css/type.css), for the dense surfaces Tailwind's scale bottoms out above: text-2xs (0.6875rem, 11px at the default root) for the dense body size that panel rows, node metadata and inline detail are written at, and text-3xs (0.625rem, 10px) for the caption tier below it: pill and badge text on a board node, a quoted source excerpt, an axis or legend caption. The section eyebrow is text-2xs, not text-3xs, and is not written by hand at all (see the last bullet). Neither declares a paired LINE HEIGHT: dense text inherits its rhythm from the surrounding block, and a block that wants a specific rhythm says leading-*, which wins over a step's paired value anyway.
  • Never a literal size (text-[11px], text-[0.6875rem]). A theme document's fontSize is applied as a root font-size (utils/theme/css.ts), so every rem step follows it and a pixel literal does not: raising the font size would grow Nuxt UI's components while the app's own text sat still. A literal is also unnameable, so a UBadge size="sm" beside one is sized by a system the label cannot refer to. scripts/check-frontend-type-scale.mjs (CI) bans the utility forms (text-[length:11px], [font-size:11px], [font:…]), a px declaration in CSS (font-size: 11px, the font: shorthand) and a px JS style property (fontSize: '11px', style.fontSize = …); its header lists exactly what it matches. A line that genuinely needs one says why with a type-literal-ok: comment, on that line or the one above.
  • A section eyebrow is common/SectionLabel.vue, not a hand-written class list. The label that titles a group inside a panel, window, inspector section or dropdown is one recipe, text-2xs font-semibold uppercase tracking-wide text-muted, and the component owns all five: they are not overridable, because a caller wanting a different size or colour wants a different thing. The caller picks the ELEMENT with as (div by default; h3 / h4 for a real heading, label with an :id for a field; a collapsing section is a UButton around as="span") and owns SPACING through the ordinary class attribute. An ACCENT-coloured uppercase label (text-primary, text-app-info-400) is not this component: it is signalling state or category rather than titling a section, so it keeps its own classes. Nor is a pill, a chip, a metadata row that merely shares the uppercase styling (BlockNode.vue's composition line), or a caption inside a control (a switcher trigger's label line, a slider's end labels): the test is whether the text TITLES the block after it. The colour is text-muted and not the fainter text-dimmed for contrast: at 11px text-dimmed is 2.63:1 on a light surface, under WCAG AA, where text-muted is 4.76:1. (The SPA's wider use of text-dimmed outside this component is a separate problem.)

Radius through the theme scale

Every rounded-* utility in the SPA derives from --ui-radius, so a theme document's radius reaches all of them. Nuxt UI rebuilds seven of Tailwind's steps on it (rounded-xs is half it, rounded-sm is it, then 1.5x, 2x, 3x, 4x and 6x), and main.css binds the two spellings that sat outside that: --radius, which the bare rounded alias and its side and corner forms resolve through, and --radius-4xl. Tailwind declares both @theme default, so the app's own @theme inline block wins, and inline is what makes the utility compile to var(--ui-radius) rather than to a value frozen at build time. Neither number moves on the default theme.

That is the whole mechanism, and it is why there is no rule here about WHICH step to pick. Two things still name a number instead of the scale:

  • Never an arbitrary value: rounded-[10px], rounded-t-[0.6rem], or Tailwind v4's CSS-variable shorthand rounded-(--x). Welded to one number the way a pixel font size is, and unnameable besides, so a UBadge beside one is shaped by a system the box cannot refer to. The SPA has none.
  • In CSS, reach the scale through var(--ui-radius), on its own or inside a calc(): calc(var(--ui-radius) * 2) is the rounded-lg step, and calc(var(--ui-radius) - 1px) is an inner box kept concentric with its parent, which follows the theme just as well. Never a --radius-* theme variable, even though one works today: Tailwind declares that scale in a @theme default inline block and emits a variable only where the COMPILED stylesheet graph references it, so var(--radius-lg) is correct until the last other reference is deleted and then silently computes to 0. --ui-radius is an ordinary custom property on :root, so it is there in a scoped SFC <style> block too, where the --radius-* scale never is. Never a raw literal in px, pt, rem or em, the per-corner longhands (border-top-left-radius, the logical border-start-start-radius) and the JS borderRadius / 'border-radius' spelling a :style binding uses included. The pill spellings (9999px, 50%) and 0 are exempt, and a shorthand is read value by value, so 50% 50% 8px 8px is still caught on the 8px.
  • scripts/check-frontend-radius.mjs (CI) bans both. It needs no notion of where a class list lives, because rounded-[ and rounded-( are not English words. A line that genuinely needs a fixed radius says why with a radius-literal-ok: comment, on that line or the one above.

The default radius lives on the Cat Factory document in app/utils/theme/builtins.ts. It equals THEME_DEFAULTS.radius and so writes no CSS, which is the point: changing the number there is the whole edit.

Full-screen overlays bind their root class

A phone's notch and home indicator cover whatever sits at the screen edge, and on a screen without them nothing looks wrong. So a full-screen overlay never spells fixed inset-0 itself. Its root binds one of the two class lists in utils/safeArea.ts, which carry the positioning, the 100dvh cap and the safe-area padding, and the site adds only its stacking, direction and backdrop:

  • FULL_BLEED_TAKEOVER_CLASS for a takeover that fills the screen (AgentStepDetail, the model configuration takeover, the observability, reports, operator dashboard and Kaizen panels, the artifact lightbox). It pads by the inset alone, so a screen without one keeps the edge-to-edge layout.
  • WINDOWED_OVERLAY_CLASS for a window on a backdrop (ResultWindowShell). Its gutter is 1rem or the inset, whichever is larger.

Each class list belongs to ONE shell, and a surface renders inside that shell instead of binding the class itself: a takeover inside common/TakeoverShell.vue, a window inside panels/ResultWindowShell.vue. The shell binds the padding AND the modal behaviour (useModalBehavior: a focus trap, focus return, a body-scroll lock, and a ticket on the shared overlay stack), so focus stays inside and Escape closes only the top overlay. A takeover gates the shell with its own v-if inside its own Teleport and Transition, passes a label (the dialog's accessible name), and handles the shell's close event; it never adds an Escape listener of its own. A Nuxt UI menu, select, popover or modal opened inside a shell holds its own ticket on the same stack while it is open (utils/foreignLayers.ts, wired by a client plugin), so Escape and Tab are its keys until it closes. No site pushes a ticket by hand. Why this is not UModal fullscreen, and the rest of the overlay consolidation: docs/initiatives/overlay-shells.md.

scripts/check-frontend-safe-area.mjs (CI) refuses any other fixed surface that spans a whole side of the viewport, in .vue, .ts and .css. A surface with no edge content (a scrim, a drawing layer) or one that pads its own edges (the sidebar drawer, the inspector's bottom sheet) says why with a safe-area-ok: comment, on the line or the one above it, or above the element's opening tag. The same guard refuses either class list named outside its owning shell, with no waiver.

Modals open at a named width

A modal states what KIND of content it holds, and the width follows from that in one place. Before this, each modal picked its own max-w-*: five widths for three kinds of content, and no rule for which one a new modal takes.

  • <AppModal size="prompt"> (448px): a confirmation, a decision, a short read such as the keyboard help, or a dialog with one or two fields.
  • <AppModal size="form"> (576px): a form, a list or a hub to work through.
  • <AppModal size="wide"> (672px): cards that each carry a paragraph and an action beside it, a row of tabs over a form, or three pickers side by side (the AI provider onboarding, the vendor credentials, the setup checklist, describe new work).
  • common/AppModal.vue is UModal with the width applied through ui.content; every other prop, listener and slot passes through, and a call site still passes other ui slots and non-width content classes (a stacked dialog's z-[70]). The widths live in utils/overlaySize.ts. It is a wrapper rather than an app.config.ts variant because UModal has no size prop and passes only transition, fullscreen, overlay and scrollable into its variant function, so a size variant would never be selected.
  • An always-fullscreen modal has no width and stays a plain UModal. A modal bound fullscreen only sometimes (:fullscreen="isPhone") is centered everywhere else, so it takes an AppModal size, which AppModal drops while the modal is fullscreen.
  • A workbench surface keeps its own width for now: the libraries, settings panels, bug hunt, adoption review and environment wizard at 768px, the sandbox and the agent prompt editor above that. Their shell is the open decision in #2359, so a named width for them now would be dead work. Each says why in a <!-- workbench-width-ok: … --> comment directly above its tag.
  • scripts/check-frontend-geometry.mjs (CI) refuses a raw UModal (or LazyUModal) outside the wrapper, an AppModal that names its own width utility (max-w-*, min-w-*, w-*) in class or in the content slot of :ui, or reads either from a variable or a spread it cannot see through, a waiver with no reason, and a waiver with no raw modal directly below it. A USlideover is a side panel with no named width, so the guard does not read it.

A control is its Nuxt UI component

Every CONTROL in the SPA is the Nuxt UI component for it, never the raw HTML element. A raw <button> beside a UButton is the same control rendered twice, and only one of the two follows the theme, the focus ring, the disabled state and the size scale; the SPA carried 152 of them, 24 raw <input>, five hand-built tables and six hand-rolled <details>, each with its own border, background and hover recipe.

  • The mapping: <button> → UButton; <input> → UInput / UInputNumber / UCheckbox / URadioGroup / USlider / UFileUpload, by what the control IS rather than by its type=; <select> → USelect (USelectMenu when it needs searching, UInputMenu when the list is open-ended); <textarea> → UTextarea; <table> → UTable with column definitions; <details> → UCollapsible; <form> → UForm; <a href> → ULink; <datalist> → UInputMenu with mode="autocomplete", which is what a datalist was (the DEFAULT combobox mode writes its model only on SELECT, so a typed free-text value is dropped). Which input for which job is the skill's component-selection matrix, not restated here.
  • An icon-only button is common/IconButton.vue, which wraps UButton in a UTooltip and applies label as the tooltip text AND aria-label. A title attribute is not a tooltip: it never fires on touch, is unstyled, waits about a second, and looks nothing like the app's other hints. IconButton and CopyButton own their tooltip, so passing either a title renders a second hint over the first. A title on a LABELLED UButton is still fine: there it is a hover hint beside the control's own name rather than the name itself. class on IconButton lands on the wrapper the tooltip needs, which is the box the parent lays out, so it carries layout (ms-auto, shrink-0, nodrag); the button's own look goes through its props and :ui="{ base: '…' }". The guard refuses an icon-only UButton whose only name is a title.
  • Prose and layout markup is not in scope. <p>, <h1>-<h6>, <ul>, <li>, <dl>, <pre>, <code>, <img>, <nav>, <div> and <span> are not controls and have no Nuxt UI component; heading typography is the type scale's rule above.
  • scripts/check-frontend-primitives.mjs (CI) holds the count at zero. There is deliberately no allow-list: the migration expected two exceptions (a Vue Flow gesture handle, a full-bleed media tile) and both converted cleanly, so the exception table would have had more surface than the thing it governed. A site that genuinely cannot convert says why with a raw-control-ok: comment, on that line or the one above; nothing in the tree needs one, and a second one appearing is the signal that the rule needs revisiting rather than the site.

Buttons and badges follow one variant policy

What a primary, a secondary or a destructive action looks like is decided once, here, and not per call site. The weights are Nuxt UI's own variant table (solid highest, then outline, soft, subtle, ghost, and link for a text link inside content), mapped onto the app's kinds of action:

| Kind | Props | | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Primary action, ONE per view | none (the default, primary solid) | | Dismissal in a dialog or form (Cancel, Close) | color="neutral" variant="ghost" | | Real secondary action (Save draft, Back) | color="neutral" variant="outline" | | Bulk action in a dialog footer, beside Close (Reseed all, Update all) | variant="ghost" (primary colour): the dialog's main action, kept light because each row above it has its own button | | Send-back on a gate (bounce, request a fix) | color="warning" variant="soft", so the approve beside it stays the one primary | | Inline, navigation and chrome actions | color="neutral" variant="ghost", usually with size="xs" | | Icon-only action | IconButton with color="neutral" variant="ghost" | | Destructive action, labelled | color="error" variant="soft"; color="error" alone (solid) only as the confirm step of a destructive action (a dialog's confirm, or an armed inline confirm such as a step's reject) | | Destructive action, icon-only (a row's trash icon) | IconButton with color="error" variant="ghost" | | A banner's call to action | solid, in the banner's colour (color="warning", color="error") | | Status chip | UBadge with variant="subtle" size="sm", and a BadgeColor map for the colour |

  • The defaults are Nuxt UI's own: primary solid md for both UButton and UBadge, stated in app.config.ts (ui.button.defaultVariants, ui.badge.defaultVariants) so the value has one visible home and the guard can read it. A call site states only what DIFFERS.
  • One solid button per view. Two solid buttons side by side compete for the same attention, so the second action takes a lighter variant from the table. The guard refuses two solid buttons under one parent element; it counts a v-if chain as one button and a v-for list as one, looks through a plain <template> wrapper, and treats a slot <template #name> as its own region. A file holding several forms that each end in a Save is several views and passes. A site that genuinely needs two says why with a solid-ok: comment on its own line, directly above the second button.
  • The unstyled group is the one a theme restyles, so it is the primary tier. A theme document's style.defaults (a size, a button variant, a button colour) REPLACES the default for every control that does not state that prop; it does not scale or shift the others. So whichever group is unstyled is the group a theme moves. With the primary actions unstyled, a theme with size: 'lg' or variants: { buttons: 'outline' } restyles the actions and leaves the chrome alone. With the quiet chrome unstyled, the same theme would grow or fill every toolbar and icon button past the actions and invert the hierarchy. This is why the dense, quiet chrome carries its props and the primary action carries none, and why Nuxt UI's own internal buttons (a modal's close button, a toast's actions) agree with the app's.
  • A restated default is a bug, not noise. It pins the value against a theme that changes the default, and it hides which props are the reason a control looks the way it does. scripts/check-frontend-variants.mjs (CI) refuses a value equal to the default on UButton and UBadge (either casing, either quote style, or a key of a v-bind="{ … }" object) and on every component that spreads its attributes into one (IconButton; the list is derived, so a new wrapper is covered), reading the defaults from app.config.ts itself. Inside a UFieldGroup or UButtonGroup it does not check size: the group sets the size there, so an explicit size overrides the group rather than restating the default. It reads template tags only, so a ButtonProps object (a toast's actions, a modal's :close) is not checked. A site that must keep a literal says why with a variant-default-ok: comment on its own line, directly above the tag; it waives that tag only.

Themes and colour mode

A theme is a Nuxt UI theme editor document (https://ui.nuxt.com/theme): the sparse ThemeDoc the editor edits and shares as a ?doc= link, decoded by utils/theme/link.ts and typed in utils/theme/doc.ts (a port of the editor engine's tables). The app speaks THAT format rather than one of its own, so a theme built in the editor imports as-is and a built-in theme is a document a user could have exported. utils/theme/builtins.ts ships Cat Factory (the default; its colors MUST equal app.config.ts's ui.colors, which paints the first frame) and the editor's own Mono preset, referenced from the preset table rather than copied. The theme store persists the pick and the user's imported documents in this browser; plugins/theme.client.ts applies the active document at runtime: aliases onto appConfig.ui.colors (Nuxt UI's own colours plugin re-emits the ramps), default variants and component overrides onto appConfig.ui.<component> merged INTO the layer's own overrides, and tokens / radius / fonts onto one <style id="app-theme"> whose selectors anchor on the data-theme attribute on <html> (utils/theme/css.ts, which is also the write boundary for untrusted values). The faces the built-in themes name (Geist, Geist Mono) are self-hosted through @nuxt/fonts (fonts.families in nuxt.config.ts, pinned by builtins.spec.ts); a font an IMPORTED theme names is applied as a family name only, so it renders when the device or the deployment provides it. A editor link whose document equals one of the editor's presets collapses to { preset: 'mono' }; utils/theme/presets.ts is a copy of the editor's preset table so such a link rebuilds (a decode table, not menu entries).

Colour mode (system / light / dark) is @nuxtjs/color-mode's, registered by Nuxt UI, with its own persistence; nuxt.config.ts follows the system preference and falls back to dark. AppearanceSwitcher at the sidebar bottom is the one control for both axes. The pre-JS loading shell (spa-loading-template.html) cannot resolve a token (no stylesheet is loaded yet), so the plugin caches the computed colours of the four tokens the shell paints with, per colour mode, in localStorage (utils/theme/bootPalette.ts), and a two-line inline script in the shell applies the entry for the mode the <head> colour-mode script has already put on <html>. The hardcoded defaults are the default theme's, used only until a theme has been seen in that mode.

Nuxt UI: agent tooling and where the SPA overrides the skill

The SPA runs on Nuxt UI 4. Two first-party sources carry the rules this repo does not restate:

  • The vendored nuxt-ui skill teaches WHEN to reach for which component (Modal vs Slideover vs Drawer, Tabs vs NavigationMenu, Toast vs Alert) and HOW to build well (the variant-weight table, one solid primary per view, semantic utility roles). It is vendored under .claude/skills/nuxt-ui/, beside the project's own skills, so the rules version with the code and every contributor on Claude Code reads the same text. This is the Claude Code surface: .claude/skills/ auto-loads for a local Claude Code session, and the repo .mcp.json is offered once for approval on the first session in the repo (re-offered when the file changes); a decline is remembered, and claude mcp reset-project-choices un-sticks it. Another tool that reads AGENTS.md (Codex, for one) gets the pointers below but not an auto-loaded skill or an auto-wired server. Making the platform's OWN coder agents (Claude Code, Codex, Pi in the runner image) apply the skill is a separate, backend change: they stage skills and tool servers per run through the agent-capabilities system (ADR 0024 / custom-agents.md), not from these repo files. The @nuxt/ui npm package does not ship the skill, so it cannot move with pnpm update: it is vendored from the v4 branch of nuxt/ui (which tracks the 4.x line) and pinned by convention to the Nuxt UI major in package.json (@nuxt/ui@^4.11). Refresh it by re-vendoring skills/nuxt-ui/ from that branch (or npx skills add nuxt/ui) when the major moves. The vendored files are kept byte-for-byte as upstream: .oxfmtrc.json exempts them from the formatter and scripts/check-doc-links.mjs skips them, so a refresh stays a clean diff even when upstream ships a link this repo's own doc rules would reject.
  • The nuxt-ui MCP server (https://ui.nuxt.com/mcp, declared repo-level) answers WHAT a component accepts: props, slots, events, theme files and examples per component. The skill defers to it for every API question.

The SPA's own rules win where they and the skill disagree. Each entry below links the rule it protects, so the two never contradict silently:

  • Visible copy is an i18n key, never a literal string. The skill's examples hard-code labels and placeholders (label="Email", placeholder="[email protected]"); here every user-facing string goes through @nuxtjs/i18n and lands in the catalog. Rule.
  • Schema validation goes through valibot, never zod. The skill's forms.md recipe opens with import * as z from 'zod' and validates through z.object({...}); this package depends on valibot (and @toad-contracts/valibot) and has no zod, so a zod import fails typecheck and "fixing" it pulls in a dependency the repo deliberately lacks (which then has to clear the 24h minimumReleaseAge gate). Validate with valibot, mirroring the wire contracts (@cat-factory/contracts).
  • A backend-declared form renders through DescriptorFields.vue, not a hand-built UForm. The skill teaches the hand-rolled UForm + UFormField shape for every form; when the backend declares the fields and the SPA only collects them, that shape is forbidden here. Rule.
  • Toasts go through the two funnels, never a bare toast.add. The skill uses useToast() freely; here a failed backend call is reported with usePipelineErrorToast().present(error, key) and every other toast with useActionToast().success|info|warning|error(key, opts). Rule.
  • Loading is :loading, USkeleton or <Spinner>; empty is <EmptyState>. Never a hand-written animate-spin. Rule.
  • Icon-only buttons carry an accessible name. Use common/IconButton.vue (source), which applies label as both the tooltip and aria-label, rather than a bare <UButton icon>.
  • Colour goes through theme tokens, never a fixed palette shade. The skill bans raw Tailwind hues; this repo also bans fixed numbered aliases, enforced by scripts/check-frontend-palette.mjs. Rule.
  • Confirmation dialogs use the shared useConfirm. The skill shows useOverlay() per call site; here callers await useConfirm().confirm({...}) (source) against one app-mounted <ConfirmDialog />.

Task swimlanes

A service frame lays its tasks out in status lanes, not at coordinates. Three lanes a reader works in (not_started, in_progress, needs_you) plus a collapsed Done strip beneath them. The vocabulary, the classification and the Done caps are app/utils/swimlanes.ts; the ordering and grouping are app/utils/laneSort.ts; composables/useFrameLanes.ts is the only store-facing half.

useFrameLanes runs ONE INSTANCE PER MOUNTED FRAME and any execution event invalidates all of them, so what it derives is a performance decision as much as a modelling one. Two rules hold that line: a derivation over WORKSPACE-wide state belongs on the store that owns its input, never here (the review-debt map is notifications.reviewDebtByBlock for exactly this reason), and the assembled output passes through utils/laneIdentity.ts, which hands back the previous lane / group / entry objects wherever the fresh ones match, so the common event that moves no card leaves TaskLane and LaneGroup diffing on ===. That reuse is sound only while every DERIVED field is compared (those exist nowhere but the entry, so a stale one is a lie nothing corrects), which is why a new LaneTaskEntry field must be added to sameEntry in the same change. The task itself is compared by reference and deliberately not field by field: a replaced block is a new reference, and an in-place patch (board/placement.ts, the optimistic drag and field edits) is one object both entries share, which the renderer reads through and deep reactivity invalidates on its own.

A lane is a CLAIM, which is a higher bar than a badge. A mislabelled badge sits beside the truth; a card filed in the wrong lane states something false and hides the card from the column its reader was scanning. Three rules follow, and they are what the tests pin:

  • The classification is TOTAL. BASE_REASON_BY_STATUS is a Record<BlockStatus, …>, so a new status fails the build; and a status the TYPE says is impossible while the DATABASE still holds it (a retired picklist member on an old row) resolves to unclassified → needs_you, never to undefined, which would drop the card out of every lane with nothing left to say it existed.
  • An imprecise reason beats a wrong lane. The SPA models only decisions and approvals as global per-block selectors; a judge / human-test / visual-confirmation / fork / follow-up / input-gate park is reachable only by drilling into a step. But every one of them parks the RUN at status: 'blocked', and that coarse marker places the card correctly even when this layer cannot name the surface. Such a park reports parked — named but imprecise, never demoted to "in flight".
  • The reason is a separate answer from the lane. The lane says "look at this"; the reason says "what would I do". failed, budget_paused and approval all stop work dead and need three unrelated actions, which is why blocking_reason is a grouping and why a lane header can name it.

Ordering defaults per lane (smart), because the actionable order genuinely differs by column: what can be started now / what has gone quiet / what has waited longest. Two rules bind any new comparator: an unknown timestamp sorts last in BOTH directions (a run that reported no activity is not stale and not fresh, and ranking it as either invents a fact), and every comparator ends on board order, since these re-run on every live push and an unresolved tie makes the lane shuffle itself. nullsLast is the single place the first rule lives.

Sizing is decoupled from content (utils/laneGeometry.ts). A lane scrolls; it does not grow. A service with 300 open tasks gets the same frame as one with three, which is what the old free layout could not do — a busy service grew until it dwarfed its neighbours.

Three consumers have to agree about that size, which is why the module exports two FUNCTIONS rather than leaving each to do the arithmetic: frameContentSize is the frame's floor (read by contentSize, and by framePlacement.EMPTY_FRAME_SIZE, which has to reserve a spot for a frame that does not exist yet and so cannot measure it), and laneBodyHeightIn is its inverse, handing a lane whatever the frame's ACTUAL size leaves it. A dragged border therefore grows the lanes rather than leaving dead canvas below them, and a constant restating either answer is exactly what went stale before. A frame with no children at all is the one that skips the lanes: it renders one "add the first task" panel and is sized for that.

Two preferences, deliberately different scopes. The sort/group choice is per user, per browser (stores/laneView.ts, persisted like the interface tier): it is personal and changes several times an hour, and making it shared would let one person's triage sweep re-arrange everyone's board. The Done lane's two caps are per-workspace settings (doneLaneMaxItems, doneLaneRetentionDays), because what the board may show of a service's history is a shared decision. Both caps hide cards only, and the lane reports the two drop counts separately: an age drop means "there is older history", a cap drop means "there is more from this period".

A task with no completedAt is exempt from the age cap, not treated as ancient. Every block merged before that column existed reads that way, and hiding history on the strength of a timestamp nobody recorded would be the platform inferring a fact it does not have. The count cap still bounds them, so the exemption cannot make the lane unbounded, and undatedShown says how many there are.

A FRAME still has coordinates, and they live on its WorkspaceMount, not on the block (one shared service sits at a different spot on every board that mounts it), so every frame-returning read projects through kernel's applyMountLayout. The resize path is where people hit a missed projection, because a size-only edit is the one frame patch with no other visible effect: the SPA upserts the authoritative block the mutation returned and the frame jumps to coordinates no board shows it at.

No two top-level board nodes may overlap, and that is a standing invariant rather than a rule each write remembers. useFrameOverlapGuard (mounted by BoardCanvas) watches the rendered geometry of every frame and epic and, whenever two come to overl