[OCaml] High Intensity Training Online
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.
lib/presenter/pages_workout.ml
@@ -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
@@ -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
@@ -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). *)