Skip to content

feat: page builder — engine (L1) + editor backend (L2a-1) + editor frontend (L2a-2)#232

Draft
bambamboole wants to merge 38 commits into
mainfrom
feat/page-builder-engine-core
Draft

feat: page builder — engine (L1) + editor backend (L2a-1) + editor frontend (L2a-2)#232
bambamboole wants to merge 38 commits into
mainfrom
feat/page-builder-engine-core

Conversation

@bambamboole

@bambamboole bambamboole commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Gutenberg-style page builder for Lattice, built in layers. Draft.

Design + plans in docs/superpowers/ (gitignored, local).

Layer 1 — engine core

A block = a discoverable #[AsBlock] class with an attribute schema (Fields) + render(FormData, BlockSlots): PageSchema.

  • AsBlock/blocks discovery, BlockDefinition, BlockRegistry, BlockRenderer (unknown → placeholder, restricted-nesting slots), AsBlocks cast + HasBlocks trait. Renders in-app via the existing wire→React registry; no prescribed Page model.

Layer 2a-1 — editor backend

  • BlockEditor form field (extends Builder, field.block-editor); ->blocks([HeroBlock::class]) builds inspector templates from #[AsBlock] classes; serializes each stored row's rendered wire.
  • Signed POST lattice/blocks/render endpoint for on-blur live preview (allowed-blocks scoped, validated, unsigned-rejected).

Layer 2a-2 — editor frontend

A field.block-editor React editor:

  • Canvas renders each block as real output; click to select; dnd-kit drag-to-reorder with a keyboard-accessible drag handle.
  • Inspector side panel edits the selected block's attributes; on blur the block re-renders via the signed endpoint.
  • All block attributes submit through the form pipeline (every row's inputs stay mounted); slots round-trip via hidden inputs.
  • dnd-kit added as an external dependency.

Correctness fixes made during review

  • move-block tree move hardened against silent drops on nested moves.
  • All blocks' attributes are submitted (not just the selected one) — fixed a data-loss bug; covered by a multi-block browser regression test.
  • Shared Builder/HandlesRowSchemas: optional row fields lacking ->rules() are now retained through validate() (previously dropped by Laravel's excludeUnvalidatedArrayKeys).

Still to come

  • L2b in-place text editing · L3 public SSR · per-slot live drop zones + sortableKeyboardCoordinates refinement.

Tests / gates

Green: composer check (1020 PHP tests) and npm run check (971 Vitest + build:lib), plus a Pest v4 browser suite covering add / select / edit / on-blur preview / multi-block save.

🤖 Generated with Claude Code

@codecov

codecov Bot commented Jul 9, 2026

Copy link
Copy Markdown

Bundle Report

Changes will increase total bundle size by 48.96kB (2.74%) ⬆️. This is within the configured threshold ✅

Detailed changes
Bundle name Size Change
lattice-workbench-esm 1.84MB 48.96kB (2.74%) ⬆️

Affected Assets, Files, and Routes:

view changes for bundle: lattice-workbench-esm

Assets Changed:

Asset Name Size Change Total Size Change (%)
assets/app-*.js 48.66kB 423.52kB 12.98% ⚠️
assets/app-*.css 300 bytes 63.32kB 0.48%

Files in assets/app-*.js:

  • ./resources/js/form/components/fields/block-editor/inspector.tsx → Total Size: 1.09kB

  • ./resources/js/form/components/fields/block-editor/move-block.ts → Total Size: 2.2kB

  • ./resources/js/form/components/fields/block-editor/dnd.ts → Total Size: 684 bytes

  • ./resources/js/form/index.ts → Total Size: 1.2kB

  • ./resources/js/form/components/fields/block-editor/hidden-inputs.tsx → Total Size: 529 bytes

  • ./resources/js/form/components/fields/block-editor/use-block-preview.ts → Total Size: 1.04kB

  • ./resources/js/form/components/fields/block-editor/canvas.tsx → Total Size: 2.53kB

  • ./resources/js/form/components/fields/block-editor/index.tsx → Total Size: 3.81kB

@codecov

codecov Bot commented Jul 9, 2026

Copy link
Copy Markdown

@bambamboole bambamboole changed the title feat: page builder engine core (Layer 1) feat: page builder — engine core (L1) + editor backend (L2a-1) Jul 9, 2026
@bambamboole bambamboole changed the title feat: page builder — engine core (L1) + editor backend (L2a-1) feat: page builder — engine (L1) + editor backend (L2a-1) + editor frontend (L2a-2) Jul 9, 2026
@bambamboole bambamboole force-pushed the feat/page-builder-engine-core branch from 8a4f5a7 to cee9cfa Compare July 9, 2026 16:30
Wires a workbench form (BlockPageForm) with a BlockEditor field behind two
demo blocks (HeroBlock, ColumnsBlock) at /block-editor, and adds a Pest
browser test that adds a block, selects it, edits its title, verifies the
canvas preview updates on blur, and confirms the submission persists.
The wire prop carried the raw config value ('lattice/blocks/render'), which
the browser resolves relative to the current page URL — previews 404 on any
nested route. Normalize with a leading slash, matching DefinitionRegistry::endpointFor().
DefinitionRegistry::keyFor already validates the definition subclass and
extracts the attribute key; expose it instead of re-implementing the
attribute read in BlockEditor, and hoist the BlockRenderer container
resolution out of the per-row map.
Hand-building the bracket syntax from the dot path breaks the submitted
name once the editor sits inside a field scope; toHtmlName(appendPath())
matches how the sibling type inputs are named.
The component inherited Builder's full prop surface but ignored it:
visibleWhen/requiredWhen conditions no-opped, validation errors never
displayed, and append ignored maxItems. Wire the same hooks
BuilderComponent uses.
Blur bubbles from every child, so tabbing between inspector fields fired
a render request per focus move. Commit only when focus leaves the
inspector, and track each block's last-rendered source (type + attributes,
seeded from the server payload) so unchanged commits skip the request —
the same change-aware principle precognitive validation follows. Failed
renders keep the previous signature and retry on the next commit.
Drag handle, empty-inspector hint, and unknown-block note were hardcoded
English; route them through useT with en/de entries like the sibling
field components.
…e facade

Blocks are headed for a first-party package, so core must not hardwire
BlockRegistry into LatticeRegistry/the facade. Apps register explicit
blocks via app(BlockRegistry::class)->register() (or rely on AsBlock
discovery); the extracted package will own any registration sugar.
@bambamboole bambamboole force-pushed the feat/page-builder-engine-core branch from 863fc6b to a5b3fd5 Compare July 9, 2026 19:59
- BlockEditor extends TypedRowsField: blocks() builds RowTemplates from the
  AsBlock classes, and the table/row-action chrome props it never honored
  drop off its wire type.
- The client reads the templates wire key via rowTemplatesOf, submits
  type + rowId per row through the shared RowKeyInputs, and slot rows keep
  their rowId on submit (hiddenInputsFor no longer strips it).
- Browser tests select block shells positionally since row ids are uuids
  now; the type in:-rule assertion follows Rule::in.
@bambamboole bambamboole force-pushed the feat/page-builder-engine-core branch from a5b3fd5 to ac8d8bd Compare July 9, 2026 20:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant