Skip to content

Latest commit

 

History

History

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 
 
 
 
 

README.md

@object-ui/app-shell

Minimal Application Shell for ObjectUI

A lightweight, framework-agnostic rendering engine that enables third-party systems to integrate ObjectUI components without inheriting the full console infrastructure.

Purpose

This package provides the essential building blocks for rendering ObjectUI schemas:

  • Basic layout components (AppShell, Sidebar, Main)
  • Route-level views for objects, dashboards, pages and records (ObjectView, DashboardView, PageView, RecordDetailView)
  • Zero console-specific dependencies
  • Bring-your-own-router design

Installation

pnpm add @object-ui/app-shell

Requires a bundler — plain Node cannot import this package

This package's own artifact is clean, but DashboardView and ReportView import @object-ui/plugin-dashboard statically, and that package imports react-grid-layout's stylesheet at module scope. Node has no loader for .css, so importing the published entry from plain Node ESM — no bundler, no loader hooks — resolves and then fails during evaluation:

TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".css"
  for .../react-grid-layout/css/styles.css

This is a supported-configuration statement, not a bug to report. Unbundled Node consumption is not supported for style-carrying plugin packages, and app-shell inherits the boundary through the import above. It was ruled that way on objectui#5384, over the alternative of moving those stylesheet imports out of module scope, because no unbundled-Node consumer exists to serve.

Consume it through a host that handles CSS imports, which every supported host does: Vite, webpack, or Next with the package listed in transpilePackages. If you have a real need to import it under plain Node — SSR with no bundler, a Node-side script — please open an issue. That reopens the question as a design decision rather than a defect, and the shape of your consumer is the missing input.

Usage

Basic Setup

import type { ReactNode } from 'react';
import { AppShell } from '@object-ui/app-shell';
import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
import type { DataSource, ObjectViewSchema } from '@object-ui/types';

// Your app supplies these two: your own sidebar, and the data source you
// already talk to. `DataSource` is the adapter contract from @object-ui/types.
declare const MySidebar: () => ReactNode;
declare const myDataSource: DataSource;

const contactView: ObjectViewSchema = { type: 'object-view', objectName: 'contact' };

function MyCustomConsole() {
  return (
    <AppShell sidebar={<MySidebar />}>
      <SchemaRendererProvider dataSource={myDataSource}>
        <SchemaRenderer schema={contactView} />
      </SchemaRendererProvider>
    </AppShell>
  );
}

With Dashboard

DashboardRenderer ships from @object-ui/plugin-dashboard — this package's own DashboardView imports it from there.

import { DashboardRenderer } from '@object-ui/plugin-dashboard';
import type { DashboardComponentSchema, DataSource } from '@object-ui/types';

declare const dashboardSchema: DashboardComponentSchema;
declare const myDataSource: DataSource;

function MyDashboard() {
  return (
    <DashboardRenderer
      schema={dashboardSchema}
      dataSource={myDataSource}
    />
  );
}

Key Features

  • Zero Dependencies on Console: No routing, no auth, no app management
  • Framework Agnostic: Works with React Router, Next.js, Remix, or any router
  • Lightweight: ~50KB vs 500KB+ for full console
  • Composable: Mix and match components as needed
  • Type-Safe: Full TypeScript support
  • Console AI Entry Point: The lazy chatbot FAB keeps mobile bottom navigation clear until the full assistant panel is loaded
  • Full-Page AI Workspace: The /ai surface provides a responsive chat workspace with a desktop conversation rail, mobile Chats drawer, and a constrained reading width for long conversations
  • Notification Surfaces: ConsoleShell mounts NotificationProvider and every spec displayType presents distinctly — no per-app wiring

Notifications

ConsoleShell mounts NotificationProvider, so any console route can call useNotifications() and get the presentation it asked for:

displayType Surface Mounted by
toast sonner, via presentNotificationToast ConsoleShell
snackbar <NotificationSnackbar /> ConsoleShell
alert <NotificationAlerts /> ConsoleShell
banner <NotificationBanners /> ConsoleLayout, top of the content area
inline <NotificationInline scope="…" /> the surface that raises it

inline is deliberately not mounted globally — rendering in place at the raiser is the whole difference between it and a banner. Both console-side pieces are exported for hand-assembled shells: presentNotificationToast (the onToast delegate) and ConsoleNotificationBanners (the banners, guarded by useHasNotificationProvider() so a layout without the provider renders no banners instead of throwing). See the notifications guide.

Read-rate report (environment admin)

ConsoleShell also mounts <ReadRateBanner />, beside the impersonation indicator, so every console route carries it — including /home, which has its own layout. It is not a notification banner: nothing in this app raises it. It renders the tenant runtime's own verdict, read by useReadRateReading from the optional readRate key on GET /api/v1/usage/storage.

the reading what renders
state: 'anomalous', with a readsPerWrite the ratio, and the line it was measured against
state: 'anomalous', readsPerWrite ABSENT the no-writes reading: an unbounded ratio, its own words, the heavier tone
state: 'ok' nothing — measured, and under the line
no readRate at all nothing — the control plane reported NO reading
the endpoint could not be read nothing

The last three all render nothing and are three different facts; classifyReadRate keeps them apart, because "why does my environment show no banner" has more than one answer and one of them is nobody has measured it.

Two more properties of that contract are load-bearing. An absent readsPerWrite means the environment made no writes at all, so the ratio has no upper bound — it is the most severe reading there is, never a missing number to hide or dash out. And the threshold is data: it is rendered from ratioThreshold on the wire, the verdict is never re-derived from it, and this package holds no copy of the line.

It is a report. It never refuses, throttles or degrades anything, and the copy says so. It is shown only to a workspace admin, who is also the only session that issues the request.

Storage capacity (environment admin)

Beside it, ConsoleShell mounts <StorageUsageBanner />, driven by the flat storage half of the same GET /api/v1/usage/storage response, read by useStorageUsageReading. The tenant runtime serves the same storage verdict its upload and bulk-import guardrail refuses with, and the banner renders that verdict and nothing else:

the verdict what renders
blocked: true "storage is full: uploads and imports are paused", the used / limit figures, and an upgrade link
warn: true the used / limit figures (usedMb / limitMb)
anything else — ok, unknown, unlimited nothing
the endpoint could not be read, or answered off-contract nothing

classifyStorageUsage decides the banner from warn and blocked alone. It never compares usedMb with limitMb, or fraction with warnFraction, so the banner cannot drift from the enforcement point when the line moves. The upgrade link goes to the control plane's origin (cloudConsoleUrl) and is left out on a runtime that names no upstream cloud.

Both banners read that endpoint through one shared reader, so a page load issues one request for the two of them. The storage banner has the same audience and the same gate as the read-rate report.

Neither banner asks a runtime that does not serve that endpoint. Both read one flag from the server-pushed runtime config, features.storageUsage, and ask only when it is true (objectui#11002). The cloud distribution sends it exactly when it mounts the endpoint; every other runtime sends no key, which reads as off, so a self-hosted admin's page load issues no request and logs no 404. Only the literal true turns it on.

Default auth pages and the sign-up offer

DefaultLoginPage and DefaultRegisterPage are the sign-in and sign-up pages a host mounts at /login and /register (examples/console-starter does). They offer a generic sign-up only where the server would accept one. The server states that rule in two keys of GET /api/v1/auth/config: emailPassword.disableSignUp (the hard off switch) and features.audiencePosture (who may self-register). Under the default invite_only posture the server keeps disableSignUp off so that a pending invitee can still register, and refuses anyone else with SELF_REGISTRATION_CLOSED. The pages read both keys (objectui#11705), and offer nothing until that read has answered (objectui#11806):

the visitor /login /register
signed in as in the rows below sent on to /, where a successful sign-up lands; no row below applies
the config read is still pending the form, behind its own spinner, with no "Sign up" link nothing yet
the config read failed (the auth client retries it first) "Cannot connect to server" with Retry, in place of the form; no "Sign up" link the same
disableSignUp: true no "Sign up" link bounces to /login
posture open or email_domain, or no posture sent "Sign up" link the form
invite_only, ?redirect= is an invitation (/accept-invitation/ID) "Sign up" link, carrying the redirect the form
invite_only, the deployment has no owner yet "Sign up" link the form
invite_only, anyone else no "Sign up" link "registration is by invitation", before any form

Retry reads the config again. While that read is in flight the panel stays up and its button reads "Retrying…"; once the server answers, the row for that answer applies.

The decision is one exported function, which the console's own login and register pages call too, so a host that builds its own pages can follow the same rule:

import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useAuth, type AuthPublicConfig } from '@object-ui/auth';
import {
  decideSignUpOffer,
  isInvitationRedirect,
  needsBootstrapProbe,
  useBootstrapStatus,
  type SignUpOffer,
} from '@object-ui/app-shell';

type ConfigRead =
  | { status: 'loading' }
  | { status: 'failed' }
  | { status: 'known'; config: AuthPublicConfig | null };

/** 'signed-in' | 'unreachable' | 'form' | 'by-invitation' | 'closed' | 'pending' */
export function useSignUpOffer(): SignUpOffer | 'signed-in' | 'unreachable' {
  const [searchParams] = useSearchParams();
  const { user, isLoading, getAuthConfig } = useAuth();
  // Whether the first session check has answered. Every sign-up in flight
  // raises `isLoading` again, so latch the first time it clears.
  const [sessionChecked, setSessionChecked] = useState(!isLoading);
  if (!isLoading && !sessionChecked) setSessionChecked(true);
  // Where the `/auth/config` read stands. The auth client retries a failed
  // read before it rejects, so `failed` means the server did not answer.
  const [read, setRead] = useState<ConfigRead>({ status: 'loading' });
  useEffect(() => {
    getAuthConfig().then(
      (config) => setRead({ status: 'known', config: config ?? null }),
      () => setRead({ status: 'failed' }),
    );
  }, [getAuthConfig]);
  const authConfig = read.status === 'known' ? read.config : null;

  const invitationRedirect = isInvitationRedirect(searchParams.get('redirect'));
  // Probes GET /api/v1/auth/bootstrap-status only for a visitor known to be
  // signed out, when the posture is closed and nothing else admits them.
  const signedOut = sessionChecked && !user;
  const bootstrap = useBootstrapStatus(signedOut && needsBootstrapProbe(authConfig, invitationRedirect));
  if (user) return 'signed-in';
  if (!signedOut) return 'pending';
  // Ask the decision only once the read has answered.
  if (read.status === 'failed') return 'unreachable';
  if (read.status === 'loading') return 'pending';
  return decideSignUpOffer(authConfig, { invitationRedirect, bootstrap });
}

A signed-in visitor is not who the offer is about, so the hook answers signed-in before anything else, without the probe: move that visitor on. DefaultRegisterPage sends them to /, where it sends a visitor after a successful sign-up (objectui#11714). The hook asks decideSignUpOffer only once the config read has answered (objectui#11806): while the read is pending it answers pending, and when the read failed it answers unreachable, which the default pages render as "Cannot connect to server" with a Retry. To offer Retry, keep an attempt counter in the effect's dependencies and bump it, as the default pages do. decideSignUpOffer itself is unchanged: it still answers a null config with form, leaving the server's own gate as the source of truth, which is why a read that has not answered must not reach it. An invitation redirect is an affordance, not an authorization: the server still refuses a non-invitee's sign-up.

Components

AppShell

Basic layout container with sidebar support.

import type { ReactNode } from 'react';
import { AppShell } from '@object-ui/app-shell';

declare const YourSidebar: () => ReactNode;
declare const YourHeader: () => ReactNode;
declare const children: ReactNode;

<AppShell
  sidebar={<YourSidebar />}
  header={<YourHeader />}
>
  {children}
</AppShell>;

ObjectView

The route-level object surface (Grid, Kanban, List, etc.). It resolves the object and view from the host's route, so it takes no objectName prop — mount it on a route that supplies them, as the console does with /apps/:appName/:objectName and /apps/:appName/:objectName/view/:viewId.

Its props are the exported ConsoleObjectViewProps:

  • dataSource (required) — the host's adapter, the DataSource contract from @object-ui/types.
  • objects (required) — the app's object definitions; the route's :objectName is resolved against this list, and an unknown name renders the "object not found" state.
  • onEdit (required) — called with the record to edit.
  • externalRefreshKey (optional) — bump it to refetch after a change made outside the view.
import { ObjectView, type ConsoleObjectViewProps } from '@object-ui/app-shell';
import type { DataSource } from '@object-ui/types';

declare const dataSource: DataSource;
declare const objects: ConsoleObjectViewProps['objects'];
declare const openEditor: (record: Record<string, unknown>) => void;

<ObjectView
  dataSource={dataSource}
  objects={objects}
  onEdit={openEditor}
  externalRefreshKey={0}
/>;

To render an object view from a schema instead of from a route, use SchemaRenderer from @object-ui/react — see Basic Setup.

DashboardView / PageView

DashboardView and PageView are the route-level equivalents for dashboards and custom pages; like ObjectView they resolve their target from the route (dashboardName / pageName) rather than from a schema prop.

import { DashboardView } from '@object-ui/app-shell';
import type { DataSource } from '@object-ui/types';

declare const dataSource: DataSource;

<DashboardView dataSource={dataSource} />;
import { PageView } from '@object-ui/app-shell';

<PageView />;

DashboardView hands DashboardRenderer an onRefresh handler, so the renderer shows its "Refresh All" button and honours an authored refreshIntervalSeconds: every that many seconds the widgets re-read their data. A refresh declares an unscoped change on the data-invalidation bus (notifyDataChanged({ objectName: '*' }) from @object-ui/react), and each widget that holds a bus subscription re-reads in place; nothing is remounted. 0, a negative value or no value means no timer. A dataset-bound KPI tile does not re-read yet: its widget subscribes on the base object the query's answer names, and the answer to a query with no dimensions names none (objectui#11062).

The schema-driven renderers live elsewhere: DashboardRenderer in @object-ui/plugin-dashboard, and everything else through SchemaRenderer in @object-ui/react, which resolves type against the component registry.

ActionParamDialog

Collects user input for a declared action's params before execution. Every param renders through the shared form field-widget renderer from @object-ui/fields (getLazyFieldWidget), so a param of any form-supported field type — select, lookup, date, file, image, richtext, color, … — gets its real widget instead of a text-input fallback (ADR-0059). The pure paramToField() adapter owns the param → field translation, and a drift test pins param support ⊇ form support. required validation and visible CEL gating are applied by the dialog; file/image uploads use the ambient UploadProvider, lookup/user pickers the surrounding SchemaRendererContext.

A param that declares the spec's carryOver (with the defaultFromRow: true the spec requires beside it) is shown, never collected: it renders as a collapsed read-only summary with no field widget at all, and its row value is submitted verbatim — serializeParamValues leaves it untouched even on an upload field (objectui#6246). The permission-set Clone action declares it on its JSON permission facets, so a clone cannot be hand-edited into granting more than its base.

{ "field": "row_level_security", "defaultFromRow": true, "carryOver": true }

Because each param now emits its widget's own value shape on confirm, the shape the dialog POSTs for every type is pinned as a contract in utils/paramValueShape.ts (PARAM_VALUE_SHAPES / expectedParamShape) and guarded by paramValueShape.test.ts (#2714): number→number, boolean→boolean, date/datetime/time→string, select→string (string[] when multiple), lookup/user→id string(s), file/image→fileId string(s) (via serializeParamValues, #2698/#2710), object/address→object, grid→object[].

For datetime the string is an ISO-8601 instant with an explicit zone (2026-08-10T07:00:00.000Z) — not the datetime-local control's zone-less wall clock. That is the platform's own datetime value contract, enforced by the dispatcher before the handler runs (validateActionParams, ADR-0104 D2), so the naive shape earned a 400 on every submission until objectstack#5061. DateTimeField already converts on both sides (objectui#3127), so the dialog POSTs its value unchanged — no zone handling lives at the dialog boundary. See the full table in ADR-0059.

Metadata designers

The metadata-admin engine (src/views/metadata-admin) renders an in-app editor for each metadata type. Every type has a pure-renderer preview that doubles as its designer when given editing + onPatch props — no backend round trip is required to edit a draft.

AI chat conversation key (ADR-0057)

The console's AI chat surfaces are views over one conversation model, not separate chats. A conversation is keyed on (user, app, product) — never on the surface — by the pure chatConversationScope({ appId, product }) helper (src/hooks/chatScope.ts). product is the ADR-0063 binding axis (ask | build), derived from the resolved agent via chatProductOfAgent(name), never a per-surface choice. A package-scoped surface resolves app:${packageId}:${product}, so the Studio design copilot editing package X and the full-page focus view /ai/build?package=X (the "Edit with AI" entry) resume the same thread instead of forking; a generic /ai/:agent visit with no package degrades to the product alone (build / ask). Enablement is the single access-filtered agent-catalog gate (useAiSurfaceEnabled, ADR-0068): a seat-less user's empty catalog hides the whole AI surface.

App → Studio reverse bridge

Inside a running app, workspace admins get a "Design in Studio" entry in the top bar (AppHeader) that deep-links to the app's owning package on the Studio design surface. When the current route names a specific interface — a dashboard, page, or report — it opens straight to that surface in the Interfaces pillar (/studio/:packageId/interfaces?surface=<type>:<name>); on object routes and the app root it opens the package's Data tab (/studio/:packageId/data). The route-type → surface-type decision lives in appStudioRoutePath. It is the reverse of the builder's "Open app" bridge (ADR-0080): the entry only renders for admins and only when the app has an owning package (_packageId), and package writability stays a server-side concern — a read-only package opens in Studio as browse-only.

Studio package scope

Studio treats the selected package as the authoring scope. The package selector is mandatory, and Studio repairs a missing ?package= query parameter from the first project package so scoped pages do not drift out of sync with the sidebar. It is not repaired from the last selected package any more: Studio declares persist: 'query', and each persist value now names exactly one medium — the URL for 'query', sessionStorage for 'session', neither for 'none' (objectstack#5994). An app that wants its scope remembered beyond the URL declares persist: 'session', which keeps it out of the address bar in exchange. The Studio home overview, quick-create links, metadata counts, and diagnostics all follow that active package. The dedicated package-management page remains the global place to create, import, publish, enable, or disable packages; direct /metadata/package links redirect there. The Studio sidebar also flattens the root Overview group so Home and package navigation sit directly under the package selector.

The Setup catalog: the environment scope (?scope=environment)

Positions and permission sets live in the environment registry (objectstack ADR-0131 D3), and Setup lists that registry (D7). Setup does not add a page family for them: it uses the same metadata-admin list and editors at …/metadata/position and …/metadata/permission, with the query parameter scope=environment (ENVIRONMENT_SCOPE_QUERY, from views/metadata-admin/catalog-scope.ts). The scope changes three things:

  • The list shows every item the registry serves for the type, the platform's own sets included. It is not narrowed to one project package. Every link it emits keeps the scope, and so does the editor's breadcrumb.
  • The gates. A caller without manage_metadata gets no create affordance and a read-only editor, and the page says why in the deployment's own terms. Under single the platform administrator defines these items. Under a wall the operator defines them in Studio. In both cases the organization assigns them. The editors apply the caller gate in every scope, because the metadata door refuses that caller's save in every scope.
  • The Setup half of an item. It renders under the definition: a permission set's holders (AssignedUsersSection), and a position's holders (PositionHoldersSection). Both read and write assignment rows by the item's name.

The list has an active switch and an active/inactive filter, through catalog-activation.ts. Both show the activation ledger's state (sys_metadata_activation, read through the data door), which is what the authorization resolver reads: an item with no ledger row is active. The switch writes through the ledger's door, POST /api/v1/security/_activation/:type/:name with { enabled } (objectstack's ADR-0131 stage 2c), and reads or writes no catalog row. A refusal from the door (403 for a caller who may not switch, or for switching off the last administrator who can sign in; 404; 503) is shown in the server's own words. The audience anchors everyone and guest, which the door refuses to switch, show a disabled switch and the reason. Capabilities are not in this scope yet: the Capabilities page and the capability picker still read sys_capability rows, and a capability has no switch.

Package-less flows (/studio/~org/automations)

A flow that belongs to no package — a clone of a packaged flow is one, by ADR-0126 §7.1 — matches no package scope, so Studio gives it one scope of its own: /studio/~org/automations (studioOrgScopePath(), segment STUDIO_ORG_SCOPE_SEGMENT). It is the same StudioDesignSurface with no package under it, reached from the Studio home and from the package switcher. Its Automations rail lists every flow whose served _packageId names no package, opens each one editable, and saves package-less drafts; its Publish promotes those drafts one by one through the single-item publish door, because the package batch publish cannot reach a draft bound to no package. It offers no other pillar and no "New" flow. ~ is outside every package-id alphabet, so the segment can never name a package. A ?surface=flow: deep link that names a flow the open rail does not hold opens no other flow in its place; from a package's pillar, a package-less flow is opened in this scope instead.

Access matrix (package-scoped)

The Access pillar's permission matrix follows the active package (ADR-0086 P0). A Permission Set / Profile is a single record whose objects / fields maps accumulate authorization rows contributed by many packages, so the matrix:

  • lists only the objects the active package declares — the panel never exposes the whole environment's objects; and
  • saves via slice-merge — it re-reads the record and writes back just this package's slice, leaving rows contributed by other packages untouched.

The left rail lists only permission sets this package owns — the metadata API filters permission by the record-level package_id provenance server-side (framework ADR-0086 P1), via client.list('permission', { packageId }), so environment-owned platform defaults (admin_full_access, member_default, …) are excluded by the backend. (The ?package= list rows don't echo the provenance columns, so a client-side filter can't do this.) Save writes a package draft and publishes with the whole package (ADR-0086 P2). Rendering PermissionMatrixEditPage without a packageId keeps the environment-wide behavior (full object list, whole-record save). The scope/merge helpers (scopePermissionSet, mergePermissionSlice) live in metadata-admin/permission-slice.ts.

Row-Level Security — CEL authoring safety (objectui#2413)

Below the object matrix, PermissionAdvancedFacets edits the three advanced facets (Row-Level Security, Tab Visibility, Delegated Admin Scope). RLS is the highest-risk surface: USING (read filter) / CHECK (write filter) predicates are hand-typed CEL, and a typo silently mis-scopes rows — some paths fail open, widening access with no error. The USING/CHECK editors therefore run three author-time safeties, all delegated to the framework's canonical CEL engine (@objectstack/formula) so the GUI reaches the same verdict as the server instead of maintaining a second grammar:

  • Inline lint (CelPredicateField) — validateExpression flags parse faults inline (blocking Save) and unknown-field near-misses as non-blocking "did-you-mean" warnings; a non-pushdown-able USING filter is flagged as a fail-open blast-radius advisory (isPushdownableCel).
  • Field autocomplete — introspectScope supplies the target object's fields plus scope vars (current_user, record, …) and stdlib functions as you type, so an identifier that would silently never match is caught early.
  • Test-run (CelTestRunDialog) — dry-runs a predicate against a sample record + current_user through ExpressionEngine.evaluate and shows allow / deny / non-boolean / error before you ship.

The engine is loaded lazily (dynamic import, feature-detected and error-swallowing like preview/capabilityLint.ts), so the CEL parser stays out of the main bundle and a missing/older engine degrades to "no assistance" rather than breaking the editor. The bridge is metadata-admin/celAuthoring.ts.

Field conditional rules — CEL editors (objectui#1582)

The object designer's field inspector (ObjectFieldInspector, Advanced → Conditional rules) edits the ADR-0036 B2 field-level predicates visibleWhen / readonlyWhen / requiredWhen with the same CelPredicateField editor, in scope="record" mode:

  • These rules evaluate with the record bound only as the record namespace (see @object-ui/core's evalFieldPredicate), so a bare field reference is flagged as an error with the exact record.<field> fix, and autocomplete offers the roots that are actually bound at runtime — record / previous / parent (master-detail header) — plus the stdlib; typing record. / previous. completes the object's own field names.
  • Values round-trip both wire shapes: a bare CEL string or the { dialect, source } Expression envelope (envelope extras such as meta.rationale are preserved on edit). requiredWhen is the only required-predicate slot — the conditionalRequired alias was removed in @objectstack/spec 17 (#3855), so a draft carrying it is rejected by the spec parse itself, with the rename prescription, in the same issue banner.
  • The same lint also runs draft-wide in clientValidation.ts (validateMetadataDraft('object', …)), so an invalid predicate on any field — not just the selected one — surfaces in the editor's issue banner under a fields.<field>.<rule> path before save.

Formula fields — CEL value expressions (objectui#1582 follow-up)

A formula field's Formula (CEL) editor (Type-specific section) is the same CelPredicateField, in role="value" mode (bare CEL of any type, still scope="record", autocomplete roots record only — formulas see neither previous nor parent):

  • The editor shows the inferred result type (Number / Text / Boolean / Date) under the field — the same @objectstack/formula inferExpressionType verdict dataset derivation keys measure eligibility off, so "this formula won't be a SUM measure" is visible before saving. An unprovable type reads Unknown with the double() / int() / string() pinning hint.
  • Edits land on the spec's expression key (either wire shape, envelope extras preserved) — the legacy formula key, which the engine never read, seeds the editor and is migrated on the first edit — and the proven type is stamped onto Field.returnType (cleared when the type can't be proven).
  • Formula expressions are also linted draft-wide under fields.<field>.expression, alongside the conditional rules.
  • summary fields have no CEL expression — the spec models them as summaryOperations — so the inspector edits the roll-up structurally instead: child object, aggregation function (count / sum / min / max / avg), the child field to aggregate, and (optionally) the child relationship field pointing back to the parent.

Visual flow canvas

The flow designer (FlowPreview → FlowCanvas) renders an automation as an industry-standard top-down node-link diagram (think n8n / Power Automate / Salesforce Flow Builder) instead of a flat step list. It is dependency-free — no ReactFlow / @xyflow — so the app-shell bundle stays lean.

JSON shape (a flow draft):

// flows/renewal_reminder.json
{
  "name": "renewal_reminder",
  "label": "Renewal reminder",
  "type": "autolaunched",
  "nodes": [
    { "id": "start", "type": "start", "label": "Start" },
    { "id": "decide", "type": "decision", "label": "Renew?",
      "position": { "x": 220, "y": 180 } },   // optional persisted canvas position
    { "id": "email", "type": "notify", "label": "Send reminder",
      "config": { "recipients": "{record.owner}", "title": "Renewal reminder" } },
    { "id": "end", "type": "end", "label": "End" }
  ],
  "edges": [
    { "id": "e1", "source": "start", "target": "decide" },
    { "id": "e2", "source": "decide", "target": "email",
      "condition": "${days <= 30}", "label": "Due" },
    { "id": "e3", "source": "decide", "target": "end",
      "isDefault": true, "label": "Skip" },
    { "id": "e4", "source": "email", "target": "end" }
  ]
}

That whole object — identity keys included — is what the metadata admin hands to client-side validation: validateMetadataDraft('flow', draft) parses the draft with the spec's own FlowSchema, so anything the example leaves out is a save-time error rather than a detail left to the reader.

  • Layout — nodes without a position are placed by a deterministic layered auto-layout (cycle-guarded), so a flow always renders cleanly even before any manual positioning. Dragging a node persists its position to the spec's node.position.{x,y} (FlowNode.position — x and y both required); positions degrade gracefully (they are layout hints, not required data). Flows stored with the designer's retired node.ui.{x,y} spelling still render pinned, and the canvas lifts them onto position in the first patch it emits (objectui#3172) — FlowNodeSchema is .strict(), so a draft that still carries ui fails client-side validation and is rejected on save with a 422.
  • Edges — every edge carries an id: FlowEdgeSchema declares it required (a free-form string, unconstrained by the spec), beside source and target, and the canvas mints one for each edge you draw. Branch semantics (condition, label, isDefault) are rendered as labels on the connectors and preserved when a node is inserted on an edge; a bare condition string is widened to the ADR-0089 expression envelope on parse, so the edge above is stored as "condition": { "dialect": "cel", "source": "${days <= 30}" }.

Interactions (design mode):

  • Add node — toolbar palette (Action / Decision / Wait / Subflow / Signal / End); the new node is auto-selected.
  • Append — the bottom + handle on a node adds a connected child.
  • Insert on edge — the + on a connector splices a node between two nodes, preserving the original branch condition on the first segment.
  • Connect — drag from a node's connect handle (the dot beside its bottom +; an End has none) onto another node to connect the two; a selected connection's From / To in FlowEdgeInspector re-point it. Both refuse, with the reason, a node to itself, a (source, target) pair another edge already joins, and a node the flow does not have (edgeConnectionRefusal).
  • Reposition — drag a node (committed on pointer-up).
  • Delete — Delete / Backspace removes the selected node and its edges.
  • Navigate — fit-to-view, zoom in/out, and background pan.

Selecting a node opens FlowNodeInspector, which renders typed form fields per node type (see flow-node-config.ts) rather than a raw JSON blob. Node types follow the spec FlowNodeAction enum (@objectstack/spec/automation/flow.zod.ts): start, decision, assignment, loop, create_record, update_record, delete_record, get_record, http_request, script, screen, wait, subflow, connector_action, parallel_gateway, join_gateway, boundary_event, end. Field keys mirror the real production vocabulary used by installed apps (the spec leaves config freeform, so the app metadata is the de-facto standard): a start node exposes Object / Entry condition (criteria, a CEL string) / Cron schedule (schedule); the trigger category is a flow-level concern, so start deliberately stores no triggerType. A decision uses condition; get_record/update_record/delete_record use a filter object; loop uses iteratorVariable. Spec structured blocks are edited through dedicated fields, not JSON: a wait node maps waitEventConfig.* (Wait-for / Duration / Timeout / On timeout), a connector_action maps connectorConfig.* (Connector / Action / Input), and a boundary_event maps boundaryConfig.*. CRUD/script/http fields live under node.config; spec blocks and timeoutMs live at the node top-level. Type-specific fields sit under a Configuration divider, and conditional fields (showWhen) only appear when relevant — e.g. a script node switches between a Code / Output variables shape and an email/SMS notification shape (Template / Recipients / Template variables) based on its Action type (actionType, defaulting to code), and a wait node shows Duration / Signal name based on the selected Wait for mode. A conditional field is never hidden while it still holds a value, so existing config is always reachable.

A config key the installed @objectstack/spec refuses the node without — an http node's URL, a record node's Object, a decision branch's Label and Expression, a screen field's Name — carries the same required marker (*) SchemaForm draws, and a control the inspector renders itself also carries aria-required. No list of required keys is kept here: flow-required-keys.ts removes the key from a copy of the node and asks the spec's own judges (flowNodeConfigRefusals, the predicate-slot walk, FlowNodeSchema). So a rule-dependent key such as a notify node's Title, which is required only while the node has no template, is marked only while the rule applies.

Config keys come in three editable shapes so authors never hand-write JSON:

  • Flat object maps — a create_record node's Field values, a connector_action's Input, a get_record's Filter — use an inline key/value editor (keyValue kind). Scalar values are auto-typed (3 → number, true → boolean); object/array values such as a filter operator {"$ne": null} round-trip losslessly. On a map the spec's expression ledger declares value-role (FLOW_NODE_EXPRESSION_PATHS; today the assignment node's Assignments), each value also has a Write as a CEL expression toggle: off, a {token} string is stored exactly as typed; on, the value is stored as the CEL value envelope { dialect: 'cel', source }, and a malformed envelope shows the spec's AssignmentValueSchema refusal inline (objectui#7588, flow-value-envelope.ts).
  • String arrays — a script's Recipients / Output variables — use a single-column string-list editor (stringList kind).
  • Arrays of objects — a screen node's Fields (a list of {name,label,type,required,visibleWhen} definitions) — use a column-driven object-list repeater (objectList kind). A repeater column can itself be a list: an item property that is an array of strings / numbers / objects renders as a nested repeater (stringList / numberList / objectList column) — a repeater-in-repeater — so an engine-published nested-array config is editable inline instead of falling to the Advanced JSON block (#2678 P2-5).

A decision node's Branches repeater additionally shows a per-branch Target column (#1942) — a node picker scoped to this flow — so the whole decision (conditions and destinations) is authored in one table, like Salesforce Flow Decision Outcomes. The column is virtual: it is derived from the decision's outgoing edges (routing truth lives on edge.condition / label / isDefault, which the engine and simulator evaluate) and is never stored on config.conditions. Picking a target creates or retargets the branch's out-edge carrying its condition/label/default; clearing it detaches (removes) that edge — never the node. Because edges stay the single source of truth, it round-trips with the reciprocal per-edge Branch picker in FlowEdgeInspector (#1930), with that panel's From / To, and with connections drawn from a node's connect handle on the canvas; custom hand-written edge guards and fault/back edges are never touched (flow-decision-edges.ts).

Anything still not covered by a field (nested objects, arrays, plugin-specific keys) lives in an optional Advanced (JSON) escape hatch: it is shown only when such keys already exist, and is otherwise reachable through a low-emphasis "Advanced (JSON)" button — it never alarms authors into thinking the form is incomplete, and it can never overwrite a key a form field already owns. Node types with no configuration (e.g. parallel) show a plain "No configuration needed" note instead of an empty JSON box. The ui layout hint is always kept out of the config entirely and preserved across edits.

Flow simulator (designer-time debug runner)

The canvas toolbar has a Debug toggle that opens an in-designer flow simulator (FlowSimulatorPanel → simulator/flow-simulator.ts). It lets a low-code author test a flow draft without a backend — answering "how do I mock-run and step through this flow?".

It is a pure, client-side interpreter. It never calls a dataSource: every side-effecting node (CRUD / get_record / http_request / connector_action / script) is MOCKED, so a simulation can never write or delete real data and never needs a live environment. Its guiding rule is never silently simulate semantics that differ from the runtime — anything that cannot be faithfully modelled is surfaced loudly instead of faked.

  • Preflight validation — before a run, validateFlowDraft blocks on structural errors (no resolvable entry, duplicate ids, edges to missing nodes, multiple decision defaults) and warns on soft issues (unreachable nodes, a decision with no default). Errors disable Run so problems surface up front.
  • Controls — Run (to completion), Step (one node), Reset, and Continue (after a pause). Flow variables marked isInput become a seed form; values are auto-typed (30 → number, true → boolean, {…} → JSON).
  • Set variables / Mock outputs — because a decision often reads a value no declared input produces (e.g. a computed daysToExpiry), the panel adds a free-form Set variables editor that injects/overrides any variable at start, so every branch is reachable. A Mock outputs editor lets the author pin what each mocked side-effect node "returns" (written to its outputVariable), so data-dependent logic downstream of a get_record or script can be exercised too.
  • Semantics — every node leaves by the runtime's successor selection (traverseNext, objectui#10692): an out-edge with a condition is guarded (the first true guard is taken — the runtime takes every true one, pending objectstack#15429), an isDefault edge with no condition is taken only when no guard was true, every other edge is always taken, a fault edge is never an ordinary successor, and a node that takes nothing ends its branch without an error. A decision that declares config.conditions first picks the first true entry's label (else default), which narrows its out-edges to the ones carrying that label. Guards and branch expressions are CEL on the runtime's engine and variable scope (@objectstack/formula's ExpressionEngine: bare names, vars.*, record.*); one the runtime refuses or cannot evaluate stops the run on that node with the error, a CEL fault fails the run at runtime, and a refused guard is refused at registration (objectui#10615); an assignment interpolates {var} tokens inside nested objects and arrays too, as the runtime's interpolate does, and a token it does not model (NOW(), $User.*, arithmetic) is kept as written and named on the step; a paused screen renders through the flow runner's own ScreenView, which decides each field's visibleWhen live over the screen's declared fields and the values being typed (one client evaluator, objectui#10743), and the screen step names as an error a predicate that references a name that is not a field on this screen or a shape registerFlow refuses — the Problems panel judges that column by the same rule (a warning), and so does the inline inspector's visibleWhen cell, whose picker offers the screen's declared fields and record (objectui#10772; every other expression column keeps the flow scope); the runtime's resume door still evaluates over the run's variables until objectstack#20178 lands; side-effect nodes write their mock to outputVariable (the legacy script outputVariables[] list is ignored — the engine never binds those names, framework#4278); wait and screen pause for manual continue; join_gateway, subflow, and boundary_event are marked unsupported (token sync / nested runs are not modelled) rather than faked.
  • Live feedback — the panel shows a variable watch, a step timeline (status badges OK / MOCKED / PAUSED / SKIPPED / ERROR, per-node edge diagnostics, and write summaries), while the canvas highlights the active node (pulsing sky ring), visited nodes (emerald), and traversed edges (sky), dimming nodes not yet reached.

The engine is covered by unit tests in previews/simulator/__tests__/flow-simulator.test.ts.

Documentation pages (doc)

A doc (ADR-0046) is written in the metadata admin like any other type — the generic edit page loads it and saves it through PUT /meta/doc/:name — with previews/DocPreview as its canvas, during create as well as edit:

  • Markdown source and live preview side by side. The preview renders through SchemaRenderer as { type: 'markdown' }, the registry entry @object-ui/plugin-markdown provides and the docs portal renders with, so this package takes no dependency on the plugin; a host that registers no markdown renderer shows the unknown-component notice in the preview pane.
  • Locale variants are entries of the doc's translations map, which is how DocSchema models them (it declares no locale key). A new variant starts as a copy of the default body.
  • Book placement writes the doc's own group key: a book stores no members, so a doc joins a book group by that key or by the group's name/tag rule. The readout of where the doc appears is computed with the spec's resolveBookTree, the resolver GET /meta/book/:name/tree answers with.

The canvas owns content, translations and group, so the properties form beside it shows only the header keys. The pure draft arithmetic lives in previews/doc-draft.ts.

Architecture

This package sits between the low-level @object-ui/react (SchemaRenderer) and the high-level apps/console (full application):

Third-Party App
    ↓
@object-ui/app-shell ← You are here
    ↓
@object-ui/react (SchemaRenderer)
    ↓
@object-ui/components + @object-ui/fields + plugins

Comparison with Console

Feature @object-ui/app-shell apps/console
Bundle Size ~50KB ~500KB+
Routing BYO Built-in React Router
Auth BYO Built-in ObjectStack Auth
Admin Pages No Users, Roles, Audit, etc.
App Management No Create/Edit Apps
Data Source Any ObjectStack
Customization Full control Limited

Examples

See examples/byo-backend-console for a complete working example that demonstrates:

  • Custom routing with React Router
  • Custom data adapter (not ObjectStack)
  • Custom authentication
  • Cherry-picking only needed components
  • Building a console in ~100 lines of code

Record create/edit modes

The default <DefaultAppContent> shell mounts a global <ModalForm> for record create/edit interactions. Each object can opt in to a route-driven full-screen experience instead by setting editMode on its metadata:

// objects/account.json
{
  "name": "account",
  "label": "Account",
  "editMode": "page",        // ← opt-in. Default is "modal".
  "fields": { /* ... */ }
}

When editMode: 'page' is set, clicking Create or Edit for an account record navigates to a dedicated route instead of opening the dialog:

Action URL
Create /apps/:appName/account/new
Edit /apps/:appName/account/record/:recordId/edit

These routes are deep-linkable (refresh-safe), respect the browser back button, and render the same <ObjectForm> pipeline as the modal — so tabbed, wizard, and section configurations work in both modes.

JSON action:button schemas can also trigger the page routes directly via the action runner, regardless of the object's editMode. The block's props go in its properties bag, the spec's ComponentPropsMap['action:button'] row; objectui validate and the spec's page component refuse them written flat on the node. The handler name goes in properties.actionType — the key the button renderer forwards to the action runner as the action's type, once SchemaRenderer hoists the bag onto the node — and the runner dispatches to the handler registered under it. Arguments are static values under properties.params (an action's params is only the ActionParam[] list of inputs to collect; a node-level params object is ignored):

{
  "type": "action:button",
  "properties": {
    "label": "New Account",
    "actionType": "navigate_create",
    "params": { "objectName": "account" }
  }
}

navigate_edit additionally needs the record to open. Every string in properties.params is a template, evaluated like other properties values, so a button on a record page names its record with ${record.id}:

{
  "type": "action:button",
  "properties": {
    "label": "Edit",
    "actionType": "navigate_edit",
    "params": {
      "objectName": "account",
      "recordId": "${record.id}"
    }
  }
}

For a per-row Edit that follows the record under the cursor, use the list or detail view's built-in Edit entry point instead: under editMode: 'page' it already routes to the same URL.

See content/docs/guide/record-edit-modes.md for a longer walkthrough.

Record approval visibility (Approvals tab / record:approvals)

When a record has approval requests, its detail page grows an Approvals tab — a peer of Details/Related with a request-count badge (#3461): which step the approval sits at (with the flow's step strip), the server-computed decision progress (quorum tally, per-group 会签 ticks), the waiting-on approvers resolved to display names (group approvers labeled with their group), one chronological decision timeline merged across all of the record's requests (comments and attachments included), and an inline Send reminder button for the submitter. Records without requests carry no tab at all.

Visibility is gated by record READ access, not approver status — anyone who can open the record sees where its approval stands, without a trip to the Approval Center (a setup-app surface business roles typically cannot reach). The tab wraps the schema-addressable record:approvals node (RecordApprovalsPanel): on the synthesized default page the host threads its live useRecordApprovals read through the node — the same read behind the header's Approve/Reject buttons, so the two can never disagree — while on authored pages the renderer self-fetches via RecordContext, and an authored page that omits the node gets a bottom-of-page fallback append. The timeline reads GET /approvals/requests/:id/actions per request; the submitter's remind posts the existing POST /approvals/requests/:id/remind (throttled server-side). Copy reuses the Approval Center's approvalsInbox.* i18n keys so the two surfaces never drift.

User-scoped state (favorites, recent items)

<ConsoleShell> includes FavoritesProvider and RecentItemsProvider — shared, user-scoped state for pinned apps and recently visited entities.

Both providers are localStorage-first: instant first paint, no flash of empty UI. If a UserDataAdapter is attached via UserStateAdaptersProvider, they additionally hydrate from and write through to a backend (debounced). The official ObjectStack adapter lives in @object-ui/data-objectstack (createObjectStackUserStateAdapter).

import { useFavorites, useRecentItems, useNavPins } from '@object-ui/app-shell';

const { favorites, toggleFavorite, isFavorite } = useFavorites();
const { recentItems, addRecentItem } = useRecentItems();
// Sidebar pins live in the same store as Favorites — synced to the backend
// via the same `UserDataAdapter<FavoriteItem>` when one is attached.
const { pinnedIds, togglePin, isPinned, applyPins } = useNavPins();

A recent object, dashboard, page or report entry stores its identity only (type and name), never display text: label it where you render it with useRecentItemLabel(), which reads the item's current metadata label in the current language (a record entry keeps the title it was visited under). A Studio package entry (type: 'package') is stored the same way; label it with useRecentItemLabel({ packages }), passing the package list you loaded, or leave it out where you have no list. The provider writes nothing when a visit leaves the list unchanged, such as revisiting the item already at its head.

import { useRecentItems, useRecentItemLabel } from '@object-ui/app-shell';

function RecentList() {
  const { recentItems } = useRecentItems();
  const recentLabel = useRecentItemLabel();
  // No package list is loaded here, so Studio package entries are left out.
  const shown = recentItems.filter((item) => item.type !== 'package');
  return <ul>{shown.map((item) => <li key={item.id}>{recentLabel(item)}</li>)}</ul>;
}

Nav pins and Favorites share a single favorites collection. FavoriteItem carries optional type: 'nav', pinned, and navId fields so a single adapter syncs both flows. The legacy objectui-nav-pins localStorage key is migrated on first mount and then removed. Content favorites (20) and nav pins (20) each have an independent cap. See the guide below for details.

The sidebar's Pinned section keeps the user's order, stored with the pins: pinnedIds lists them in it, a new pin joins the end, and reorderPins(orderedIds) saves a new order. The sidebar passes both to NavigationRenderer (pinnedOrder / onPinnedReorder), which lets a user drag a pinned row, or move it from the keyboard. The app's own menu is not reorderable: its order is authored in Studio.

See User-Scoped State Persistence for the adapter contract, backend schema, and how to plug in your own backend.

Command palette (⌘K)

<ConsoleShell> mounts a global ⌘K command palette for cross-app navigation and record search. Its open state and the command that opens it are provided by CommandPaletteProvider (wired in by ConsoleLayout) and exposed via useCommandPalette().

CommandPalette has two scopes. Inside an app it takes apps, activeApp, objects, onAppChange and an optional dataSource, and searches that app. <CommandPalette scope="studio" /> takes no other prop: it is the palette of the /studio landing, a frame outside every app, which the console mounts under its own CommandPaletteProvider. It leaves out every app-scoped group and the full-search command (their links start with /apps/APP), and lists the Studio's packages, their objects and their flows, each opening its Studio page. The header's search trigger is drawn wherever a provider is mounted, so AppHeader with variant="studio" shows it there.

import { useCommandPalette } from '@object-ui/app-shell';

function MyToolbarButton() {
  const { openCommandPalette } = useCommandPalette();
  // Idempotent: calling when already open is a no-op.
  return <button onClick={openCommandPalette}>Search…</button>;
}

Designed to be deterministic for automated (AI) browser testing — see ADR-0054 "UI testability contract":

  • Idempotent, direct open (C1). The top-bar search button, the ⌘K shortcut, and the deep-link all call the same idempotent openCommandPalette() (setOpen(true)), never a toggle(). The button calls the command directly — it does not re-dispatch a synthetic ⌘K KeyboardEvent (which silently did nothing under automation and in ⌘K-reserving browsers). ⌘K stays a keyboard accelerator and may still toggle (close-on-repeat).
  • URL-addressable (C3). Open state lives in the ?palette=1 search param, so the palette is deep-linkable (/apps/<app>?palette=1), restores on reload, and works with browser back/forward. ?cmdk=1 is accepted as an alias on read.
  • Stable locators (C4). The dialog carries data-testid="overlay:command-palette" plus an ARIA role/name; the header trigger carries data-testid="action:command-palette:open" (and :open-mobile for the compact header). CommandDialog accepts contentProps to forward a data-testid/ARIA name onto the underlying dialog element.
  • Trusted-input note (C6). The palette search is a controlled + debounced input. Value-injection (el.value = …) does not fire React's onChange; drive it with a real-input / CDP-keystroke driver so the debounced fetch fires.

URL-addressable overlays (useUrlOverlay)

useUrlOverlay(key) is the reusable building block behind the command palette's URL-addressable open state (ADR-0054 C3). It stores a navigable overlay's open state in a ?<key>=1 search param instead of component useState, so the overlay is deep-linkable, restores on reload, and works with back/forward — and its open path is idempotent (C1).

import { useUrlOverlay } from '@object-ui/app-shell';

function HelpMenu() {
  const { open, setOpen, openOverlay } = useUrlOverlay('shortcuts');
  // Header button (any component under the router):  onClick={openOverlay}
  // Dialog (elsewhere, reads the same param):        <Dialog open={open} onOpenChange={setOpen}>
  // Deep-link that opens on load:                    /apps/foo?shortcuts=1
}

Because state lives in the URL, a trigger and the overlay it controls need no shared provider or prop-drilling — they just use the same key. The command palette (?palette=1, ?cmdk=1 alias) and the keyboard-shortcuts dialog (?shortcuts=1, openable from the Help menu — no longer ?-key-only) both build on it. replace/alias/value are configurable.

The shared overlay primitives in @object-ui/components (Dialog/Sheet/Drawer/Popover/DropdownMenu/AlertDialog) already forward a data-testid onto their content element and emit Radix data-state="open|closed", so overlays are locatable and their open/closed state is machine-readable by construction (C4).

Keyboard-shortcuts dialog

KeyboardShortcutsDialog (?, or ?shortcuts=1) has no list of its own. Each shortcut is advertised by the code that handles it, beside the handler and for as long as the handler is mounted, and the dialog lists what is advertised at that moment (objectui#11674). Inside an app that is the command palette (⌘K), the dialog itself (?), closing a dialog or panel (Esc) and the sidebar (⌘B, the SidebarProvider listener). The AI chat page advertises ⌘⇧O and ⌘⇧S itself, so they are listed only where that page is mounted. A shortcut without a mounted handler is never listed. Each listed row carries data-shortcut-id, and KeyboardShortcutsDialog.wiredOnly-11674.test.tsx fires every row the console lists against its real handler.

Settle signal (is the app idle?)

<ConsoleShell> exposes one global "no requests in flight" predicate so an automated (AI) browser driver can wait for the app to settle instead of hardcoding timeouts (ADR-0054 C5). The data layer increments a counter around every outbound request (it wraps the adapter's fetch), mirrored onto window.__objectui:

// In an e2e / browser driver:
await page.waitForFunction(() => window.__objectui?.idle === true);
// or:  window.__objectui.pendingRequests === 0
// or:  await window.__objectui.whenIdle();   // resolves when settled (10s cap)

In React, useSettleSignal() returns { pending, idle } for a global busy indicator; the lower-level getPendingRequests / subscribeSettle / whenIdle / withSettleSignal / installSettleSignalGlobal are also exported.

Async data regions additionally expose region-level state for finer waits: the list view and record-picker results set aria-busy while fetching and data-state="loading|idle", complementing the Radix data-state already on overlays.

Field locators (field:{object}.{field})

Generated forms emit a metadata-derived stable locator on every field wrapper, so an automated (AI) driver can target a field without relying on i18n-fragile labels or positional selectors (ADR-0054 C4). The form renderer derives it from the form's objectName and each field's name — every form (ObjectForm, ModalForm, DrawerForm, SplitForm, WizardForm) inherits it with zero per-app work:

<div data-testid="field:account.industry" data-field="industry"> … input … </div>
// e2e / AI driver:
await page.getByTestId('field:account.industry').locator('input').fill('SaaS');

The object prefix is omitted (field:{field}) when a form has no owning object. This complements the action/overlay locators already emitted by the renderer (overlay:command-palette, action:command-palette:open, …).

Testability ratchet

The invariants above are kept from regressing (ADR-0054 Phase 5, "counts can only go down"):

  • A conformance test (runs in the gating pnpm test job) fails the build if a new synthetic-event trigger (el.dispatchEvent(new KeyboardEvent/MouseEvent/ PointerEvent …)) is introduced anywhere in packages/*/src or apps/*/src. Legitimate CustomEvent / PopStateEvent dispatch (event bus / history nudge) is allowed. Replace a synthetic trigger with a direct, idempotent command (useCommandPalette / useUrlOverlay).
  • A matching ESLint rule object-ui/no-synthetic-event-trigger flags the same pattern in-editor (the repo Lint workflow is manual, so the test is the CI gate).

Platform preview badge

While the whole platform is pre-GA, the top bar (AppHeader) shows a small Preview chip next to the product wordmark on every console surface (home / app / orgs / studio). It's rendered by PreviewBadge, driven by the platform stage in runtime-config:

// packages/app-shell/src/runtime-config.ts — `RuntimeBranding.stage`
import type { PlatformStage, RuntimeBranding } from '@object-ui/app-shell';

declare const branding: RuntimeBranding;

// Optional on the wire; `getPlatformStage()` falls back to 'preview'.
const stage: PlatformStage = branding.stage ?? 'preview';

// The three stages, restated against the shipped union — retiring or
// misspelling a member fails this block rather than rotting silently.
const everyStage: PlatformStage[] = ['preview', 'beta', 'ga'];
  • getPlatformStage() reads it (defaults to 'preview', so the badge shows out of the box on any runtime that hasn't sent a stage yet).
  • The server pushes it via GET /api/v1/runtime/config (branding.stage). Operators set it with OS_PRODUCT_STAGE or new RuntimeConfigPlugin({ stage }).
  • At launch, set stage: 'ga' — PreviewBadge renders nothing and the chip disappears with no code change. 'beta' shows a "Beta" chip instead.
import { PreviewBadge, getPlatformStage } from '@object-ui/app-shell';

<PreviewBadge className="ml-2 hidden sm:inline-flex" />; // used inside AppHeader

Labels are localized under topbar.stage.* (@object-ui/i18n).

Links

License

MIT — see LICENSE.