refactor Use vertical exercise buttons

Replace horizontal workout tabs with full-width vertical exercise navigation.

Commit
587c8d4b28522397968382aafb91abb59160fbcf
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
lib/web/assets/hito.css
index 706a5a89..21bf650e 100644..100644
@@ -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
index 8200140d..c69ad5aa 100644..100644
@@ -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
index a5679aa7..803d17e6 100644..100644
@@ -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
index 155d2c1f..72a3e0a0 100644..100644
@@ -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
index c1410bdc..9afd9a80 100644..100644
@@ -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);