Skip to content

Surface System

Status: ✅ Decided (grill sessions, 2026-07-03) — Phases 1–2 + the v0.1 query grammar (D14–D16) are implemented in @vinxi/surface-runtime; Phase 3 (real widgets) is next. Upstream canon: Surface Platform (SUR-1..14) and the kernel LLD §17.1/§21/§22 — both in this repo since the docs reorg. The shells/slots/planes design is in UI Framework & Lexicon. This doc records how this repo realizes that vision, and the deltas we consciously take.

The thesis

Applications on Vinxi are Surface documents, not code: declarative trees whose data slots are query references, whose buttons are Action references, and whose form fields are trait references. One headless TypeScript interpreter — @vinxi/surface-runtime — resolves a document into a render tree (data subscriptions, capability visibility, dial state), and thin per-form-factor Shells render that tree through a widget registry. Shells ship once; realities — and agents — ship Surfaces.

The NetworkAccess NOC demo is the forcing function: today it is journey #1 hand-coded (apps/network-access/src/components/noc/*, focus in useState). It becomes the first Surface document, the first golden fixture, and the proof that “adding a screen” means authoring a document.

What this repo already provides

Vision conceptIn hadar todayDisposition
Slot engine@vinxi/tunnel (Portkey)Keep — the motor
Stage plane (persistent twin)@vinxi/maps-ui — one deck.gl instance, declarative <MapLayer>, camera survives navigationKeep — map projections render through it
Workspace archetype seed@vinxi/app-ui Items frameworkGeneralize into projection widgets
Composition seedapp-ui dashboard-pageLater (v0.1 node)
Shellsapp-ui workspace-layout + mobile-layout (parallel files, divergent slot names)Rework into one slot contract + two thin Shells (D3)
Package/install machinery@vinxi/client ExtensionsRegistrySurfaces install through it (D2)
Reactive spine@vinxi/client (atoms, molecules, reactor — pure TS)Runtime state lives here (D2)
Theming@vinxi/design tokens via CSS varsKeep — provider pattern
Leaf widgets@vinxi/ui (shadcn + terminal set)Widget raw material
The seamcrates: six verbs declared; Act(RegisterResourceVersion) only; no IR-2, no capability vocabulary, no sdkgen SDKThe long pole — v0 runs on fixture adapters (D5, D6)

Decision record

Format: decision → rationale → consequence. All decided 2026-07-03 unless noted.

D1 · Web-first here, headless-enforced — amends SUR-2 for this repo

The runtime’s first consumer is the web app; mobile remains the reference target. SUR-2’s intent (the interpreter must not absorb platform assumptions) is enforced mechanically instead of by build order: the runtime package forbids DOM/browser imports (lint + dependency rules), golden fixtures (SG-3) are the platform-free contract, and the storage/platform interfaces ship in the contract from day one even though web doesn’t implement offline. Consequence: web-first risks DOM leakage; the guardrails above are non-optional, and the future Expo shell is the audit.

D2 · The runtime builds on @vinxi/client

@vinxi/surface-runtime registers as VinxiClient services: dials and resolved trees are atoms (reactor-observable), Surfaces install through the ExtensionsRegistry, query caching uses the client’s QueryClient. @vinxi/client is platform-free (jotai, mitt, TanStack — all RN-safe), so the mobile shell carries it too. Dependency is strictly one-directional: surface-runtime → client, never the reverse.

D3 · Two thin web Shells over one canonical slot contract

Canonical slots (stage · primary · focus · aside · drawer · hud · command · nav · status) are defined once; DeskShell (floating/resizable panels, ⌘K, sidebar) and PhoneShell-web (full-bleed stage, snap-point drawer, push pages, bottom bar) are thin compositions selected by viewport class. This replaces workspace-layout/mobile-layout, realizes “interpret the UI differently for mobile and web” as shells per form factor, not CSS squishing, and is the one-runtime-many-shells proof before React Native exists. The future Expo PhoneShell reuses the same composition contract.

D4 · Surface IR v0 node set

Page · Workspace (collection + projections map/list/table + focus contract) · DetailCard · Form, plus reference types (QueryRef, ActionRef, TraitRef) and the TaskInbox Workspace preset. Form is in v0 deliberately even though the NOC barely needs it: the kind→widget offer table is one of the three consequences that make IR-3 real, and it must not be retrofitted. Deferred to v0.1: Composition, Panel, and the cards/board/timeline/chart projections. Schema source of truth is zod (repo-native), with a JSON Schema projected from it for editor tooling and agent authoring. Documents carry irVersion: "surface-ir/v0"; the runtime refuses unknown majors — loudly at install, never blankly at render.

D5 · A fixture mini-IR-1 (Realm Schema) ships now

Install-time validation and kind-driven widget offers are meaningless without an ontology, and the kernel has no P0/IR-1 yet. So v0 ships a small zod-defined Realm Schema format — entity types, interfaces, traits with parameterized kinds (geo(point), categorical(scheme), media, temporal, numeric, graph-edge), actions with capability requirements — and a hand-authored networkaccess realm covering the NOC’s joints, cables, and work items. The runtime’s install step validates Surface→realm; the offer table keys off real kinds. The format is shaped to match what IR-1 will later project, so the kernel swap replaces a file, not machinery.

D6 · QueryRefs are structured objects

A Workspace collection contains { iface | entityType, filters, params } as a zod-validated object, with binding expressions ($session.actor, $dials.focus, $dials.altitude) as typed string literals — the closed set of scalar bindings, validated at parse. Object-valued dials ($dials.viewport, $dials.time, $dials.layers) are rejected in filter positions: filters are equality predicates and an object has no meaning under equality — spatial/temporal filtering by viewport awaits IR-2 operators (amended after adversarial QA, 2026-07-03). Not the illustrative ir2:// URI grammar — field-level validation gives agents precise authoring errors, and we don’t ratify a URI syntax the kernel team hasn’t designed. This is the schema’s most seam-exposed region: when kernel IR-2 and the sdkgen SDK land, the ref shape is renegotiated against what the SDK actually emits. (Partially superseded 2026-07-03 by D14–D16: the trafficure stress-test grill replaced equality-only traitPayload with a where condition list over a closed operator set, and opened the binding set to document-declared dials. The seam-renegotiation clause stands.)

D7 · Capability visibility with a stub session

The realm schema’s actions carry capability requirements; a stub session carries an acquirable-capability set; the runtime computes per-action state. Documents may set onDenied: hide | disable per action reference; the default is disabled-with-reason (matching the worked example in the surface platform doc). No role conditionals exist anywhere in shell or widget code — this invariant starts now, not when the kernel enforces it.

D8 · Dials: full object now; viewport is in the URL

The runtime owns the full SUR-8 object {focus, altitude, time, layers, arrangement} and its canonical string grammar; the NOC exercises focus and layers immediately. The Stage viewport is URL-encoded, Google-Maps style (@lat,lng,zoom[,pitch,bearing]), written via debounced replaceState so panning never spams history; discrete dial changes (focus, layers, altitude) push history — back is dial history. Defaults elide from the URL. This resolves the Lexicon’s open fork #1 residual for web: a shared NOC link reproduces the exact operating picture, and agents direct spatial attention through inspectable state.

D9 · The widget registry is typed React component maps

Node kind → widget, projection kind → widget, and the trait-kind offer table (geo(point) → map pin/picker, categorical(scheme) → select/tree, media → gallery + capture, temporal → date/range). Registry entries are React components typed against resolved-node props — legitimate because both target shells (DOM and RN) are React; platform freedom lives in the runtime, not the registry. Widgets are dumb by contract: resolved data, capability state, and tokens in; focus(entity) · dispatch(actionRef, payload) · setDial(…) intents out. The package-widget (sandboxed) registration point is typed but not shipped (SUR-10 unchanged).

D10 · NOC re-expression: full parity, including the incident

The fixture data adapter plays kernel: it serves the generated fiber network, advances WorkItem/entity state along the scripted incident timeline, and handles dispatched Acts (approve-reroute / dispatch-crew) by advancing the script. This exercises the entire loop — subscribe, focus, capability-gated actions, dispatch, state change — not just the read path. The agent chat panel is agent-plane (hud slot) shell content, not Surface document content. At parity the hand-coded /home is deleted (strangler); no permanent hand-coded twin.

D11 · Surfaces are JSON documents

apps/network-access/surfaces/noc.json (+ generated $schema for editor validation), validated at install and in CI. Not TS fixture objects: “documents, not code” is the point — diffable, package-shippable, emittable by an agent with no TS toolchain. Install path in v0: the app registers its Surface documents through the ExtensionsRegistry at boot.

D12 · Golden fixtures are the merge gate (SG-3, realized)

Runtime tests snapshot resolved render trees as stable JSON — node kind, selected widget, data-slot resolution state, capability state — across scenario variations (dial positions, capability sets; offline-flag scenarios join the suite with R1, alongside the first offline implementation). The NOC Surface is fixture #1. A green suite gates every runtime change and is the compat proof for every IR version bump.

D13 · Package layout

Two new packages in web-modules/:

web-modules/surface-runtime @vinxi/surface-runtime
src/ir/ zod schema for Surface IR v0 + JSON Schema projection
src/realm/ Realm Schema (mini-IR-1) format + install-time validation
src/runtime/ interpreter: resolve() → render tree; subscribe() facade;
action dispatch; capability computation
src/dials/ typed dial object + canonical grammar + URL codec
src/registry/ widget registry contract types (subpath export)
src/adapters/ DataAdapter interface + fixture adapter utilities
web-modules/surface-widgets-web @vinxi/surface-widgets-web
DOM implementations adapting maps-ui (map projection → Stage),
ui + app-ui Items (table/list), ui primitives (DetailCard, Form inputs)

Shells are not a third package: DeskShell + PhoneShell-web land inside @vinxi/app-ui as its canonical-slot rework — app-ui is the designated chrome home, and the shells reuse its Items/Dashboard/command-bar code.

D14 · Strict mirror: query grammar = seam spec (grill, 2026-07-03)

Resolved kernel-side (2026-07-03): the renegotiation this decision anticipated has happened — the kernel ratified QuerySpec v3 as the wire surface (condition tree, four doorways, streaming delivery, shared conformance suite) in ADR-0028; the grammar reference now lives at Query Surface. v2 specs parse forever (flat where = {all: […]} sugar); the strict-mirror pledge below is enforced by the shared golden-pair suite both CIs run.

The document query grammar never exceeds what the query seam executes. Every grammar addition is written as a QuerySpec v2 extension on @vinxi/client/kernel: FixtureKernelClient implements it in-memory now, kernel IR-2 inherits the exact shape later. The runtime and widgets NEVER filter/sort/aggregate fetched rows client-side — one query semantics everywhere is where cross-system predictability is won (Grafana’s format-specifier mini-language exists only because variable values splice into query strings; structured bindings avoid the class by construction).

Filters are a flat where: [{trait, field?, op, value}] list (AND-composed; traitPayload left the doc grammar). Ops are the closed set of nine — eq neq in gt gte lt lte within contains — each validated against the trait’s parameterized kind at install. Sort is a multi-key array [{trait, field?, dir}]; absent sort means the seam’s deterministic default order (stable ascending entityId — never random). limit is authorable; cursor is runtime-owned; traverse stays out until a screen demands it. Aggregates are seam-side by the same doctrine (aggregate(spec): over/bucket/ groupBy/measures — over is a full QuerySpec, so dials and guards flow into every aggregate for free); chart/stats projection grammar waits for R3 rendering evidence.

D15 · Declared typed dials (grill, 2026-07-03)

Documents declare their own dial vocabulary: dials: {id: {kind: categorical|text|sort, options?, default?, multi?, presents?}}. The binding set opens from a fixed list to built-ins + declared ids ($dials.<id>, flat namespace, built-in names reserved) — binding SYNTAX is checked at parse, binding SEMANTICS (id exists, dial kind permits the operator) at install. Dial VALUES stay runtime-owned and URL-encoded (d.<id>=; absent = declared default, explicit empty = cleared). D8’s doctrine holds and extends: values are shell state; vocabulary beyond the universal built-ins is document-declared. Unset ⇒ vanish: a condition whose bound dial is unset/cleared is omitted from the executed query (“no filter chosen = unfiltered”) and recorded in the resolved tree’s dropped — the X-ray shows exactly what the query does not contain.

D16 · Scoped guards: one mechanism for segments, tabs, and column variance (grill, 2026-07-03)

A container (Workspace, DetailCard) declares at most one scope dial (single-select categorical). when: [values] — set membership against the container’s scope, never expression strings — may guard where-conditions, list/table fields, and DetailCard sections. Query segments (live/historical), detail tabs, and per-segment columns are the SAME grammar; the resolver emits one concrete tree per dial state. Rendering is offer-driven (tabs/select/chips/tree/hidden) plus a presents hint — idiom, never semantics — and presents is the uniform hint keyword everywhere (dials AND actions: auto|modal|inline). Scope dials are dials: every tab state is URL-shareable and agent-drivable — no widget-local tab state, no agent backdoor. Future guard complexity becomes a declared derived boolean dial (Workshop’s fork), never VS Code-style inline expression strings over undeclared keys (silent-false is the documented failure mode).

D17 · Realms are vocabulary namespaces, not binding boundaries (2026-07-03)

Cross-realm UI, entities, and traits are the norm: realms are DDD bounded contexts in the ontology (P0), never runtime isolation. Documents validate and resolve against the union of installed realms — safe by construction, since every reference is realm-qualified (trafficure.Alert, networkaccess.Joint) and keys cannot collide. A document’s realm dependencies are derived from its refs; each referenced realm must be installed, reported as ONE deduped issue per missing realm (“realm X is not installed — referenced via Y”) instead of per-ref unknown-noise — the original “wrong universe” signal without the single-realm restriction it was entangled with. doc.realm is the home namespace: provenance, never a gate — requiring the home realm would conflate realm with package, the OS-level install unit (vocabulary + Surfaces + policies shipping together; frontend modules/apps are userland composition). The G7 manifest round owns the package question and may rename the field.

D18 · Subpages: within-surface drill-ins on the page dial (grill, 2026-07-03)

Amended 2026-07-04 by the App System (A2): the subpage chain moves from the page dial into path segments (/<app>/<surface>/<sub>/<sub>…); the reserved page dial and bare p= retire, and p.<region>= is reserved for parallel subpage stacks. Chain semantics below (shared dials, nesting, truncation, breadcrumb, presents) are unchanged — only the address moves.

A Subpage (G5) is a within-surface drill-in: a child Page node — full page powers (workspaces + forms with scope dials + guards) — that shares the document’s dials, so binding $dials.focus threads the parent’s focused entity with zero new grammar (citypulse advanced-insights, a road deep-dive). A drill-in that stands alone with its own dial vocabulary is a separate Surface reached via nav (D19), never a subpage — the split is by ownership. Subpages nest to any depth; the active chain lives in the new reserved built-in dial page (URL p=a/b/c). Being a discrete dial, changing it pushes history — the back button closes drill-ins (dial history = nav history, D8), and deep links + agent-driving come free; this also resolves the Lexicon’s route-grammar residual for web (one URL grammar, all dials). presents: auto|modal|page per level (deepest wins; phone push-stacks, desk may flatten) — idiom, never semantics. Hostile tails truncate to the deepest valid prefix at resolve — never a throw, never a blank (D4); the resolver emits the active chain as a breadcrumb and resolves only the deepest active subpage’s children. Structural id-uniqueness (per level) is a parse concern; children reuse every existing compat rule.

D19 · Packages install; nav is a derived, capability-gated registry (grill, 2026-07-03)

Amended 2026-07-04 by the App System (A4/A6): the module (cross-package nav bucket) retires — packages now provide apps, and an app’s roster carries the nav metadata that lived on SurfaceEntry.nav. Nav stays derived and capability-gated exactly as below, computed per app roster (plus the launcher across apps); computed cross-surface navigation (S6/openIn) now yields (app, surface) pairs. Install-as-Act, no-requires, and the loud install gate stand.

A package (G7) is the OS-level install unit. Its manifest declares only what it provides{realms[], surfaces[] (+nav metadata), widgets[] (T1), modules{}} — with no requires list: a Surface’s realm dependencies are derived from its refs (D17) and resolved against the union of everything installed, so declaration/reality drift is impossible rather than linted. The install gate validates every surface against that union and fails loudly naming any missing realm. Installing/upgrading/removing a package is a kernel Act (T9) — audit and rollback are native. The app shell’s nav is derived, never authored: f(installed surfaces × nav metadata × session capabilities), rendered by the nav slot. Surfaces declare {module, label, icon?, order?, capability?}; packages declare module metadata; the same module id across packages merges (union, like realms). A surface whose capability the session cannot acquire is hidden from nav (unlike actions’ disabled-with-reason — personas shouldn’t see modules they can never enter). Cross-surface entity navigation is computed (S6): the registry knows each surface’s collections, so focusing an entity yields “Open in X” affordances for every surface whose collection can hold it, carrying the focus dial — installing a package retroactively enriches every existing detail card that shows a compatible entity. Authored link intents wait for a screen that demands them.

Phases

Phase 1 — schema + headless runtime (no UI). Surface IR v0 zod schema + JSON Schema projection; Realm Schema format + the networkaccess fixture realm; interpreter core (parse → validate → resolve); dial object + URL codec; fixture DataAdapter carrying the incident script; golden-fixture suite with the NOC document as fixture #1. Done when: the NOC Surface resolves to a snapshot-stable render tree under scenario variations, with zero DOM imports anywhere in the package.

Phase 2 — registry, widgets, shells; NOC parity. Registry contract; web widget implementations over maps-ui/ui/Items; app-ui slot-contract rework into DeskShell + PhoneShell-web; mount the runtime in apps/network-access; re-express the NOC; delete the hand-coded /home. Done when: the demo renders from noc.json with full loop parity on both web form factors, and the layers panel / focus / camera round-trip through the URL.

Phase 3 — the seam grows underneath. Kernel-side: IR-2 query endpoint over the Query verb, capability vocabulary, sdkgen SDK. Swap the fixture adapter for the SDK adapter behind subscribe() — invisible to every widget. Renegotiate QueryRef (D6) and replace the fixture realm with the IR-1 projection (D5). This phase is kernel work; D5/D6 mark the exact re-entry points.

Playground feedback outcomes (2026-07-03)

First user tour of the playground produced these steering decisions:

  1. X-ray must stay derived, never forked. The wireframe is acceptable as a permanent debug lens only because it renders the actual resolved tree from the real pipeline. When Phase 3 real widgets land, X-ray becomes a toggle over the same ResolvedPage the widgets consume — a second projection of one tree, never a parallel render path.
  2. IR stress-testing by reproduction. Next validation round: screenshots of existing production apps get re-expressed as Surface documents. Expected pressure points, to be confirmed by the exercise: explicit layout IR (today composition is shell-implied via slots) and subpage/navigation IR (multi-page Surfaces, drill-in routes). These feed R3 (Composition/Panel) and likely a new Route/Subpage node kind.
  3. Controls (dials/session levers) validated — no grammar changes requested; the levers correctly drive resolveSurface inputs rather than cosmetic view state.
  4. Playground chrome: resizable panel layout (react-resizable-panels, persisted) and devtools-style device presets replaced the fixed grid + desk/phone toggle.
  5. (Resolved 2026-07-03, same day:) the stress test ran — alerts re-expressed, gap ledger written, and a full grill produced D14–D16 (QuerySpec v2 strict mirror, declared dials, scoped guards), implemented with the trafficure alerts document as golden fixture #2. Remaining gaps: Subpage/modal-as-route + app-level nav (next grill), layer styling (R2), chart/stats projections (R3, seam spec already landed). (Resolved 2026-07-04: that next grill ran — the app layer, routing/URL grammar, and app-level nav are decided in the App System decision record, which amends D18/D19 above.)

Residuals (parked, not decided)

#ItemRe-entry point
R1Offline replica + device-branch queue on webContract present in runtime interfaces; first implementation is the mobile workstream’s (SG-1)
R2P24 layer descriptorsv0 map layers use maps-ui’s LayerDescriptor shape; revisit when P24 exists kernel-side
R3Composition, Panel, cards/board/timeline/chart projectionsv0.1, with the first dashboard Surface
R4Agent-plane attention model (hud slot)Slot exists in the contract, stays empty until agents ship
R5Package-widget sandbox substrateSUR-10 / SQ-6 unchanged — post-pilot
R6Ephemeral in-session Surfaces (generative UI at runtime)SUR-12 unchanged — authoring-time validation only in v0

Naming note

Per the naming canon (2026-07-02): Vinxi is the kernel/platform (“Surface system on Vinxi”), Lepton is the company, NetworkAccess is the product whose NOC demo drives v0. The upstream docs’ “Sanjaya” is a retired term — this doc and the implementation say agent plane / hud slot. Package names stay @vinxi/*.