Prerequisites

      Grid Layout (Phase 1) must be implemented first. This plan assumes 'Grid' already exists in HMBlockChildrenTypeSchema, the editor guards pattern (isInGridContainer) is established, and BlockNodeList rendering has been extended.

    Context

      With Grid done, we now want Fixed Columns: explicit column containers where each child IS a column with its own independent content. Unlike Grid (items flow/wrap), Columns give users explicit control — each column is a separate content area with its own block tree.

      Think Notion columns or newspaper layouts — not a card grid.

      Key Difference from Grid

        | Aspect | Grid (Phase 1) | Columns (Phase 2) | | ----------------- | --------------------------------- | --------------------------------------------- | | Nesting depth | 1 level (container -> items) | 2 levels (container -> column wrappers -> content) | | Column count | Attribute (columnCount) | Number of direct children | | Item overflow | Wraps to next row | No overflow, each column is independent | | Content per column| Single block per cell | Multiple blocks per column | | Width control | Equal width, CSS grid | Per-column via columnWidths attribute |

    Data Model

      BlockNode {
        block: {
          id: "cols-1",
          type: "Paragraph",
          text: "",
          attributes: { childrenType: "Columns", columnWidths: [60, 40] }
        }
        children: [
          BlockNode {
            block: {
              id: "col-1",
              type: "Paragraph",
              text: "",
              attributes: { childrenType: "Group" }
            }
            children: [
              BlockNode { block: { id: "p1", type: "Paragraph", text: "Left content" } }
              BlockNode { block: { id: "img1", type: "Image", ... } }
            ]
          },
          BlockNode {
            block: {
              id: "col-2",
              type: "Paragraph",
              text: "",
              attributes: { childrenType: "Group" }
            }
            children: [
              BlockNode { block: { id: "p2", type: "Paragraph", text: "Right content" } }
            ]
          }
        ]
      }
      

      ProseMirror tree:

      blockNode (container)
        block:paragraph ("")                    <- empty, invisible
        blockChildren [listType='Columns']      <- CSS flex row
          blockNode (column 1)
            block:paragraph ("")                <- empty, invisible
            blockChildren [listType='Group']    <- column 1 content
              blockNode -> paragraph ("Left text")
              blockNode -> image (...)
          blockNode (column 2)
            block:paragraph ("")
            blockChildren [listType='Group']    <- column 2 content
              blockNode -> paragraph ("Right text")
      

      Attributes on container:

        columnWidths: number[] (optional, percentages summing to 100, e.g. [60, 40])

        Column count = number of direct children (not an attribute)

    CRDT Behavior

      Different columns = independent RGA sublists -> no conflicts

      Same column = standard RGA merge within that column's child list

      Add/remove columns = OpMoveBlocks on container's child list

      Move blocks between columns = OpMoveBlocks changing parent -> last-write-wins if concurrent

      No CRDT changes needed.

    Implementation Steps

      Step 1: Types & Schema

        frontend/packages/shared/src/hm-types.ts

        Add 'Columns' to HMBlockChildrenTypeSchema (Grid already present from Phase 1):

        z.union([
          z.literal('Group'),
          z.literal('Ordered'),
          z.literal('Unordered'),
          z.literal('Blockquote'),
          z.literal('Grid'),
          z.literal('Columns'),
        ])
        

        frontend/packages/editor/src/blocknote/core/extensions/Blocks/api/defaultBlocks.ts

        childrenType: {
          default: 'Group',
          values: ['Group', 'Unordered', 'Ordered', 'Blockquote', 'Grid', 'Columns'],
        }
        

      Step 2: Block Conversion

        frontend/packages/shared/src/client/hmblock-to-editorblock.ts

          Accept 'Columns' as valid childrenType (same pattern as Grid from Phase 1)

        frontend/packages/shared/src/client/editorblock-to-hmblock.ts

          Pass through 'Columns' childrenType

          Preserve columnWidths attribute in conversion

      Step 3: Editor — BlockChildren Rendering

        frontend/packages/editor/src/blocknote/core/extensions/Blocks/nodes/BlockChildren.ts

          listNode(): Add case for 'Columns' — render as <div> with display: flex; flex-direction: row; gap styles

          Each child blockNode inside a Columns container renders as a flex item

          addInputRules(): Phase 1 Grid guard already exists — extend to also cover Columns

      Step 4: Editor — Keyboard Guards

        frontend/packages/editor/src/blocknote/core/extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts

        Rename Phase 1's isInGridContainer() -> isInLayoutContainer() covering both Grid and Columns.

        Additional guards needed beyond what Grid already has:

        | Handler | Guard | | ------------------------ | -------------------------------------------------------------------- | | handleBackspace (~26) | Prevent merging across column boundaries (col-1 block can't merge with col-2 block) | | handleDelete (~315) | Same — prevent cross-column merging | | handleEnter (~423) | On empty block inside column: stay in column, don't unnest to parent | | handleTab (~541) | Prevent indent of column wrappers — already guarded by Phase 1 | | Shift-Tab (~621) | Prevent outdent of column wrappers — already guarded by Phase 1 |

      Step 5: Editor — Block Manipulation Commands

        frontend/packages/editor/src/blocknote/core/api/blockManipulation/commands/nestBlock.ts

        Phase 1 already prevents nesting in Grid. Extend to:

          Prevent nesting column wrapper blocks (direct children of Columns container)

          Content blocks INSIDE a column CAN still be nested normally (they're inside a regular Group)

        frontend/packages/editor/src/blocknote/core/api/blockManipulation/commands/mergeBlocks.ts

          canMerge(): Add guard — blocks in different columns cannot merge

          getPrevBlockInfo(): Stop traversal at column wrapper boundaries

      Step 6: Editor — Slash Menu

        frontend/packages/editor/src/slash-menu-items.tsx

        {
          name: 'Columns',
          aliases: ['cols', 'side-by-side', 'split'],
          group: 'Layout',
          icon: ColumnsIcon,
          execute: (editor) => {
            // 1. Replace current block with empty block, set childrenType: 'Columns'
            // 2. Create 2 child blocks (column wrappers), each with childrenType: 'Group'
            // 3. Each column wrapper gets 1 empty paragraph child
            // 4. Place cursor in first column's paragraph
          }
        }
        

      Step 7: Editor — Column Management UI

        Floating toolbar on the Columns container (on hover/selection):

          "Add Column" button — creates a new column wrapper child

          "Remove Column" button — removes the last column (min 2 enforced)

          Column count display (e.g., "2 columns")

          "Convert to normal blocks" — flattens: removes column wrappers, moves all content blocks to parent level

        Column width resize handles:

          Draggable divider between columns

          Updates columnWidths attribute on the container block

          Visual feedback during drag

      Step 8: Read-Only Rendering

        frontend/packages/ui/src/blocks-content.tsxBlockNodeList (line 464):

        if (childrenType === 'Columns') {
          return (
            <div
              className="flex flex-row gap-4 w-full"
              data-node-type="blockGroup"
              data-list-type="Columns"
            >
              {children}
            </div>
          )
        }
        

        BlockNodeContent: When parent is Columns:

          Each child renders as a flex item with flex: 1 (or proportional width from columnWidths)

          Hide the empty container paragraph block (invisible wrapper)

          Column content renders normally via recursive BlockNodeList

      Step 9: Editor — Drag and Drop

        frontend/packages/editor/src/blocknote/core/extensions/SideMenu/SideMenuPlugin.ts

          blockPositionsFromSelection(): Prevent selection spanning multiple columns

          onDrop(): Calculate drop target with column awareness — blocks dropped on a column land inside it

          Future follow-up: side-drop indicators (like BlockNote's multiColumnDropCursor) for dragging blocks into new columns

      Step 10: Responsive Behavior

        CSS media queries:

          Below tablet breakpoint: flex-direction: column — columns stack vertically

          Column widths reset to 100% on mobile

    Testing

      Unit Tests — Block Conversion

        frontend/packages/shared/src/client/__tests__/hmblock-to-editorblock.test.ts

        Add to existing describe('childrenType'):

        test('Columns', () => {
          const hmBlock: HMBlock = {
            id: 'foo',
            type: 'Paragraph',
            text: '',
            annotations: [],
            attributes: {childrenType: 'Columns'},
            revision: 'revision123',
          }
          const val = hmBlockToEditorBlock(hmBlock)
          expect(val.props.childrenType).toBe('Columns')
        })
        
        test('Columns with columnWidths attribute', () => {
          const hmBlock: HMBlock = {
            id: 'foo',
            type: 'Paragraph',
            text: '',
            annotations: [],
            attributes: {childrenType: 'Columns', columnWidths: [60, 40]},
            revision: 'revision123',
          }
          const val = hmBlockToEditorBlock(hmBlock)
          expect(val.props.childrenType).toBe('Columns')
        })
        
        test('Columns with nested children structure', () => {
          const blocks: HMBlockNode[] = [
            {
              block: {
                id: 'cols-1',
                type: 'Paragraph',
                text: '',
                annotations: [],
                attributes: {childrenType: 'Columns'},
              },
              children: [
                {
                  block: {
                    id: 'col-1',
                    type: 'Paragraph',
                    text: '',
                    annotations: [],
                    attributes: {childrenType: 'Group'},
                  },
                  children: [
                    {
                      block: {
                        id: 'p1',
                        type: 'Paragraph',
                        text: 'Left',
                        annotations: [],
                        attributes: {},
                      },
                      children: [],
                    },
                  ],
                },
                {
                  block: {
                    id: 'col-2',
                    type: 'Paragraph',
                    text: '',
                    annotations: [],
                    attributes: {childrenType: 'Group'},
                  },
                  children: [
                    {
                      block: {
                        id: 'p2',
                        type: 'Paragraph',
                        text: 'Right',
                        annotations: [],
                        attributes: {},
                      },
                      children: [],
                    },
                  ],
                },
              ],
            },
          ]
          const result = hmBlocksToEditorContent(blocks)
          expect(result[0].props.childrenType).toBe('Columns')
          expect(result[0].children).toHaveLength(2)
          expect(result[0].children[0].children).toHaveLength(1)
          expect(result[0].children[1].children).toHaveLength(1)
        })
        

        frontend/packages/shared/src/client/__tests__/editorblock-to-hmblock.test.ts

        Add to existing describe('childrenType'):

        test('Columns', () => {
          const editorBlock: EditorBlock = {
            id: 'foo',
            type: 'paragraph',
            children: [],
            props: {childrenType: 'Columns'},
            content: [],
          }
          const val = editorBlockToHMBlock(editorBlock)
          expect(val.attributes.childrenType).toBe('Columns')
        })
        

      Unit Tests — Editor Commands

        frontend/packages/editor/src/blocknote/core/api/blockManipulation/__tests__/nestBlock.test.ts

        describe('Columns container', () => {
          it('prevents nesting column wrapper blocks', () => {
            // Column wrapper (direct child of Columns) cannot be indented
            const doc = buildDoc(schema, [
              {
                id: 'cols-container',
                text: '',
                children: {
                  listType: 'Columns',
                  blocks: [
                    {
                      id: 'col-1',
                      text: '',
                      children: {blocks: [{id: 'p1', text: 'Left'}]},
                    },
                    {
                      id: 'col-2',
                      text: '',
                      children: {blocks: [{id: 'p2', text: 'Right'}]},
                    },
                  ],
                },
              },
            ])
            const state = EditorState.create({doc, schema})
            const editor = createMockEditor(state)
            const pos = findPosInBlock(doc, 'col-2')
            const result = nestBlock(editor, pos)
            expect(result).toBe(false)
          })
        
          it('allows nesting content blocks inside a column', () => {
            // Content inside a column (regular Group) CAN be nested normally
            const doc = buildDoc(schema, [
              {
                id: 'cols-container',
                text: '',
                children: {
                  listType: 'Columns',
                  blocks: [
                    {
                      id: 'col-1',
                      text: '',
                      children: {
                        blocks: [
                          {id: 'p1', text: 'First'},
                          {id: 'p2', text: 'Second'},
                        ],
                      },
                    },
                  ],
                },
              },
            ])
            const state = EditorState.create({doc, schema})
            const editor = createMockEditor(state)
            const pos = findPosInBlock(doc, 'p2')
            const result = nestBlock(editor, pos)
            expect(result).toBe(true)
          })
        })
        

        frontend/packages/editor/src/blocknote/core/api/blockManipulation/__tests__/mergeBlocks.test.ts

        describe('Columns container', () => {
          it('prevents merging blocks across column boundaries', () => {
            // Block at end of col-1 + Backspace should NOT merge with start of col-2
            const doc = buildDoc(schema, [
              {
                id: 'cols-container',
                text: '',
                children: {
                  listType: 'Columns',
                  blocks: [
                    {
                      id: 'col-1',
                      text: '',
                      children: {blocks: [{id: 'p1', text: 'Left'}]},
                    },
                    {
                      id: 'col-2',
                      text: '',
                      children: {blocks: [{id: 'p2', text: 'Right'}]},
                    },
                  ],
                },
              },
            ])
            const state = EditorState.create({doc, schema})
            const editor = createMockEditor(state)
            // Place cursor at start of p2 and try to merge backwards
            const pos = findPosInBlock(doc, 'p2')
            const result = mergeBlocks(editor, pos)
            expect(result).toBe(false)
          })
        })
        

      Unit Tests — Type Schema

        frontend/packages/shared/src/__tests__/hm-types.test.ts

        test('HMBlockChildrenType accepts Columns', () => {
          expect(HMBlockChildrenTypeSchema.parse('Columns')).toBe('Columns')
        })
        

    Pre-Completion Checks

      # Format all code
      pnpm format:write
      
      # Type check everything
      pnpm typecheck
      
      # Run shared package tests (block conversion)
      pnpm --filter @shm/shared test
      
      # Run editor package tests (block manipulation)
      pnpm --filter @shm/editor test
      
      # Run all tests
      pnpm test
      

      All must pass with zero failures.

    Files to Modify

      | File | Changes | | ---------------------------------------------------------------------------------------------- | ------------------------------------------ | | frontend/packages/shared/src/hm-types.ts | Add 'Columns' to schema | | frontend/packages/editor/src/blocknote/core/extensions/Blocks/api/defaultBlocks.ts | Add 'Columns' to values | | frontend/packages/editor/src/blocknote/core/extensions/Blocks/nodes/BlockChildren.ts | Flex row rendering | | frontend/packages/editor/src/blocknote/core/extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts | Cross-column merge guards, Enter guard | | frontend/packages/editor/src/blocknote/core/api/blockManipulation/commands/nestBlock.ts | Prevent nesting column wrappers | | frontend/packages/editor/src/blocknote/core/api/blockManipulation/commands/mergeBlocks.ts | Prevent cross-column merges | | frontend/packages/editor/src/blocknote/core/extensions/SideMenu/SideMenuPlugin.ts | Column-aware drag-drop | | frontend/packages/editor/src/slash-menu-items.tsx | /columns slash command | | frontend/packages/shared/src/client/hmblock-to-editorblock.ts | Accept 'Columns' | | frontend/packages/shared/src/client/editorblock-to-hmblock.ts | Pass through 'Columns' | | frontend/packages/ui/src/blocks-content.tsx | Flex row rendering in BlockNodeList |

    Test Files

      | File | Additions | | -------------------------------------------------------------------------------------------------------- | ------------------------------------- | | frontend/packages/shared/src/client/__tests__/hmblock-to-editorblock.test.ts | Columns conversion + nested structure | | frontend/packages/shared/src/client/__tests__/editorblock-to-hmblock.test.ts | Columns reverse conversion | | frontend/packages/editor/src/blocknote/core/api/blockManipulation/__tests__/nestBlock.test.ts | Prevent nesting column wrappers | | frontend/packages/editor/src/blocknote/core/api/blockManipulation/__tests__/mergeBlocks.test.ts | Cross-column merge prevention | | frontend/packages/shared/src/__tests__/hm-types.test.ts | Columns schema validation |

    Backwards Compatibility

      Old clients: 'Columns' falls through to default <ul class="pl-3"> — content visible, stacked vertically

      Column wrapper blocks render as empty paragraphs with nested content — readable but not side-by-side

      No proto changes needed (attributes are open Struct)

      No CRDT changes needed