Warehouse & logistics equipment for the Pascal editor — pallets, racking, handling equipment, and a capacity readout.
Built against plugin API v1 (contract).
flowchart TD
subgraph host["Pascal host"]
core["@pascal-app/core<br/>nodeRegistry · useScene"]
editor["@pascal-app/editor<br/>panels · controls"]
viewer["@pascal-app/viewer<br/>R3F canvas"]
end
subgraph plugin["@ovurrsl/plugin-warehouse"]
manifest["index.ts<br/><i>manifest barrel — SSR-eager</i>"]
adapter["host-adapter.ts<br/><i>every host-schema read,<br/>behind runtime guards</i>"]
kinds["warehouse: node kinds<br/>schema · parts · slots"]
geom["geometry-builder.ts<br/><i>one merged mesh per shape</i>"]
panels["panels/<br/><i>inline styles, host CSS vars</i>"]
store["store.ts<br/><i>plugin-owned zustand</i>"]
end
manifest -->|"Plugin · NodeDefinition"| core
panels -->|"EditorHostPanel"| editor
kinds --> geom
geom -->|"renderer"| viewer
panels --> store
store --> kinds
adapter -.->|"guarded reads only"| core
kinds -.->|"area figures"| adapter
classDef owned fill:#1e3a8a22,stroke:#1e40af;
classDef guarded fill:#c2410c22,stroke:#c2410c;
class manifest,kinds,geom,panels,store owned;
class adapter guarded;
Capacity figures come entirely from this plugin's own schemas, so a host change cannot break them. Only the area figures cross the dashed line, and those degrade to "no data" rather than throwing.
This plugin is meant to keep working when it is dropped into a newer editor. That shapes what it is allowed to depend on:
| Layer | Examples | Treatment |
|---|---|---|
| Contract | Plugin, NodeDefinition, EditorHostPanel, apiVersion |
Depend freely. Version-guarded — loadPlugin throws on a mismatch, so a break is loud. |
| Non-contract exports | useScene, useEditor, host UI controls |
Import from package barrels only, never deep paths. |
| Host node schemas | slab.polygon, level.children |
Never read directly. Everything goes through src/host-adapter.ts behind runtime guards. |
| Owned outright | node schemas, geometry, capacity maths | Plugin-local. No host coupling. |
The split that matters: capacity figures come entirely from this plugin's own node schemas, so they cannot be broken by a host change. Only the area figures read host slabs, and those degrade to "no data" rather than throwing if the slab schema moves.
src/compat.ts prints one console block at panel mount naming which optional reads are live — so a host change reads as ❌ slab polygon — 0/7 readable instead of an unexplained zero.
src/
index.ts manifest barrel — SSR-eager, keep it thin
plugin-id.ts identity constants (PLUGIN_ID is persisted user data)
host-adapter.ts every host-schema read, behind runtime guards
compat.ts boot-time probe + console report
catalog.ts catalog data table (sections + items)
store.ts plugin-owned zustand: panel tab, stats scope
panels/
catalog-panel.tsx the rail panel — Catalog / Stats tabs
- Every dimension is metres. Published warehouse specs are millimetres — divide by 1000. Never write a bare dimension literal greater than 100.
@pascal-app/*are peer dependencies. A pinned copy creates a secondnodeRegistrysingleton and the kinds silently never resolve.- Node kinds are namespaced
warehouse:. Duplicate kinds warn in dev but throw in production.
The package is self-contained — its tsconfig.json extends nothing, its dependencies pin real versions, and it bundles no assets (panel icons are Iconify names the host resolves). It works identically as a monorepo workspace and as a git dependency, which is what lets it be developed here and shipped from its own repository without a rewrite.
Three edits in the host app:
1. Depend on it
Pin a commit SHA rather than a branch. A branch pin means bun install can
change the plugin under a host that did not ask for it, and the symptom — a
kind that used to register and now does not — points at the host.
github:does not work for a private repository, which is worth recording because the failure names the wrong thing. Bun resolves that shorthand throughapi.github.com/repos/.../tarball/…and fetches it unauthenticated, so a private repo answers404and the install fails withfailed to resolve. Bun 1.3 has no token setting that changes this —GITHUB_TOKEN,GH_TOKEN,BUN_CONFIG_TOKEN,BUN_AUTH_TOKEN,NPM_CONFIG_TOKENand~/.netrcwere each verified to make no difference, andgit+https://github.com/…is rewritten to the same tarball URL.The
git+ssh://git@github.com/owner/repo.git#<sha>form is what makes bun shell out togitinstead, and git authenticates the way it already does forpush— through the credential helper. On a machine wheregit pushworks this installs with no extra setup and no SSH key is required, despite the scheme name.
2. Transpile it — the package ships TypeScript source, not built JS:
// next.config.ts
transpilePackages: ['@ovurrsl/plugin-warehouse', /* … */]3. Register it, before the bootstrap kicks off discovery
import { warehouseCatalogPanel, warehousePlugin } from '@ovurrsl/plugin-warehouse'
extendPluginDiscovery(async () => [warehousePlugin])
registerEditorHostPanel(warehouseCatalogPanel)Use extendPluginDiscovery, never setPluginDiscovery — the latter replaces the whole chain and silently drops every other plugin.
This requires a host you can edit. Plugin API v1 has no URL-manifest loader: discovery is a function somebody has to call, and the bootstrap module is the only call site. So the plugin drops into your own build or fork of the editor, but not into a hosted editor whose source you do not control.
packages/editor/src/lib/scene-clipboard.ts validates clipboard nodes with AnyNode.parse. AnyNode is the host's hand-maintained union of built-in kinds, so a plugin-contributed kind can never be a member of it — the registry validates those against def.schema at runtime instead. The result: capabilities.duplicable cannot be honoured for any plugin, including the first-party pascal:trees pack, which declares it and fails identically.
Nothing in the plugin API works around it. DuplicableConfig exposes only subtree and prepareSubtreeClone, and both run after the parse that throws.
The host needs a fallback to the registry schema, at both call sites (the remapNodeReferences return, and the system-clipboard parse in readClipboardPayload):
function parseClipboardNode(candidate: unknown): AnyNode {
const builtin = AnyNode.safeParse(candidate)
if (builtin.success) return builtin.data
const type = (candidate as { type?: unknown } | null)?.type
const definition = typeof type === 'string' ? nodeRegistry.get(type) : undefined
if (definition) return definition.schema.parse(candidate) as AnyNode
throw builtin.error
}Built-in behaviour is unchanged — AnyNode is tried first — and an unknown type still raises the original error.
Submitted upstream as pascalorg/editor#547, with a test that registers a definition the way a plugin does and fails without the fallback.
Until that lands it is a local patch to the host checkout. It is not part of this package and does not travel with it: pulling a newer editor reverts it and duplicate silently starts failing again — with the error pointing at the plugin, so anyone debugging it without this note looks in the wrong place.
The package is written to make this a copy, not a port:
cp -r packages/plugin-warehouse ../plugin-warehouse
cd ../plugin-warehouse && git init && bun install
bun run check-types && bun testNothing needs editing. .github/workflows/ci.yml is inert inside the monorepo (GitHub only reads workflows from a repo root) and starts running on the first push.
Then point the host at the new repo by swapping the dependency to the git URL from step 1.
To keep it extractable, four rules hold for anything added here:
- Import from package barrels only —
@pascal-app/core, never@pascal-app/core/src/.... Deep paths are outside theexportsmap and break on the first release. - No workspace-only dependencies — nothing resolved by
"*"or by a path inside the editor monorepo. - No host assets.
/icons/shelf.webpexists in the editor app'spublic/, not in a consumer's. Icons are Iconify names; anything else must be bundled or inlined. - No Tailwind classes in panel markup. Panels style themselves with inline styles resolving the host's own CSS variables (
src/panels/styles.ts), which is why installing needs no@sourceline. Tailwind v4 does not scan symlinked directories, and a git dependency is always a symlink into bun's store — so a utility class written here is silently never compiled. There is no error; the panel simply renders unstyled. Verified both ways: pointing@sourceat the store's real path emits the class, pointing it at the symlink emits nothing, and neither a**/*glob norbun install --backend=copyfilechanges it. The first-partyplugin-treespack has the same problem.
In dev the console prints:
[pascal:registry] + N discovered plugin(s)
[ovurrsl:warehouse] host compatibility ✅
That is the anchor. If those lines are missing, fix the wiring before debugging anything else.
bun run check-types # tsc --noEmit
bun test # bun's runner
bunx biome check . # lint + format, `--write` to fix
bun run scripts/update-readme.mjs # regenerate the blocks below
bunx git-cliff --output CHANGELOG.md- Drop npm version updates — Dependabot cannot maintain bun.lock (
9e9e849) - CI, CodeQL, changelog automation and repo scaffolding (
a5f6e2b)
- Refresh generated README blocks [skip ci] (
b56d575) - The handling-vehicle plan, with what it refuses to guess (
aca7840) - Refresh generated README blocks [skip ci] (
502f0f2) - Refresh generated README blocks [skip ci] (
88924fc) - The vehicle research, and the architecture it should have been written for (
d5a9b87) - Refresh generated README blocks [skip ci] (
2b4bf37) - Refresh generated README blocks [skip ci] (
c639e36) - Refresh generated README blocks [skip ci] (
a3faffc) - Refresh generated README blocks [skip ci] (
3863fa1) - Refresh generated README blocks [skip ci] (
4b9066f) - Refresh generated README blocks [skip ci] (
9829b0c) - Refresh generated README blocks [skip ci] (
b9a6e43) - Refresh generated README blocks [skip ci] (
ee3ad32) - Refresh generated README blocks [skip ci] (
a488732)
…
Full history in CHANGELOG.md.