Skip to content

The Surface Plane — applications, page templates, widget catalog, the shared renderer

The surface plane is Atelier's "what is experienced" layer (m10 PRD §2.1, dev_docs/specs/m10-composable-application-system-prd.md:30). It projects the data plane (entities) and the execution plane (actions — see The Action Engine) into running staff-admin and citizen-portal experiences without writing UI code. Operators DECLARE an experience as an ordered list of Block descriptors — declarative pointers, never baked data — and a single, deliberately presentation-only renderer shipped in @aiaiai-pt/design-system (version 0.38.0 at portal/node_modules/@aiaiai-pt/design-system/package.json:3, renderer/* export) dispatches each Block to a widget. Both hosts import this ONE renderer; #492 consolidated them onto it. This is the compound-software thesis made concrete: any entity renders in any widget, any action triggers from any surface, and the widget catalog grows without breaking existing compositions.

Data model

NamePurposePlaneKey fields
Block (descriptor)Atomic, persisted unit of a page/dashboard — a pointer (widget intent + data binding + placement). Stores only a pointer; rows fetched at render.runtimetype (untrusted hint), slot, name, binding, props, importance(optional|structural), ownsH1, errorSummary, lock, bleed, layout{x,y,w,h}, children(reserved)
WidgetKindBounded, governed enum of ~35 widget kinds — the compound-growth catalog.vocabularylist/detail/form/map/calendar/kpi/content/vote/status/forms/hero/lookup/feed/filters/actions/aggregate/subscriptions/consent/deliveries/owned-list + presentational set (feature-grid/cta/media-text/steps/testimonial/faq/logo-strip/media-gallery)
RegistryEntryOne dispatchable widget: known-good key, Svelte component, tester→score.runtimekey (TH-08 literal), payload, tester (10=byKind, 20=byTypeOnKind, −1=NOT_APPLICABLE)
page_template (#74)Shared page-shape catalog: named ordered slots + per-slot lock/importance/a11y reservations + locked defaults. NOT forked.vocabularycode (9: landing/service-flow/browse/detail/tracker/results/content/account/error), slots(json), steps, rhythm, is_active; unique(code); tenant_scoped=False
portal_page (#79)Tenant binding of a route → template + sparse content overrides, OR an embedded forked slot tree.tenantroute, application FK, template+overrides OR kind=forked; tenant_scoped=True
admin_entity_config __dashboard__ rowApp-level dashboard authoring blob.templateentity_code='__dashboard__', dashboard_config{kpi_cards, embeds, chart_blocks}
action_surfaceSystem registry of surfaces an ACTION can render/trigger on (execution-plane registry, distinct from the widget registry).vocabularycode (public_submit/voting/browse/detail/map/…), audience(citizen|staff|programmatic), presentation; tenant_scoped=False
BlockLayout (#176)Optional 12-col grid coords; consumed by both render and authoring.runtimex, y, w(1..12), h(≥1)

The canonical seam is portal/node_modules/@aiaiai-pt/design-system/components/renderer/types.ts:149-202: Block, Binding ({kind, entity, action, filter, limit, order, expand} — "render <kind> of <entity|action>, scoped by filter"), the WidgetKind union, and WidgetProps — the uniform {data, schema, actionDef, props, app?, ownsH1?, locale?, dataPath?, now?} contract every widget receives so one dispatcher can mount any widget.

How it's declared

Three authoring surfaces, all keyed to the render-registry keys, none touching DS editing metadata:

  1. Dashboard authoring — admin route /settings/applications/[code], Dashboard tab (admin/src/routes/settings/applications/[code]/tabs/DashboardEditor.svelte). Operators add/reorder/remove KPI cards, charts, and Metabase embeds. The chart-type dropdown is populated by aggregateChartTypeKeys() (admin/src/lib/dashboard/widget-authoring.ts:56-65), which PROBES the live DS registry — calling resolveWidget per hardcoded candidate and keeping only keys that round-trip, so no free-text and a removed widget silently drops out. A live preview renders each draft chart through the SAME resolveData+resolveWidget path as the live dashboard.
  2. Portal page authoringportal_page rows bind a route to a page_template code + a sparse content override map (or a forked slot tree). Overrides flow through resolveSlots (portal/src/lib/templates/catalog.ts:103-245).
  3. Catalog authoringpage_template and action_surface are seeded vocabulary (system tenant), edited by platform authors via provisioning sheets / seed.py, not per-tenant operators.

chartBlockToBlock (widget-authoring.ts:120-139) is the single builder turning a ChartBlockConfig into a Block, consumed by the live render, the preview proxy (dashboard-preview/+server.ts:47), and the editor preview — making preview == live true by construction.

How it's provisioned

  • Vocabulary plane (system, NOT forked): page_template rows (admin-api/app/seed.py:6389-6472, tenant_scoped=False, code unique), action_surface rows (seed.py:8800+), and the DS widget registry itself (compiled code in the npm package) are global and read by every tenant.
  • Template plane (template_municipality, fork source): the __dashboard__ admin_entity_config row, the portal row, and portal_page rows are authored here via the same editors with X-Author-Tenant: template_municipality (admin/src/lib/api/authoring.ts:20-30), then copied + FK-rewired by the one-shot full-catalog fork (admin-api/app/tenants/fork.py).
  • Tenant plane (per fork, e.g. lisbon): a tenant admin edits THEIR copy with the identical editors and zero special-case code, because every config endpoint is keyed by the caller's resolved tenant. Inferred (not re-confirmed at file:line this session): that the fork copies the __dashboard__ and portal_page rows specifically — established by the X-Author-Tenant doctrine and prior fork work; confirmable in fork_plan.py.

The portal renders through a single dynamic catch-all (portal/src/routes/[...path]/+page.server.ts:39-192): match the path to a portal_page → load the shared page_templateresolveSlots → per-block resolveData via the public provider → apply the page-level fail-closed status gate.

Extension points (the growth vector)

  • New widget: implement a Svelte component honoring WidgetProps, then registerWidget(byKind(key, kind, component)) (or byTypeOnKind for a type-ranked variant) at host startup — no change to dispatch.ts, resolve-data.ts, or existing widgets (open/closed; registry.ts:93-95). A new data shape adds a WidgetKind plus a branch in bffPathForBinding.
  • New chart type: register the DS aggregate widget, add its key to AGGREGATE_CHART_CANDIDATES + CHART_TYPE_LABELS; the probe auto-includes it once the registering DS is consumed.
  • New page template: append a page_template row under system — every portal can reference it immediately; resolveSlots compiles any valid slots contract.
  • New portal page / action surface: append a portal_page (tenant) or action_surface (system) row; the single [...path] route and the placement editor's surface picker pick them up by row.
  • 2D layout: set Block.layout; the renderer switches to the 12-col grid when any block carries layout (hasGridLayout, grid.ts:19-44), fully back-compatible with the stacked default.
  • New host: implement a DataProvider over that host's own security-scoped lane and reuse resolveData/resolveWidget unchanged (the admin host is the worked example over the authed /views lane).

Invariants

  • TH-08 / R-SEC-07: the widget identifier reaching the DOM is ALWAYS the matched registry entry's known-good key literal — never the operator's untrusted block.type/binding.kind (dispatch.ts:73, types.ts:152-156).
  • Fail-closed blast radius (§14.8): an unmappable binding never fetches; a structural slot that can't load surfaces a visible error (and the portal returns the right HTTP status); an optional slot soft-empties — never a 200 with a broken shell (dispatch.ts:90-99).
  • Per-host security boundary: each host owns its own DataProvider; the public (auth-optional /bff) and authed (operator-Bearer /views or /me) transports are NEVER shared — the admin provider THROWS on citizen lanes (admin-data-provider.ts:104-130; SECURITY INVARIANT comment data-provider.ts:29-37).
  • A Block stores only a POINTER (types.ts:1-14); preview == live via the single chartBlockToBlock builder.
  • Exactly one <h1> per page: resolveSlots stamps ownsH1 onto only the first block of an owns-h1 slot; validateTemplate rejects >1 owns-h1 / >1 error-summary slot (catalog.ts:200-209, 242-243).
  • page_template is tenant_scoped=False (shared catalog, code is the natural PK); per-tenant structural divergence lives ONLY on tenant-scoped portal_page rows.
  • DS stays presentation-only: it carries no editing metadata; per-widget authoring field-schema lives in the host, keyed to the render-registry keys (widget-authoring.ts:1-15).

Gaps & open edges

  • The m10 PRD diverged from what shipped and is not marked superseded. The PRD describes applications:/pages:/widgets: YAML, semantic data_source specs, interaction-pattern lenses (Monitor/Triage/Execute/…), and a "surface type catalog." What shipped is Block[] + a WidgetKind enum + page_template/portal_page rows + ontology VIEWs as the aggregate source (see The Aggregate/Views Plane). A reader treating the PRD as ground truth would be misled.
  • The admin host only PARTIALLY uses the converged renderer: only aggregate chart_blocks route through resolveWidget ([application]/+page.svelte:216-240); KPI cards (StatCard/StatGrid loop) and Metabase embeds (iframe) on the same dashboard are still bespoke. "One renderer drives admin" is true only for charts.
  • The kpi WidgetKind has NO wired public read path: bffPathForBinding returns null for it (resolve-data.ts:225-230); stat-grid is registered but its data path is unimplemented, and admin KPI stats load via a separate server path. The catalog advertises a kind the data layer doesn't serve.
  • Block.children (grid-in-grid) is declared but RESERVED — single-level grids only (types.ts:196-201).
  • No single enumerable catalog: the DS-internal mutable _entries is the real dispatch source; the portal's hand-maintained WIDGET_ENTRIES mirror (35 entries) and the hardcoded AGGREGATE_CHART_CANDIDATES allowlist can drift — the probe confirms availability but does not discover keys.
  • portal-config.ts still hardcodes a VALONGO-specific default (tokens, enabledVerticals=['occurrences']) as the fail-soft base; the data-driven overlay is wired but the built-in default is demo-tenant-specific. A 10s config cache with no cross-process bust means an admin branding edit only appears in the portal after the TTL lapses.
  • Free tenant STRUCTURAL composition (lock=false via authoredBySlot) exists in resolveSlots but is a11y-review-gated; the authoring UI for free composition is not confirmed present this session.

Atelier — Platform Specification. Internal canonical reference.