feat restore the workout slots as an accordion

Show the prescription slots as one <details> per slot sharing a name, so the browser keeps exactly one open with no script. The active slot opens; a record or correction reopens the slot it belongs to. The summary carries the slot's level-two heading, struck through once recorded. Update the web test that had asserted the flat-list markup.

Commit
ef2e35684ac4dcf56a7b025ebc7e4bc01e3bae27
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
lib/presenter/pages_workout.ml
index 678ec7ba..44200655 100644..100644
@@ -238,7 +238,7 @@
238 238 ]
239 239
240 240 let workout request ~viewer ?(errors = []) ?editing ?logging ~record_id
241 Removed: ~active_slot:_ (vm : View_model.Workout.t) =
241 Added: ~active_slot (vm : View_model.Workout.t) =
242 242 let enhanced = Option.is_none record_id in
243 243 (* A live workout view is itself the workout in progress, so the middle nav
244 244 action reads "Current workout". A saved-record view defers to the handler,
@@ -254,12 +254,14 @@
254 254 | None -> action Routes.workout_slot_edit slot
255 255 | Some record_id -> action Routes.record_slot_edit record_id slot
256 256 in
257 Removed: (* Each prescription slot is a flat, always-visible section: a heading (its
258 Removed: number and exercise label, struck through once recorded) followed by the
259 Removed: record form, or — once recorded — a pre-filled correction form (a
260 Removed: correction replaces the slot rather than adding volume). The struck heading
261 Removed: and the pre-filled fields already show what was recorded, so no separate
262 Removed: summary line is repeated. Errors render only under the slot being edited. *)
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 picks it from
260 Added: [?slot=], so a record or correction reopens the slot it belongs to. The
261 Added: summary carries the slot's level-two heading: its number and exercise label,
262 Added: struck through once recorded. A recorded slot shows a pre-filled correction
263 Added: form (which replaces the slot rather than adding volume); an outstanding
264 Added: slot shows the record form. Errors render only under the slot being edited. *)
263 265 let slot_section (slot : View_model.Workout.slot) =
264 266 let index = slot.index in
265 267 let errors = if editing = Some index then errors else [] in
@@ -283,23 +285,36 @@
283 285 ~enhanced ~errors ();
284 286 ]
285 287 in
286 Removed: tag "section"
287 Removed: [ class_ "slot-section"; Dream_html.string_attr "id" "slot-%d" index ]
288 Removed: (tag "h2"
289 Removed: [ class_ "slot-heading" ]
290 Removed: [
291 Removed: tag "span" [ class_ "slot-number" ] [ txt "%d." (index + 1) ];
292 Removed: tag "span"
293 Removed: [
294 Removed: Dream_html.string_attr "class" "slot-label%s"
295 Removed: (if done_ then " slot-label-done" else "");
296 Removed: ]
297 Removed: [ txt "%s" slot.label ];
298 Removed: ]
299 Removed: :: body)
288 Added: tag "details"
289 Added: ([
290 Added: class_ "slot-accordion";
291 Added: Dream_html.string_attr "name" "exercise";
292 Added: Dream_html.string_attr "id" "slot-%d" index;
293 Added: ]
294 Added: @ (if index = active_slot then [ Dream_html.attr "open" ] else [])
295 Added: @ if done_ then [ Dream_html.string_attr "data-done" "true" ] else [])
296 Added: [
297 Added: tag "summary" []
298 Added: [
299 Added: tag "h2"
300 Added: [ class_ "slot-heading" ]
301 Added: [
302 Added: tag "span" [ class_ "slot-number" ] [ txt "%d." (index + 1) ];
303 Added: tag "span"
304 Added: [
305 Added: Dream_html.string_attr "class" "slot-label%s"
306 Added: (if done_ then " slot-label-done" else "");
307 Added: ]
308 Added: [ txt "%s" slot.label ];
309 Added: ];
310 Added: ];
311 Added: tag "div"
312 Added: [ class_ "slot-panel" ]
313 Added: [ tag "div" [ class_ "slot-panel-body" ] body ];
314 Added: ]
300 315 in
301 316 let slots =
302 Removed: [ tag "div" [ class_ "slot-sections" ] (List.map slot_section vm.slots) ]
317 Added: [ tag "div" [ class_ "slot-accordions" ] (List.map slot_section vm.slots) ]
303 318 in
304 319 let complete_note =
305 320 if vm.all_recorded then
lib/web/assets/hito.css
index 6fa4c9a9..82cf628a 100644..100644
@@ -1076,27 +1076,83 @@
1076 1076 .in-progress h1 { margin: 0.35rem 0; }
1077 1077 .in-progress p { margin: 0; }
1078 1078
1079 Removed: /* The prescription slots as a flat, always-visible list. Each slot is a section
1080 Removed: with a heading (number + exercise label, struck through once recorded) and its
1081 Removed: record or correction form directly below. With few slots per session, every
1082 Removed: slot's state is visible at once, no click needed. Vertical spacing alone
1083 Removed: separates slots — no rules between them. */
1084 Removed: .slot-sections {
1079 Added: /* The exercise accordion: one <details> per prescription slot. They share a
1080 Added: [name], so the browser keeps exactly one open — no script. The summary holds
1081 Added: the slot's level-two heading (number + exercise label, struck through once
1082 Added: recorded) and a +/− marker. A recorded slot gets a brass left accent. */
1083 Added: .slot-accordions {
1084 Added: display: grid;
1085 Added: gap: 0.65rem;
1085 1086 margin: 1.25rem 0 0;
1086 1087 }
1087 Removed: .slot-section {
1088 Removed: padding: 1rem 0;
1088 Added: .slot-accordion {
1089 Added: border: 1px solid var(--rule);
1090 Added: border-radius: var(--radius);
1091 Added: background: var(--paper-raised);
1092 Added: overflow: visible;
1089 1093 }
1094 Added: .slot-accordion > summary {
1095 Added: display: flex;
1096 Added: align-items: baseline;
1097 Added: gap: 0.6rem;
1098 Added: padding: 0.8rem 1rem;
1099 Added: color: var(--ink);
1100 Added: cursor: pointer;
1101 Added: list-style: none;
1102 Added: }
1103 Added: .slot-accordion > summary::-webkit-details-marker { display: none; }
1104 Added: .slot-accordion > summary::marker { content: ""; }
1105 Added: .slot-accordion > summary::after {
1106 Added: margin-left: auto;
1107 Added: content: "+";
1108 Added: color: var(--muted-ink);
1109 Added: }
1110 Added: .slot-accordion[open] > summary::after { content: "−"; }
1111 Added: /* The summary's level-two heading lays out inline: number, then label. It keeps
1112 Added: the global h2 size and only resets the default heading margin so it sits
1113 Added: centered in the summary bar. */
1090 1114 .slot-heading {
1091 1115 display: flex;
1092 1116 align-items: baseline;
1093 1117 gap: 0.6rem;
1094 Removed: margin: 0 0 0.5rem;
1118 Added: margin: 0;
1095 1119 }
1096 1120 .slot-heading .slot-number { color: var(--muted-ink); }
1097 1121 .slot-heading .slot-label-done {
1098 1122 color: var(--muted-ink);
1099 1123 text-decoration: line-through;
1124 Added: }
1125 Added: .slot-accordion[data-done="true"] > summary {
1126 Added: border-left: 4px solid var(--brass);
1127 Added: }
1128 Added:
1129 Added: /* Unfold and collapse. ::details-content is the box the browser toggles for a
1130 Added: <details>; keep it rendered when closed and drive a grid row from 0fr to 1fr
1131 Added: off the [open] attribute, so both opening and closing ease in every browser
1132 Added: that has ::details-content. visibility keeps a closed panel out of the tab
1133 Added: order and the accessibility tree; it flips to visible at the start of an open
1134 Added: and to hidden at the end of a close. Browsers without ::details-content
1135 Added: ignore all of this and snap. Reduced motion snaps too. */
1136 Added: .slot-accordion::details-content {
1137 Added: content-visibility: visible;
1138 Added: display: grid;
1139 Added: grid-template-rows: 0fr;
1140 Added: visibility: hidden;
1141 Added: transition:
1142 Added: grid-template-rows 220ms ease,
1143 Added: visibility 220ms;
1144 Added: }
1145 Added: .slot-accordion[open]::details-content {
1146 Added: grid-template-rows: 1fr;
1147 Added: visibility: visible;
1148 Added: }
1149 Added: .slot-accordion > .slot-panel {
1150 Added: min-height: 0;
1151 Added: overflow: clip;
1152 Added: }
1153 Added: .slot-panel-body { padding: 0 1rem 1rem; }
1154 Added: @media (prefers-reduced-motion: reduce) {
1155 Added: .slot-accordion::details-content { transition: none; }
1100 1156 }
1101 1157
1102 1158 /* The sticky workout timer bar. It pins to the top of the scroll area while a
test/web/workout.ml
index 8df3c14e..a795450e 100644..100644
@@ -524,7 +524,7 @@
524 524 (match Recovery.basis (Evidence.Workout.clearance workout) with
525 525 | Recovery.Recovered -> true
526 526 | Recovery.Overridden _ -> false) );
527 Removed: ( "recording a slot crosses it off in the flat slot list",
527 Added: ( "recording a slot crosses it off in the accordion",
528 528 `Quick,
529 529 fun () ->
530 530 let c = client () in
@@ -554,16 +554,14 @@
554 554 Alcotest.(check bool)
555 555 "the recorded slot label is crossed off" true
556 556 (Option.is_some (index_from ~needle:"slot-label-done" page 0));
557 Removed: (* The slots are a flat list, not an accordion: no <details>. *)
557 Added: (* The slots are an accordion: each is a <details class=slot-accordion>. *)
558 558 Alcotest.(check bool)
559 Removed: "no accordion details element remains" true
560 Removed: (Option.is_none (index_from ~needle:"slot-accordion" page 0));
559 Added: "the slots render as an accordion" true
560 Added: (Option.is_some (index_from ~needle:"slot-accordion" page 0));
561 Added: (* Every slot is present at once, including a later one (closed
562 Added: <details> stay in the DOM). *)
561 563 Alcotest.(check bool)
562 Removed: "the slots render as flat sections" true
563 Removed: (Option.is_some (index_from ~needle:"slot-section" page 0));
564 Removed: (* Every slot is present at once, including a later one. *)
565 Removed: Alcotest.(check bool)
566 Removed: "a later slot is visible without interaction" true
564 Added: "a later slot is present without interaction" true
567 565 (Option.is_some (index_from ~needle:"id=\"slot-1\"" page 0));
568 566 (* The recorded slot offers a correction form with paired Save and
569 567 Reset actions, shown only once a field changes (via the client). *)