[OCaml] High Intensity Training Online
feat refine mobile accessibility
Make navigation and validation feedback work reliably on narrow screens and with assistive technology while preserving server-rendered operation without JavaScript.
Changed files
lib/web/assets/hito.css
@@ -58,7 +58,8 @@
58
58
59
59
a:focus-visible,
60
60
input:focus-visible,
61
Removed:
select:focus-visible {
61
Added:
select:focus-visible,
62
Added:
summary:focus-visible {
62
63
outline: 3px solid var(--focus);
63
64
outline-offset: 3px;
64
65
}
@@ -323,7 +324,11 @@
323
324
padding: 0.65rem 0.75rem;
324
325
}
325
326
326
Removed:
input:invalid { border-color: var(--error); }
327
Added:
input:invalid,
328
Added:
input[aria-invalid="true"],
329
Added:
select[aria-invalid="true"] {
330
Added:
border-color: var(--error);
331
Added:
}
327
332
input[type="number"] { font-family: var(--mono); font-variant-numeric: tabular-nums; }
328
333
329
334
input[type="submit"] {
@@ -550,4 +555,67 @@
550
555
551
556
@media (prefers-reduced-motion: reduce) {
552
557
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; }
558
Added:
}
559
Added:
560
Added:
/* Keyboard users bypass persistent navigation before it becomes visible. */
561
Added:
.skip-link {
562
Added:
position: fixed;
563
Added:
z-index: 100;
564
Added:
top: 0.75rem;
565
Added:
left: 0.75rem;
566
Added:
transform: translateY(-200%);
567
Added:
border: 2px solid var(--focus);
568
Added:
border-radius: 0.25rem;
569
Added:
color: var(--ink);
570
Added:
background: var(--paper-raised);
571
Added:
padding: 0.65rem 0.85rem;
572
Added:
}
573
Added:
.skip-link:focus { transform: translateY(0); }
574
Added:
575
Added:
main:focus {
576
Added:
outline: 3px solid var(--focus);
577
Added:
outline-offset: 0.35rem;
578
Added:
}
579
Added:
580
Added:
/* Mobile-first navigation. Compact controls are the baseline; wider layouts
581
Added:
opt into inline navigation only when they have enough horizontal room. */
582
Added:
.primary-nav,
583
Added:
.account { display: none; }
584
Added:
.menu { display: block; }
585
Added:
.bottom-nav {
586
Added:
position: fixed;
587
Added:
z-index: 20;
588
Added:
right: 0;
589
Added:
bottom: 0;
590
Added:
left: 0;
591
Added:
display: grid;
592
Added:
grid-template-columns: repeat(4, minmax(0, 1fr));
593
Added:
gap: 1px;
594
Added:
border-top: 2px solid var(--ink);
595
Added:
background: var(--ink);
596
Added:
box-shadow: 0 -0.35rem 1.2rem rgba(26, 26, 22, 0.15);
597
Added:
padding-bottom: env(safe-area-inset-bottom);
598
Added:
}
599
Added:
.bottom-nav > a {
600
Added:
display: grid;
601
Added:
min-height: 3.5rem;
602
Added:
place-items: center;
603
Added:
color: var(--ink);
604
Added:
background: var(--paper-raised);
605
Added:
font: 700 0.76rem/1.1 var(--sans);
606
Added:
letter-spacing: 0.025em;
607
Added:
text-align: center;
608
Added:
text-decoration: none;
609
Added:
}
610
Added:
.app-shell { padding: 0 0.75rem calc(6.75rem + env(safe-area-inset-bottom)); }
611
Added:
612
Added:
@media (min-width: 42.0625rem) {
613
Added:
.app-shell { padding: 0 1rem 2.5rem; }
614
Added:
.bottom-nav { display: none; }
615
Added:
}
616
Added:
617
Added:
@media (min-width: 60.0625rem) {
618
Added:
.primary-nav { display: flex; }
619
Added:
.account { display: block; }
620
Added:
.menu { display: none; }
553
621
}
lib/web/pages.ml
@@ -11,6 +11,8 @@
11
11
let rel = Dream_html.string_attr "rel"
12
12
let type_ = Dream_html.string_attr "type"
13
13
let step = Dream_html.string_attr "step"
14
Added:
let id = Dream_html.string_attr "id"
15
Added:
let tabindex = Dream_html.string_attr "tabindex"
14
16
let required = Dream_html.attr "required"
15
17
let href path = Dream_html.path_attr (Dream_html.uri_attr "href") path
16
18
let src path = Dream_html.path_attr (Dream_html.uri_attr "src") path
@@ -22,18 +24,25 @@
22
24
omit both. *)
23
25
let html_page ?trainee ?request ?(active = "") title content =
24
26
let spa_client = true in
25
Removed:
let nav_link path label =
26
Removed:
tag "a"
27
Removed:
([ href path ]
28
Removed:
@ if spa_client then [ Dream_html.attr "data-hito-app-link" ] else [])
29
Removed:
[ txt "%s" label ]
27
Added:
let nav_link page path label =
28
Added:
let attrs = [ href path ] in
29
Added:
let attrs =
30
Added:
if spa_client then Dream_html.attr "data-hito-app-link" :: attrs
31
Added:
else attrs
32
Added:
in
33
Added:
let attrs =
34
Added:
if String.equal page active then
35
Added:
Dream_html.string_attr "aria-current" "page" :: attrs
36
Added:
else attrs
37
Added:
in
38
Added:
tag "a" attrs [ txt "%s" label ]
30
39
in
31
40
let navigation_links username =
32
41
[
33
Removed:
nav_link Routes.home "Overview";
34
Removed:
nav_link Routes.routine "Routine";
35
Removed:
nav_link Routes.workout "Current workout";
36
Removed:
nav_link Routes.history username;
42
Added:
nav_link "home" Routes.home "Overview";
43
Added:
nav_link "routine" Routes.routine "Routine";
44
Added:
nav_link "workout" Routes.workout "Current workout";
45
Added:
nav_link "history" Routes.history username;
37
46
]
38
47
in
39
48
let account_area =
@@ -87,7 +96,14 @@
87
96
class_ "menu-toggle";
88
97
Dream_html.string_attr "aria-label" "Menu";
89
98
]
90
Removed:
[ tag "span" [ class_ "menu-toggle-bars" ] [] ];
99
Added:
[
100
Added:
tag "span"
101
Added:
[
102
Added:
class_ "menu-toggle-bars";
103
Added:
Dream_html.string_attr "aria-hidden" "true";
104
Added:
]
105
Added:
[];
106
Added:
];
91
107
tag "nav"
92
108
[
93
109
class_ "menu-panel";
@@ -124,7 +140,8 @@
124
140
]
125
141
| _ -> []
126
142
in
127
Removed:
tag "html" []
143
Added:
tag "html"
144
Added:
[ Dream_html.string_attr "lang" "en" ]
128
145
[
129
146
tag "head" []
130
147
[
@@ -141,6 +158,11 @@
141
158
tag "body"
142
159
[ Dream_html.string_attr "class" "hito-app page-%s" active ]
143
160
([
161
Added:
tag "a"
162
Added:
[
163
Added:
class_ "skip-link"; Dream_html.string_attr "href" "#main-content";
164
Added:
]
165
Added:
[ txt "Skip to main content" ];
144
166
tag "div"
145
167
([
146
168
Dream_html.string_attr "class" "app-shell page-%s" active;
@@ -171,7 +193,8 @@
171
193
];
172
194
]
173
195
@ primary_nav @ account_area @ menu);
174
Removed:
tag "main" []
196
Added:
tag "main"
197
Added:
[ id "main-content"; tabindex "-1" ]
175
198
[
176
199
tag "div"
177
200
([ class_ "page-surface" ]
@@ -198,7 +221,7 @@
198
221
html_page title
199
222
[
200
223
tag "p" [ class_ "eyebrow" ] [ txt "Attention required" ];
201
Removed:
tag "h2" [] [ txt "%s" title ];
224
Added:
tag "h1" [] [ txt "%s" title ];
202
225
tag "p" [ class_ "warn" ] [ txt "%s" detail ];
203
226
]
204
227
@@ -206,7 +229,12 @@
206
229
207
230
let auth_error = function
208
231
| None -> []
209
Removed:
| Some message -> [ tag "p" [ class_ "warn" ] [ txt "%s" message ] ]
232
Added:
| Some message ->
233
Added:
[
234
Added:
tag "p"
235
Added:
[ class_ "warn"; Dream_html.string_attr "role" "alert" ]
236
Added:
[ txt "%s" message ];
237
Added:
]
210
238
211
239
let credentials_form request ~submit ~action_path =
212
240
tag "form"
@@ -262,7 +290,7 @@
262
290
in
263
291
html_page ~active:"auth" "Sign in"
264
292
([
265
Removed:
tag "h2" [] [ txt "Sign in" ];
293
Added:
tag "h1" [] [ txt "Sign in" ];
266
294
credentials_form request ~submit:"Sign in" ~action_path:Routes.login;
267
295
]
268
296
@ register_prompt @ auth_error error)
@@ -270,7 +298,7 @@
270
298
let register request ?error () =
271
299
html_page ~active:"auth" "Register"
272
300
([
273
Removed:
tag "h2" [] [ txt "Create an account" ];
301
Added:
tag "h1" [] [ txt "Create an account" ];
274
302
credentials_form request ~submit:"Register" ~action_path:Routes.register;
275
303
tag "p" []
276
304
[
@@ -285,6 +313,7 @@
285
313
(* --- application pages --- *)
286
314
287
315
let id_string (id : Repository.routine_id) = (id :> string)
316
Added:
let error_id input_id = input_id ^ "-error"
288
317
289
318
let describe_prescription prescription =
290
319
let min_reps, max_reps =
@@ -298,7 +327,15 @@
298
327
Printf.sprintf "%s into %s, no pause — %s" (Exercise.name isolation)
299
328
(Exercise.name compound) window
300
329
301
Removed:
let extension_select ?(selected = "") ~input_id () =
330
Added:
let extension_select ?(selected = "") ?(invalid = false) ~input_id () =
331
Added:
let error_attrs =
332
Added:
if invalid then
333
Added:
[
334
Added:
Dream_html.string_attr "aria-invalid" "true";
335
Added:
Dream_html.string_attr "aria-describedby" "%s" (error_id input_id);
336
Added:
]
337
Added:
else []
338
Added:
in
302
339
let option (code : string) label =
303
340
let attrs = [ Dream_html.string_attr "value" "%s" code ] in
304
341
let attrs =
@@ -308,7 +345,8 @@
308
345
tag "option" attrs [ txt "%s" label ]
309
346
in
310
347
tag "select"
311
Removed:
[ name "extension"; Dream_html.string_attr "id" "%s" input_id ]
348
Added:
([ name "extension"; Dream_html.string_attr "id" "%s" input_id ]
349
Added:
@ error_attrs)
312
350
[
313
351
option "" "to positive failure";
314
352
option "forced" "then forced reps";
@@ -320,7 +358,7 @@
320
358
let choose_routine request ~trainee ~routines =
321
359
html_page ~trainee ~request ~active:"home" "Choose a routine"
322
360
[
323
Removed:
tag "h2" [] [ txt "Routines" ];
361
Added:
tag "h1" [] [ txt "Routines" ];
324
362
tag "div" []
325
363
(List.map
326
364
(fun (routine_id, routine) ->
@@ -399,7 +437,7 @@
399
437
in
400
438
html_page ~trainee ~request ~active:"home" "Home"
401
439
([
402
Removed:
tag "h2" [] [ txt "%s" routine_name ];
440
Added:
tag "h1" [] [ txt "%s" routine_name ];
403
441
tag "p"
404
442
[ class_ "ledger-meta" ]
405
443
[ txt "Next: %s" (Prescription.Workout.name next) ];
@@ -410,7 +448,7 @@
410
448
let routine request ~trainee routine =
411
449
html_page ~trainee ~request ~active:"routine" "Routine"
412
450
[
413
Removed:
tag "h2" [] [ txt "%s" (Prescription.Routine.name routine) ];
451
Added:
tag "h1" [] [ txt "%s" (Prescription.Routine.name routine) ];
414
452
tag "div"
415
453
[ class_ "routine-accordions" ]
416
454
(List.map
@@ -439,17 +477,29 @@
439
477
(Prescription.Routine.workouts routine));
440
478
]
441
479
442
Removed:
let error_for field errors =
480
Added:
let error_for ~input_id (field : string) (errors : (string * string) list) =
443
481
match List.assoc_opt field errors with
444
482
| None -> []
445
483
| Some error ->
446
484
[
447
485
tag "p"
448
Removed:
[ class_ "warn" ]
486
Added:
[
487
Added:
Dream_html.string_attr "id" "%s" (error_id input_id);
488
Added:
class_ "warn";
489
Added:
Dream_html.string_attr "role" "alert";
490
Added:
]
449
491
[ txt "%s" (Decode.errors_to_text [ (field, error) ]) ];
450
492
]
451
493
452
Removed:
let input_row ?value:v ~input_id field label =
494
Added:
let input_row ?value:v ?(invalid = false) ~input_id (field : string) label =
495
Added:
let error_attrs =
496
Added:
if invalid then
497
Added:
[
498
Added:
Dream_html.string_attr "aria-invalid" "true";
499
Added:
Dream_html.string_attr "aria-describedby" "%s" (error_id input_id);
500
Added:
]
501
Added:
else []
502
Added:
in
453
503
tag "div"
454
504
[ class_ "field" ]
455
505
[
@@ -459,11 +509,12 @@
459
509
void "input"
460
510
([
461
511
type_ "number";
462
Removed:
name field;
512
Added:
Dream_html.string_attr "name" "%s" field;
463
513
Dream_html.string_attr "id" "%s" input_id;
464
514
step "0.5";
465
515
required;
466
516
]
517
Added:
@ error_attrs
467
518
@
468
519
match v with
469
520
| None -> []
@@ -473,13 +524,17 @@
473
524
(* Load and reps sit side by side. The [.row] class is a two-column grid that
474
525
collapses to one column on a narrow screen. Each movement of a pre-exhaust
475
526
pair gets its own row, so both stay paired. *)
476
Removed:
let load_reps_row ~load_id ~load_field ~reps_id ~reps_field ?load_value
477
Removed:
?reps_value ~reps_label () =
527
Added:
let load_reps_row ~(errors : (string * string) list) ~load_id ~load_field
528
Added:
~reps_id ~reps_field ?load_value ?reps_value ~reps_label () =
478
529
tag "div"
479
530
[ class_ "row" ]
480
531
[
481
Removed:
input_row ?value:load_value ~input_id:load_id load_field "Load (kg)";
482
Removed:
input_row ?value:reps_value ~input_id:reps_id reps_field reps_label;
532
Added:
input_row ?value:load_value
533
Added:
~invalid:(List.mem_assoc load_field errors)
534
Added:
~input_id:load_id load_field "Load (kg)";
535
Added:
input_row ?value:reps_value
536
Added:
~invalid:(List.mem_assoc reps_field errors)
537
Added:
~input_id:reps_id reps_field reps_label;
483
538
]
484
539
485
540
(* A recorded number, rendered the way a numeric input expects. *)
@@ -501,7 +556,8 @@
501
556
(* [recorded] pre-fills the form when correcting a slot; [submit] names the
502
557
action. Load and reps of each movement are laid out side by side. *)
503
558
let form_for_stimulus request ~action_path ~slot ~prescription ?recorded
504
Removed:
?(enhanced = false) ?(submit = "Record") ~errors () =
559
Added:
?(enhanced = false) ?(submit = "Record") ~(errors : (string * string) list)
560
Added:
() =
505
561
let field_id field = Printf.sprintf "slot-%d-%s" slot field in
506
562
let efforts = Option.map Evidence.Stimulus.efforts recorded in
507
563
let nth n = Option.bind efforts (fun es -> List.nth_opt es n) in
@@ -524,7 +580,7 @@
524
580
match Prescription.Stimulus.delivery prescription with
525
581
| Prescription.Stimulus.Single _ ->
526
582
[
527
Removed:
load_reps_row ~load_id:(field_id "load") ~load_field:"load"
583
Added:
load_reps_row ~errors ~load_id:(field_id "load") ~load_field:"load"
528
584
~reps_id:(field_id "reps") ~reps_field:"reps"
529
585
?load_value:(load_of 0) ?reps_value:(reps_of 0)
530
586
~reps_label:"Reps to failure" ();
@@ -532,15 +588,15 @@
532
588
| Prescription.Stimulus.Pre_exhaust { isolation; compound } ->
533
589
[
534
590
tag "p" [ class_ "done" ] [ txt "%s" (Exercise.name isolation) ];
535
Removed:
load_reps_row ~load_id:(field_id "iso_load") ~load_field:"iso_load"
536
Removed:
~reps_id:(field_id "iso_reps") ~reps_field:"iso_reps"
537
Removed:
?load_value:(load_of 0) ?reps_value:(reps_of 0) ~reps_label:"Reps"
538
Removed:
();
591
Added:
load_reps_row ~errors ~load_id:(field_id "iso_load")
592
Added:
~load_field:"iso_load" ~reps_id:(field_id "iso_reps")
593
Added:
~reps_field:"iso_reps" ?load_value:(load_of 0)
594
Added:
?reps_value:(reps_of 0) ~reps_label:"Reps" ();
539
595
tag "p" [ class_ "done" ] [ txt "%s" (Exercise.name compound) ];
540
Removed:
load_reps_row ~load_id:(field_id "comp_load") ~load_field:"comp_load"
541
Removed:
~reps_id:(field_id "comp_reps") ~reps_field:"comp_reps"
542
Removed:
?load_value:(load_of 1) ?reps_value:(reps_of 1) ~reps_label:"Reps"
543
Removed:
();
596
Added:
load_reps_row ~errors ~load_id:(field_id "comp_load")
597
Added:
~load_field:"comp_load" ~reps_id:(field_id "comp_reps")
598
Added:
~reps_field:"comp_reps" ?load_value:(load_of 1)
599
Added:
?reps_value:(reps_of 1) ~reps_label:"Reps" ();
544
600
]
545
601
in
546
602
tag "form"
@@ -557,10 +613,12 @@
557
613
tag "label"
558
614
[ Dream_html.string_attr "for" "%s" (field_id "extension") ]
559
615
[ txt "Ending" ];
560
Removed:
extension_select ~selected ~input_id:(field_id "extension") ();
616
Added:
extension_select ~selected
617
Added:
~invalid:(List.mem_assoc "extension" errors)
618
Added:
~input_id:(field_id "extension") ();
561
619
]
562
620
@ List.concat_map
563
Removed:
(fun field -> error_for field errors)
621
Added:
(fun field -> error_for ~input_id:(field_id field) field errors)
564
622
[
565
623
"load";
566
624
"reps";
@@ -850,7 +908,7 @@
850
908
let history request ~trainee records =
851
909
html_page ~trainee ~request ~active:"history" "History"
852
910
[
853
Removed:
tag "h2" [] [ txt "History" ];
911
Added:
tag "h1" [] [ txt "History" ];
854
912
(if records = [] then tag "p" [] [ txt "Nothing logged yet." ]
855
913
else
856
914
tag "ul" []
test/test_web.ml
@@ -145,10 +145,18 @@
145
145
"has a password field" true
146
146
(contains ~substring:"type=\"password\"" page);
147
147
Alcotest.(check bool)
148
Added:
"starts the main content with a page-level heading" true
149
Added:
(contains ~substring:"<h1>Sign in</h1>" page);
150
Added:
Alcotest.(check bool)
148
151
"loads the app client and marks the sign-in form" true
149
152
(contains ~substring:"data-hito-app-shell" page
150
153
&& contains ~substring:"data-hito-app-form" page
151
Removed:
&& contains ~substring:"/assets/workout-client.js" page) );
154
Added:
&& contains ~substring:"/assets/workout-client.js" page);
155
Added:
Alcotest.(check bool)
156
Added:
"offers a skip link to the main landmark" true
157
Added:
(contains ~substring:"skip-link" page
158
Added:
&& contains ~substring:"href=\"#main-content\"" page
159
Added:
&& contains ~substring:"id=\"main-content\"" page) );
152
160
( "registration signs the trainee in and reaches the overview",
153
161
`Quick,
154
162
fun () ->
@@ -162,6 +170,9 @@
162
170
"shows the routine catalog" true
163
171
(contains ~substring:"Routines" (body overview));
164
172
Alcotest.(check bool)
173
Added:
"starts the overview with a page-level heading" true
174
Added:
(contains ~substring:"<h1>Routines</h1>" (body overview));
175
Added:
Alcotest.(check bool)
165
176
"shows the username in history navigation" true
166
177
(contains ~substring:"href=\"/history\"" (body overview)
167
178
&& contains ~substring:">lifter</a>" (body overview));
@@ -169,6 +180,12 @@
169
180
"omits the header username" false
170
181
(contains ~substring:"account-username" (body overview));
171
182
Alcotest.(check bool)
183
Added:
"declares the document language" true
184
Added:
(contains ~substring:"<html lang=\"en\">" (body overview));
185
Added:
Alcotest.(check bool)
186
Added:
"marks the overview as the current navigation destination" true
187
Added:
(contains ~substring:"aria-current=\"page\"" (body overview));
188
Added:
Alcotest.(check bool)
172
189
"shows primary navigation" true
173
190
(contains ~substring:"primary-nav" (body overview));
174
191
Alcotest.(check bool)
@@ -286,7 +303,10 @@
286
303
Alcotest.(check int) "rejected" 400 (status response);
287
304
Alcotest.(check bool)
288
305
"explains the minimum length" true
289
Removed:
(contains ~substring:"at least 4 characters" (body response)) );
306
Added:
(contains ~substring:"at least 4 characters" (body response));
307
Added:
Alcotest.(check bool)
308
Added:
"announces the error" true
309
Added:
(contains ~substring:"role=\"alert\"" (body response)) );
290
310
( "registering a too-long username is refused with a message",
291
311
`Quick,
292
312
fun () ->
@@ -578,6 +598,44 @@
578
598
Alcotest.(check bool)
579
599
"shows the corrected load" true
580
600
(contains ~substring:"20" record_page) );
601
Added:
( "invalid workout fields identify and describe their errors",
602
Added:
`Quick,
603
Added:
fun () ->
604
Added:
let c = client () in
605
Added:
let _ = sign_in_new c in
606
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
607
Added:
let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
608
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
609
Added:
let _ =
610
Added:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
611
Added:
in
612
Added:
let page = body (get c "/workout") in
613
Added:
let token = Option.get (csrf_token page) in
614
Added:
let invalid =
615
Added:
post c "/workout/slots/0"
616
Added:
[
617
Added:
("dream.csrf", token);
618
Added:
("iso_load", "not-a-number");
619
Added:
("iso_reps", "8");
620
Added:
("comp_load", "40");
621
Added:
("comp_reps", "6");
622
Added:
("extension", "");
623
Added:
]
624
Added:
in
625
Added:
Alcotest.(check int) "invalid field rejected" 400 (status invalid);
626
Added:
let invalid_page = body invalid in
627
Added:
Alcotest.(check bool)
628
Added:
"marks the invalid input" true
629
Added:
(contains ~substring:"id=\"slot-0-iso_load\"" invalid_page
630
Added:
&& contains ~substring:"aria-invalid=\"true\"" invalid_page);
631
Added:
Alcotest.(check bool)
632
Added:
"links the invalid input to its stable error ID" true
633
Added:
(contains ~substring:"aria-describedby=\"slot-0-iso_load-error\""
634
Added:
invalid_page);
635
Added:
Alcotest.(check bool)
636
Added:
"gives the announced error the referenced stable ID" true
637
Added:
(contains ~substring:"id=\"slot-0-iso_load-error\"" invalid_page
638
Added:
&& contains ~substring:"role=\"alert\"" invalid_page) );
581
639
( "the workout view shows one slot at a time and defaults to the first \
582
640
incomplete slot",
583
641
`Quick,