@adaptiveapp/runtime
v0.3.0
Published
Adaptive UI SDK — the personalizable widget runtime (published to npm)
Readme
@adaptiveapp/runtime
The Adaptive UI SDK: a widget runtime whose layout is personalizable per user, per role, per breakpoint — and whose personalization can only ever subtract from what the server already allows.
Embedded in every app AdaptiveApp AI generates, and usable on its own. It has no platform coupling: it talks to a database through an interface you implement, and it works in an exported repo with the platform deleted.
pnpm add @adaptiveapp/runtime react react-domPeer dependencies: React ≥ 19.
The one idea
Layout is data, and resolveLayout() is the only thing that turns that data into placement.
Stored layouts arrive as a stack of layers — platform, app, org, role, team, user, device — which merge property-wise in that order. A user who moves a widget stores {x, y} and nothing else, so they cannot re-assert someone else's hidden. The resolver then filters by permission first and last, clamps to each widget's declared constraints, applies dependency fallbacks, and reflows deterministically.
That order is a security property, not a style. A stored layer, however privileged the row it came from, can never put back a widget the user may not see.
manifests + permissions + layers ─▶ resolveLayout() ─▶ EffectiveLayoutIdentical inputs produce byte-identical output. Migrations, SSR and the personalization matrix all depend on it.
Quick start
import {
AdaptiveProvider,
AdaptiveGrid,
defineWidget,
createInMemoryPersistence,
} from '@adaptiveapp/runtime';
import 'react-grid-layout/css/styles.css';
import '@adaptiveapp/runtime/styles.css';
defineWidget(patientsManifest, PatientsList);
export function Dashboard({ userId, permissions }) {
return (
<AdaptiveProvider
config={{
userId,
permissions, // server-derived. The ceiling, not a suggestion.
manifests,
persistence: createInMemoryPersistence(),
appLayoutEpoch: 1,
}}
>
<AdaptiveGrid pageId="dashboard" />
</AdaptiveProvider>
);
}permissions must be derived on the server from the signed-in user's role and passed in. The runtime never computes them, because a permission set the browser can edit is not a permission set.
API
Components
| Export | What it does |
|---|---|
| <AdaptiveProvider config store? registry?> | Creates the store once and provides it. config is read on first render only; later changes go through actions. |
| <AdaptiveGrid pageId rowHeight? margin? width? breakpoint? className?> | The grid. Placement comes from resolveLayout(); react-grid-layout is an input device, never the authority. Hidden widgets are not rendered at all. |
| <WidgetShell manifest placed children?> | Chrome: header, variant switcher, hide affordance, drag handle — each shown only when personalize mode and the manifest allow it. Also renders the inline selectors the resolver assigned. |
| <InlineSelector slot label?> | The fallback picker. Options come from the provider widget's registered slotOptions, so hiding a provider cannot change what the slot means. |
Hooks
| Export | Returns |
|---|---|
| usePersonalizeMode() | { enabled, enable, disable, toggle } — the only thing that unlocks drag, resize, hide and variant switching. |
| useLayoutViews() | { views, currentViewId, save, switchTo, remove, reset, undo } — named views per page. |
| useSlot<T>(slotId) | [value, publish] for a context slot. |
| usePublish() | publish(slotId, value), stable across renders. |
| useSlotWithFallback<T>(slotId, requirement) | { value, hasProvider, strategy, inlineSelector, publish }. Implements defaultValue, preserveLast and inlineSelector. Inside <WidgetShell>, do not render inlineSelector yourself — the shell already did. |
| useAdaptive(selector) | Subscribe to a slice of personalization state. |
| useRunAction() | Fire a store action from a handler, routing failures to config.onError instead of an unhandled rejection. |
Registry
defineWidget(manifest, Component, {
slotOptions: { selectedRoomId: () => fetchRooms() }, // for providers
});Manifests are validated on registration. A widget that provides a slot should register slotOptions for it, or <InlineSelector> has nothing to offer when the widget is hidden.
Pure functions — @adaptiveapp/runtime/server
Import from the /server subpath in server components and route handlers. The main entry contains 'use client' modules, and the Next App Router refuses those from a server context.
| Export | Purpose |
|---|---|
| resolveLayout(input) | The resolver. Pure; safe for SSR and tests. |
| runLayoutMigrations({doc, migrations, targetEpoch, manifests}) | Epoch and schema migrations. Stops at the last epoch it actually reached and says so — it never claims one it did not. |
| migrateLayers(layers, args) | The same, over a whole stack. |
| createAdaptiveStore(config) | The vanilla store, without React. |
| createInMemoryPersistence(seed?) | Reference LayoutPersistenceAdapter — and the one the tests use. |
| breakpointForWidth(px), BREAKPOINT_MIN_WIDTH | Container width → breakpoint. Note these measure the grid's container, not the viewport. |
| withUserPatch, withoutUserLayer, findUserDoc, emptyUserDoc | Sparse edits to the user layer. |
Persistence
Implement LayoutPersistenceAdapter over your own storage:
interface LayoutPersistenceAdapter {
loadLayers(key): Promise<LayoutDoc[]>;
saveUserLayout(key, doc): Promise<void>; // append a version, do not overwrite
resetUserLayout(key): Promise<void>;
undoUserLayout(key): Promise<LayoutDoc | undefined>;
listViews({userId, pageId}): Promise<LayoutView[]>;
saveView({userId, pageId, view}): Promise<void>;
deleteView({userId, pageId, viewId}): Promise<void>;
recordEvent(event): Promise<void>;
}Two things to get right:
- Append, don't overwrite.
undopops a version, and there is nothing to pop otherwise. Compute the next version inside the insert — read-then-write races under rapid personalization. - Scope by the session, server-side. If the adapter runs in the browser and posts to your API, take the user id from your verified session and ignore the one in the request.
Testing
@adaptiveapp/runtime/testing ships demo manifests covering every componentType, a createDemoEnvironment(), and an <AdaptiveHarness> that renders a whole personalizable page. The SDK's own component tests use it, and so should yours.
What personalization cannot do
The resolver enforces these, so you do not have to:
- Show a widget whose
permissionsthe user does not hold — filtered first and last. - Hide a
mandatorywidget, or one withhideable: false. - Place a widget outside its
minW/maxW/minH/maxH, or off the 12-column grid. - Leave a visible consumer with no value: a hidden provider triggers
defaultValue,preserveLast, aninlineSelector, or un-hides the provider (blockHidingProvider). - Produce an overlapping layout — reflow is top-left gravity in widget-id order, and a widget that still fits is never moved.
Presentation never grants power. Hiding a widget is not an access-control decision, and the API must enforce independently.
Versioning
Every stored layout carries layoutSchemaVersion (this package's doc shape) and appLayoutEpoch (your app's widget set). Bump the epoch whenever the widget set changes and register a migration; runLayoutMigrations() applies them lazily at read, and your adapter persists the result at the next write.
License
UNLICENSED — private package.
