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