User feedback: the move tool shouldn't render a separate translucent
preview. The actual mesh (registered with sceneRegistry through the
kind's renderer) should follow the cursor — that's what's already
happening via useLiveTransforms + the imperative position.set on the
registered Object3D.
Removed from MoveRegistryNodeTool:
- The lazy `def.preview` load + Suspense-wrapped <Preview> render.
- Now only CursorSphere shows as the aim indicator. The shelf's
actual rendered mesh follows the cursor via:
- `useLiveTransforms.set(...)` triggers ShelfRenderer to re-render
with `position={liveTransform.position}`.
- `sceneRegistry.nodes.get(node.id).position.set(...)` is a
defensive imperative update so motion feels snappy.
Added: `sfx:grid-snap` emit on grid-cell cross, matching the placement
tools' behavior. Move now sounds like placement.
The `preview` slot on NodeDefinition stays — still used by ShelfTool
for the placement cursor (where no real mesh exists yet). Phase 4 may
consolidate placement preview with the renderer too.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User feedback: the move tool's CursorSphere is just a vertical line,
hard to tell what you're moving. Placement gets a translucent shape
that follows the cursor; move should too.
NodeDefinition.preview?: () => Promise<{ default: ComponentType<{ node }> }>
opt-in lazy component that renders a translucent ghost of the node.
Used by:
- The placement tool (ShelfTool) — renders the preview at the cursor
position so the user sees the shape they're placing.
- The move tool (MoveRegistryNodeTool) — renders the preview at the
drag target alongside the CursorSphere. Plus the original node is
also dragged via live transforms, so the user sees both: the actual
node moving + a translucent ghost at the same spot.
Implementation:
- New nodes/shelf/preview.tsx: ShelfPreview component. Renders the
same shape as ShelfRenderer but `transparent: true, opacity: 0.5`.
- shelfDefinition.preview = () => import('./preview').
- ShelfTool's placement preview now uses <ShelfPreview node={defaults} />
instead of an inline copy of the box geometry.
- MoveRegistryNodeTool lazy-loads `def.preview` (cached by loader,
Suspense-wrapped). If a kind doesn't define `preview`, only the
CursorSphere shows — matches today's behavior.
Phase 4 may merge `preview` with `renderer` behind an `opacity` prop
so kinds don't duplicate JSX between the solid and translucent
versions; until then defining `preview` is opt-in and one extra file.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Shelf had the floating action menu (move/delete) showing thanks to
the previous registry-driven selection commit, but clicking move
did nothing and duplicate silently failed. Two hardcoded chains:
1) FloatingActionMenu.handleMove guarded `setMovingNode` behind a
hardcoded `node.type === 'item' || ... || node.type === 'spawn'`
chain. Added `|| isRegistrySelectable(node.type)` so any
registry kind triggers the move flow.
2) MoveTool dispatched per-kind components (MoveItemContent,
MoveColumnTool, MoveWallTool, ...). The default fallback
mounted MoveItemContent, which assumes the node is an ItemNode
with asset/scale/metadata — crashes for shelf. Added a generic
MoveRegistryNodeTool (kind-agnostic clone of MoveColumnTool):
pure position+rotation drag with grid snap, re-parses orphan
re-creates via `nodeRegistry.get(kind).schema.parse(...)`.
MoveTool dispatches to it for any `nodeRegistry.has(movingNode.type)`
before the MoveItemContent fallback.
3) FloatingActionMenu.handleDuplicate had a hardcoded
`node.type === 'door' ? DoorNode.parse(...) : ...` chain. Added
a registry-driven fallback after it:
`const def = nodeRegistry.get(node.type); duplicate = def.schema.parse(duplicateInfo)`.
Then the createNode + setMovingNode branches also augment with
`nodeRegistry.has(duplicate.type)` so the new shelf gets
created in the scene and handed off to the move tool for
placement.
After this:
- Click shelf → move icon in floating menu → cursor follows mouse,
click to place at new position.
- Click shelf → duplicate icon → new shelf appears, offset by (1,0,1),
handed to move tool so the user can position it.
Phase 4 will collapse MoveRegistryNodeTool with the per-kind movers
once they all reduce to the same position+rotation shape, and read
`capabilities.movable` to gate handleMove instead of the OR chain.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two concerns from the spike:
1) Selection / floating-action-menu had hardcoded kind lists scattered
across 4 files. Adding 'shelf' to each one per migration was the
wrong abstraction — the user's question "did you make it generic
from the noderegistry?" was the right one. Done now.
Added to @pascal-app/core/registry:
- getSelectableKinds(): string[] — returns all registered kinds
whose definition declares `capabilities.selectable`.
- isRegistrySelectable(kind): boolean — predicate for OR-chains.
Refactored hardcoded sites to merge registry kinds at runtime,
keeping legacy hardcoded lists intact so existing kinds keep
working unchanged:
- editor SelectionManager: 4 subscription loops (enter/leave/click)
+ structure.isValid + getSelectionTarget — all augment with
registry kinds. Phase 6 deletes the hardcoded lists.
- viewer SelectionManager: subscription loop + SelectableNodeType
broadened with `(string & {})` to accept registry kinds.
- floating-action-menu: ALLOWED_TYPES OR'd with isRegistrySelectable.
- Removed the manually-added 'shelf' entries from previous commit
857ddd4; they were redundant once the registry-driven path landed.
Future built-in nodes that declare `capabilities.selectable` get
click-selection + hover + the floating action menu (move/delete
icons) for free, no editing of these 4 files.
2) Spawn parity is signed off. Drop the
NEXT_PUBLIC_USE_REGISTRY_FOR_SPAWN flag entirely; spawn registers
unconditionally in builtinPlugin.nodes. Restored SPAWN_COLOR to
the original #22c55e green (was #ef4444 red as a Phase 2
verification marker).
Pre-existing typecheck errors in editor (ceiling/fence/slab tree-node,
scene.ts buildingId) are unchanged.
630 tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Shelf renderer was emitting `shelf:click` / `shelf:enter` / `shelf:leave`
via useNodeEvents from the previous commit, but no listener subscribed
— the SelectionManager components (one in editor, one in viewer) each
maintain hardcoded allTypes arrays that didn't include 'shelf'.
Adds 'shelf' to:
- editor/selection-manager: 5 allTypes arrays (one per selection strategy
— structure, structure-hover, furnish, site, deselect-also-listens-to).
- viewer/selection-manager: the SelectableNodeType union + allTypes
array.
Shelves can now be clicked / hovered in the 3D canvas and the
selection state updates correctly.
The hardcoded arrays are exactly the kind of cross-cutting friction
the registry is supposed to eliminate. Phase 4 should derive these
lists from `nodeRegistry.entries().filter(d => d.capabilities.selectable)`
so adding a new kind doesn't require editing two files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Matches the wall / slab / curve-wall tool pattern: emit
sfx:grid-snap only when the snapped position changes (cursor crosses
a grid cell), not every frame of mouse movement within the same cell.
Tracked via a `previousSnapRef` per tool, reset when the tool
re-activates.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-visible follow-ups after first running the Phase 2 spike.
Spawn tool now matches legacy UX:
- CursorSphere from @pascal-app/editor for the placement indicator
(ring + line + tool-icon tooltip) — was a plain sphere mesh.
- Emits sfx:structure-build on commit + setTool(null) + setMode
('select') to exit build mode, matching legacy spawn-tool.
Shelf tool placement:
- Emits sfx:structure-build on commit.
- Cursor preview now shows top board + brackets (was just the top),
matching what gets placed.
Shelf selectable from the 3D canvas:
- ShelfEvent type added to @pascal-app/core/events/bus.
- 'shelf' added to NodeConfig in useNodeEvents.
- ShelfRenderer wires `useNodeEvents(node, 'shelf')` handlers onto
every mesh. Clicks/hovers now bubble through the editor's selection
manager and update useViewer.selection.
Shelf appears in the sidebar:
- ShelfTreeNode component (mirrors spawn-tree-node's shape +
selection/hover/rename wiring; lucide Layers icon).
- TreeNode dispatcher adds a `case 'shelf':` arm.
Framework changes:
- @pascal-app/editor exports CursorSphere alongside triggerSFX.
- @pascal-app/nodes now declares @pascal-app/editor as peer/dev dep.
Pre-existing typecheck errors in @pascal-app/editor (ceiling-tree-node,
fence-tree-node, slab-tree-node, scene.ts) are unchanged — present on
main and not introduced by this commit.
630 tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three concrete bugs surfaced when first-running the spike in community:
1) NEXT_PUBLIC_USE_REGISTRY_FOR_SPAWN flag never detected:
The previous readEnvFlag used dynamic bracket access
(`env?.[name]`), which Next.js / Turbopack does NOT substitute at
build time. Only literal `process.env.NEXT_PUBLIC_FOO` references
get inlined into the client bundle. Switched to literal access
plus a `typeof process` guard. Spawn now toggles via the flag as
designed.
2) Shelf cursor appeared offset from the mouse:
The cursor mesh lives inside the ToolManager's building-local
group, but the tool was setting `cursorRef.current.position` to
level-local coordinates (computed via `worldToLocal(level)`).
Result: cursor shifted by (building-pos − level-pos) in worst
case. Switched cursor display to use `event.localPosition`
(already building-local) with grid snap — matches the legacy
spawn-tool pattern. The commit path keeps the worldToLocal(level)
conversion since the shelf node's `position` field is stored
relative to its level parent.
3) Shelf rendered invisibly after click (suspected):
The renderer used a useEffect-swap pattern where it mounted an
empty <group> and imperatively added Three.js children from a
buildShelfGeometry() Group. Plausibly fragile under StrictMode
double-invoke or fast HMR. Switched to inline R3F JSX — top
board + brackets as plain <mesh> primitives. The pure geometry
function still exists in geometry.ts for tests and AI-authored
consumers; renderer just doesn't go through it.
Diagnostics added (dev-only; removed once spawn parity ships):
- `[shelf] placed <id> level-local <pos> parent <levelId>` on click
- `[shelf] rendered <id> at <pos>` on mount
Also: types: ["node"] in nodes/tsconfig.json so the typeof process
guard typechecks cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small follow-ups after the first user-visible spike test.
shelf cursor stuck at origin:
The placeholder cursor is a translucent slab inside a <group ref>.
The tool was calling setCursor(state) on every grid:move, which
triggered a React re-render. R3F re-applies props on each render,
and since the <group> had no `position` prop, the implicit default
[0,0,0] clobbered the imperative `position.set` from the previous
tick. Result: cursor stuck at level origin instead of following
the mouse.
Fix: drop the unused useState entirely. Pure imperative position
updates via the ref. No re-renders, no clobbering. (The legacy
spawn tool gets away with the same pattern because CursorSphere
buffers its position prop differently — but for the spike, the
simpler model is fine.)
shelf snap step:
Was 0.1 (10cm) — much finer than the editor's default 0.5 grid.
Bumped to 0.5 (matches the toolbar grid setting and the legacy
half-meter snap pattern used by spawn/column).
registry-driven spawn renderer paints red:
Temporary verification marker. With NEXT_PUBLIC_USE_REGISTRY_FOR_
SPAWN=1, spawns rendered via the new path now appear in #ef4444
red. Legacy renderer stays in #22c55e green. Easy visual check
for "which dispatch path is this spawn on?" Reverted in the PR
that signs off spawn parity (alongside legacy file deletion).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Verification anchor for "which path is running this kind?":
[pascal:registry] loaded pascal:core v1 (1 kinds: shelf)
prints in the browser dev console on app boot. Empty array means every
kind is on the legacy dispatch path. A kind in the array means the
registry-first NodeRenderer / ToolManager shims own it (legacy path is
short-circuited).
Also exposes `globalThis.__pascalNodeRegistry` in dev so you can run
ad-hoc inspections like `__pascalNodeRegistry.has('spawn')` from the
console. Production builds skip both.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The first time registry-driven nodes actually run in the editor.
Spawn migration (under NEXT_PUBLIC_USE_REGISTRY_FOR_SPAWN flag):
- New packages/nodes/src/spawn/ folder with renderer, tool, schema
(re-exported from core), parametrics, definition, index.
- Spawn definition appended to builtinPlugin.nodes only when the flag
is set. With the flag off, the Phase 0 shims fall through and the
legacy SpawnRenderer / SpawnTool keep ownership.
- New no-props SpawnTool reads activeLevelId from useViewer directly,
matches legacy placement behavior (half-meter snap, singleton-per-
level, replace-on-reclick).
- Structural parity test (9 cases) validates definition shape +
schema identity. Pixel-diff defers to Phase 4 alongside more nodes.
New shelf node (no legacy — registered unconditionally):
- ShelfNode schema in core/schema/nodes/shelf.ts (hand-maintained
AnyNode union for now; Phase 6 derives the union from the registry
and moves the schema fully into nodes/shelf/).
- packages/nodes/src/shelf/ folder: pure geometry builder
(buildShelfGeometry returns a Three.js Group of top board +
brackets), R3F renderer that mounts the built group, no-props
placement tool, parametrics descriptor (width/depth/thickness/
height/bracketStyle/color), definition with surfaces.top stackable
surface for future stacking, and presentation metadata for the
palette.
- 13 unit tests across schema bounds and geometry behavior.
- Palette wiring: 'shelf' added to StructureTool union + an entry in
the structure-tools array (placeholder icon, replaced in Phase 4
when palette is registry-driven).
Framework changes:
- @pascal-app/viewer now exports useNodeEvents from its public barrel
so node bundles in @pascal-app/nodes can subscribe to node-specific
pointer events. (Used by spawn renderer; shelf renderer skips it
for now since useNodeEvents has a hardcoded kind list — Phase 4
generalizes it via the registry.)
- @pascal-app/nodes gains @pascal-app/viewer as a peer + dev dep so
node bundles can import from it.
630 tests pass across 76 files (22 new this phase). Editor app
continues to ship green with both legacy spawn and the new shelf
node co-existing through the Phase 0 dispatch shims.
To validate end-to-end in dev:
- bun dev:community → open editor → click 'Shelf' in structure
toolbar → click to place. Confirms full registry path
(NodeRenderer dispatch + ToolManager dispatch + sceneRegistry
byType Proxy).
- Set NEXT_PUBLIC_USE_REGISTRY_FOR_SPAWN=1, restart dev, place spawn
→ visually identical to legacy. Confirms parity.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Builds a 15200-node fixture (50×100 wall grid + 8000 hosted doors +
200 sparsely-indexed slabs) and runs `cascadeDirty` 1000 times with
warm-up. Reports p50/p95/p99/mean/max in ms.
Runs via `bun run bench:registry` from the core package.
Today: p95 measured at ~0.002ms — three orders of magnitude under the
Phase 1 gate of 2ms. Headroom is substantial; we'll only revisit this
if Phase 3 wall introduces `linkedBy: 'endpoint-match'` and pushes the
inner cascade past the gate.
Not wired into CI for v1 — regressions reviewed manually before phase
gates. Output is JSON so a future CI step can diff against a baseline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure orchestrator in core, thin React wrapper in editor:
- `core/services/drag-session.ts` — `createDragSession(action, scene,
options)` returns an imperative session with `start / move / commit
/ cancel / dispose / isActive / getDraft`. Pauses history on start,
resumes on terminate. Per-move runs preview → snap → apply, then
cascades dirty marks via the relations resolver (deduped across
ticks). Re-entry guard, idempotent dispose, fires onCommit/onCancel
callbacks. All tested in bun:test — no React needed.
- `editor/src/hooks/use-drag-action.ts` — wraps the session with the
editor's grid-event emitter and an Esc-to-cancel keyboard listener.
Builds a `SceneApi` once via `createSceneApi(useScene)` at module
init. The hook itself is small enough to read top-to-bottom; all
behavior lives in the session.
Tests (13 cases) cover the hard parts: history pause/resume bracket,
explicit cancel restoring all touched nodes, dispose mid-drag, commit
returning false short-circuiting to cancel, snap callback wired in,
re-entry rejected, deduped dirty-mark across multiple move ticks,
hosts cascade from the registry firing in apply.
No callers yet — Phase 2 column and shelf tools are the first
consumers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure constraint math built on the registry's `MovableConfig`:
- `resolveMovable(node)` — reads `def.capabilities.movable`, runs the
optional `override(ctx)` callback (returning null falls back to the
base config). Returns null when the kind isn't movable.
- `applyAxisLock(current, target, axes)` — projects 3D motion onto
the allowed axes; locked components fall back to current.
- `moveToward(node, current, target, options?)` — top-level helper
combining axis lock + (optional) grid snap. Returns null when the
node is not movable.
- `movePlanToward(node, currentY, current, target, options?)` —
X/Z-plane convenience for floor/plan-view placement.
- `isMovable(node)` — predicate for tools/UI gating.
Tests cover override callback, null-override fallback, axis lock
permutations, grid-snap on/off, and the 2D plan convenience.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure math, no React, no scene access. Three primitives plus a facade:
- `snapScalar(value, step)` / `snapPointToGrid(point, step)` /
`snapVec3ToGrid(point, step)` — regular grid snapping. Default
step 0.25m matches the editor's wall tool.
- `snapPointToAngle(from, cursor, angleStep, gridStep?)` — locks a
cursor to the nearest angle multiple from a fixed point, preserves
distance, optionally re-grids the projected point. Default angle
step π/12 (15°).
- `snapAngleToList(angle, list, tolerance)` — snaps a free angle to
the nearest entry in a fixed list (e.g. 0/45/90/135) within a
tolerance; returns the original angle otherwise. Handles wrap.
- `snapServices` facade — `grid.*` + `angle.*` namespaces. Stable
contract that `DragAction.snap` callbacks receive. Phase 3 ports
the existing `snapWallDraftPoint` family from
`editor/.../wall-drafting.ts` under a `wall.*` namespace.
17 unit tests cover the math + the facade pass-through. No existing
callers re-wired yet — Phase 2 column/shelf tools are the first
consumers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First service in `core/src/services/`: pure, no React or R3F, takes
SceneApi + node data, returns results.
Exports:
- `canAttach(childId, hostId, scene)` — validates host attachment.
Rejects self-host, cycles (host's ancestor chain contains child),
chains past MAX_HOST_DEPTH (6), and host kinds outside the child
def's `capabilities.hostable.parents` allowlist. Returns a typed
AttachError discriminated union so callers can render specific
messages.
- `getSurface(host)` / `getTopSurfaceHeight(host)` — reads
`def.capabilities.surfaces` from the registry; resolves
function-valued heights with the node.
- `clampYToHostTop(host, y)` — convenience for placement code.
- `pickHost({ point, candidates, placedKind, hitTest? })` — given
spatially pre-filtered candidates, returns the first hostable.
The runtime is responsible for spatial filtering; this function
stays pure.
MAX_HOST_DEPTH = 6: the explore earlier found today's editor has no
cap on item-on-item nesting. Cap is bounded by hostable depth, not
total tree depth (sites/buildings/levels don't count).
17 tests cover all rejection paths + happy paths + function-valued
surface heights.
Re-exported from `@pascal-app/core` via a new `services/` barrel.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure traversal that walks a node's declared `relations` and returns the
full set of IDs that should be marked dirty alongside it. Two
implementations:
- `cascadeDirty(id, ctx)` — follows `hosts` (matching children) and
`affectsSpatial` (via injected spatialQuery). Phase 3 will add
`linkedBy: 'endpoint-match'`.
- `collectDescendants(id, ctx)` — pure subtree traversal for
`cascadeDelete: 'descendants'` and subtree deletion tools.
Both bounded by maxDepth (default 16) and visited-set so cycles in
bad data can't loop forever.
Context-based design: spatialQuery and childQuery are injected, so the
resolver itself stays pure — the DragAction runtime can plug in
spatialGridManager-backed queries; tests pass stubs.
Today, registry has no kinds → cascadeDirty(id) always returns just
{id}. No behavior change. Phase 3 wall is the first real consumer.
11 unit tests cover empty/no-relations baseline, hosts cascade, depth
limit, spatial query, missing spatialQuery branch, cycle protection,
childQuery override, descendant collection.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds optional `presentation` field for tool palette metadata: sentence-
case label, optional description, icon (iconify reference, inline SVG,
or lazy React component), palette section override, sort order, and a
`hidden` flag for container kinds that exist but should not appear in
the palette.
Consumer arrives in Phase 4 (auto-derived palette buttons) — defining
the type now means Phase 2's `column` and `shelf` definitions ship with
the field already populated, no later round-trip.
Iconify is the encouraged form for built-ins and AI-authored nodes:
matches the @iconify-react setup the editor app already uses, and AI
emits a name string from a curated list (no asset upload step needed).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Without a "test" script the package was invisible to `turbo test` —
private-editor's CI runs `bun run test` (= `turbo test`), which only
walks workspace packages that declare a test runner. Mirrors mcp and
nodes which already do this.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before mounting any legacy build tool, ToolManager checks whether the
active tool's kind has a registered NodeDefinition with a tool
contribution. If yes, the registry tool wins; the legacy tool map and
special-cased spawn/column/elevator branches are skipped for that kind.
Lazy-loaded via React.lazy (cached by loader) and wrapped in Suspense.
Today the registry is empty, so useRegistryTool is always false and
every code path renders unchanged. The moment a kind registers (Phase
2+), its registry tool takes over without further edits here.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two additions plus a viewer JSX rewire:
- legacy-system.tsx: <LegacySystem kind="..."> wrapper that renders its
children only when nodeRegistry.has(kind) is false. Lets one wrapper
cover all legacy systems for a kind (door has DoorSystem and
DoorAnimationSystem — both belong to 'door' so they yield together).
- registered-systems.tsx: <RegisteredSystems /> iterates the registry,
filters entries that contribute a system, sorts by system.priority
(default 5; e.g. wall mitering at 8 runs after door cuts at 3),
mounts each via React.lazy. Today empty registry = renders nothing.
- viewer/index.tsx: every existing per-kind system is wrapped in
LegacySystem. RegisteredSystems is mounted alongside.
With the registry empty (Phase 0), every LegacySystem passes through
unchanged and RegisteredSystems is a no-op — zero behavior change.
Once a kind registers in Phase 2+, its legacy systems yield and its
registry-contributed system runs in their place.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
NodeRenderer now checks the registry first. Registered kinds load
their renderer module via React.lazy (cached by RendererSource so the
Suspense boundary is stable across re-renders). Unregistered kinds
fall through to the legacy chain below.
Today the registry is empty (Phase 0 builtinPlugin.nodes is []), so
every node still hits the legacy chain — no behavior change. The
moment a kind registers in @pascal-app/nodes (Phase 2 onward), the
registry path takes over without further edits here.
GLB / instanced-GLB RendererSource kinds are typed but not yet
honored — they get their built-in renderers in Phase 5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
byType was a hardcoded object keyed by the built-in node kinds. With
the registry, kinds can come from @pascal-app/nodes (or future
plugins) — so byType now wraps a Map via a Proxy that auto-creates an
empty Set the first time any kind is touched.
Built-in kinds are still pre-seeded at module init so the fast path
(no Proxy trap) is preserved. clear() iterates the backing Map.
useRegistry's `type` parameter widens from `keyof typeof byType` to
`KnownNodeKind | (string & {})` — preserves autocomplete for
built-ins while accepting plugin-supplied kinds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds apps/editor/lib/bootstrap.ts that calls loadPlugin(builtinPlugin)
as a module-side-effect on first import. Imported from scene-loader.tsx
so it runs on the client side where the editor mounts.
Idempotency guard handles HMR re-execution (would otherwise throw on
duplicate registerNode). For the empty plugin this commit, the entire
call is a no-op — included now so future commits that add real node
kinds only need to push them onto builtinPlugin.nodes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Companion changes for the new nodes package: bun.lock entry from
`bun install`, and a Biome-auto-sort of the registry barrel.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New workspace package that owns built-in node bundles, one folder per
kind. Today the plugin is empty — no behavior change. Future commits
will add column/, shelf/, wall/, etc. and append each definition to
builtinPlugin.nodes.
The package depends on core (registry types) at v1; viewer, editor,
react, three are declared as peer deps so future node bundles can use
them without bumping their own version on every monorepo bump.
External plugins land as separate packages with the exact same shape
— this package is the dogfooded reference.
Tests: builtinPlugin shape + loadPlugin succeeds with zero kinds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a Biome noRestrictedImports rule scoped to core/, viewer/, and
editor/ packages. Framework code must reach node-specific behavior via
nodeRegistry.get(kind), never via direct import. The @pascal-app/nodes
package doesn't exist yet (lands in the next commit) so the rule is a
no-op today; codifies the boundary ahead of node bundles.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces the @pascal-app/core/registry surface that future node-bundle
packages (and external plugins) will use to register node kinds with the
host. No runtime behavior changes — registry is empty until subsequent
PRs populate it.
- types.ts: NodeDefinition, Capabilities, Relations, DragAction, Plugin,
ParametricDescriptor, Affordance, SceneApi, NodeRegistry. Capability
configs accept an override escape hatch; additive-only after v1.
- registry.ts: nodeRegistry singleton, registerNode, async loadPlugin.
Validates kind, schemaVersion, apiVersion; rejects duplicate kinds.
- scene-api.ts: createSceneApi factory wrapping the scene store with
copy-on-write snapshot semantics for pauseHistory/restore/resumeHistory.
- index.ts: barrel re-exporting the public surface.
- core/index.ts + package.json: export * from registry and add the
./registry subpath so consumers can import either way.
Tests (27 cases, all bun:test): registry registration / validation /
plugin loading; SceneApi read/write/dirty/history; lazy snapshot capture
with update/upsert/delete reversal via restore and restoreAll.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The file imports useEffect/useRef from React, which Next.js RSC builds
flag as client-only. Other core systems (e.g. elevator-runtime-system)
use useFrame from @react-three/fiber and slip through, but this one
needs the directive explicitly.
Fixes Turbopack build failure in private-editor community app:
"You're importing a module that depends on useEffect into a React
Server Component module."
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>