feat Show workout slots as an accordion, not a dropdown

The workout page hid every exercise but one behind a select; choosing another navigated to it. Replace the selector and the single panel with an accordion: one native <details> per prescription slot, all sharing a name, which the browser treats as an exclusive group so exactly one is open. The active slot opens; the ?slot= contract is unchanged, so a record or correction still reopens the slot it belongs to, and the handler still clamps out-of-range requests. Each summary shows the slot number, the exercise, and a done mark once recorded; the panel holds the same summary and record or correction form as before. Errors render only under the slot being edited. No script is needed for any of this, so the dropdown's change handler and the no-JS "Open" fallback go. The accordion borrows the routine accordion's shape so the two read as the same control.

Commit
c2e13fdc3c69955abcd63eec8f3a02dfcb9a3c81
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
ARCHITECTURE.org
index fd4c3863..f087f48e 100644..100644
@@ -495,13 +495,15 @@
495 495 ** The workout view
496 496
497 497 One page serves both the workout in progress and a saved record. It
498 Removed: shows one prescribed slot at a time under a server-rendered exercise
499 Removed: selector carrying its slot in =?slot==. The selected option identifies
500 Removed: the active slot. A handler clamps a requested slot against the
501 Removed: prescription and otherwise opens on the default: the first slot still
502 Removed: awaiting a record, or the first slot when all are filled.
498 Added: renders every prescribed slot as an accordion: one native =<details>= per
499 Added: slot, all sharing a =name=, which the browser treats as an exclusive group
500 Added: so exactly one is open. The open slot is the active one, carried in
501 Added: =?slot== so a record or correction reopens the slot it belongs to. A
502 Added: handler clamps a requested slot against the prescription and otherwise
503 Added: opens on the default: the first slot still awaiting a record, or the
504 Added: first slot when all are filled.
503 505
504 Removed: This is form navigation and route state, not an ARIA widget —
506 Added: This is native disclosure and route state, not an ARIA widget —
505 507 deterministic, and correct with no client script at all.
506 508
507 509 ** Progressive enhancement
@@ -525,7 +527,6 @@
525 527 | =data-hito-dialog-open= | a button that opens the modal with that id |
526 528 | =data-hito-confirm-*= | the confirm-modal parts: form, accept, dismiss |
527 529 | =data-hito-feedback-*= | open and dismiss the feedback modal in place |
528 Removed: | =data-hito-exercise-*= | the slot selector that navigates on change |
529 530
530 531 Behaviour: the client fetches the same URL the link or form would have
531 532 used, extracts the marked region from the response, and swaps
lib/presenter/pages.mli
index 1398defe..c8b79921 100644..100644
@@ -38,15 +38,16 @@
38 38 active_slot:int ->
39 39 View_model.Workout.t ->
40 40 page
41 Removed: (** Renders one prescribed slot at a time under a dropdown that selects the
42 Removed: exercise. Authenticated pages carry generic progressive-enhancement markers.
43 Removed: Current workouts additionally mark slot links and forms, while saved-record
44 Removed: edits use the generic authenticated form path. [active_slot] is the slot the
45 Removed: view opens on. A handler clamps a requested slot and falls back to the first
46 Removed: outstanding slot. [record_id] is [None] for the workout in progress and
47 Removed: [Some id] for a saved history record. [logging] sets the middle navigation
48 Removed: action. It defaults to a live workout view (true when [record_id] is
49 Removed: [None]). *)
41 Added: (** Renders every prescribed slot as an accordion — one native [details] per
42 Added: slot, sharing a [name] so the browser keeps exactly one open — with the
43 Added: active slot open. Authenticated pages carry generic progressive-enhancement
44 Added: markers. Current workouts additionally mark slot links and forms, while
45 Added: saved-record edits use the generic authenticated form path. [active_slot] is
46 Added: the slot that opens. A handler clamps a requested slot and falls back to the
47 Added: first outstanding slot. [record_id] is [None] for the workout in progress
48 Added: and [Some id] for a saved history record. [logging] sets the middle
49 Added: navigation action. It defaults to a live workout view (true when [record_id]
50 Added: is [None]). *)
50 51
51 52 val feedback_factors : (string * string) list
52 53 (** Ordered factor codes and labels. Each is a radio group in the feedback form
lib/presenter/pages_workout.ml
index 78a8c0fb..3df42c36 100644..100644
@@ -254,96 +254,59 @@
254 254 ]
255 255 else []
256 256 in
257 Removed: (* The exercise selector: a dropdown naming every prescription slot. Choosing
258 Removed: an option opens that exercise below. The selector is a GET form so it works
259 Removed: without script — submitting navigates to the slot via a [?slot=] query, the
260 Removed: same contract the panel already reads. The client enhances it: it navigates
261 Removed: the moment the selection changes, so the submit button is a no-JS fallback.
262 Removed: A recorded slot is marked done in its label. *)
263 Removed: let select_action_base =
264 Removed: match record_id with None -> "/workout" | Some id -> "/logbook/" ^ id
265 Removed: in
266 Removed: let exercise_group =
267 Removed: let option_for (slot : View_model.Workout.slot) =
268 Removed: let done_ = Option.is_some slot.recorded_summary in
269 Removed: let label =
270 Removed: if done_ then Printf.sprintf "%d. %s (done)" (slot.index + 1) slot.label
271 Removed: else Printf.sprintf "%d. %s" (slot.index + 1) slot.label
272 Removed: in
273 Removed: let attrs = [ Dream_html.string_attr "value" "%d" slot.index ] in
274 Removed: let attrs =
275 Removed: if slot.index = active_slot then Dream_html.attr "selected" :: attrs
276 Removed: else attrs
277 Removed: in
278 Removed: tag "option" attrs [ txt "%s" label ]
279 Removed: in
280 Removed: let form_attrs =
281 Removed: [
282 Removed: Dream_html.string_attr "method" "get";
283 Removed: Dream_html.string_attr "action" "%s" select_action_base;
284 Removed: class_ "exercise-select";
285 Removed: ]
286 Removed: in
287 Removed: let form_attrs =
288 Removed: if enhanced then Dream_html.attr "data-hito-exercise-form" :: form_attrs
289 Removed: else form_attrs
290 Removed: in
291 Removed: tag "form" form_attrs
292 Removed: [
293 Removed: tag "label"
294 Removed: [ Dream_html.string_attr "for" "exercise-choice"; class_ "sr-only" ]
295 Removed: [ txt "Exercise" ];
296 Removed: tag "select"
257 Added: (* The exercise accordion: one <details> per prescription slot, all sharing a
258 Added: [name], which the browser treats as an exclusive group — opening one closes
259 Added: the rest, with no script. The active slot is open; the handler still picks
260 Added: it from [?slot=], so a record or correction reopens the slot it belongs to.
261 Added: A recorded slot shows its summary and a pre-filled correction form (which
262 Added: replaces the slot rather than adding volume); an outstanding slot shows the
263 Added: record form. Errors render only under the slot being edited. *)
264 Added: let slot_section (slot : View_model.Workout.slot) =
265 Added: let index = slot.index in
266 Added: let errors = if editing = Some index then errors else [] in
267 Added: let done_ = Option.is_some slot.recorded_summary in
268 Added: let body =
269 Added: match slot.recorded_summary with
270 Added: | Some summary ->
297 271 [
298 Removed: name "slot";
299 Removed: id "exercise-choice";
300 Removed: Dream_html.attr "data-hito-exercise-select";
272 Added: tag "p" [ class_ "eyebrow" ] [ txt "Recorded" ];
273 Added: tag "p" [ class_ "done" ] [ txt "%s" summary ];
274 Added: form_for_stimulus request ~action_path:(edit_action index)
275 Added: ~slot:index ~movements:slot.movements
276 Added: ~selected_extension:slot.selected_extension
277 Added: ~selected_extension_amount:slot.selected_extension_amount
278 Added: ~enhanced ~submit:"Save correction" ~errors ();
301 279 ]
302 Removed: (List.map option_for vm.slots);
303 Removed: void "input"
280 Added: | None ->
304 281 [
305 Removed: type_ "submit"; class_ "secondary exercise-select-go"; value "Open";
282 Added: form_for_stimulus request ~action_path:(record_action index)
283 Added: ~slot:index ~movements:slot.movements
284 Added: ~selected_extension:slot.selected_extension
285 Added: ~selected_extension_amount:slot.selected_extension_amount
286 Added: ~enhanced ~errors ();
287 Added: ]
288 Added: in
289 Added: tag "details"
290 Added: ([
291 Added: class_ "slot-accordion";
292 Added: Dream_html.string_attr "name" "exercise";
293 Added: Dream_html.string_attr "id" "slot-%d" index;
294 Added: ]
295 Added: @ (if index = active_slot then [ Dream_html.attr "open" ] else [])
296 Added: @ if done_ then [ Dream_html.string_attr "data-done" "true" ] else [])
297 Added: [
298 Added: tag "summary" []
299 Added: [
300 Added: tag "span" [ class_ "slot-number" ] [ txt "%d." (index + 1) ];
301 Added: tag "span" [ class_ "slot-label" ] [ txt "%s" slot.label ];
302 Added: (if done_ then tag "span" [ class_ "slot-done" ] [ txt "done" ]
303 Added: else txt "");
306 304 ];
305 Added: tag "div" [ class_ "slot-panel" ] body;
307 306 ]
308 307 in
309 Removed: (* The panel for the active slot. It shows one exercise at a time: a recorded
310 Removed: slot renders its read-only summary and a pre-filled correction form (which
311 Removed: replaces the slot rather than adding volume). An outstanding slot renders
312 Removed: the record form. *)
313 Removed: let active_panel =
314 Removed: match List.nth_opt vm.slots active_slot with
315 Removed: | None ->
316 Removed: [
317 Removed: tag "p"
318 Removed: [ class_ "warn" ]
319 Removed: [ txt "That exercise is not part of this workout." ];
320 Removed: ]
321 Removed: | Some (slot : View_model.Workout.slot) ->
322 Removed: let errors = if editing = Some active_slot then errors else [] in
323 Removed: let heading = tag "h2" [] [ txt "%s" slot.label ] in
324 Removed: let body =
325 Removed: match slot.recorded_summary with
326 Removed: | Some summary ->
327 Removed: [
328 Removed: tag "p" [ class_ "eyebrow" ] [ txt "Recorded" ];
329 Removed: tag "p" [ class_ "done" ] [ txt "%s" summary ];
330 Removed: form_for_stimulus request ~action_path:(edit_action active_slot)
331 Removed: ~slot:active_slot ~movements:slot.movements
332 Removed: ~selected_extension:slot.selected_extension
333 Removed: ~selected_extension_amount:slot.selected_extension_amount
334 Removed: ~enhanced ~submit:"Save correction" ~errors ();
335 Removed: ]
336 Removed: | None ->
337 Removed: [
338 Removed: form_for_stimulus request
339 Removed: ~action_path:(record_action active_slot)
340 Removed: ~slot:active_slot ~movements:slot.movements
341 Removed: ~selected_extension:slot.selected_extension
342 Removed: ~selected_extension_amount:slot.selected_extension_amount
343 Removed: ~enhanced ~errors ();
344 Removed: ]
345 Removed: in
346 Removed: [ tag "section" [ class_ "slot-panel" ] (heading :: body) ]
308 Added: let accordion =
309 Added: [ tag "div" [ class_ "slot-accordions" ] (List.map slot_section vm.slots) ]
347 310 in
348 311 let complete_note =
349 312 if vm.all_recorded then
@@ -470,9 +433,8 @@
470 433 tag "p"
471 434 [ class_ "ledger-meta" ]
472 435 [ txt "%d of %d recorded." filled_slots slot_count ];
473 Removed: exercise_group;
474 436 ]
475 Removed: @ active_panel @ complete_note @ finish_section
437 Added: @ accordion @ complete_note @ finish_section
476 438 @
477 439 if enhanced then
478 440 [
lib/presenter/view_model.ml
index e1af2073..105ddde6 100644..100644
@@ -90,7 +90,7 @@
90 90
91 91 type slot = {
92 92 index : int;
93 Removed: label : string; (** Exercise label for the selector option and heading. *)
93 Added: label : string; (** Exercise label for the accordion summary. *)
94 94 movements : movement list;
95 95 recorded_summary : string option;
96 96 (** [Some description] once the slot is filled. [None] while
lib/web/assets/hito.css
index 7f202e11..a714e10c 100644..100644
@@ -614,34 +614,47 @@
614 614 .in-progress-card h1 { margin: 0.35rem 0; }
615 615 .in-progress-card p { margin: 0; }
616 616
617 Removed: /* The exercise selector. A dropdown naming every prescription slot, in a GET
618 Removed: form that submits to the workout path. The client navigates on change, so the
619 Removed: submit button is a no-JS fallback. Select and button share one row and wrap
620 Removed: on a narrow screen. */
621 Removed: .exercise-select {
617 Added: /* The exercise accordion: one <details> per prescription slot. They share a
618 Added: [name], so the browser keeps exactly one open — no script. The summary shows
619 Added: the slot number, the exercise, and a done mark once recorded. It borrows the
620 Added: routine accordion's shape so the two read as the same control. */
621 Added: .slot-accordions {
622 Added: display: grid;
623 Added: gap: 0.65rem;
624 Added: margin: 1.25rem 0 0;
625 Added: }
626 Added: .slot-accordion {
627 Added: border: 1px solid var(--rule);
628 Added: border-radius: var(--radius);
629 Added: background: var(--paper-raised);
630 Added: overflow: hidden;
631 Added: }
632 Added: .slot-accordion > summary {
622 633 display: flex;
623 Removed: flex-wrap: wrap;
624 Removed: align-items: end;
634 Added: align-items: baseline;
625 635 gap: 0.6rem;
626 Removed: margin: 1.25rem 0 0;
636 Added: padding: 0.8rem 1rem;
637 Added: color: var(--ink);
638 Added: font-weight: 700;
639 Added: cursor: pointer;
640 Added: list-style: none;
627 641 }
628 Removed: .exercise-select label { width: 100%; margin: 0 0 0.35rem; }
629 Removed: .exercise-select select { flex: 1 1 12rem; min-width: 0; margin: 0; }
630 Removed: .exercise-select .exercise-select-go {
631 Removed: flex: none;
632 Removed: width: auto;
633 Removed: min-height: 3rem;
634 Removed: margin: 0;
635 Removed: padding: 0.65rem 1.1rem;
642 Added: .slot-accordion > summary::-webkit-details-marker { display: none; }
643 Added: .slot-accordion > summary::marker { content: ""; }
644 Added: .slot-accordion > summary::after {
645 Added: margin-left: auto;
646 Added: content: "+";
647 Added: color: var(--muted-ink);
636 648 }
637 Removed: /* With script active, the dropdown navigates on change only when the client
638 Removed: enhances the form (the live workout view). A saved-record view is not
639 Removed: enhanced, so its dropdown has no change handler and the submit button is the
640 Removed: only way to switch exercise; keep it visible there. Scope the hide to the
641 Removed: enhanced form so record editing stays usable, notably on mobile. */
642 Removed: [data-hito-js="on"] .exercise-select[data-hito-exercise-form] .exercise-select-go {
643 Removed: display: none;
649 Added: .slot-accordion[open] > summary::after { content: "−"; }
650 Added: .slot-accordion > summary .slot-number { color: var(--muted-ink); }
651 Added: .slot-accordion > summary .slot-done {
652 Added: font: 700 var(--font-size-small)/1 var(--sans);
653 Added: letter-spacing: 0.055em;
654 Added: text-transform: uppercase;
655 Added: color: var(--brass);
644 656 }
657 Added: .slot-accordion[data-done="true"] > summary { border-left: 4px solid var(--brass); }
645 658
646 659 /* The sticky workout timer bar. It pins to the top of the scroll area while a
647 660 workout is being logged and shows the elapsed time. The client ticks the
@@ -674,7 +687,7 @@
674 687 font-variant-numeric: tabular-nums;
675 688 }
676 689
677 Removed: .slot-panel { margin-top: 1.25rem; }
690 Added: .slot-panel { padding: 0 1rem 1rem; }
678 691
679 692 /* The cancel confirmation modal. A native dialog styled as a raised paper
680 693 surface: the oxblood top accent, radius, border, and shadow echo the page
lib/web/workout_client.ml
index 7caa8df9..0cb2d931 100644..100644
@@ -398,30 +398,6 @@
398 398 Js._false
399 399 | None -> Js._true)
400 400
401 Removed: (* The exercise dropdown navigates the moment its selection changes. It lives in
402 Removed: a GET form ([data-hito-exercise-form]) that submits to the workout path. The
403 Removed: client turns a change into a workout-scoped content swap to [?slot=N], so no
404 Removed: submit click is needed. Without script the form's submit button still works. *)
405 Removed: let exercise_change event =
406 Removed: match closest "[data-hito-exercise-form]" (Dom_html.eventTarget event) with
407 Removed: | None -> Js._true
408 Removed: | Some form -> (
409 Removed: let action =
410 Removed: match Js.Opt.to_option (form##getAttribute (Js.string "action")) with
411 Removed: | Some action -> Js.to_string action
412 Removed: | None -> "/workout"
413 Removed: in
414 Removed: match query_one form "[data-hito-exercise-select]" with
415 Removed: | None -> Js._true
416 Removed: | Some node -> (
417 Removed: match Js.Opt.to_option (Dom_html.CoerceTo.select node) with
418 Removed: | None -> Js._true
419 Removed: | Some select ->
420 Removed: let slot = Js.to_string select##.value in
421 Removed: Dom.preventDefault event;
422 Removed: Lwt.async (fun () -> get ~workout:true (action ^ "?slot=" ^ slot));
423 Removed: Js._false))
424 Removed:
425 401 let () =
426 402 Dom_html.addEventListener document Dom_html.Event.click
427 403 (Dom_html.handler link_click)
@@ -439,10 +415,6 @@
439 415 (Dom_html.handler dialog_click)
440 416 Js._false
441 417 |> ignore;
442 Removed: Dom_html.addEventListener document Dom_html.Event.change
443 Removed: (Dom_html.handler exercise_change)
444 Removed: Js._false
445 Removed: |> ignore;
446 418 Dom_html.addEventListener document Dom_html.Event.click
447 419 (Dom_html.handler confirm_click)
448 420 Js._false
@@ -462,7 +434,7 @@
462 434 Js._false
463 435 |> ignore;
464 436 (* Mark the document as script-enhanced so CSS can hide no-JS fallbacks such
465 Removed: as the exercise-selector submit button. Set only once the client runs, so
437 Added: in CSS. Set only once the client runs, so
466 438 a no-JS visitor keeps every fallback. *)
467 439 document##.documentElement##setAttribute
468 440 (Js.string "data-hito-js") (Js.string "on");