Skip to content
Documentation GitHub
Editor

Layouts — CSS Grid Template Application, Block Placement, and Conflict Resolution

Layouts — CSS Grid Template Application, Block Placement, and Conflict Resolution

Covers the full layout lifecycle: listing the 8 builtin layout definitions, applying a layout to a page, removing a layout, assigning blocks to named grid areas, creating new blocks in specific areas, checking layout compatibility before switching, resolving conflicts when blocks would be displaced, custom layout creation, type-layout default association, and validation of CSS Grid template area strings. Layouts are a P1 concern because they affect the structural integrity of every page that uses them — a bug in apply_layout that silently clears block area assignments, or a validation failure that accepts non-contiguous area shapes, corrupts the user’s carefully structured page content.

The domain enforces contiguous-rectangle invariants: every named area in a grid-template-areas string must form a bounding box that is fully and exclusively occupied by that name. The infrastructure stores layouts as JSON in the pages.layout column and re-validates the template on deserialization (Layout::from_json re-runs validate_template). The HTTP bridge exposes layout commands covering all layout lifecycle operations.

Preconditions

  • HTTP bridge running on port 9990
  • A workspace initialized via initialize_workspace before each scenario
  • Bridge shim injected via playwright.config.ts
  • The HTTP bridge exposes layout routes: list_layouts, create_layout, update_layout, delete_layout, apply_layout, remove_layout, assign_block_to_area, create_block_in_area, and check_layout_compatibility. All scenarios in this spec are exercisable via the bridge.

Scenarios

Seed: seed.spec.ts

1. List available layout definitions — 8 builtins present

When a workspace is initialized, the 8 builtin layout definitions are seeded via INSERT OR IGNORE. They must always be available.

Steps:

  1. Open a freshly initialized workspace.
  2. Call list_layouts.
  3. Observe the returned list.

Expected: The list contains exactly 8 entries (plus any user-defined layouts). The builtin names are:

  • “Single Column”
  • “Two Equal Columns”
  • “Sidebar + Main”
  • “Main + Sidebar”
  • “Two-by-Two Grid”
  • “Header + Two Columns + Footer”
  • “Character Sheet”
  • “Dashboard”

Each entry has is_builtin = true and a deterministic UUID derived from its name (UUID v5 based on Uuid::NAMESPACE_DNS). Calling list_layouts a second time returns the same IDs — they are stable across restarts.

2. Apply a builtin layout to a page

Applying a layout definition to a page stores a snapshot of the Layout value object in pages.layout.

Steps:

  1. Create a page “Character Overview”.
  2. Open the layout picker (LayoutPicker component) for the page — typically via the toolbar or page options menu.
  3. Select “Character Sheet” from the picker.
  4. Confirm the selection.
  5. Observe the editor view.

Expected: The editor switches to a grid layout with 4 named areas: portrait, stats, bio, and notes. The page renders as a 3×3 CSS Grid:

portrait stats stats
portrait bio bio
notes notes notes

Calling get_page returns the page with a layout field that is non-null. The areas field contains ["portrait", "stats", "bio", "notes"] in reading order.

3. Remove layout from a page — reverts to single-column

Removing a layout clears the pages.layout column and restores the default single-column view.

Steps:

  1. Apply the “Two Equal Columns” layout to a page “Clean Slate”.
  2. Open the layout picker for “Clean Slate”.
  3. Select “Remove Layout” (or the equivalent option to clear the layout).
  4. Observe the editor view.

Expected: The editor reverts to a single-column view. The layout field returned by get_page is null (or absent). Any blocks that had area assignments of "left" or "right" have their area cleared (area becomes null). No data is lost — block content is preserved.

4. Layout persists after page navigation

The applied layout is stored in the database and must survive navigating away and back.

Steps:

  1. Apply “Sidebar + Main” to a page “Persistent Layout”.
  2. Navigate to a different page in the sidebar.
  3. Navigate back to “Persistent Layout”.
  4. Observe the editor view.

Expected: The editor opens “Persistent Layout” with the “Sidebar + Main” grid still active — a narrower sidebar column on the left and a wider main area on the right. The layout was not lost during navigation.

5. Layout persists after page reload

The layout must be rehydrated from the JSON stored in pages.layout, surviving a full application restart or page reload.

Steps:

  1. Apply “Dashboard” to a page “Analytics Overview”.
  2. Quit and relaunch the application (or reload via the debug reset mechanism).
  3. Open “Analytics Overview”.
  4. Observe the editor view.

Expected: The “Dashboard” layout is restored correctly with all 5 areas: metric1, metric2, metric3, detail-left, detail-right. Block content in each area is preserved.

6. Assign a block to a named grid area

After a layout is applied, individual blocks can be dragged or assigned to specific named areas.

Steps:

  1. Apply “Two Equal Columns” to a page “Split View” (areas: left, right).
  2. In the editor, drag the first block (or use the block menu → “Assign to area”) to the left area.
  3. Drag or assign the second block to the right area.
  4. Observe the grid.

Expected: The first block appears in the left column and the second in the right column. Calling get_page returns these blocks with area = "left" and area = "right" respectively. The assignment is visible in the grid layout view.

7. Assign block to nonexistent area is rejected

Block area names must exist in the page’s current layout. Assigning to a made-up area name is a validation error.

Steps:

  1. Apply “Two Equal Columns” (areas: left, right) to a page “Guard Test”.
  2. Attempt to assign a block to an area named "center" (which is not in this layout) via an API call or a crafted bridge request.

Expected: A validation error is returned (“Area ‘center’ does not exist in the current layout” or equivalent). The block’s area assignment is unchanged. No state corruption occurs.

8. Assign block to area when page has no layout is rejected

assign_block_to_area requires the page to have an active layout.

Steps:

  1. Create a page “No Layout Page” (no layout applied).
  2. Attempt to assign the page’s first block to area "content" via assign_block_to_area.

Expected: An error is returned indicating the page has no layout (mapped from DomainError::InvalidState). No area assignment is made.

9. Create a new block in a specific area

create_block_in_area creates a new block and immediately assigns it to the specified area.

Steps:

  1. Apply “Sidebar + Main” (areas: sidebar, main) to a page “Side Notes”.
  2. Click the “Add block” target in the sidebar area (the EmptyCell component, or equivalent insert point).
  3. Observe the sidebar area after the block appears.

Expected: A new block is created and its area field is "sidebar". The sidebar column in the grid now shows the new block. The block is positioned within the sidebar area and does not appear in the main area.

10. Check layout compatibility — compatible case

Before switching layouts, check_layout_compatibility reports whether any blocks would be displaced.

Steps:

  1. Apply “Two Equal Columns” (areas: left, right) to a page “Compat Test”.
  2. Assign the first block to left.
  3. Call check_layout_compatibility with the ID of “Sidebar + Main” (also has left but not right — wait, “Sidebar + Main” has sidebar and main, not left and right). Instead, call with the ID of a layout definition that also contains a left area (e.g., a custom layout with left, right, bottom areas).

Steps (revised):

  1. Apply “Two Equal Columns” (areas: left, right) to a page “Compat OK”.
  2. Assign the first block to "left".
  3. Create a custom layout with template "left right bottom" (also has left and right).
  4. Call check_layout_compatibility for “Compat OK” against the custom layout ID.

Expected: LayoutCompatibilityResult.compatible = true. displaced_blocks is empty. The left block area still exists in the new layout.

11. Check layout compatibility — displaced blocks case

When a block’s current area does not exist in the target layout, it is reported as displaced.

Steps:

  1. Apply “Two Equal Columns” (areas: left, right) to a page “Compat Conflict”.
  2. Assign the first block to "left" and the second block to "right".
  3. Call check_layout_compatibility against “Single Column” (area: content only — neither left nor right exists).

Expected: LayoutCompatibilityResult.compatible = false. displaced_blocks contains 2 entries, one for the left-assigned block and one for the right-assigned block. Each entry includes block_id, current_area, and content_preview (first 50 characters of block content). The operation is read-only — no state changes.

12. Layout conflict dialog — user can cancel

When check_layout_compatibility returns compatible = false, the UI must show a LayoutConflictDialog before proceeding.

Steps:

  1. Set up a page “Conflict Dialog Test” with a block in the "left" area using “Two Equal Columns”.
  2. Open the layout picker and select “Single Column”.
  3. The system calls check_layout_compatibility and receives compatible = false.
  4. Observe that the LayoutConflictDialog appears, listing the displaced block(s).
  5. Click “Cancel” in the dialog.

Expected: The dialog closes. The page retains “Two Equal Columns” as its active layout. The block remains in the "left" area. No layout change was applied.

13. Layout conflict dialog — user confirms, layout applied, displaced areas cleared

When the user confirms a layout switch despite displaced blocks, the new layout is applied and displaced block areas are cleared (set to null).

Steps:

  1. Set up a page “Conflict Apply Test” with a block in the "left" area using “Two Equal Columns”.
  2. Open the layout picker and select “Single Column”.
  3. The LayoutConflictDialog appears showing the displaced block.
  4. Click “Apply anyway” (or equivalent confirm action).
  5. Observe the editor and call get_page.

Expected: The page now uses “Single Column” (area: content). The previously displaced block has area = null (its area assignment was cleared, not the block itself). The block’s content is fully preserved. No data loss occurred.

14. CSS Grid template validation — valid multi-area template accepted

Custom layout creation validates the template string at the domain layer.

Steps:

  1. Open the layout creator (LayoutCreator component) or call create_layout directly.
  2. Enter name "My Custom Layout" and template:
    header header
    left right
    footer footer
  3. Submit.

Expected: The layout is created successfully. list_layouts returns the new entry with is_builtin = false. The areas field contains ["header", "left", "right", "footer"] in reading order (left-to-right, top-to-bottom first occurrence).

15. CSS Grid template validation — non-contiguous area shape rejected

An L-shaped or T-shaped area is rejected because it does not form a contiguous rectangle.

Steps:

  1. Call create_layout with the template:
    a b
    a a
    (Area a forms an L-shape: rows 1–2 col 1, plus row 2 col 2 — bounding box is 2×2 but only 3 cells are a, so cell (0,1) = b violates the rectangle.)

Expected: A validation error is returned: “Area ‘a’ does not form a contiguous rectangle” (or equivalent). No layout is created.

16. CSS Grid template validation — mismatched row column counts rejected

All rows in a template must have the same column count.

Steps:

  1. Call create_layout with the template:
    a b c
    a b
    (Row 0 has 3 columns, row 1 has 2.)

Expected: A validation error is returned: “All rows must have the same column count” (or equivalent). No layout is created.

17. CSS Grid template validation — invalid area name characters rejected

Area names accept only ASCII alphanumerics, hyphens, and underscores.

Steps:

  1. Call create_layout with template "area!name content" (contains !).

Expected: A validation error is returned: “Area name ‘area!name’ contains invalid characters; only alphanumeric, hyphens, and underscores are allowed”. No layout is created.

18. CSS Grid template validation — area names with hyphens and underscores accepted

The character set [a-zA-Z0-9\-_] is explicitly allowed.

Steps:

  1. Call create_layout with template "top-bar content_area".
  2. Observe the result.

Expected: The layout is created successfully with areas = ["top-bar", "content_area"]. No validation error is raised.

19. Custom layout creation — name validation enforced

Layout definition names must be non-empty and at most 100 characters.

Steps:

  1. Attempt to create a layout with an empty name "" and any valid template.
  2. Attempt to create a layout with a 101-character name.

Expected: Both attempts return validation errors (“Layout name must not be empty or whitespace-only” and “Layout name must not exceed 100 characters” respectively). No layouts are created.

20. Delete a builtin layout is rejected

Builtin layout definitions (is_builtin = true) cannot be deleted.

Steps:

  1. Call list_layouts and find the ID of “Single Column” (a builtin).
  2. Call delete_layout with that ID.

Expected: A BuiltinImmutable error is returned (“Cannot modify builtin layout”). The “Single Column” definition remains in list_layouts. It was not deleted.

21. Delete a custom layout succeeds

User-defined layouts (is_builtin = false) can be deleted.

Steps:

  1. Create a custom layout “My Grid” with template "a b".
  2. Note its ID.
  3. Call delete_layout with that ID.
  4. Call list_layouts.

Expected: “My Grid” is no longer returned by list_layouts. No error occurs. If any page was using “My Grid” at the time of deletion, that page retains its existing layout JSON snapshot (it is not cleared — the delete only removes the definition, not page snapshots).

22. Type-layout default association

A type definition can have a default_layout_id that is applied automatically when a new page of that type is created.

Steps:

  1. Create a custom layout “Character Grid” with template "portrait stats\nportrait bio".
  2. Call update_type (or create_type) for a type “Character” and set default_layout_id to the ID of “Character Grid”.
  3. Create a new page and assign it the type “Character” (via set_page_type).
  4. Observe the editor view for the new page.

Expected: The new “Character” page opens with “Character Grid” already applied. The editor shows the 2×2 grid (4 areas: portrait, stats, bio — wait, the template only has 3 distinct areas: portrait spans 2 rows, stats is row 1 col 2, bio is row 2 col 2). The layout field of the page is non-null and contains the “Character Grid” template. The TypeAssignmentBadge in the editor shows the type association.

23. Layout picker shows previews for all layouts

The LayoutPicker component renders a LayoutPreview for each definition in the picker grid.

Steps:

  1. Open a page in the editor.
  2. Open the layout picker (via toolbar or page options menu).
  3. Observe the picker.

Expected: All 8 builtin layouts are shown with their names and a visual preview (LayoutPreview component rendering a miniature grid). Any user-defined layouts also appear in the picker. The currently applied layout (if any) is visually indicated as selected.

24. Empty cell shows insert affordance

When a layout is applied but some areas have no blocks, the EmptyCell component renders a visual placeholder to indicate the area is empty and accepts new blocks.

Steps:

  1. Apply “Two-by-Two Grid” (areas: tl, tr, bl, br) to a page “Grid Page”.
  2. Assign the first block to tl.
  3. Leave tr, bl, and br empty.
  4. Observe the editor.

Expected: The three empty cells (tr, bl, br) display an EmptyCell placeholder — typically a dashed border or a ”+ Add content” affordance. The tl cell shows the actual block content. Clicking an EmptyCell opens an insert action (creates a new block in that area via create_block_in_area).

25. Per-cell CRDT isolation — editing one area does not affect others

Each grid area has its own independent LoroDoc instance (get_block_content_snapshot_by_id / save_block_content_by_id). Changes to one cell are isolated from other cells.

Steps:

  1. Apply “Two Equal Columns” to a page “CRDT Grid”.
  2. Assign block A to left and block B to right.
  3. Edit block A’s content to “Left side content”.
  4. Edit block B’s content to “Right side content”.
  5. Undo in block B (undo should revert only block B’s content, not block A’s).
  6. Observe both cells.

Expected: Block A retains “Left side content” unchanged. Block B reverts to its prior content (before the last edit). The Loro undo stack for each block is independent — undoing in one cell does not affect another.

Test Data

KeyValueNotes
builtin_count8LayoutDefinition::builtin_layouts() always returns exactly 8
single_column_areas[“content”]Default single-column layout — area named “content”
two_equal_cols_areas[“left”, “right”]Template: “left right”
sidebar_main_areas[“sidebar”, “main”]Template: “sidebar main main” (1×3 — sidebar narrow, main wide)
two_by_two_areas[“tl”, “tr”, “bl”, “br”]Template: “tl tr\nbl br”
character_sheet_areas[“portrait”, “stats”, “bio”, “notes”]Template: 3×3 grid — portrait spans col 1 rows 1-2, notes spans full row 3
dashboard_areas[“metric1”, “metric2”, “metric3”, “detail-left”, “detail-right”]Template: 2-row grid — 3 metrics top, wide+narrow detail bottom
max_template_rows20MAX_TEMPLATE_ROWS constant in domain
max_template_cols20MAX_TEMPLATE_COLS constant in domain
max_template_areas50MAX_TEMPLATE_AREAS distinct area names
invalid_l_shape_template”a b\na a”a forms L-shape — fails contiguous rectangle validation
invalid_t_shape_template”a a a\nb a b”a forms T-shape — fails contiguous rectangle validation
invalid_mismatch_template”a b c\na b”Row 0 has 3 cols, row 1 has 2 — mismatched column count
invalid_chars_template”area!name content”! is not alphanumeric, hyphen, or underscore
valid_special_chars_name”top-bar content_area”Hyphens and underscores are valid in area names
content_preview_max_chars50DisplacedBlock.content_preview is truncated to 50 characters

Notes

  • The HTTP bridge exposes all layout commands. Layout operations are fully exercisable via the bridge.
  • apply_layout snapshots the layout into the page at write time. If the underlying LayoutDefinition is later deleted, the page retains its layout snapshot and continues to function correctly. The layout JSON is self-contained in pages.layout.
  • Layout::from_json re-runs validate_template on every deserialization — this guards against corrupt stored JSON causing silent failures at render time.
  • Builtin layout IDs are deterministic UUID v5 values derived from Uuid::NAMESPACE_DNS and the layout name. They are identical across workspace initializations and application versions, making them safe to hardcode in test fixtures.
  • check_layout_compatibility is a read-only operation — it does not apply the layout. It must be called before apply_layout when the page has blocks with area assignments, to avoid a silent displacement error from apply_layout.
  • create_block_in_area is a compound operation: it creates a block (equivalent to CreateBlockUseCase) and immediately assigns it to the named area. The block is created within the page’s block list and also assigned to the area column.
  • The LayoutConflictDialog receives DisplacedBlock entries from LayoutCompatibilityResult.displaced_blocks. Each entry includes the block UUID, its current area name, and up to 50 characters of content for user-facing identification.
  • delete_layout is implemented with dual repositories (layout_repo and type_repo): after removing the layout definition, it clears any default_layout_id references on type definitions via type_repo.clear_default_layout_references(id). This prevents dangling FK references.
  • The TypeAssignmentBadge React component is used in the editor to display the current type and its associated default layout. This is a frontend concern not directly testable via the bridge, but the underlying data (default_layout_id on the type) is fully testable via list_types after update_type.

Was this page helpful?