Context

      Documents are trees of BlockNode { block, children }. Blocks stack vertically — no way to flow items into a multi-column grid. The Query block has a columnCount attribute with CSS grid rendering in DocumentCardGrid, but this only applies to query results (document cards), not arbitrary block content.

      We want a Flow Grid layout: items wrap into N columns automatically. Column count is defined by an attribute, and items stack/wrap when there are more items than columns. Think Pinterest / card grid / the Query block's card view — but for any block type.

      Why childrenType and not dedicated block types?

        Our editor is forked from an old BlockNote. The ProseMirror schema enforces blockNode → block blockChildren?block nodes can only contain inline* or ''. Adding custom PM nodes outside this hierarchy (like Notion's column_list/column or BlockNote v0.19's @blocknote/xl-multi-column) requires refactoring 15-20+ editor files. The childrenType approach produces the same tree shape but fits within the existing schema.

    Data Model

      BlockNode {
        block: {
          id: "grid-1",
          type: "Paragraph",
          text: "",
          attributes: { childrenType: "Grid", columnCount: 3 }
        }
        children: [
          BlockNode { block: { id: "item-1", type: "Image", ... } }
          BlockNode { block: { id: "item-2", type: "Image", ... } }
          BlockNode { block: { id: "item-3", type: "Image", ... } }
          BlockNode { block: { id: "item-4", type: "Image", ... } }   <- wraps to row 2
        ]
      }
      

      ProseMirror tree:

      blockNode (container)
        block:paragraph ("")                    <- empty, invisible
        blockChildren [listType='Grid']         <- CSS grid
          blockNode -> block:image (...)
          blockNode -> block:image (...)
          blockNode -> block:image (...)
          blockNode -> block:image (...)        <- wraps to row 2
      

      Attributes on container block:

        columnCount: number (1-4, default 3) — max columns before wrapping

        gap: number (optional, px, default from layout unit)

    CRDT Behavior

      Uses existing parent-child tree mechanics — no changes needed:

        Grid items are children in the container's RGA sublist

        Concurrent adds: both items appear, ordered by opID

        Reordering: standard OpMoveBlocks

    Implementation Steps

      Step 1: Types & Schema

        frontend/packages/shared/src/hm-types.ts (line 42-44)

        // Before:
        z.union([
          z.literal('Group'),
          z.literal('Ordered'),
          z.literal('Unordered'),
          z.literal('Blockquote'),
        ])
        
        // After:
        z.union([
          z.literal('Group'),
          z.literal('Ordered'),
          z.literal('Unordered'),
          z.literal('Blockquote'),
          z.literal('Grid'),
        ])
        

        frontend/packages/editor/src/blocknote/core/extensions/Blocks/api/defaultBlocks.ts (line 14-17)

        // Add 'Grid' to childrenType values
        childrenType: {
          default: 'Group',
          values: ['Group', 'Unordered', 'Ordered', 'Blockquote', 'Grid'],
        }
        

      Step 2: Block Conversion

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

          In hmBlocksToEditorContent() where childrenType is validated/read: accept 'Grid' as valid value

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

          In editorBlockToHMBlock() where childrenType is written to attributes: pass through 'Grid'

          Ensure columnCount attribute is preserved in conversion

      Step 3: Editor — BlockChildren Rendering

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

          listNode() (line 228-238): Add case for 'Grid' — render as <div> with CSS grid classes

          addInputRules(): Add guard so list input rules (- , 1. , > ) don't trigger inside Grid containers

      Step 4: Editor — Keyboard Guards

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

        Add utility: isInGridContainer(state, pos) -> boolean — checks if current block's parent blockChildren has listType='Grid'.

        | Handler | Guard | | ------------------- | -------------------------------------- | | handleTab (~541) | Prevent indent when parent is Grid | | Shift-Tab (~621) | Prevent outdent of grid children |

        Note: Enter, Backspace, Delete work normally inside a Grid — items are flat siblings, no cross-boundary issues.

      Step 5: Editor — Block Manipulation Commands

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

          sinkListItem() / canNestBlock(): Return false if parent is Grid (prevent indenting grid items)

          liftListItem() / canUnnestBlock(): Return false if block's parent group is Grid

      Step 6: Editor — Slash Menu

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

        Add slash menu item:

        {
          name: 'Grid',
          aliases: ['gallery', 'cards', 'grid'],
          group: 'Layout',
          icon: GridIcon,
          execute: (editor) => {
            // 1. Replace current block with empty block, set childrenType: 'Grid', columnCount: 3
            // 2. Create 3 empty paragraph child blocks
            // 3. Place cursor in first child
          }
        }
        

      Step 7: Editor — Grid Settings UI

        Create a toolbar/popover on the Grid container block that allows:

          Column count selector (1, 2, 3, 4) — updates columnCount attribute

          Reuse the pattern from the Query block's column count selector in frontend/apps/desktop/src/editor/query-block.tsx

      Step 8: Read-Only Rendering

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

        if (childrenType === 'Grid') {
          return (
            <div
              className={cn('grid gap-4 w-full', gridColumnClass(columnCount))}
              data-node-type="blockGroup"
              data-list-type="Grid"
            >
              {children}
            </div>
          )
        }
        

        Helper function:

        function gridColumnClass(count: number): string {
          switch (count) {
            case 1:
              return 'grid-cols-1'
            case 2:
              return 'grid-cols-1 sm:grid-cols-2'
            case 3:
              return 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3'
            case 4:
              return 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4'
            default:
              return 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3'
          }
        }
        

        BlockNodeContent: When inside a Grid, each child renders normally (no list markers). The empty container paragraph is hidden.

      Step 9: Responsive Behavior

        Tailwind responsive prefixes handle column reduction automatically:

          Mobile: 1 column

          Tablet (sm): 2 columns

          Desktop (md/lg): full columnCount

    Testing

      Unit Tests — Block Conversion

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

        Add to existing describe('childrenType') block (follows pattern of Group/Unordered/Ordered tests):

        test('Grid', () => {
          const hmBlock: HMBlock = {
            id: 'foo',
            type: 'Paragraph',
            text: '',
            annotations: [],
            attributes: {childrenType: 'Grid', columnCount: 3},
            revision: 'revision123',
          }
          const val = hmBlockToEditorBlock(hmBlock)
          expect(val.props.childrenType).toBe('Grid')
        })
        
        test('Grid with children preserves structure', () => {
          const blocks: HMBlockNode[] = [
            {
              block: {
                id: 'grid-1',
                type: 'Paragraph',
                text: '',
                annotations: [],
                attributes: {childrenType: 'Grid', columnCount: 2},
              },
              children: [
                {
                  block: {
                    id: 'item-1',
                    type: 'Paragraph',
                    text: 'Item 1',
                    annotations: [],
                    attributes: {},
                  },
                  children: [],
                },
                {
                  block: {
                    id: 'item-2',
                    type: 'Paragraph',
                    text: 'Item 2',
                    annotations: [],
                    attributes: {},
                  },
                  children: [],
                },
              ],
            },
          ]
          const result = hmBlocksToEditorContent(blocks)
          expect(result[0].props.childrenType).toBe('Grid')
          expect(result[0].children).toHaveLength(2)
        })
        

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

        Add to existing describe('childrenType') block:

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

      Unit Tests — Editor Commands

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

        Add test using existing buildDoc/createMockEditor helpers:

        describe('Grid container', () => {
          it('prevents nesting inside Grid', () => {
            const doc = buildDoc(schema, [
              {
                id: 'grid',
                text: '',
                children: {
                  listType: 'Grid',
                  blocks: [
                    {id: 'item-1', text: 'Item 1'},
                    {id: 'item-2', text: 'Item 2'},
                  ],
                },
              },
            ])
            const state = EditorState.create({doc, schema})
            const editor = createMockEditor(state)
            const pos = findPosInBlock(doc, 'item-2')
            // Tab should NOT indent item-2 inside a Grid
            const result = nestBlock(editor, pos)
            expect(result).toBe(false)
          })
        })
        

      Unit Tests — Type Schema Validation

        frontend/packages/shared/src/__tests__/hm-types.test.ts (add to existing or create):

        import {HMBlockChildrenTypeSchema} from '../hm-types'
        
        describe('HMBlockChildrenType', () => {
          test('accepts Grid', () => {
            expect(HMBlockChildrenTypeSchema.parse('Grid')).toBe('Grid')
          })
        
          test('rejects invalid values', () => {
            expect(() => HMBlockChildrenTypeSchema.parse('InvalidType')).toThrow()
          })
        })
        

    Pre-Completion Checks

      Run these before marking the task as done:

      # 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.

    Backwards Compatibility

      Old clients: 'Grid' falls through to default <ul class="pl-3"> in BlockNodeList — content visible, stacked vertically as a plain list

      No proto changes needed (attributes are open Struct)

      No CRDT changes needed