Appearance
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.mdBranch: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-indexdirectly (viaBlockMixin.tsline 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
| Aspect | Integer (current) | Fractional | Array |
|---|---|---|---|
| Insert between | Renumber all siblings | O(1), no side effects | Array splice |
| Move to group | normalizeOrders + syncGroupChildren | generateKeyBetween, done | Complex |
| Code removed | None | ~80 lines of normalizeOrders + calls | ~same |
| CE compatibility | Already broken (Bug 1-4) | Fix + simplify | Major rewrite |
| Risk | Low (patches) | Medium (type change) | High |
Research: How Modern Design Tools Handle Ordering
| Tool | Ordering Mechanism |
|---|---|
| Figma | Fractional indexing (string keys). Invented for their multiplayer CRDT. |
| tldraw | Fractional indexing via @tldraw/indices package. Open source, well-documented. |
| Excalidraw | Fractional indexing. Switched from integers after hitting the same reordering bugs. |
| Sketch | Array-based layer order. Works because they don't have real-time collaboration. |
| Notion | Fractional 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.ordertyped asnumberat 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 sitessortedBlocksgetter order-mutation side effectisValidSortLocationcomplex comparison logic- Sort area index formula (
draggingBlock.order > block.order ? ...)
Code added
fractional-indexingnpm dependency (~2KB)generateKeyBetweencalls in addBlock, updateBlockOrder, groupBlocks, moveBlockToGroup, moveBlockOutOfGroup, duplicateBlock_zIndexcomputation 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:
- Type changes + npm install
- AF store migration (addBlock, updateBlockOrder, etc.)
- SortArea/BlocksList simplification
- CE z-index derivation + CreativeBody/GroupBlock fixes
- 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