@luckydraw/plastic-page
v0.4.0
Published
Plastic editable pages and workflows: host block/step catalogs, revisioned definitions, shared UI/agent op reducers, reactive block bindings
Downloads
251
Readme
@luckydraw/plastic-page
Editable pages for Plastic. The host owns a block catalog and the renderers. A page is stored data: a revisioned tree of blocks. The UI and an agent change it through one reducer, and blocks bind to each other's outputs, such as a selection.
@luckydraw/plastic-page: React-free core (catalog,applyPageOps,validatePage,pageOpsToolSchema,createPageStore). Use it unchanged on a server or inside an agent.@luckydraw/plastic-page/react:PageViewandusePageEditor. Requires React 18 or later.@luckydraw/plastic-page/workflow: editable workflows (step catalog,applyWorkflowOps,validateWorkflow,nextStep,workflowOpsToolSchema). React-free. See Workflows.
Minimal example
import { useEffect } from 'react';
import { createCatalog, createPageStore, storageBackend } from '@luckydraw/plastic-page';
import { PageView, usePageEditor, type BlockRenderProps } from '@luckydraw/plastic-page/react';
const catalog = createCatalog([
{ type: 'account-table', label: 'Accounts', outputs: { selection: {} } },
{
type: 'contact-table',
label: 'Related contacts',
config: {
type: 'object',
properties: { columns: { type: 'array', items: { type: 'string' } } },
},
inputs: { accountIds: { required: true } },
},
]);
const store = createPageStore({
catalog,
backend: storageBackend(localStorage),
factory: [
{
id: 'accounts',
revision: 0,
title: 'Accounts',
blocks: [{ id: 'accounts', type: 'account-table' }],
},
],
});
function AccountTable({ setOutput }: BlockRenderProps) {
return <button onClick={() => setOutput('selection', ['a1'])}>Select a1</button>;
}
function ContactTable({ inputs, config }: BlockRenderProps) {
// Re-runs only when the bound selection actually changes
useEffect(() => {
/* fetch contacts for inputs.accountIds */
}, [inputs.accountIds]);
return <table>{/* … */}</table>;
}
const renderers = { 'account-table': AccountTable, 'contact-table': ContactTable };
function AccountsPage() {
const ed = usePageEditor({ catalog, store, pageId: 'accounts' });
if (!ed.page) return null;
return (
<>
<PageView
page={ed.page}
catalog={catalog}
renderers={renderers}
onAction={e => console.log(e)}
/>
<button
onClick={() =>
ed.apply([
{
op: 'add',
block: {
id: 'contacts',
type: 'contact-table',
bind: { accountIds: { block: 'accounts', output: 'selection' } },
},
},
])
}
>
Add contacts
</button>
<button onClick={ed.undo} disabled={!ed.canUndo}>
Undo
</button>
<button onClick={ed.save} disabled={!ed.dirty}>
Save
</button>
<button onClick={ed.reset}>Factory reset</button>
</>
);
}Agent integration
- Give the agent
pageOpsToolSchema(catalog)as the tool's input schema anddescribeCatalog(catalog)in its prompt. - Apply each tool call with
applyPageOps(page, call.ops, { catalog, expectedRevision: call.expectedRevision, scope? }). A batch applies completely or not at all. A rejected batch returnserrorswith codes the agent can act on, for exampleSTALE_REVISION,DANGLING_BINDINGorINVALID_CONFIG. - Pass
scope: blockIdto keep an edit inside one block's subtree. - Pass
author: { kind: 'agent', turn }so changes are attributed, and aPagePolicyto narrow what one turn may do (see 0.3.0 below).
Rules
- Ops:
add,remove,move,configure(shallow merge, wherenullclears a key, orreplace),bind,setTitle,replaceBlocks,place,setRole. - Each successful batch adds 1 to
revisionand returns theinverseops that undo it. store.save(page, expectedRevision)only succeeds if the stored revision still equalsexpectedRevision; otherwise it throwsRevisionConflictError. Passnullfor a page not yet stored. Revisions only go up, including afterreset.- Selection and other outputs belong to the
PageViewsession and are never saved.
Roles (0.2.0)
Optional. Pages without roles behave exactly as in 0.1.x.
- Role is
subjectorsupport. ABlockDefinition.roleis the default;BlockInstance.roleoverrides it, because the same type can lead one page and support another. A page holds at most one subject, counted across every nesting depth: a container is not a surface, so two columns cannot each hold one. An inline page in chat is its own page. The primary action is a control inside a block, not a role. setRoleop (0.2.1):{ op: 'setRole', id, role }, withrole: nullto fall back to the type's default. Its inverse restores the previous override, or clears it if there was none. To move the subject, demote the old one and promote the new one in one batch.- Errors reject the batch:
INVALID_ROLE,MULTIPLE_SUBJECTS. They are checked on the result of the whole batch, so moving the subject in one batch is fine. - Warning: a successful result carries
warnings.NO_SUBJECTappears when roles are in use but the page has blocks and no subject, which is normal while an agent composes a page over several batches.pageWarnings(page, catalog)runs the same check.
Lanes (0.4.0)
A page lays out in bands and lanes, so no card leaves a hole beside a taller one. Breaking: this replaces 0.2.0's 3×3 grid; cell, BlockCell, CELL_OUT_OF_RANGE, CELL_OVERLAP, PAGE_GRID_CLASS and the .plastic-page-grid/.plastic-page-cell styles are gone.
- Lane is
full,mainorside.BlockInstance.laneoverridesBlockDefinition.lane; with neither, the subject goes inmainand everything else inside(effectiveLane(block, catalog)). - Bands. A
fullblock is a band of its own. The blocks between full ones share a band of two lanes, a wide main lane and a narrow side lane. Each lane stacks its own cards one gap apart, so cards in one lane never wait for a row in the other. - One straight edge. The last card in the shorter lane grows to the bottom of the band. A shorter subject instead keeps its natural height and stays in view (sticky) while the taller lane scrolls past.
- One-lane bands span the full width rather than leave the other lane empty.
placeop:{ op: 'place', id, lane }, withlane: nullto fall back to the default. Its inverse restores the previous lane, or clears it if there was none. An unknown lane isINVALID_LANE.- Rendering: each top-level block's wrapper is
.plastic-page-blockwithdata-lane.PageViewmeasures the cards, places them on a grid of 1px rows, and does it again whenever a card or the page changes size. BelowstackBelow(default 640px, the page's own width, so it works inline in chat too) the page is one column in page order, and the root losesdata-lanes; reorder there with CSSorderif you need to. Import@luckydraw/plastic-page/plastic-page.css; size the lanes with--plastic-page-main(defaultminmax(0, 2fr)),--plastic-page-side(defaultminmax(260px, 1fr)) and--plastic-page-gap(default 20px). Lanes apply to the page's top-level blocks; a container renderer lays out its own children. laneColumnsandplanLanesare the pure layout, exported for hosts that render pages without React.
Agent policy, asks, sources and history (0.3.0)
- Narrow the tool per turn. A
PagePolicyis{ ops?, blockTypes?, scope? }. Pass the same policy topageOpsToolSchema(catalog, policy)and toapplyPageOps(page, ops, { catalog, ...policy }). The schema then offers only those ops and block types (a scoped schema dropssetTitleandreplaceBlocks), and the reducer rejects anything else withOP_NOT_ALLOWEDorBLOCK_TYPE_NOT_ALLOWED, nested children included. Deciding which policy fits a message is the agent host's job. - The agent hears from a page only when the user asks.
ask(ASK_ACTION) is a reserved action. A block type declares it inactionswith a payload schema (createCatalogthrows without one).PageViewsends it toonAsk(with the page revision), never toonAction. A block must dispatchaskonly from a user gesture such as an "Ask about this" button — never on load, on a timer, or when an input or selection changes. Selection changes stay local and update bound blocks. - Payloads are checked. Every declared action's payload is validated against its schema.
dispatchreturnsfalseand delivers nothing for an undeclared action (on a type in the catalog) or an invalid payload. Breaking: 0.2.x delivered undeclared actions with a warning; declare every action a block dispatches. - Source.
BlockInstance.sourceis{ label, detail? }, for example{ label: 'CRM · Accounts owned by me' }. Show it with the block. - Limits.
BlockDefinition.limits:maxPerPage(TOO_MANY_BLOCKS, counted across the whole page) andrequireSource(a block of that type added by the agent must have asource:MISSING_SOURCE; the tool schema markssourcerequired). Data caps such as a maximum number of columns belong in the config schema (maxItems). - Author. Pass
author: { kind: 'user' | 'agent', id?, turn? }toapplyPageOps. Blocks an agent adds are always stampedcreatedBy: author, so the agent cannot claim user authorship; blocks that existed before the batch keep their attribution. A user author only fills in missing attribution, so undoing an agent's removal restores the agent's stamp. Every successful result carrieschange: { revision, author?, ops, inverse }. - History.
store.save(page, expectedRevision, changes)appends the changes, timestamped, to a per-page log stored with the page in one write;store.reset(id, author?)logs a reset;store.history(id, limit?)returns the log newest first (default cap 100,historyLimit).usePageEditor({ author })sets the default author, takesauthor(and a policy) perapply, saves pending changes with the page, and addsundoTurn(turn): it undoes all of that agent turn's changes as one batch, and refuses if someone else's change is newer.
Factory reset through the reducer
A host that keeps its own page persistence resets with one ordinary batch, so the reset is validated, revision-checked and undoable like any other edit:
applyPageOps(
current,
[
{ op: 'replaceBlocks', blocks: factory.blocks }, // blocks, configs and bindings
{ op: 'setTitle', title: factory.title ?? null },
],
{ catalog, expectedRevision: current.revision }
);params are host-declared and are not changed by ops.
Backend catalog
BlockDefinition is plain JSON with no React in it. Serialize catalog.list(), rebuild it with createCatalog(defs) on the server, and validate with the React-free root entry (@luckydraw/plastic-page). Renderers are only passed to PageView.
Workflows
A workflow is the Plastic Flow graph plus a revision: a trigger and a list of steps, each pointing to the next. The host registers step types and runs them; Plastic validates definitions, applies edits, and defines branching.
import {
createStepCatalog,
applyWorkflowOps,
entryStep,
nextStep,
} from '@luckydraw/plastic-page/workflow';
const steps = createStepCatalog([
{
type: 'query',
label: 'Query accounts',
effect: 'none',
config: { type: 'object', properties: { segment: { type: 'string' } }, required: ['segment'] },
output: {
type: 'object',
properties: { count: { type: 'integer' }, accounts: { type: 'array' } },
},
},
{ type: 'condition', label: 'Condition', effect: 'none', branches: ['true', 'false'] },
{ type: 'review', label: 'Review', pause: true, branches: ['approved', 'rejected'] },
{ type: 'draft', label: 'Draft email', effect: 'internal' },
]);
const workflow = {
id: 'renewals',
revision: 0,
name: 'Renewal outreach',
trigger: { type: 'schedule', cron: '0 9 * * 1' },
blocks: [
{ id: 'q', blockType: 'query', config: { segment: 'renewals' }, next: 'c' },
{
id: 'c',
blockType: 'condition',
config: { expression: '{{ steps.q.count > 0 }}' },
next: { true: 'r', false: null },
},
{ id: 'r', blockType: 'review', config: {}, next: { approved: 'd', rejected: null } },
{ id: 'd', blockType: 'draft', config: { accounts: '{{steps.q.accounts}}' } },
],
};
const result = applyWorkflowOps(workflow, ops, {
catalog: steps,
expectedRevision: workflow.revision,
});
// Runner: start at entryStep(wf); after a step finishes, nextStep(wf, stepId, outcome) — null = doneRules
- Entry is
blocks[0]. It has no incoming edges, every step is reachable from it, and there are no cycles (retry belongs in the runner). After every batch the reducer puts steps in execution order. - Next: an unbranched step has a step id, or no
next/nullto end. A branched step has exactly one key per declared branch;nullis an explicit terminal branch. A missing or undeclared branch is an error. - Effects:
none/internal/external(defaultinternal).allowEffectsdefaults to['none', 'internal'], so anexternalstep (a send) is rejected unless the host opts in. Pass the same value toworkflowOpsToolSchema, which then leaves those types out. - Triggers:
manual,schedule(needscron),event(needssource+events).allowTriggerslimits them per host. - References:
{{steps.<id>.<field>}}inside config strings must name a step that runs before this one on every path, and a field itsoutputschema declares (nested fields too, where the schema hasproperties). Step ids are letters, digits and_so refs parse in any evaluator. Other expressions pass through to the host's evaluator untouched. - Ops:
add(withafter+branchit splices into that edge;after: null= new start),remove(re-threads incoming edges to its next step, orreconnect),move(reorder an unbranched step),configure,replace(new type in place, keeps id and incoming edges),connect,setLabel,setTrigger,setName,replaceSteps. Each batch is atomic, revision-checked, adds 1 torevision, and returnsinverseops that restore the exact previous definition. - Factory reset:
{ op: 'replaceSteps', blocks: factory.blocks, trigger: factory.trigger }plussetNameif needed, as one batch.sameWorkflowContent(a, b)compares ignoring revision. - Pause:
pause: trueon a step tells the runner to suspend there until the host resumes it with an outcome. Plastic does not run steps or store runs.
