Review a PR against the Pascal architectural rules — package boundaries (core/viewer/editor/nodes), the registry-driven composition model (def.geometry / def.renderer / def.system), legacy-dispatch regressions, the slots + world-scale-UV convention for new nodes/geometry, hook hygiene (useEditor/useScene/useViewer), and selector performance. Use when the user asks to review a PR, audit a branch, or check that changes respect the codebase's architecture.
npx skills add https://github.com/pascalorg/editor --skill review-architecture
Architectural review for Pascal PRs. The user will provide a PR URL, branch name, or ask to review the current branch.
Read these before reviewing any diff. They are the source of truth, not your training data:
wiki/architecture/layers.mdwiki/architecture/systems.md — core systems vs viewer systems, what each may dowiki/architecture/renderers.md — renderer responsibilities and prohibitionswiki/architecture/tools.md — editor tools live only in apps/editor/components/tools/ or packages/nodes/src/<kind>/wiki/architecture/viewer-isolation.md — viewer must stay editor-agnosticwiki/architecture/node-definitions.md — the three-checkbox composition model (geometry / renderer / system)wiki/architecture/plugin-authoring.md — public contract for external node packsRequired on every review. Read the remaining pages on demand when the diff touches their subject area:
wiki/architecture/selection-managers.mdwiki/architecture/scene-registry.mdwiki/architecture/spatial-queries.mdwiki/architecture/node-schemas.mdwiki/architecture/inspector-field-limits.md — no arbitrary min/max on dimension fields. Read whenever the diff adds or edits parametrics.ts, a kind panel.tsx, or <SliderControl> bounds.wiki/architecture/events.mdwiki/architecture/interaction-scope.md — the interaction state machine + the unified snapping/modifier convention. Read whenever the diff touches a tool, a move-tool / selection / endpoint / reshape file, lib/interaction/**, lib/snapping-mode.ts, or use-interaction-scope.If anything in the diff looks like a new dispatch surface or registry concept, also skim the live charter at plans/editor-node-registry.md (in the private-editor repo) — it owns the current contract and which kind sits at which migration stage.
# If the user gave a PR URL or number:
gh pr diff <pr-number-or-url>
# If reviewing the current branch:
git diff main...HEAD
Also list changed files so you can map each to the relevant rule:
gh pr view <pr> --json files --jq '.files[].path'
# or
git diff --name-only main...HEAD
For every new file, new type, new store field, or new exported helper introduced by the diff, answer one question: which package does this belong to — core, viewer, editor, or nodes? If the answer is "editor" but the code lives in packages/core or packages/viewer (or vice versa), or if kind-specific code lands anywhere other than packages/nodes/src/<kind>/, flag it as a blocker. This is the most common and most damaging class of violation, and the checklist below won't reliably catch it on its own — do this pass explicitly.
packages/core — domain data + pure logic.
Owns: node schemas, the scene store (useScene), live transforms store, core systems (wall mitering, slab polygons, space detection), event bus, plain 2D/3D math helpers, sceneRegistry, the registry primitives (nodeRegistry, registerNode, loadPlugin, discoverPlugins/setPluginDiscovery, SceneApi, Plugin/NodeDefinition types). Consumed by every downstream package, including read-only embeds. Must not know about: Three.js/R3F, packages/viewer, apps/editor, packages/nodes, any rendering or UI concept, any tool/mode/phase concept, or any *view*-specific concept (floorplan, paint preview, cursor indicators, selection outline styling).
packages/viewer — the 3D canvas, shippable standalone.
Owns: <Viewer>, the generic <NodeRenderer> / <ParametricNodeRenderer> / <GeometrySystem> / <RegisteredSystems> / <FloorplanRegistryLayer> plumbing, viewer systems (cutouts, zones, level positions, scans), the viewer store (useViewer) *for genuine presentation state only* (selection path, camera/level/wall/view modes, theme, display toggles, hover id), useNodeEvents. Consumed by both the editor and the read-only /viewer/[id] route. Must not know about: editor state (useEditor, tools, phases, modes), editor-only names baked into presentation modes ('delete', 'paint-ready'), editor-only state types (material preview, active paint target, floorplan anything), packages/nodes.
packages/editor (and apps/editor) — the editing experience.
Owns: the tool framework (useDragAction, ParametricInspector, <MoveRegistryNodeTool>, the registry-aware dispatchers in tool-manager.tsx / MoveTool / panel-manager.tsx / helper-manager.tsx), useEditor, action menus, panels, the floorplan panel and its helpers, paint mode, selection-manager phase/mode logic, cursor badges, command palette, keyboard shortcuts — anything absent from the read-only viewer route. Injects itself into <Viewer> via children and props, never the reverse. Must not import from packages/nodes.
packages/nodes — the built-in plugin (pascal:core).
Owns: one folder per node kind (packages/nodes/src/<kind>/) containing definition.ts, schema.ts, optionally geometry.ts / renderer.tsx / system.tsx / floorplan.ts / tool.tsx / move-tool.tsx / panel.tsx / parametrics.ts / preview.tsx. Exports builtinPlugin. Depends on editor, viewer, and core via their public surfaces — the same surfaces a third-party plugin uses (peer-dep style). Nothing in core/, viewer/, or editor/ may import from @pascal-app/nodes. The dependency arrow is one-way: framework code consults nodeRegistry, never reaches into a specific kind's folder.
/viewer/[id] route need this? If no, it belongs in apps/editor / packages/editor.Floorplan, Paint…, Draft…, Marquee, CursorBadge, HoverMode, …Tool, Moving…, Curving….) Default to editor and justify loudly if it's anywhere else.'delete', 'paint-ready', 'material-paint', 'site'/'structure'/'furnish', 'build'/'edit'.) Belongs in useEditor, not useViewer or core.Floorplan prefix.setMaterialPreview in useViewer that only the editor would ever invoke.) That's a layering smell — the state belongs in the caller's layer.door-…, wall-…, item-…, etc.) Then it belongs in packages/nodes/src/<kind>/, not under packages/viewer/src/components/renderers/<kind>/, packages/viewer/src/systems/<kind>.ts, packages/editor/src/components/tools/<kind>/, or packages/editor/src/components/ui/panels/<kind>-panel.tsx. Those legacy locations were deleted at Phase 6 cleanup — reintroducing one is a regression to the dispatch model.import line read from '@pascal-app/nodes' inside core/, viewer/, or editor/? Blocker. The Biome noRestrictedImports rule already bans this; if it slipped through, the framework is reaching down into the plugin.Write the classification down before writing findings. If core gains "Floorplan" types, the viewer gains paint-mode vocabulary, a renderer grows editor awareness, or a kind-specific file appears outside packages/nodes/src/<kind>/ — those are the blockers to lead with, not downstream symptoms.
packages/viewer/** does not import from @pascal-app/editor, apps/editor, or @pascal-app/nodes, and does not reference useEditor, tool state, phase, or mode.packages/core/** does not import Three.js, react-three-fiber, @pascal-app/viewer, @pascal-app/editor, or @pascal-app/nodes.packages/editor/** does not import from @pascal-app/nodes.packages/core/** does not introduce types or helpers named after an editor view (Floorplan*, Paint*, Draft*). Generic plan-geometry helpers are fine; view-specific vocabulary is not.case '<kind>': clauses (or equivalent kind-specific branching keyed on node.type) inside packages/viewer/ or packages/editor/. Phase 6 deleted these; the dispatch happens via nodeRegistry. The exceptions left in tree are treeNodeByType (a lookup *map*, not a switch) and unit-formatting switches (centimeters / feet / inches). Any new case 'door'|'wall'|'item'… in a framework package is a blocker — the behavior belongs on the kind's NodeDefinition.useScene (committed state) and useLiveTransforms (ephemeral drag state); direct sceneRegistry mesh transforms are allowed only under the live-drag exception in wiki/architecture/tools.md. No business logic, no imports from packages/viewer.packages/nodes)If the PR adds or modifies a node kind, check against wiki/architecture/node-definitions.md and wiki/architecture/plugin-authoring.md:
def.geometry?: (node, ctx) => Object3D, def.renderer?: () => Promise<{ default }>, def.system?: () => Promise<{ default }>. There is no discriminator — presence is participation. Setting all three is fine if the kind genuinely needs them; setting a def.system whose only job is to rebuild geometry on dirty is a smell — collapse to def.geometry and let <GeometrySystem> do the work.def.geometry function must not import useScene, must not mutate the store, and must not depend on React context. Read other nodes via GeometryContext (ctx.resolve / ctx.children / ctx.siblings / ctx.parent).<ParametricNodeRenderer> binds <group position={liveTransform?.position ?? node.position}> in JSX. A builder that bakes world position into vertex coords, or a system that imperatively writes group.position / group.rotation, will desync R3F's prop binding — the node will snap to (0,0,0) after rebuild. Flag any imperative group.position.set(...) inside def.geometry or a registered system. (Tool-driven sceneRegistry.nodes.get(id).position.set(...) during a live drag is fine and is the documented pattern — see hook hygiene below.)<GeometrySystem> only disposes children carrying userData.__fromGeometry = true. Custom systems that imperatively add children to a registered group must follow the same convention if the group can host React-mounted children (e.g. shelf surfaces hosting items).sceneRegistry.nodes.get(id)).def.preview calls the geometry builder and then sets material.opacity = 0.5, but the builder caches materials at module scope (most do, keyed on material / materialPreset), the mutation leaks into every committed instance. Clone, mutate the clone, reassign mesh.material, dispose only the clone on unmount. Reference: nodes/src/shelf/preview.tsx.AnyNode in SceneState.setScene). A *new* field needs a Zod .default() / .optional(). A *rename / removal / retype* needs a migrateNodes entry in packages/core/src/store/use-scene.ts that rewrites the legacy shape before parse — a .default() alone silently drops the old value. A schema diff that does neither is a blocker: it breaks every existing scene. See wiki/architecture/node-schemas.md § Schema Evolution.children on the schema. If def.relations.hosts is set, the schema must declare children: z.array(z.string()).default([]) (and migrateNodes must patch existing scenes). Otherwise useScene.createNode(child, parentId) writes a parent.children entry into nothing and the host never sees the new child.MoveTool dispatches to MoveRegistryNodeTool only when def.capabilities.movable is set. Kinds with bespoke move semantics (wall endpoint drag with linked-wall cascade, slab vertex edit, etc.) deliberately omit movable and supply def.affordanceTools.move instead. Force-routing a bespoke-move kind through generic dispatch (nodeRegistry.has(kind) instead of def.capabilities.movable) is a regression — call it out. The bug history is documented in plans/editor-node-registry.md ("Capability-driven move dispatch").def.capabilities.paint. A paintable kind declares resolveRole / buildPatch / applyPreview (+ optional getEffectiveMaterial) on PaintCapability; the editor's selection-manager routes hover / click / preview through the generic dispatcher. A PR that adds an if (node.type === '<kind>') arm to paint-mode handling, paint-preview application, or material picker resolution is a regression — the behaviour belongs on the kind's paint capability. See packages/core/src/registry/types.ts (PaintCapability).slots record (slotId → MaterialRef, scene:/library:) on the schema, resolved via def.capabilities.paint — not ad-hoc per-surface material / materialPreset fields, and not a parallel store. A new paintable kind whose schema lacks slots (or whose duplicate / preset / clone path drops it) is a blocker: it silently loses painted materials. (Slots are plain data — generic clone/parse preserves them; bespoke draft-rebuild placement paths must thread them through explicitly. Reference bug: item duplicate rebuilt the draft from asset and dropped slots.)def.geometry producing a surface a finish can tile onto must generate UVs at the same world scale walls / slabs / roofs use, because catalog finishes set repeat as tiles-per-metre. Unitless, bounding-box-normalised, or hardcoded UVs that don't scale with the surface are a blocker — finishes won't tile consistently. Flat-colour-only surfaces need no UVs. GLB item authoring follows the same contract via slot_-prefixed materials (case-insensitive, slot_ → slot id). See wiki/architecture/materials-and-themes.md § "Texture world scale" and wiki/architecture/item-authoring.md.def.capabilities.floorPlaced. Kinds that rest on a level and lift over overlapping slabs declare a footprint (and optional applies predicate) on FloorPlacedConfig; the generic <FloorElevationSystem> writes slabElevation + node.position[1] onto the registered mesh on each dirty mark. A new per-kind useEffect / per-kind system that recomputes Y from slab overlap is a regression — the per-kind block was lifted out of ItemSystem in Phase 6.1. See packages/core/src/registry/types.ts (FloorPlacedConfig).def.surfaceRole. Solid / Rendered / Clay viewer modes look up the kind's surfaceRole on NodeDefinition to pick the right material strategy (clay overrides, edge passes, theme overlays). New if (node.type === '<kind>') arms inside the render-mode pipeline or theme application are a regression — the behaviour belongs on def.surfaceRole. See packages/core/src/registry/types.ts:529.movable / paint / floorPlaced / cuttable / selectable describe *what the node does*. A new capability named after a host kind (slabAccessory, wallAccessory, siteAccessory, anything Xaccessory / Xhosted shaped) couples the registry's type surface to one specific host and reads as precedent for the next reviewer. Blocker. Push back: generalise into a paired *host-side* capability ("I merge subtractive accessories from my children") + *accessory-side* capability ("I provide a cut geometry, cascade my dirty mark to my host's parent"). The single existing case — capabilities.roofAccessory (packages/core/src/registry/types.ts:791, consumed by packages/viewer/src/systems/roof/roof-system.tsx) — is documented tech debt; do not extend the pattern.viewer/src/components/renderers/<kind>/*, viewer/src/systems/<kind>-system.tsx, editor/src/components/tools/<kind>/*, editor/src/components/ui/panels/<kind>-panel.tsx, editor/src/components/ui/helpers/<kind>-helper.tsx, or inline useMemo floor-plan entry-builders inside editor/src/components/editor/floorplan-panel.tsx — all of these were systematically deleted at Phase 6. The behavior belongs on the kind's NodeDefinition (def.renderer / def.system / def.geometry / def.tool / def.affordanceTools / parametrics.customPanel / def.toolHints / def.floorplan).def.floorplan. New per-kind floor-plan rendering must return FloorplanGeometry from def.floorplan(node, ctx) and be rendered by <FloorplanRegistryLayer>. New inline branches in floorplan-panel.tsx are a blocker.plugin.materials, plugin.systems, plugin.panels, or making plugins extend host stores (useScene / useEditor / useViewer) — is out of scope for the v1 contract documented in wiki/architecture/plugin-authoring.md. Either the change belongs as a new field on NodeDefinition (additive, doesn't bump apiVersion) or it needs its own plan.useEditor, useScene, useViewer)useViewer must be presentation-only (selection, camera, level mode, display toggles). Editor-only state (active tool, phase, edit mode, paint preview, floorplan state) goes in useEditor.useScene directly. A kind's geometry / system / tool should read and write through SceneApi (passed in by the framework) or GeometryContext. Direct useScene.getState() calls inside packages/nodes/src/<kind>/ are a smell — they bypass the registry's IoC point and make the code harder to test.useLiveTransforms.set(...) per grid:move tick to animate registered parametric kinds — the selector path doesn't reliably re-render and the mesh visibly disappears mid-drag. Use sceneRegistry.nodes.get(node.id)?.position.set(x, y, z) instead, and commit once at the end via useScene.temporal.getState().resume() → updateNode → pause(). The reference implementation is MoveRegistryNodeTool. This is the *only* sanctioned use of imperative mesh transforms by a tool; flag any other location that does the same.useLiveNodeOverrides, never per-tick useScene. A kind whose geometry is recomputed from data fields (wall start/end, opening host-cut, endpoint reshape) previews by publishing field patches to useLiveNodeOverrides (merged by getEffectiveWall / getEffectiveNode), writing the scene store once on commit. A tool that calls useScene.updateNodes/updateNode on grid:move (or any per-pointer-move tick) is a blocker — it swaps the nodes map ref and re-renders every useScene(s => s.nodes) subscriber app-wide each frame. markDirty per tick is fine for bounded gestures (drag marks drain every frame); a useFrame/animation loop that marks dirty for as long as something animates is a blocker — the scene can then never settle to DIRTY 0. Animations signal rebuilds through their own records (useInteractive animations), marking dirty once on completion; see wiki/architecture/node-definitions.md § "geometry + system". Grep tell: updateNode(s)?( in an onGridMove/onMove/applyPreview path under packages/nodes/src/<kind>/. See wiki/architecture/tools.md § "Data-driven live drag".<Viewer> siblings) must not subscribe to large or frequently-changing slices — e.g. useScene(s => s.nodes), useScene(s => s). Flag these: they re-render the whole subtree on every mutation.s => ({ a: s.a, b: s.b }), s => s.items.filter(...)) without a custom equality function (shallow or custom) are re-render hazards.<XxxPanel> (legacy or parametrics.customPanel-mounted), avoid useScene(s => s.nodes[selectedId]) as a callback dep — it changes every tick and pushes useCallback into infinite-loop territory. The recipe is in plans/editor-node-registry.md under "Panel slider-drag fix recipe".FloorplanRegistryLayer → FloorplanRegistryEntry) must have each child subscribe to its own slice (useLiveTransforms(s => s.transforms.get(id)) / overrides.get(id)) and be memo'd with referentially stable props; the parent subscribes only to the stable id list. Subscribing the parent or a child to the whole transforms/overrides Map, dropping a memo, or passing unstable props re-renders all N children every drag tick — a flood that type-checks and passes tests. Sibling invalidation goes through a per-node epoch, not a whole-layer re-render. See wiki/architecture/tools.md § "Floorplan registry: per-node subscriptions".<Viewer>, not added inside the viewer package.<mesh> / <line*> / <points> / <sprite> an editor overlay or tool component adds to the 3D scene (gizmos, handles, guides, previews, cursor meshes, marquees) must set layers={EDITOR_LAYER} — or GRID_LAYER for the ground grid, ZONE_LAYER for zone fills. The thumbnail/snapshot camera renders only layer 0, so an untagged overlay leaks into exports (and gets inked / SSGI-darkened in the live view). Flag any overlay-component primitive that omits the layer assignment. See wiki/architecture/layers.md.packages/nodes/src/<kind>/ and registering its definition in builtinPlugin.nodes. Adding to a hand-maintained list elsewhere is a sign the registry hasn't absorbed that surface yet — check plans/editor-node-registry.md § "Known un-shimmed hardcoded lists" before assuming it's a violation.AnyNode is hand-maintained for now (full runtime derivation would lose static typing); packages/nodes/src/index.test.ts is the drift gate. If a PR adds a kind to AnyNode without adding it to builtinPlugin.nodes (or vice versa), the parity test catches it — but flag it in review too.Apply when the diff touches a tool, a move-tool / selection / endpoint / reshape file, lib/interaction/**, lib/snapping-mode.ts, or use-interaction-scope. Source of truth: wiki/architecture/interaction-scope.md and wiki/architecture/tools.md.
useEditor interaction flag. "What the user is doing" is owned by useInteractionScope (begin / update / end / endIf). A new useEditor boolean for an in-flight interaction (moving…, curving…, dragging…, editing…, …InFlight) is a blocker — it goes through the scope. The legacy mirror flags are being retired, not extended.move-tool / selection file that reads event.shiftKey, event.nativeEvent?.shiftKey, or modifiers.shiftKey to bypass snapping (raw cursor, skip grid, skip angle) is a blocker — the convention is Shift = *cycle the mode*, Alt = force/free. Snap state must come from isGridSnapActive() / isMagneticSnapActive() / isAngleSnapActive(). Grep tell: shiftKey near a snap / step / projectToAngleLock / alignment expression in packages/nodes/src/<kind>/{tool,move-tool,selection}.tsx. (Shift for *multi-select* in select mode, or a documented topology opt-out, is fine — confirm which it is.)isGridSnapActive() — always useEditor.getState().gridSnapStep, or a constant WALL_GRID_STEP / 0.5 / getSegmentGridStep() applied unconditionally — ignores the active mode and is a blocker. The gated form is const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0.snapProfile. A kind whose tool snaps but whose NodeDefinition omits snapProfile ('item' | 'structural') gets no contextual chip and the wrong default mode-set — flag it (suggestion, blocker if it ships a bespoke per-kind snapping switch instead).moving scope. useMovingNode() reads the scope, and tool-manager mounts the generic MoveRegistryNodeTool whenever it's non-null. A bespoke move-tool.tsx that calls begin(movingScope(...)) or setMovingNode(node) re-creates the dual-path double-handling (FPS collapse / teleport on move). Blocker. Mode-driven snapping inside a bespoke mover must resolve the mode without a global moving / reshaping scope (see interaction-scope.md § "Snapping mode & modifiers").event.altKey is not an alignment bypass. A drafting/preview path that reads event.altKey to suppress Figma-alignment is a blocker in any new or touched tool — alignment follows the magnetic snap mode (bypass: !isMagneticSnapActive()). Alt is force/free for placement/move; it is not a snap/alignment modifier. The one sanctioned Alt use outside force is the wall/fence chain-mode toggle (clean Alt-tap → cycleWallChainMode / cycleFenceChainMode, via hooks/use-keyboard.ts isChainModeContext()), allowed only because wall/fence drafting has no force role. Grep tell: event.altKey near an align / bypass expression in a tool.tsx / floorplan preview path.plans/editor-placement-interaction-overhaul.md; a PR that touches one must migrate it, not extend it; a new tool on either legacy pattern is a blocker regardless. (1) shiftKey snap-bypass in the MEP move/endpoint tools (packages/nodes/src/{duct-segment,pipe-segment,liquid-line,lineset,duct-fitting}/{move-tool,selection}.tsx). (2) altKey alignment-bypass in the roof / polygon / slab pointer-move previews (components/editor/floorplan-panel.tsx) and the resolveSlabPlanPointSnap / resolveCeilingPlanPointSnap paths. Already migrated — do not regress: wall + fence drafting (both modifier patterns) and zone drafting (components/tools/zone/zone-tool.tsx — mode-driven grid/angle gates, no Shift bypass).Group findings by severity:
wiki/architecture/ or breaks a layer/package boundary. Must be fixed before merge.For each finding, include:
path/to/file.ts:42wiki/architecture/viewer-isolation.md, wiki/architecture/node-definitions.md)Skip formatting, import ordering, and anything CI already covers.
If the PR fully complies, say so explicitly — do not invent nits to appear thorough.
End with:
Take pascalorg/review-architecture from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.