left4me/docs/superpowers/specs/2026-05-08-l4d2-blueprint-overlay-picker-design.md
mwiegand dec4fed809
docs(specs): blueprint overlay picker — drag-list + add-dropdown
Replace per-row checkbox + numeric Order inputs with a drag-to-reorder
list of selected overlays plus a native <select> for adding more.
Native HTML5 DnD; no library, no JS-disabled fallback. Server contract
unchanged (overlay_ids in DOM order; existing fallback_position branch
absorbs the omitted overlay_position_<id> fields).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:32:45 +02:00

8.5 KiB
Raw Blame History

L4D2 Blueprint Overlay Picker Design

Goal: Replace the checkbox + numeric-Order table on the blueprint detail page with a drag-to-reorder list and a single dropdown to add overlays. Drag-and-drop is the primary reorder mechanic; per-row Order text inputs are removed.

Approval status: User-approved. No companion implementation plan — small surface, implemented directly.

Context

templates/blueprint_detail.html:14-28 currently renders one HTML table for the blueprint's overlays. Each row carries a Use checkbox, a numeric Order text input, and the overlay name. To enable an overlay you check it; to reorder you type integers into per-row text fields. Adding a new overlay between existing ones means renumbering everything below it by hand.

This spec replaces that table with a single ordered list of selected overlays plus a <select> dropdown for adding more. Drag-to-reorder is the only reorder interaction. A ✕ button on each row removes it (returning it to the dropdown). Picking an entry from the dropdown appends it to the list (and removes it from the dropdown).

The change is intentionally scoped small: no two-panel layout, no filter widget, no touch / keyboard reorder support, no JS-disabled fallback. The native <select> element supplies typeahead-by-letter and keyboard navigation for free, which covers the no-drag path. The page is desktop-primary.

Locked Decisions

  1. Single ordered list of selected overlays only. No second pane. The "available" set lives in the <select>. Adding via dropdown is one click; removing via ✕ is one click; reordering is one drag.
  2. Native HTML5 drag-and-drop. No vendored library, no polyfill. Touch-screen drag is unsupported on Android and rough on iOS — accepted because the page is desktop-primary. Add and remove still work on touch via the <select> and the <button>.
  3. JS-required UI. If JS does not load, the page is unusable. No degradation to the old checkbox table.
  4. Server contract unchanged. Each list row owns one <input type="hidden" name="overlay_ids" value="{id}">. Form-submission order = DOM order. The existing ordered_overlay_ids_from_form handler in routes/blueprint_routes.py already falls back to enumerate index when no overlay_position_<id> field is present, so it accepts the new shape with no Python edit.
  5. Dropdown re-sorted alphabetically on remove. When ✕ removes a row, the corresponding <option> is sorted-inserted back into the <select> (case-insensitive name compare). The dropdown stays predictable.
  6. Drop-indicator visual. A 2px focus-color bar drawn via box-shadow … inset on the row under the cursor: top-bar = "drop will land before this row", bottom-bar = "drop will land after this row". The hover side is computed by comparing event.clientY to the row's vertical midpoint.
  7. Drop on empty space inside the list = append. Drop directly on the dragged row (or with no movement) = no-op. Escape during drag triggers dragend, which clears all visual classes.
  8. Out of scope: keyboard reorder, ARIA live announcements, touch DnD polyfill, server-side cleanup of the now-unused overlay_position_<id> form-field path.

Architecture

GET /blueprints/<id>
   page_routes.blueprint_page
       ├─▶ selected_overlays  (ordered by BlueprintOverlay.position)
       └─▶ available_overlays = all_overlays \ selected_overlays
                            (alphabetical)

   templates/blueprint_detail.html
       <ol data-overlay-list>                    ← drag target, hidden inputs
         <li data-overlay-id draggable> × ⋮⋮ name </li>
         …
       </ol>
       <select data-overlay-add>                 ← add path
         <option>Pick a name…</option>
         <option value=overlay.id>name</option>  ← available_overlays
         …
       </select>

   static/js/blueprint-overlay-picker.js
       ├─ dragstart/over/leave/drop/end → reorder DOM under [data-overlay-list]
       ├─ click [data-action="remove"]  → remove row + sorted-insert <option>
       ├─ change [data-overlay-add]     → append <li>, remove <option>
       └─ refreshEmpty()                → toggle [data-overlay-empty][hidden]

POST /blueprints/<id>
   form-encoded body: overlay_ids=<id>&overlay_ids=<id>&… (in DOM order)
   blueprint_routes.update_blueprint_form
     → ordered_overlay_ids_from_form (existing; fallback_position branch)
     → replace_blueprint_overlays    (existing)

Form-contract details

The new template emits one hidden input per selected row, colocated as a child of the <li>:

<li data-overlay-id="3" draggable="true">
  <span class="overlay-picker-handle">⋮⋮</span>
  <span class="overlay-picker-name">workshop_maps</span>
  <button type="button" data-action="remove">×</button>
  <input type="hidden" name="overlay_ids" value="3">
</li>

Browser form serialization preserves DOM order across multiple inputs that share a name. Werkzeug's request.form.getlist("overlay_ids") returns them in submission order. ordered_overlay_ids_from_form then assigns each id its enumerate-index position via the fallback_position branch (lines 19-31 of routes/blueprint_routes.py) and feeds the result to replace_blueprint_overlays.

The JSON path (POST /blueprints with application/json) already takes overlay_ids list order at line 64 of the same file — this spec does not affect it.

UI / UX details

  • Empty state. When no overlays are selected, a [data-overlay-empty] paragraph reads "No overlays selected. Pick one below to add." JS toggles its hidden attribute on every list mutation.
  • Drag handle. Visual only (⋮⋮ glyph). The whole row is draggable="true"; the user does not have to grab the handle specifically.
  • Drop indicator math. During dragover, compute event.clientY < rect.top + rect.height/2; that boolean picks drop-before (bar at top) vs drop-after (bar at bottom). On drop, read which class is set and insertBefore or insertBefore(…, target.nextSibling) accordingly.
  • Sorted insert on remove. Walk <select> children comparing option.dataset.overlayName (lowercased) against the removed name; insertBefore the new option ahead of the first option whose name sorts later, or append if none.
  • Reset select after add. Set select.value = "" so the placeholder reappears after each add.

Files

Path Change
l4d2web/routes/page_routes.py Compute available_overlays; pass to template.
l4d2web/templates/blueprint_detail.html Replace overlay table with <ol> + <select>; add <script defer>.
l4d2web/static/css/components.css Append .overlay-picker-* rules. Reuse existing tokens.
l4d2web/static/js/blueprint-overlay-picker.js New IIFE. ~150 LOC.
l4d2web/tests/test_blueprints.py Two new GET-page assertions.
l4d2web/tests/test_pages.py Update test_blueprint_detail_has_ordered_overlay_form to match new shape.

Verification

Manual browser flow (/blueprints/<id>):

  1. Initial render shows the saved selection in saved order; dropdown holds the rest. No console errors.
  2. Drag a row up/down. Focus-colored bar appears at the top or bottom of the hover-target row (depending on which half is hovered). On drop, the row moves; hidden inputs reflect the new order.
  3. Click ✕ on a row. Row vanishes; the same name appears in the dropdown in alphabetical position.
  4. Pick from the dropdown. New row appears at the end of the list; the option leaves the dropdown; the placeholder is reselected.
  5. Save the blueprint, reload. Order survives the round-trip.
  6. Press Escape mid-drag. Drop indicators clear; source row regains opacity; nothing moved.

Test commands:

pytest l4d2web/tests/test_blueprints.py -q
pytest l4d2web/tests -q

Out of scope / future follow-ups

  • Drop the overlay_position_<id> server-side path. Once no client emits those fields, ordered_overlay_ids_from_form collapses to [int(v) for v in request.form.getlist("overlay_ids")]. Test test_form_update_preserves_ordered_overlays_and_multiline_fields (l4d2web/tests/test_blueprints.py:220) gets simplified accordingly.
  • Touch-friendly DnD. Vendor a polyfill (drag-drop-touch) or rewrite the picker on pointer events if mobile editing becomes a real use case.
  • Keyboard reorder. Space-to-grab + arrow-keys + ARIA live announcements. Currently only add/remove are keyboard-accessible.
  • Filter on the selected list. Not needed at v1's overlay counts; revisit if blueprints commonly carry 20+ overlays.