[OCaml] High Intensity Training Online
refactor Use vertical exercise buttons
Replace horizontal workout tabs with full-width vertical exercise navigation.
Changed files
lib/web/assets/hito.css
@@ -263,48 +263,48 @@
263
263
264
264
.row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.8rem; }
265
265
266
Removed:
/* The exercise tab strip. Server-rendered links, one per prescription slot,
267
Removed:
carrying the slot in a query parameter. The active tab reads as pressed; a
268
Removed:
recorded slot is marked with a check. On a narrow screen the strip scrolls
269
Removed:
horizontally rather than wrapping, so one slot's panel stays in view. */
270
Removed:
.slot-tabs {
266
Added:
/* The exercise button group. Server-rendered links, one per prescription slot,
267
Added:
carrying the slot in a query parameter. The active button reads as pressed; a
268
Added:
recorded slot is marked with a check. Buttons stack vertically and fill the
269
Added:
width, so a narrow screen never needs horizontal scrolling. */
270
Added:
.exercise-group {
271
271
display: flex;
272
Removed:
flex-wrap: wrap;
273
Removed:
gap: 0.35rem;
272
Added:
flex-direction: column;
273
Added:
gap: 0.4rem;
274
274
margin: 1.25rem 0 0;
275
Removed:
border-bottom: 2px solid var(--ink);
276
Removed:
padding-bottom: 0.4rem;
277
275
}
278
276
279
Removed:
.slot-tab {
280
Removed:
display: inline-flex;
277
Added:
.exercise-button {
278
Added:
display: flex;
281
279
align-items: center;
282
Removed:
gap: 0.5rem;
280
Added:
gap: 0.6rem;
281
Added:
width: 100%;
283
282
min-height: 2.75rem;
284
Removed:
padding: 0.5rem 0.85rem;
283
Added:
padding: 0.6rem 0.85rem;
285
284
border: 1px solid var(--rule-strong);
286
Removed:
border-bottom: none;
287
Removed:
border-radius: 0.3rem 0.3rem 0 0;
285
Added:
border-radius: 0.3rem;
288
286
color: var(--ink);
289
287
background: var(--paper);
290
Removed:
font-size: 0.9rem;
288
Added:
font-size: 0.95rem;
291
289
font-weight: 600;
290
Added:
text-align: left;
292
291
text-decoration: none;
293
292
}
294
293
295
Removed:
.slot-tab:hover {
294
Added:
.exercise-button:hover {
296
295
color: var(--oxblood-dark);
297
296
background: #ece4d5;
298
297
}
299
298
300
Removed:
.slot-tab.is-active {
299
Added:
.exercise-button.is-active {
301
300
color: var(--paper-raised);
302
301
background: var(--oxblood);
303
302
border-color: var(--oxblood-dark);
304
303
}
305
304
306
Removed:
.slot-tab-index {
305
Added:
.exercise-button-index {
307
306
display: grid;
307
Added:
flex: none;
308
308
width: 1.5rem;
309
309
height: 1.5rem;
310
310
place-items: center;
@@ -314,13 +314,13 @@
314
314
font-variant-numeric: tabular-nums;
315
315
}
316
316
317
Removed:
.slot-tab.is-done .slot-tab-index {
317
Added:
.exercise-button.is-done .exercise-button-index {
318
318
border-color: var(--evergreen);
319
319
color: var(--paper-raised);
320
320
background: var(--evergreen);
321
321
}
322
322
323
Removed:
.slot-tab.is-active.is-done .slot-tab-index {
323
Added:
.exercise-button.is-active.is-done .exercise-button-index {
324
324
border-color: var(--paper-raised);
325
325
color: var(--oxblood);
326
326
background: var(--paper-raised);
@@ -414,18 +414,6 @@
414
414
}
415
415
416
416
.page-surface { min-height: calc(100vh - 9rem); padding: 1.25rem; }
417
Removed:
418
Removed:
.slot-tabs {
419
Removed:
flex-wrap: nowrap;
420
Removed:
overflow-x: auto;
421
Removed:
scrollbar-width: thin;
422
Removed:
-webkit-overflow-scrolling: touch;
423
Removed:
}
424
Removed:
425
Removed:
.slot-tab {
426
Removed:
flex: 0 0 auto;
427
Removed:
white-space: nowrap;
428
Removed:
}
429
417
}
430
418
431
419
@media (max-width: 26rem) {
lib/web/handlers.ml
@@ -167,7 +167,7 @@
167
167
(Prescription.Workout.stimuli (Evidence.Workout.prescription workout))
168
168
slot
169
169
170
Removed:
(* The tab a tabbed workout view should open on. An explicit [?slot=] query
170
Added:
(* The slot a workout view should open on. An explicit [?slot=] query
171
171
wins when it names a real slot; otherwise the view defaults to the first
172
172
incomplete slot. A [?slot=] out of range, or absent, falls back to the
173
173
default, so a bookmarked or hand-edited URL never renders an empty view. *)
lib/web/pages.ml
@@ -506,20 +506,20 @@
506
506
(Format.asprintf "%a" Evidence.Stimulus.pp_extension)
507
507
extensions)
508
508
509
Removed:
(* A short tab label for a slot, drawn from its prescription. A pre-exhaust
510
Removed:
slot names both movements so the tab stays legible. *)
511
Removed:
let tab_label prescription =
509
Added:
(* A short label for a slot's exercise button, drawn from its prescription. A
510
Added:
pre-exhaust slot names both movements so the button stays legible. *)
511
Added:
let exercise_label prescription =
512
512
match Prescription.Stimulus.delivery prescription with
513
513
| Prescription.Stimulus.Single exercise -> Exercise.name exercise
514
514
| Prescription.Stimulus.Pre_exhaust { isolation; compound } ->
515
515
Printf.sprintf "%s + %s" (Exercise.name isolation)
516
516
(Exercise.name compound)
517
517
518
Removed:
(* The base path a slot's tab links to, without the [?slot=] the tab strip
519
Removed:
appends. Current-workout tabs return to [/workout]; a saved record's tabs
520
Removed:
return to [/history/<id>]. The URL is built directly so the [slot] query can
521
Removed:
be appended. *)
522
Removed:
let tab_href ~record_id slot =
518
Added:
(* The base path a slot's button links to, without the [?slot=] the exercise
519
Added:
group appends. Current-workout buttons return to [/workout]; a saved record's
520
Added:
buttons return to [/history/<id>]. The URL is built directly so the [slot]
521
Added:
query can be appended. *)
522
Added:
let exercise_href ~record_id slot =
523
523
let base =
524
524
match record_id with
525
525
| None -> "/workout"
@@ -562,30 +562,34 @@
562
562
in
563
563
let recorded_at slot = List.assoc_opt slot filled in
564
564
let is_recorded slot = Option.is_some (recorded_at slot) in
565
Removed:
(* The tab strip: one tab per prescription slot, a server-rendered link
566
Removed:
carrying the slot as a query parameter. The active tab is the panel below;
567
Removed:
a recorded slot is marked done. This is link navigation, not an ARIA
568
Removed:
widget: a labelled [nav] with [aria-current="page"] on the active link is
569
Removed:
the accessible, deterministic pattern — no client script, no focus state to
570
Removed:
manage. *)
571
Removed:
let tab_strip =
565
Added:
(* The exercise button group: one full-width button per prescription slot, a
566
Added:
server-rendered link carrying the slot as a query parameter. The active
567
Added:
button opens the panel below; a recorded slot is marked done. This is link
568
Added:
navigation, not an ARIA widget: a labelled [nav] with [aria-current="page"]
569
Added:
on the active link is the accessible, deterministic pattern — no client
570
Added:
script, no focus state to manage. Buttons stack vertically, so a narrow
571
Added:
screen never needs horizontal scrolling. *)
572
Added:
let exercise_group =
572
573
tag "nav"
573
Removed:
[ class_ "slot-tabs"; Dream_html.string_attr "aria-label" "Exercises" ]
574
Added:
[
575
Added:
class_ "exercise-group"; Dream_html.string_attr "aria-label" "Exercises";
576
Added:
]
574
577
(List.mapi
575
578
(fun slot p ->
576
579
let current = slot = active_slot in
577
580
let done_ = is_recorded slot in
578
581
let state =
579
582
match (current, done_) with
580
Removed:
| true, true -> "slot-tab is-active is-done"
581
Removed:
| true, false -> "slot-tab is-active"
582
Removed:
| false, true -> "slot-tab is-done"
583
Removed:
| false, false -> "slot-tab"
583
Added:
| true, true -> "exercise-button is-active is-done"
584
Added:
| true, false -> "exercise-button is-active"
585
Added:
| false, true -> "exercise-button is-done"
586
Added:
| false, false -> "exercise-button"
584
587
in
585
588
let attrs =
586
589
[
587
590
Dream_html.string_attr "class" "%s" state;
588
Removed:
Dream_html.string_attr "href" "%s" (tab_href ~record_id slot);
591
Added:
Dream_html.string_attr "href" "%s"
592
Added:
(exercise_href ~record_id slot);
589
593
]
590
594
in
591
595
let attrs =
@@ -595,8 +599,12 @@
595
599
in
596
600
tag "a" attrs
597
601
[
598
Removed:
tag "span" [ class_ "slot-tab-index" ] [ txt "%d" (slot + 1) ];
599
Removed:
tag "span" [ class_ "slot-tab-name" ] [ txt "%s" (tab_label p) ];
602
Added:
tag "span"
603
Added:
[ class_ "exercise-button-index" ]
604
Added:
[ txt "%d" (slot + 1) ];
605
Added:
tag "span"
606
Added:
[ class_ "exercise-button-name" ]
607
Added:
[ txt "%s" (exercise_label p) ];
600
608
])
601
609
prescribed_slots)
602
610
in
@@ -614,7 +622,7 @@
614
622
]
615
623
| Some prescription ->
616
624
let errors = if editing = Some active_slot then errors else [] in
617
Removed:
let heading = tag "h2" [] [ txt "%s" (tab_label prescription) ] in
625
Added:
let heading = tag "h2" [] [ txt "%s" (exercise_label prescription) ] in
618
626
let body =
619
627
match recorded_at active_slot with
620
628
| Some stimulus ->
@@ -694,11 +702,11 @@
694
702
(Evidence.Workout.filled_slots workout)
695
703
slot_count;
696
704
];
697
Removed:
tab_strip;
705
Added:
exercise_group;
698
706
]
699
707
@ active_panel @ complete_note @ finish_section)
700
708
701
Removed:
(* The slot a tabbed workout view should open on: the first slot still awaiting
709
Added:
(* The slot a workout view should open on: the first slot still awaiting
702
710
a record, or the first slot when every slot is filled. A caller clamps a
703
711
requested slot against the prescription; this supplies the default. *)
704
712
let default_slot workout =
lib/web/pages.mli
@@ -31,13 +31,13 @@
31
31
active_slot:int ->
32
32
Evidence.Workout.t ->
33
33
page
34
Removed:
(** Renders one prescribed slot at a time under a strip of named tabs.
35
Removed:
[active_slot] selects the visible tab; a handler clamps a requested slot and
36
Removed:
falls back to {!default_slot}. [record_id] is [None] for the workout in
37
Removed:
progress and [Some id] for a saved history record. *)
34
Added:
(** Renders one prescribed slot at a time under a vertical group of named
35
Added:
exercise buttons. [active_slot] selects the visible button; a handler clamps
36
Added:
a requested slot and falls back to {!default_slot}. [record_id] is [None]
37
Added:
for the workout in progress and [Some id] for a saved history record. *)
38
38
39
39
val default_slot : Evidence.Workout.t -> int
40
Removed:
(** The slot a tabbed view opens on: the first slot still awaiting a record, or
40
Added:
(** The slot a workout view opens on: the first slot still awaiting a record, or
41
41
the first slot when every slot is filled. *)
42
42
43
43
val history : trainee:Trainee.t -> Repository.record list -> page
test/test_web.ml
@@ -224,7 +224,7 @@
224
224
] );
225
225
( "web.flow",
226
226
[
227
Removed:
( "a signed-in trainee starts a workout and sees the tabbed slots",
227
Added:
( "a signed-in trainee starts a workout and sees the exercise buttons",
228
228
`Quick,
229
229
fun () ->
230
230
let c = client () in
@@ -243,13 +243,16 @@
243
243
Alcotest.(check int) "workout started" 303 (status started);
244
244
let workout_page = body (get c "/workout") in
245
245
Alcotest.(check bool)
246
Removed:
"renders a tab strip" true
247
Removed:
(contains ~substring:"slot-tabs" workout_page);
246
Added:
"renders a vertical exercise button group" true
247
Added:
(contains ~substring:"exercise-group" workout_page);
248
248
Alcotest.(check bool)
249
Removed:
"labels the tab strip for assistive tech" true
249
Added:
"does not use the old tab strip class" false
250
Added:
(contains ~substring:"slot-tab" workout_page);
251
Added:
Alcotest.(check bool)
252
Added:
"labels the exercise group for assistive tech" true
250
253
(contains ~substring:"aria-label=\"Exercises\"" workout_page);
251
254
Alcotest.(check bool)
252
Removed:
"marks the active tab with aria-current" true
255
Added:
"marks the active button with aria-current" true
253
256
(contains ~substring:"aria-current=\"page\"" workout_page);
254
257
Alcotest.(check bool)
255
258
"links a later slot by query parameter" true
@@ -384,11 +387,15 @@
384
387
let token = Option.get (csrf_token (body (get c "/workout"))) in
385
388
let _ = post c "/workout/finish" [ ("dream.csrf", token) ] in
386
389
(* The one saved workout is w1 for this trainee. Its recorded slot is
387
Removed:
reached through the same [?slot=] tab state. *)
390
Added:
reached through the same [?slot=] state. *)
388
391
let record_page = body (get c "/history/w1?slot=1") in
389
392
Alcotest.(check bool)
390
Removed:
"renders a tab strip for the saved workout" true
391
Removed:
(contains ~substring:"slot-tabs" record_page);
393
Added:
"renders a vertical exercise button group for the saved workout"
394
Added:
true
395
Added:
(contains ~substring:"exercise-group" record_page);
396
Added:
Alcotest.(check bool)
397
Added:
"does not use the old tab strip class" false
398
Added:
(contains ~substring:"slot-tab" record_page);
392
399
Alcotest.(check bool)
393
400
"offers a saved-slot correction form" true
394
401
(contains ~substring:"/history/w1/slots/1/edit" record_page);