Files
crewli/dev-docs/superpowers/plans/2026-05-17-gui-redesign-tier1-primitives-DRAGGABLEBLOCK-CONTRACT.md

3.7 KiB

Plan 3 — DraggableBlock contract & reconciliation gates

Theme alignment

The parity harness spans two PrimeVue installations that configure dark-mode differently. The divergence and its resolution are recorded here as spec-of-record.

crewli-starter: darkModeSelector: '[data-theme="dark"]', dark primary hardcoded #1eafb1, bespoke surface block. apps/app: darkModeSelector: '.dark' (RFC-WS-FRONTEND-PRIMEVUE AD-2, matches Vuexy), dark primary {primary.400} Aura ramp, RFC Appendix B surface tokens. Decision (option b — normalise at the harness): v2 components NEVER hardcode a dark selector or a semantic hex; they consume var(--p-*) Aura tokens only, which resolve correctly under apps/app's .dark. The parity harness renders the crewli-starter reference under [data-theme="dark"] and the v2 component under .dark; the human parity-check compares rendered pixels, not selector strings. The #1eafb1 vs {primary.400} ramp delta is accepted (same teal family; RFC AD-2 owns the apps/app ramp) and explicitly recorded so a dark-mode parity diff is read as theme-by-design, not a component bug.

DraggableBlock dual-consumer reconciliation

Spec §7.1 is the canonical DraggableBlock contract. Two crewli-starter consumers use fundamentally different drag models — TimetableGrid uses a mousedownwindow mousemove/mouseup pointer approach, while CueTimelineEditor uses native HTML5 drag (draggable="true", dataTransfer.setData). Both map cleanly to the §7.1 prop/emit contract as shown below.

TimetableGrid performance block → §7.1
  artist.name                → line1Left.text
  status tag (engagement)    → line1Left.tag {label, severity}
  genre                      → line1Right.pill
  capacity/conflict warn     → line1Right.tag {label:'!', severity:'danger'}
  blockTime(start,end)       → line2Left
  advanceCount done/total    → line2Right.progress  (0..1 → 0..100)
  selected (selectedId)      → selected
  drag.value!=null           → dragging
  baseRowHeight 56/64/76     → density compact|regular|comfy

CueTimelineEditor cue block → §7.1
  cue.label                  → line1Left.text
  cue.kind tag               → line1Left.tag {label, severity}
  cue.dest pill              → line1Right.pill
  (none)                     → line1Right.tag
  cue.time                   → line2Left
  (none)                     → line2Right  (null — no progress on cues)
  selectedCueId===cue.id     → selected
  drag?.id===cue.id          → dragging
  fixed 'regular'            → density

Drag model

Drag model = PointerEvents, parent owns positioning. DraggableBlock is presentational. On pointerdown (primary button) it calls setPointerCapture, tracks a 3px move threshold (TimetableGrid parity), emits dragstart: [e: PointerEvent] once threshold is crossed, and on pointerup/lostpointercapture emits dragend: [delta: { x: number; y: number }] (clientX/Y minus start). It performs zero snap/lane/px-min math. TimetableGrid keeps startDragMove's math but is driven by @dragstart/@dragend instead of its own mousedown. CueTimelineEditor drops HTML5 drag and adopts the same emits. click is emitted only when no drag occurred (threshold not crossed). vuedraggable is not used (wrong abstraction for free-position blocks; spec §7.1).

Retrofit proof

Plan 3 is incomplete without a retrofit proof per consumer: DraggableBlock.stories.ts MUST include an ArtistBlock story (TimetableGrid usage expressed in the §7.1 contract) and a CueBlock story (CueTimelineEditor usage in the §7.1 contract). These prove the abstraction expresses both consumers without either consumer's page existing yet (Tier-4 defers the pages, not this proof).