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>
This commit is contained in:
mwiegand 2026-05-08 21:32:45 +02:00
parent 01760a31f5
commit dec4fed809
No known key found for this signature in database

View file

@ -0,0 +1,118 @@
# 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>`:
```html
<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.