npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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: PageView and usePageEditor. 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 and describeCatalog(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 returns errors with codes the agent can act on, for example STALE_REVISION, DANGLING_BINDING or INVALID_CONFIG.
  • Pass scope: blockId to keep an edit inside one block's subtree.
  • Pass author: { kind: 'agent', turn } so changes are attributed, and a PagePolicy to narrow what one turn may do (see 0.3.0 below).

Rules

  • Ops: add, remove, move, configure (shallow merge, where null clears a key, or replace), bind, setTitle, replaceBlocks, place, setRole.
  • Each successful batch adds 1 to revision and returns the inverse ops that undo it.
  • store.save(page, expectedRevision) only succeeds if the stored revision still equals expectedRevision; otherwise it throws RevisionConflictError. Pass null for a page not yet stored. Revisions only go up, including after reset.
  • Selection and other outputs belong to the PageView session and are never saved.

Roles (0.2.0)

Optional. Pages without roles behave exactly as in 0.1.x.

  • Role is subject or support. A BlockDefinition.role is the default; BlockInstance.role overrides 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.
  • setRole op (0.2.1): { op: 'setRole', id, role }, with role: null to 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_SUBJECT appears 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, main or side. BlockInstance.lane overrides BlockDefinition.lane; with neither, the subject goes in main and everything else in side (effectiveLane(block, catalog)).
  • Bands. A full block 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.
  • place op: { op: 'place', id, lane }, with lane: null to fall back to the default. Its inverse restores the previous lane, or clears it if there was none. An unknown lane is INVALID_LANE.
  • Rendering: each top-level block's wrapper is .plastic-page-block with data-lane. PageView measures the cards, places them on a grid of 1px rows, and does it again whenever a card or the page changes size. Below stackBelow (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 loses data-lanes; reorder there with CSS order if you need to. Import @luckydraw/plastic-page/plastic-page.css; size the lanes with --plastic-page-main (default minmax(0, 2fr)), --plastic-page-side (default minmax(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.
  • laneColumns and planLanes are 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 PagePolicy is { ops?, blockTypes?, scope? }. Pass the same policy to pageOpsToolSchema(catalog, policy) and to applyPageOps(page, ops, { catalog, ...policy }). The schema then offers only those ops and block types (a scoped schema drops setTitle and replaceBlocks), and the reducer rejects anything else with OP_NOT_ALLOWED or BLOCK_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 in actions with a payload schema (createCatalog throws without one). PageView sends it to onAsk (with the page revision), never to onAction. A block must dispatch ask only 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. dispatch returns false and 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.source is { 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) and requireSource (a block of that type added by the agent must have a source: MISSING_SOURCE; the tool schema marks source required). Data caps such as a maximum number of columns belong in the config schema (maxItems).
  • Author. Pass author: { kind: 'user' | 'agent', id?, turn? } to applyPageOps. Blocks an agent adds are always stamped createdBy: 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 carries change: { 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, takes author (and a policy) per apply, saves pending changes with the page, and adds undoTurn(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 = done

Rules

  • 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 / null to end. A branched step has exactly one key per declared branch; null is an explicit terminal branch. A missing or undeclared branch is an error.
  • Effects: none / internal / external (default internal). allowEffects defaults to ['none', 'internal'], so an external step (a send) is rejected unless the host opts in. Pass the same value to workflowOpsToolSchema, which then leaves those types out.
  • Triggers: manual, schedule (needs cron), event (needs source + events). allowTriggers limits 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 its output schema declares (nested fields too, where the schema has properties). Step ids are letters, digits and _ so refs parse in any evaluator. Other expressions pass through to the host's evaluator untouched.
  • Ops: add (with after + branch it splices into that edge; after: null = new start), remove (re-threads incoming edges to its next step, or reconnect), 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 to revision, and returns inverse ops that restore the exact previous definition.
  • Factory reset: { op: 'replaceSteps', blocks: factory.blocks, trigger: factory.trigger } plus setName if needed, as one batch. sameWorkflowContent(a, b) compares ignoring revision.
  • Pause: pause: true on a step tells the runner to suspend there until the host resumes it with an outcome. Plastic does not run steps or store runs.