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

@xeplr/ui-factory

v1.1.0

Published

Design data-entry screens on @xeplr/ui-canvas, save them as JSON, run them as auto-saving forms with lists of saved records — built for Claude to draft and people to refine

Readme

@xeplr/ui-factory

Design data-entry screens, save them as JSON, run them as working forms. Drag controls onto a canvas, set their labels, validation, options, fonts and colours. The same document renders as a live form in your app, saved by its Save button over AJAX, with a list of the saved records to open, edit or delete.

It is built to work with Claude: ask for "a form for employees with name, email, department and start date", and Claude creates two screens — an employee list and an add / edit form that the list's Edit and New open in a popup — plus the two pages that show them. A person then refines either one in the designer.

One entity, two screens, four pages

| page | what it is | |---|---| | Screen · List | EmployeeList.jsx — the saved employees; New and Edit open the edit screen in a popup, Delete removes a row | | Screen · Edit | EditEmployee.jsx — the add / edit form, on its own page (the same screen the popup shows) | | Designer · List | <FactoryBuilder document={employeeList} /> — its title, columns, actions, look | | Designer · Edit | <FactoryBuilder document={employeeEdit} /> — its fields, validation, options, look |

npx xeplr-factory screens employee.entity.json -o src/screens/employee
#  employee-list.screen.json   EmployeeList.jsx     the list page — uses EditEmployee.jsx's hooks
#  employee-edit.screen.json   EditEmployee.jsx     the form page, holding the FRONT-END hooks (EmployeeHooks extends FactoryHooks)
#  employee.hooks.js           SERVER hooks: save / get / delete × before / after / error / override
#  employee.model.js           SERVER model: EmployeeModel extends FactoryModel — getters / setters for values

--no-pages leaves out the two .jsx (for an app whose pages live elsewhere). --force replaces only the screen JSON: the pages, hooks and model are your code and are always kept.

Rules: records live in real tables, never JSON · a form's key (employee) names its screens and table and never changes once published; its label (the screens' name) is renamed any time · rules that must hold go in server hooks, never front-end hooks.

See examples/ for exactly what it writes.

(The package name on npm is @xeplr/ui-factory — the GitHub repo and folder are named xeplr-ui-factory.)

Why

Most internal apps are a long tail of forms, and every one of them is hand-built: markup, layout, validation in the browser, validation again on the server, and dropdowns wired to tables. This turns a form into data:

  • a person — or Claude — describes the screen once;
  • the builder lets anyone move, resize and adjust it without a developer;
  • the renderer runs it, and the server checks submissions against the same rules.

The factory never has a database. Your app stores screen documents and supplies the rows dropdowns read. The factory draws, edits and validates.

Records live in real tables — employees, one column per field — never in JSON, so select * from employees and ordinary reporting just work. Publishing a screen creates or changes its table directly: new fields add columns, and a removed field drops its column and data only after you confirm. @xeplr/factory is the server package that does it, saves screens (draft / publish) and reads and writes those tables with the screen's own validation. createFactoryApi({ fetch }) connects the components to it.

Install

npm i @xeplr/ui-factory @xeplr/ui-canvas @xeplr/ui-table @tanstack/react-table

Peer dependencies: react ^18 || ^19, @xeplr/ui-canvas (the canvas the builder places controls on) and @xeplr/ui-table (lists of saved records). Your bundler must compile JSX and import CSS from the package (Vite does both).

The builder

import { FactoryBuilder } from '@xeplr/ui-factory'

<div style={{ height: '100vh' }}>
  <FactoryBuilder
    document={screen}                                  // omit to start a new one
    onSave={async (doc) => api.saveScreen(doc)}        // your storage — called when Save is pressed
    listTables={async () => api.listTables()}          // for "Saves to", lists and table dropdowns
    fetchOptions={async ({ table }) => api.options(table)}  // Preview with real options
    fetchRecords={async ({ source }) => api.records(source)} // Preview with real records
  />
</div>
  • Drag controls from the palette onto the screen, or click one to add it at the bottom.
  • Move, resize, align with guides; drag a box to select several; Delete removes, Ctrl/⌘ D duplicates.
  • Select a control to set its label, field name, placeholder, required, default, validation, and — for a dropdown — where its options come from.
  • Every look is a property: font, size, weight, italic, alignment, text colour, background, border colour/width, corner radius — and for an input, its label's size, weight and colour.
  • With nothing selected, the panel shows the screen: the table it saves to, its width, and the font and colours every control inherits.

Nothing saves itself — here, or anywhere else in this package. The bar says Unsaved changes and offers Save; that is the only thing that writes the draft, so trying a layout out and walking away leaves the saved screen as it was. Closing the tab with unsaved work asks first.

It used to save 800ms after every edit, and that was wrong twice over: there was no such thing as trying something out, and no moment at which anybody said "this is right" — and a field is invalid while it is being built, so the autosave met half-made work and reported it as an error nobody asked for. autosaveDelay is still accepted and ignored, so a host passing it is not broken.

Publish still saves first, because publishing what is on screen is the one thing it must never get wrong.

  • No Save button. Edits are saved (as a draft) a moment after you stop, whenever the screen is valid; problems are marked on the controls that have them until they are fixed.
  • Publish (when onPublish is given) makes the draft the version everyone sees and changes its table to match. If removed fields would drop columns, it asks first — "Publishing removes phone — 1,240 saved values" — with Remove and publish / Cancel. Unsafe changes (narrowing a field) are refused with the reason.
  • lockedNames: fields that are already columns of the table. Their names are read-only, because renaming one would leave its data behind.

The screen

import { FactoryScreen } from '@xeplr/ui-factory'

<FactoryScreen
  document={screen}
  record={employee}                                   // optional: open an existing record
  onSave={async (values, { id, source }) => api.save(source, id, values)}  // returns the saved record, with its id
  fetchRecords={async ({ source }) => api.records(source)}
  fetchRecord={async ({ screen, id }) => api.record(screen, id)}   // optional: Edit loads the record fresh
  onDelete={async ({ id, source }) => api.remove(source, id)}
  fetchOptions={async ({ table }) => api.options(table)}
  uploadFile={async (file, { screen, field }) => api.upload(file, screen, field)}  // a file field: → { path }
  screens={{ employee_edit: editScreen }}             // what a list's Edit / New open in a popup
/>

Save is a button and an AJAX call — never a form submit. Nothing is written while the person types: the footer says "Unsaved changes" until they press Save, which makes one call to your onSave. There is no <form> post and Enter saves nothing. If something is missing, Save marks every field that needs attention instead of sending. The first save of a new record creates it: return the saved record and its id makes every later save an update.

  • With onDone (a page, or the list's popup), the footer has Cancel and Save. Save saves and then calls onDone. Cancel calls onDone without saving, asking first if anything is unsaved. Without onDone (a form embedded in a page) there is Save alone, and it shows "Saved".
  • Leaving with unsaved changes asks first: Cancel, the popup's ×/Escape, a list-on-the-form opening another record, and closing or reloading the tab. In-app navigation (a side-rail click) is the host router's to guard — ctrl.dirty / onDirtyChange(dirty) say when there is something to lose.
  • useFactoryScreen returns save() (the button), dirty, confirmDiscard() and flush() (save only if unsaved — what a flow's Next does). AUTOSAVE_DELAY and autosaveDelay are gone.

If onSave throws an error with fields: [{ field, message }] (what createFactoryApi does with the server's 422 — a rule, or a hook's reject), each message shows on its field until that field is changed.

Values arrive typed: numbers as numbers, checkboxes as booleans, dates as "YYYY-MM-DD", a date and time as "YYYY-MM-DDTHH:MM", a dropdown or radio group as the option's own id (a table's numeric id stays a number), a multi-select as an array of ids, and a file as the stored file's path.

Where each value is kept

A record is columns in a real table, never JSON — so the two types that are not one scalar have a rule of their own:

  • Multi-select — every chosen id in ONE text column, joined by commas. An option id may therefore not contain a comma; the checker refuses one that does. toDbValue / fromDbValue do the conversion, and both the browser and the server use them, so it is written once.
  • File — the column holds the path the upload answered with, not the file. The file itself is stored by @xeplr/factory and read back through its authenticated route; nothing is ever served without a check.
  • Date and time — a timestamp column, read back as text so no timezone can move the moment. A date column may be widened to timestamp on publish (the day becomes midnight); the reverse is refused, as it would lose the time.

Front-end hooks

What a screen does in the browser — load, save, delete, extra row buttons — is a class you extend. Every method is the default; override the one you need and call super:

import { FactoryScreen, FactoryHooks } from '@xeplr/ui-factory'

class TaskHooks extends FactoryHooks {
  async save(values, ctx) {
    const saved = await super.save({ ...values, title: values.title.trim() }, ctx)   // before
    toast('Saved ' + saved.title)                                                   // after
    return saved
  }
  async get(ctx) {
    const rows = await super.get(ctx)
    return ctx.many ? rows.filter((r) => r.status !== 'archived') : rows
  }
  actions(ctx) {
    return [{ label: 'Mark done', onClick: async (record) => { await markDone(record.id); ctx.refresh() } }]
  }
}

const taskHooks = new TaskHooks()
<FactoryScreen document={taskList} {...api} hooks={taskHooks} />

| method | default | ctx | |---|---|---| | get(ctx) | fetchRecords for a list, fetchRecord for the record Edit opens | many, id, screen, source | | save(values, ctx) | onSave — when Save is pressed; return the saved record | id, isNew, screen, source | | delete(record, ctx) | onDelete, after the person confirmed | id, screen, source | | actions(ctx) | [] — extra row buttons { label, onClick(record, ctx) } beside Edit / Delete | screen, refresh() |

Not calling super replaces the default. The same hooks run in the popup a list opens (ctx.screen says which screen). Methods, not arrow functions — super needs them. xeplr-factory screens writes this class, every method calling super, into the edit page.

Rules that must hold belong in the server's hooks; the browser only shapes what the person sees and sends.

Lists of saved records

A List control shows the records of the table the screen saves to (or another), in @xeplr/ui-table, with the columns you tick. Its Edit in names the edit screen: Edit and New open that screen in a popup: its Save saves, closes the popup and refreshes the list; Cancel closes it unsaved.

Opens in: a page of its own sends them to a page in your app instead. The factory has no router, so it asks — onOpenRecord on the list's screen, and onDone on the page, which draws a Done button that saves what is pending and calls it:

<FactoryScreen document={taskList} {...factory.screenProps}
  onOpenRecord={({ id }) => navigate(id ? `/tasks/${id}` : '/tasks/new')} />

<FactoryScreen document={taskEdit} record={task} {...factory.screenProps}
  onDone={() => navigate('/tasks')} />
``` **Delete** removes a row after a confirmation. Dropdown columns show names, not ids — read from the edit screen's fields.

The edit screen comes from `screens` (`{ id → document }`) or your `loadScreen(id)`. A list on a form with no **Edit in** opens rows in that form's own fields instead.

`@xeplr/ui-table` reads the xeplr theme tokens (`--xeplr-bg-*`, `--xeplr-text-*`, `--xeplr-border-*`) and defaults to a dark look; define them for a light app.

On your server, check the submission against the same rules with [`@xeplr/schema-handler`](https://www.npmjs.com/package/@xeplr/schema-handler):

```js
import { formSchema } from '@xeplr/ui-factory/model'
import schemaHandler from '@xeplr/schema-handler'          // CommonJS: default import in ESM,
                                                          // or require('@xeplr/schema-handler')
const clean = schemaHandler.applySchema(formSchema(screen), req.body)   // throws ValidationError listing each field

Your screens, inside someone else's designer

@xeplr/ui-workflow's flow designer has a Screen step, and it asks its host two things: which screens exist and what each one hands back, and how to design one without leaving the flow. It cannot answer either itself — it carries a screen key and knows nothing else about forms, on purpose, so an app with a different screen system is not locked out.

This package answers both, in one hook:

import { useScreenSource, FactoryBuilder } from '@xeplr/ui-factory'

const { screens, screenEditor } = useScreenSource(factory, { Builder: FactoryBuilder })
<WorkflowDesigner screens={screens} screenEditor={screenEditor} … />
  • screens is [{ key, name, fields }]. fields comes from inputNodes(doc) — this package's own answer to "which nodes hold a value" — so a label or a stepper is never offered as a field, and a control type added here reaches every app without one of them being edited. Names arrive first and fields follow, so the menu is usable while documents load; a document that will not load leaves its screen choosable without fields.
  • screenEditor is the body of the designer's modal: the builder, on the form that step shows, or on a new one it makes (screenKey: null → name it → createEntity). Publishing is what fires onDone({ key, name, fields }), because a draft changes nothing anybody can run — and that is what tells the flow the form's new shape, so a field added there appears in every step that shows that form.

Why this is a package and not four lines in your app. It used to be the latter, and that is a bug: an app is generated once and then lives its own life, so glue copied into it can never be fixed centrally. An app scaffolded last month would keep the old version forever, and a control added here would never reach it. Anything every app would write identically belongs on this side of the line — @xeplr/cli's generated FlowDesigner.jsx is two props, and its test suite fails if the glue reappears in the app.

The pure half is screenSource.js — describeScreen, loadScreens, keyFromName, editScreenOf — testable with no React and no server.

Flows — screens, one after another

A screen is a form; a flow is the journey across several of them. Design it the same way you design a form — screens on a canvas, arrows between them:

import { FlowBuilder, FlowRunner, createFlowsApi } from '@xeplr/ui-factory'
const flows = createFlowsApi({ fetch: authFetch, base: '/api' })

<FlowBuilder {...flows.builderProps} flow={flow} screens={published} loadScreen={factory.loadScreen} />
<FlowRunner  {...flows.runnerProps} {...factory.screenProps} flowKey="employee_registration" runId={runId} />

An arrow is a question asked once its screen is filled in — the first one that fits wins, and the one with no test ("otherwise") catches the rest:

{ "stepKey": "details", "screen": "employee_edit",
  "transitions": [
    { "when": { "field": "type", "op": "=", "value": "contractor" }, "target": "contract" },
    { "when": null, "target": "payroll" }
  ] }

The browser does not run the journey. createFlowsApi talks to a service that does — Xeplr Workflow — so a run remembers where it got to: a journey can be left and picked up later, by someone else, on another day. Without such a service there are no flows; the factory has no engine of its own, on purpose.

Each screen still saves into its own table — the flow's Next saves what is unsaved (one AJAX call) and then moves on. What travels with the run is only what the arrows need and the id of the row that was written (recordId), so the data is exactly where it would be without a flow.

validateFlow(flow, { screens }) is the same check on both sides: a screen that exists, arrows that point somewhere, one "otherwise" at the end, and nothing unreachable.

A flow and a stepper

Both walk someone through something, and they answer different questions:

| | stepper | flow | |---|---|---| | where | steps of ONE screen | several screens | | when | one sitting | over days, if need be | | who | one person | can change hands | | decides | a step hook, in the browser | the engine, on the server | | needs | nothing | a workflow service |

Controls

| type | value | validation | |---|---|---| | text | string | minLength, maxLength, pattern, patternMessage — and inputType (text, email, tel, url): the phone keyboard and autofill | | textarea | string | minLength, maxLength | | number | number | min, max, integer | | date | "YYYY-MM-DD" | min, max, notFuture (on or before today, whenever it is filled in) | | datetime | "YYYY-MM-DDTHH:MM" | min, max | | checkbox | boolean | required = must be ticked | | dropdown | option id | — | | radio | option id | — | | multiselect | array of option ids | minItems, maxItems | | file | the stored file's path | accept (extensions), maxSize (MB) | | stepper | — | a journey across the top; every other control says which step it is on | | label | — | text with a heading, subheading or text preset | | list | — | saved records, with New / Edit / Delete |

A screen is a sheet, centred in the space it is given and shown at its design width: an elevated card in the app's theme (--xeplr-bg-tertiary, with --xeplr-border-primary round it) — white in a light app, the lighter grey in a dark one, with inputs in the theme's input colours. A screen whose style sets its own color / background keeps them everywhere, input boxes included, so dark ink on paper is never shown on a dark theme's input. Set --xeplr-factory-paper on the host to change every sheet. A screen with no fields (only lists) draws no sheet — the list is its own card.

Under the fields sits one footer, inset to line up with them: Back and Next, then "Step 2 of 3" with the save status under it ("Unsaved changes", "Saved"), then Cancel and Save. A screen without steps has the status, Cancel and Save alone.

A list is a single card, as tall as its rows: the title and + New sit in the table's own toolbar, paging appears only when there is more than one page, and "Nothing saved yet." shows under the title when there are no rows.

Ready-made fields

Twelve fields nobody should have to design twice, in the builder's palette under Common fields: Email, Phone, LinkedIn profile, Website, Age, Date of birth, Gender (Male, Female, Others), Country, Amount, Percentage, Yes / No, Postal code.

| field | made of | what it sets | |---|---|---| | Email / Phone / Website / LinkedIn profile | text | inputType (email, tel, url keyboards), a pattern with a plain-English message, a placeholder, a maxLength | | Postal code | text | letters, digits, spaces and -, up to 10 | | Age | number | a whole number, 0–130 | | Amount / Percentage | number | 0 or more / 0–100 | | Date of birth | date | notFuture | | Gender / Yes / No | radio, side by side | its options | | Country | dropdown | every country — stored as its ISO code (IN), shown by name |

A ready-made field is not a new control: its settings are copied into an ordinary one, where they stay editable, and props.preset remembers where it came from. Changing the catalogue never changes a form already made with it. The rules are the field's own, so the server enforces them like any others.

Any field can change kind at any time — the panel's Field type, at the top, lists the controls and the ready-made fields. The label, name, position, step and required are kept; whatever does not carry over is said ("Not carried over: the pattern"). A name that is already a column is never changed.

Forgot? A plain field whose label reads like a ready-made one ("LinkedIn URL" as a text box) gets an offer in its panel — Make it LinkedIn profile / Keep as is — and the builder's bar counts them ("1 suggestion"). They never stop a save or a publish; Keep as is sets preset: false and stops asking.

When the column has to follow. A change of kind that stays text (text → LinkedIn) publishes as usual. One that changes the column's kind (text → Age, a number) is a conversion: publishing first checks every saved value, across every company. If any will not fit, nothing changes and the publish says which ("age has 1 saved value that cannot become integer ("forty")"). If all fit, it asks — Convert and publish — and then converts, blanks becoming empty. A timestamp → date, or a dropdown moved to another table, is still refused.

In code: FIELD_PRESETS, presetFor, presetProps, suggestPreset(label), presetHints(doc), convertField(doc, id, target, controls, { lockedNames }), and conversionFor(table, from, to) / planTableChange(…, { confirmConvert }). The builder hook adds convertControl(id, target) and hints; publish(confirmDrop, confirmConvert).

Every control also takes style — see AUTHORING.md for the keys. Sizes are pixels at the screen's design width (default 800): a screen is shown at that size, never stretched, and scaled down only on a narrower display.

Steps

A stepper turns one screen into several: a bar of steps across the top, and one step's controls showing at a time. Type the steps as one line — Connect, Transform, Review — and click a step on the canvas to build it: what you drop while it is open belongs to it, and the canvas shows only that step. A control can be put on every step instead (a heading, a list), which is what one with no step means.

{ "id": "n7", "type": "stepper", "props": { "steps": [{ "key": "connect", "label": "Connect" }, { "key": "review", "label": "Review" }] } }
{ "id": "n8", "type": "text", "step": { "of": "n7", "index": 0 }, "props": { "name": "host", "label": "Host" } }

step sits on the node, beside x/y/w/h — not in props. The document stays flat, so a control is still moved, styled, checked and saved exactly as before; the step only decides when it is on screen. Renaming a step keeps its key, so the controls on it stay where they are.

A stepper also gives the front-end hooks a step method, run before every move — Next, Back and a click on the bar alike, so a rule cannot be walked around by clicking ahead:

class TaskHooks extends FactoryHooks {
  step(ctx) {                       // { from, to, direction, values, screen }
    if (ctx.direction === 'next' && ctx.from === 0 && !ctx.values.title) return false
    return super.step(ctx)          // false stays, a number goes there instead
  }
}

A screen with branches can rule steps out from the same hook — they stay on the bar, struck through, and Next and Back pass over them:

step(ctx) {
  if (ctx.from === 0 && ctx.values.kind === 'simple') {
    ctx.disable(['pricing', 'approval'])   // by key, label or number
    return 'summary'                        // and go straight there
  }
  ctx.enable()                              // every step applies again
  return super.step(ctx)
}

ctx.go, ctx.disable and ctx.enable act on that stepper; ctx.steps is the same for a screen with more than one (ctx.steps.disable('approval', stepperId)). ctx.steps is on save and get too, since a save is usually where an app learns which branch it is on. "Step 2 of 4" counts only the steps that apply.

A step is not a separate form. Every field is a column, is checked, and is saved, whichever step is showing — Save can be pressed on any step. Next holds at a step whose own fields are not filled in, and says which, rather than moving on and leaving a problem behind where nobody is looking.

Options: dropdown, radio group, multi-select

All three save an id and show a name, from either source:

{ "source": "static", "options": [{ "id": "ft", "name": "Full time" }, { "id": "pt", "name": "Part time" }] }
{ "source": "table",  "table": "departments" }

For a table, your fetchOptions({ table }) returns [{ id, name }] — in SQL terms, select id, name from departments. The factory never builds or runs a query.

Drafting screens with Claude

A screen can start from a spec — what is on it, in order — instead of coordinates:

import { screenFromSpec } from '@xeplr/ui-factory/model'

const screen = screenFromSpec({
  name: 'New employee',
  fields: [
    { label: 'First name', required: true },
    { label: 'Last name', required: true },
    { label: 'Department', type: 'dropdown', table: 'departments', required: true },
    { label: 'Employment type', type: 'dropdown', options: ['Full time', 'Part time'] },
    { label: 'Start date', type: 'date' }
  ],
  source: 'employees',
  list: true
})

It lays the fields out as a tidy one- or two-column form and returns a checked document. The same is available on the command line:

npx xeplr-factory screens entity.json -o dir   # an entity → list + edit screens, pages, server hooks + model  (--no-pages, --force)
npx xeplr-factory migration edit.screen.json   # preview the SQL Publish would run
npx xeplr-factory generate spec.json -o screen.json   # one screen from a spec
npx xeplr-factory validate screen.json      # every problem, with its path
npx xeplr-factory controls                   # controls, props and rules

AUTHORING.md is the full guide for writing screens: the spec, every control and rule, and how to choose between them.

The document

{
  "kind": "xeplr-screen", "version": 1,
  "id": "new_employee", "name": "New employee",
  "source": "employees",
  "units": "fraction", "aspect": 1, "width": 800,
  "nodes": [
    { "id": "firstName", "type": "text", "x": 0.04, "y": 0.15, "w": 0.4475, "h": 0.08, "z": 2,
      "props": { "label": "First name", "name": "firstName", "required": true, "validation": { "maxLength": 80 } } }
  ]
}

Layout is proportional, like @xeplr/ui-canvas dashboards: x and w are fractions of the width, y and h fractions of a page (width × aspect). The screen is shown at its design width; on a narrower display everything — positions and text — scales down together.

Three ways to use it

  1. Ready-made — <FactoryBuilder> and <FactoryScreen>.
  2. Your own design — useFactoryBuilder / useFactoryScreen hold all the state and actions; draw them however you like.
  3. Model only — @xeplr/ui-factory/model has no React: screenFromSpec, validateDocument, formSchema, validateValues, and the document operations (addControl, moveNode, setNodeProperty, …).

Theming

Styles are namespaced .xeplr-factory-* and read @xeplr/ui-account's theme variables (theme.css), each with a light fallback: --xeplr-accent, --xeplr-accent-text, --xeplr-text-primary, --xeplr-text-secondary, --xeplr-border-primary, --xeplr-bg-primary (the builder's plane), --xeplr-bg-tertiary (sheets, panels, popups), --xeplr-bg-hover, --xeplr-bg-overlay, --xeplr-input-bg, --xeplr-input-border, --xeplr-success, --xeplr-warning, --xeplr-danger. The screen, builder and popup follow the app's light or dark theme with the @xeplr/ui-table inside them — never a white sheet in a dark app.

Buttons, steps and list actions all show hover, a visible press, and a keyboard-only focus ring.

Try it

npm install
npm run dev     # http://localhost:19006 — Screen · List / Edit and Designer · List / Edit for the example employee, records kept in localStorage

Files

src/
  controls.js            ─ the control registry: palette, property panel contract, rules
  document.js            ─ create and edit screen documents
  validateDocument.js    ─ the document checker
  values.js              ─ values, validation, whether Save can go out, records and list columns, formSchema
  generate.js            ─ screenFromSpec, screensFromSpec (list + edit), entity names
  scaffold.js            ─ an entity → its screen JSON, .jsx pages (with front-end hooks), server hooks + model stubs
  hooks.js               ─ FactoryHooks: get / save / delete / actions — extend and call super
  presets.js             ─ ready-made fields (Email, Age, Gender…), changing a field's kind, label suggestions
  tableSchema.js         ─ a form → its table; the publish plan (create / add / widen / confirmed drops and conversions)
  remote.js              ─ createFactoryApi({ fetch }): the calls to @xeplr/factory's routes — fetch may be window.fetch or @xeplr/ui-account's authFetch (which returns parsed bodies); includes createEntity({ key, label })
  screenSource.js        ─ this app's screens as another designer wants them: describeScreen, loadScreens, keyFromName, editScreenOf (React-free)
  model.js               ─ everything above, React-free
  useFactoryBuilder.js   ─ builder controller
  useFactoryScreen.js    ─ screen controller
  useScreenSource.js     ─ { screens, screenEditor } for @xeplr/ui-workflow's Screen step
  designs/               ─ BuilderSample, ScreenSample, ScreenEditorSample, ControlView, ListView, Palette, PropertyPanel, styles.js, factory.css
  pages.jsx              ─ FactoryBuilder, FactoryScreen
bin/xeplr-factory.js     ─ screens / generate / validate / controls / schema
examples/                ─ employee.entity.json and what `screens` makes of it
dev/                     ─ the stand-in app for `npm run dev` (not published)

Tests

npm test

License

MIT