Skip to content

Block Ordering Bugs & Fractional Indexing Solution ​

Date: 2026-04-20 Context: Bugs discovered during block-grouping branch work Plan: Application-Frontend/docs/superpowers/plans/2026-04-19-fractional-block-ordering.md Branch: block-grouping (AF + CE)

The Four Ordering Bugs ​

Deep investigation during block-grouping implementation revealed four bugs in the block ordering system -- some pre-existing, some exposed by groups.

Bug 1: sortedBlocks getter mutates block.order during read ​

File: AF/src/store/modules/blocks.ts (lines 1352-1356 on block-grouping branch)

The sortedBlocks Vuex getter sorts blocks descending, then uses forEachRight to reassign order values 0, 1, 2... as a side effect. This means order values are unstable -- they change on every render cycle.

typescript
// Current (buggy):
sortedBlocks: (state) => {
  const blocks = getTopLevelBlocks(state.creativeBlocks)
  const sorted = sortBy(blocks, 'order').reverse()
  forEachRight(sorted, (block, index) => {
    block.order = index  // MUTATION INSIDE GETTER
  })
  return sorted
}

Impact: Order values get silently rewritten on every computed evaluation. This was always a bug, but it gets worse with groups because group children have their own order space that can also get corrupted.

Bug 2: Sort area index calculations are confused ​

File: AF/src/pages/.../Blocks/BlocksList.vue (line ~273)

The formula draggingBlock.order > block.order ? block.order : block.order - 1 produces wrong results when the getter has already reassigned orders. The sort area between a group and the block below it gets an index equal to the block's own order, so isValidSortLocation fires the "you're already here" check and hides the drop target.

Bug 3: CE visual elements not sorted by order ​

File: CE/src/components/creative/VisualElements/CreativeVisualElements.vue

Uses pickBy() which returns an unordered object. Visual elements render in object key insertion order, NOT by their order property. This means z-index stacking order doesn't match the AF block list order.

Bug 4: Groups and visual elements in separate DOM sections ​

File: CE/src/components/conversationflow/CreativeBody.vue

Groups render first (sorted ascending), then visual elements (unsorted). Visual elements always end up on top in DOM order regardless of their z-index relative to groups.

What order Actually Controls ​

  • In CE: order = z-index directly (via BlockMixin.ts line 38: zIndex: block.order). It's pure stacking context, NOT DOM order.
  • In AF: order = display position in the descending-sorted block list. Higher order = higher in list = in front visually.

Why Fractional Indexing ​

Three approaches were evaluated:

1. Fix Current Integer System ​

Patch the four bugs without changing the data model. The getter mutation fix is straightforward, but the real issue is that normalizeOrders() is called 6 times across group mutations to renumber orders to 0, 1, 2. Every mutation needs to know the full sibling list and reassign. Error-prone and couples every mutation to every other.

2. Fractional Indexing (chosen) ​

Use string-based ordering keys via the fractional-indexing npm package. generateKeyBetween(a, b) where a < b produces a key between them. Inserts never disturb existing keys. No normalization ever needed.

3. Array-Based Ordering ​

Store block order as an array of blockNames. Simple but breaks the "blocks as object properties" storage pattern used everywhere.

Why Fractional Wins ​

AspectInteger (current)FractionalArray
Insert betweenRenumber all siblingsO(1), no side effectsArray splice
Move to groupnormalizeOrders + syncGroupChildrengenerateKeyBetween, doneComplex
Code removedNone~80 lines of normalizeOrders + calls~same
CE compatibilityAlready broken (Bug 1-4)Fix + simplifyMajor rewrite
RiskLow (patches)Medium (type change)High

Research: How Modern Design Tools Handle Ordering ​

ToolOrdering Mechanism
FigmaFractional indexing (string keys). Invented for their multiplayer CRDT.
tldrawFractional indexing via @tldraw/indices package. Open source, well-documented.
ExcalidrawFractional indexing. Switched from integers after hitting the same reordering bugs.
SketchArray-based layer order. Works because they don't have real-time collaboration.
NotionFractional indexing for block ordering within pages.

The pattern is clear: any tool that needs insert-between-without-reindex uses fractional indexing. It's the industry standard for design tools.

Key Implementation Decisions ​

order: number to order: string ​

All blocks change from order: number to order: string. This affects:

  • AF types.ts: BlockBase.order typed as number at 3 places
  • CE types: 2 places
  • AF defaults.ts: 28 hardcoded order: <number> values (addBlock must always override default order with computed fractional key)

z-index derivation in CE ​

Fractional keys like 'a0', 'a1' can't be used as CSS z-index directly. The engine must derive _zIndex from sorted position:

typescript
// In CE, after sorting blocks by order string:
sortedBlocks.forEach((block, index) => {
  Vue.set(block, '_zIndex', index)  // Vue.set required for Vue 2 reactivity
})

BlockMixin.ts changes from zIndex: block.order ?? -1 to zIndex: block._zIndex ?? -1.

SortArea simplification ​

Instead of computing index from order arithmetic, SortAreas pass keyA/keyB (the order strings of the blocks above and below the drop target). The drop handler calls generateKeyBetween(keyA, keyB) to get the new key. No isValidSortLocation needed -- the math is inherently correct.

Code removed ​

  • normalizeOrders() function and all 6 call sites
  • sortedBlocks getter order-mutation side effect
  • isValidSortLocation complex comparison logic
  • Sort area index formula (draggingBlock.order > block.order ? ...)

Code added ​

  • fractional-indexing npm dependency (~2KB)
  • generateKeyBetween calls in addBlock, updateBlockOrder, groupBlocks, moveBlockToGroup, moveBlockOutOfGroup, duplicateBlock
  • _zIndex computation in CE

Net effect ​

Estimated ~80 lines removed, ~40 lines added. The remaining code is simpler because each mutation is self-contained: compute the key between neighbors, set it, done.

Migration ​

Existing creatives with order: number will be handled by the setCreativeBlocks mutation that initializes block state. It reads blocks from the backend and can convert integer orders to fractional keys on load:

typescript
// In setCreativeBlocks, after loading:
if (typeof block.order === 'number') {
  // Legacy migration: convert integer to fractional key
  block.order = generateNKeysBetween(null, null, totalBlocks)[block.order]
}

No backend migration needed -- the order field is stored in the creativeBlob JSON and only interpreted by AF/CE.

Implementation Plan ​

Full 15-task, 5-chunk plan at: Application-Frontend/docs/superpowers/plans/2026-04-19-fractional-block-ordering.md

Tasks cover:

  1. Type changes + npm install
  2. AF store migration (addBlock, updateBlockOrder, etc.)
  3. SortArea/BlocksList simplification
  4. CE z-index derivation + CreativeBody/GroupBlock fixes
  5. BlockActionsMenu, duplicateBlock, existing creative migration

The plan was reviewed by two automated reviewer agents. 5 critical and 8 important issues were found and fixed:

  • SortArea prop inversion (keyA/keyB naming)
  • Missing TypeScript type updates
  • defaults.ts numeric order overrides
  • BlockActionsMenu string concatenation ('a0' + 1 = 'a01')
  • Missing expandable/locked checks in canDropHere
  • CE Vue.set reactivity for _zIndex
  • CE groupBlocks sort with Number('a0') returning NaN
  • duplicateBlock getting same order key via cloneDeep

Internal documentation