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.

Commit
2ff8e39ff32440448a2ff0bc0908ec5230ba6e17
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 633a8b45..76042bc8 100644..100644
@@ -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
index c4dff461..b015f5ab 100644..100644
@@ -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
index e4b4e7ea..d0bdfa0d 100644..100644
@@ -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,