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_workspacebefore 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, andcheck_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:
- Open a freshly initialized workspace.
- Call
list_layouts. - 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:
- Create a page “Character Overview”.
- Open the layout picker (LayoutPicker component) for the page — typically via the toolbar or page options menu.
- Select “Character Sheet” from the picker.
- Confirm the selection.
- 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 statsportrait bio bionotes notes notesCalling 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:
- Apply the “Two Equal Columns” layout to a page “Clean Slate”.
- Open the layout picker for “Clean Slate”.
- Select “Remove Layout” (or the equivalent option to clear the layout).
- 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:
- Apply “Sidebar + Main” to a page “Persistent Layout”.
- Navigate to a different page in the sidebar.
- Navigate back to “Persistent Layout”.
- 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:
- Apply “Dashboard” to a page “Analytics Overview”.
- Quit and relaunch the application (or reload via the debug reset mechanism).
- Open “Analytics Overview”.
- 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:
- Apply “Two Equal Columns” to a page “Split View” (areas:
left,right). - In the editor, drag the first block (or use the block menu → “Assign to area”) to the
leftarea. - Drag or assign the second block to the
rightarea. - 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:
- Apply “Two Equal Columns” (areas:
left,right) to a page “Guard Test”. - 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:
- Create a page “No Layout Page” (no layout applied).
- Attempt to assign the page’s first block to area
"content"viaassign_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:
- Apply “Sidebar + Main” (areas:
sidebar,main) to a page “Side Notes”. - Click the “Add block” target in the
sidebararea (the EmptyCell component, or equivalent insert point). - 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:
- Apply “Two Equal Columns” (areas:
left,right) to a page “Compat Test”. - Assign the first block to
left. - Call
check_layout_compatibilitywith the ID of “Sidebar + Main” (also hasleftbut notright— wait, “Sidebar + Main” hassidebarandmain, notleftandright). Instead, call with the ID of a layout definition that also contains aleftarea (e.g., a custom layout withleft,right,bottomareas).
Steps (revised):
- Apply “Two Equal Columns” (areas:
left,right) to a page “Compat OK”. - Assign the first block to
"left". - Create a custom layout with template
"left right bottom"(also hasleftandright). - Call
check_layout_compatibilityfor “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:
- Apply “Two Equal Columns” (areas:
left,right) to a page “Compat Conflict”. - Assign the first block to
"left"and the second block to"right". - Call
check_layout_compatibilityagainst “Single Column” (area:contentonly — neitherleftnorrightexists).
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:
- Set up a page “Conflict Dialog Test” with a block in the
"left"area using “Two Equal Columns”. - Open the layout picker and select “Single Column”.
- The system calls
check_layout_compatibilityand receivescompatible = false. - Observe that the LayoutConflictDialog appears, listing the displaced block(s).
- 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:
- Set up a page “Conflict Apply Test” with a block in the
"left"area using “Two Equal Columns”. - Open the layout picker and select “Single Column”.
- The LayoutConflictDialog appears showing the displaced block.
- Click “Apply anyway” (or equivalent confirm action).
- 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:
- Open the layout creator (LayoutCreator component) or call
create_layoutdirectly. - Enter name
"My Custom Layout"and template:header headerleft rightfooter footer - 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:
- Call
create_layoutwith the template:(Areaa ba aaforms an L-shape: rows 1–2 col 1, plus row 2 col 2 — bounding box is 2×2 but only 3 cells area, so cell (0,1) =bviolates 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:
- Call
create_layoutwith the template:(Row 0 has 3 columns, row 1 has 2.)a b ca b
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:
- Call
create_layoutwith 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:
- Call
create_layoutwith template"top-bar content_area". - 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:
- Attempt to create a layout with an empty name
""and any valid template. - 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:
- Call
list_layoutsand find the ID of “Single Column” (a builtin). - Call
delete_layoutwith 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:
- Create a custom layout “My Grid” with template
"a b". - Note its ID.
- Call
delete_layoutwith that ID. - 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:
- Create a custom layout “Character Grid” with template
"portrait stats\nportrait bio". - Call
update_type(orcreate_type) for a type “Character” and setdefault_layout_idto the ID of “Character Grid”. - Create a new page and assign it the type “Character” (via
set_page_type). - 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:
- Open a page in the editor.
- Open the layout picker (via toolbar or page options menu).
- 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:
- Apply “Two-by-Two Grid” (areas:
tl,tr,bl,br) to a page “Grid Page”. - Assign the first block to
tl. - Leave
tr,bl, andbrempty. - 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:
- Apply “Two Equal Columns” to a page “CRDT Grid”.
- Assign block A to
leftand block B toright. - Edit block A’s content to “Left side content”.
- Edit block B’s content to “Right side content”.
- Undo in block B (undo should revert only block B’s content, not block A’s).
- 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
| Key | Value | Notes |
|---|---|---|
| builtin_count | 8 | LayoutDefinition::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_rows | 20 | MAX_TEMPLATE_ROWS constant in domain |
| max_template_cols | 20 | MAX_TEMPLATE_COLS constant in domain |
| max_template_areas | 50 | MAX_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_chars | 50 | DisplacedBlock.content_preview is truncated to 50 characters |
Notes
- The HTTP bridge exposes all layout commands. Layout operations are fully exercisable via the bridge.
apply_layoutsnapshots the layout into the page at write time. If the underlyingLayoutDefinitionis later deleted, the page retains its layout snapshot and continues to function correctly. The layout JSON is self-contained inpages.layout.Layout::from_jsonre-runsvalidate_templateon 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_DNSand the layout name. They are identical across workspace initializations and application versions, making them safe to hardcode in test fixtures. check_layout_compatibilityis a read-only operation — it does not apply the layout. It must be called beforeapply_layoutwhen the page has blocks with area assignments, to avoid a silent displacement error fromapply_layout.create_block_in_areais a compound operation: it creates a block (equivalent toCreateBlockUseCase) 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
LayoutConflictDialogreceivesDisplacedBlockentries fromLayoutCompatibilityResult.displaced_blocks. Each entry includes the block UUID, its current area name, and up to 50 characters of content for user-facing identification. delete_layoutis implemented with dual repositories (layout_repoandtype_repo): after removing the layout definition, it clears anydefault_layout_idreferences on type definitions viatype_repo.clear_default_layout_references(id). This prevents dangling FK references.- The
TypeAssignmentBadgeReact 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_idon the type) is fully testable vialist_typesafterupdate_type.
Was this page helpful?
Thanks for your feedback!