Thank you for being patient! We're working hard on resolving the issue
The complete lifecycle of a gesture from a DOM pointer event to a persisted mutation, then back through invalidation to the next render. Use this doc to debug where in the chain a gesture is hanging or to understand which actor owns which responsibility.
This page is the reference companion to
Gestures, which is the
how-to. The lifecycle below describes both layers — item-level
(renderer-owned via Span.callbacks) and column-level (via
CalendarGesture.Manager).
| Actor | Module | Responsibility |
|---|---|---|
| Plugin | your RenderPlugin<…> impl | Constructs subcolumns; injects mutation targets |
| Subcolumn factory | CalendarSubcolumn.Provider.subcolumnFromRow | Loads records; builds cards + handlers; populates Span.callbacks |
| Component | your LonaWebComponent (e.g. LonaCalendarTaskCard) | Visual content only; reflects state via attributes |
| Handler | renderer-private class (e.g. TaskCardHandler) | Holds drag state; builds Callbacks; paints ghost; calls target |
| Wrapper cell | CalendarColumnCell | Geometry: edge classification, capture, position resolve |
| Mutation target | renderer-defined interface (TaskMutationTarget, …) | Translates (ref, range) to producer-row writes |
| Producer client | domain client for the source row | Issues the actual API write |
| Sheet host | CalendarSheetPlugin + CalendarDaysRow | Lays out subcolumns; wires bindCallbacks; rebinds on invalidation |
GESTURE_MANAGER | shared singleton | Native pointer dispatch on cells; column-level routing |
CalendarGesture.Manager | parent-plugin-owned | Column-level gesture state machine |
The lifecycle of a drag-resize on an existing item, from cold start to persisted state.
sheet renders a row of type "task-list"
│
▼
PluginManager.getPlugin("task-list") → TaskListPlugin
│
▼
TaskListPlugin.bindCell($cell, context)
│ parent calendar plugin discovers child rows and asks each
│ for its subcolumn:
▼
TaskListPlugin.subcolumnProvider.subcolumnFromRow(sheet, row, parentRow)
├── builds TaskMutationTarget (closes over producer event client)
└── returns { renderer: "spans", makeSpans, makeLayout }
│
▼
CalendarDaysRow.renderSpans (parent plugin)
├── awaits subcolumn.makeSpans(windowed, tzr)
│ for each TaskRecord:
│ ├── $card = LonaCalendarTaskCard.make(); $card.bind(record, …)
│ ├── handler = new TaskCardHandler(record, target, …, $card)
│ └── span = { id, $e: $card, start, end,
│ pointerEvents: "all",
│ callbacks: handler.callbacks() }
├── for each span:
│ ├── wraps $card in CalendarColumnCell.makeWith({content: $card})
│ ├── sets style --default-interactable from span.pointerEvents
│ └── wrapper.bindCallbacks(span.callbacks, "task-card")
└── appends wrapper into the lane layout
After Phase 1 the DOM is hot and ready for input. The wrapper cell
holds a reference to the renderer's Callbacks. The handler holds
references to target, record, and $card. Nothing is captured;
no pointer events are pending.
user presses pointer on a card body (or top/bottom 4px edge)
│
▼
DOM pointerdown event on the wrapper CalendarColumnCell
│
▼
GESTURE_MANAGER.addPointerEvent (registered in cell.bindCallbacks)
├── reads offset.x, offset.y via the cell's bounding rect
└── delegates to the cell's onPointerDown closure:
├── classifies handle:
│ offset.y <= 4 → "top"
│ offset.y >= H - 4 → "bottom"
│ else → null (body)
├── builds PointerPosition(offset, layoutInfo.time.start)
└── invokes callbacks.onPointerDown({position, handle})
│
▼
TaskCardHandler.onPointerDown
├── stores dragStart = {handle, range: record.time, startTime: position.resolve(...)}
└── card.toggleAttribute("dragging", true) // visual reflects state
At this point: the cell has not called setPointerCapture yet —
that decision is made on pointermove. The handler is armed.
user moves pointer (any direction)
│
▼
DOM pointermove on the wrapper cell
│
▼
CalendarColumnCell.onpointermove
├── e.stopPropagation()
├── builds PointerPosition from offsetX/offsetY
└── invokes callbacks.onPointerMove({position})
│
▼
TaskCardHandler.onPointerMove
├── if dragStart is null: return { shouldCapturePointer: false }
├── t = position.resolve(windowed, pxPerHr)
├── proposed = derive(t) // depends on dragStart.handle
├── updateGhost(proposed) // paints/repositions sibling DOM
└── return { shouldCapturePointer: true }
│
▼
CalendarColumnCell honors return:
├── true → setPointerCapture(e.pointerId)
└── false → releasePointerCapture(e.pointerId)
Once captured, subsequent pointermove and pointerup events fire
on the captured cell even if the pointer has moved outside its
bounds. This is what lets a user drag past the edge of the column
without losing the gesture.
user releases pointer
│
▼
DOM pointerup on the captured wrapper cell
│
▼
GESTURE_MANAGER.addPointerEvent.onClick (cell's pointer-up wrapper)
└── invokes callbacks.onPointerUpCell({position})
│
▼
TaskCardHandler.onPointerUpCell (async)
├── if dragStart is null: return
├── t = position.resolve(windowed, pxPerHr)
├── proposed = derive(t)
├── dragStart = null
├── card.toggleAttribute("dragging", false)
├── removeGhost()
└── await target.setTime(record.ref, proposed)
│
▼
TaskMutationTarget.setTime
├── looks up the producer-row task client by record.ref.rowId
└── calls client.setTime(record.ref.itemId, proposed)
│
▼
Producer task client
├── optimistic local update (if supported)
├── network write
└── emits row invalidation when ack'd
The handler's await resolves when the target write is acknowledged.
At that moment the local row store has been updated (optimistically,
typically) and the rest of the system is told to re-render.
producer client emits invalidation for record.ref.rowId
│
▼
SheetViewModel observes the row change
│
▼
sheet diffs the affected rows; schedules rebind for changed cells
This phase happens regardless of whether the gesture started here — the same invalidation path fires when someone else edits the same event from another client.
sheet rebinds affected cells
│
▼
CalendarDaysRow.renderSpans (same as Phase 1)
├── awaits subcolumn.makeSpans(windowed, tzr) // fresh fetch
│ ├── records returned now reflect the committed change
│ └── each $e is recycled by id when possible
├── for spans whose id is unchanged, recycles the existing wrapper
└── for new ids, runs Phase 1 wrapping
The handler from Phase 1 was discarded after pointerUpCell (its
dragStart is null and nothing references it). A fresh handler is
built for the new record on the next makeSpans cycle.
The lifecycle of a drag-to-create. Most of Phase 1 is shared — the
key difference is the wrapper cell ends up with
asEmptyCellDelegate(GestureInfo) instead of span.callbacks.
TaskListPlugin.subcolumnProvider.subcolumnFromRow returns
{ renderer: "spans", makeSpans, makeLayout, createHandler }
│
SubcolumnCreateHandler ────────────────────┘
├── onDragCreate(range, ctx) — what to do on commit
├── onClickCreate(position, ctx)
├── updateGhost(range)
└── clearGhost()
CalendarDaysRow.renderSpans
├── for cells with no span at this time slot, the wrapper gets
│ bindCallbacks(asEmptyCellDelegate(GestureInfo))
└── GestureInfo carries createHandler so column gestures can call it
App-layer wiring (one-time):
CalendarGesture.Manager.addGesture(new DragToCreateTask(handle, coord))
user presses pointer on empty space
│
▼
CalendarColumnCell.onPointerDown classifies handle (always null for empty)
└── invokes asEmptyCellDelegate.onPointerDown({position, handle: null})
│
▼
asEmptyCellDelegate forwards to CalendarGesture.Manager
├── for each registered gesture, in registration order:
│ ├── if gesture.guard(info) is false: skip
│ ├── if gesture.canHandle(event) is false: skip
│ └── else: gesture.onPointerDown(...)
└── gesture returns { finished: false } → stays active
If multiple gestures canHandle the event, the manager activates all
of them concurrently. allowsConcurrentGestures() decides whether a
strong gesture preempts weaker ones.
user drags pointer
│
▼
asEmptyCellDelegate.onPointerMove forwards to active gestures
│
▼
DragToCreateTask.onPointerMove
├── computes endTime from position
├── info.createHandler.updateGhost(range)
└── return { shouldCapturePointer: true }
│
▼
Cell honors return → setPointerCapture
The createHandler.updateGhost callback is the plugin's ghost
renderer — the create flow has its own ghost concept distinct from
the per-item handler ghost in the item-level path.
user releases pointer
│
▼
asEmptyCellDelegate.onPointerUpCell or onPointerUpColumn
│
▼
DragToCreateTask.onPointerUpColumn
├── computes final range
├── info.createHandler.onDragCreate(range, ctx)
│ │
│ ▼
│ Plugin's create flow:
│ ├── reveal popover with create form
│ ├── on form submit: construct new record
│ └── target.create(/* row, range, fields */) or equivalent
├── info.createHandler.clearGhost()
└── return { finished: true } → gesture removed from active set
Column-level gestures often defer the actual write until the user fills in a form. The gesture itself only opens UI; the form submit calls the mutation target. Phases 5–6 (invalidation, rebind) are identical to the item-level lifecycle.
A gesture can be cancelled before commit. Sources of cancellation:
| Trigger | Item-level behavior | Column-level behavior |
|---|---|---|
| User releases outside any cell | onPointerUpCell still fires (cell captured); handler may treat as commit or no-op | onPointerUpColumn fires; gesture decides |
| Strong gesture preempts | n/a (cell-bound capture isolates) | onFinished(cancelledBy) called; clean up state, dismiss menus |
| Pointer cancelled (e.g. system gesture) | lostpointercapture in browser; handler should reset on next interaction | onFinished(null) called |
| Component disconnected mid-gesture | DOM removal releases capture; no onPointerUpCell fires; handler is GC'd | gesture stays active; clean up via coordinator.dismissOtherSystems if menu open |
Handlers should be defensive about dragStart === null in
onPointerUpCell (the user might have released without a prior
move) and idempotent about removeGhost().
| Phase | Component ($card) | Handler | Wrapper cell | Sheet store |
|---|---|---|---|---|
| Bind | bound from record | constructed, dragStart=null | wraps card; bindCallbacks(handler.callbacks()) | row record |
| Pointer down | dragging=true attr | dragStart set | recording pointer id (not yet captured) | unchanged |
| Pointer move | unchanged | dragStart updated; ghost painted | captured (after first move) | unchanged |
| Pointer up | dragging=false | dragStart=null; ghost removed | capture released | optimistic write applied |
| Invalidation | unchanged | unchanged (will be discarded) | unchanged | row record updated |
| Rebind | re-bound from new record (or recycled) | new handler instance | reused (same id) or replaced | up to date |
Span.pointerEvents is "all" (not "none") and the wrapper
cell's CSS pointer-events resolves to auto via
--default-interactable. Inspect the cell's bindCallbacks
registration in DevTools — there should be exactly one
addPointerEvent registration per cell.handle is always null. The cell only classifies edges when
the offset is within 4px. Confirm the cell has the expected height
(offsetHeight > 8px) and the user is actually pressing within
the edge zone. The cell's editable attribute toggles the visual
resize-handle indicators but does not change classification.shouldCapturePointer doesn't capture. Verify the handler
returns { shouldCapturePointer: true } from onPointerMove
every move once the drag starts (not just the first move). The
cell calls releasePointerCapture on any move that returns
false.target.setTime is awaiting
through to the actual API write and that the optimistic local
update is applied.LiveData<TaskRecord> instead of a plain field.subcolumnProviderRenderPlugin
and SheetDelegate