[OCaml] High Intensity Training Online
feat show the early-workout notice as a toast
The override is confirmed in a modal before starting, so on the workout screen the early-workout notice is informational only. Render it as an accessible toast (role=status, polite) above the content rather than a block in the flow. The client dismisses it after three seconds, giving time to read a brief status (WCAG 2.2.1). Without script it stays a quiet fixed notice, so a no-JS visitor is still told.
Changed files
ARCHITECTURE.org
@@ -408,6 +408,7 @@
408
408
| =data-hito-app-form= | a form eligible for in-place submission |
409
409
| =data-hito-workout-*= | the same, for current-workout slots and forms |
410
410
| =data-hito-workout-status= | an =aria-live= region for announcements |
411
Added:
| =data-hito-toast= | a transient notice the client dismisses |
411
412
412
413
Behaviour: the client fetches the same URL the link or form would have used,
413
414
extracts the marked region from the response, and swaps it. Successful
lib/web/assets/hito.css
@@ -835,3 +835,33 @@
835
835
.app-feedback-message {
836
836
white-space: pre-wrap;
837
837
}
838
Added:
839
Added:
/* The early-workout toast. It reports that the workout began before recovery
840
Added:
finished. The modal already confirmed the override, so on the workout screen
841
Added:
this is informational only: a fixed notice above the content rather than a
842
Added:
block in the flow. Rendered visible so a no-JS visitor reads it; the client
843
Added:
marks it done and removes it after three seconds. The brass wash keeps it
844
Added:
distinct from an oxblood error. */
845
Added:
.toast {
846
Added:
position: fixed;
847
Added:
left: 50%;
848
Added:
top: 1rem;
849
Added:
transform: translateX(-50%);
850
Added:
z-index: 20;
851
Added:
max-width: 30rem;
852
Added:
border: 1px solid var(--brass);
853
Added:
border-radius: var(--radius);
854
Added:
color: var(--ink);
855
Added:
background: var(--brass-wash);
856
Added:
padding: 0.7rem 1rem;
857
Added:
box-shadow: 0 6px 18px rgba(26, 26, 22, 0.18);
858
Added:
font-weight: 600;
859
Added:
}
860
Added:
/* Once the client marks the toast done it begins a short fade before removal,
861
Added:
so its disappearance is not abrupt. */
862
Added:
.toast[data-hito-toast-done] {
863
Added:
transition: opacity 400ms ease;
864
Added:
}
865
Added:
@media (prefers-reduced-motion: reduce) {
866
Added:
.toast[data-hito-toast-done] { transition: none; }
867
Added:
}
lib/web/pages.ml
@@ -816,12 +816,24 @@
816
816
| None -> action Routes.workout_slot_edit slot
817
817
| Some record_id -> action Routes.record_slot_edit record_id slot
818
818
in
819
Added:
(* The early-workout notice. The override was already confirmed in a modal, so
820
Added:
on the workout screen this is informational only: a toast rather than a
821
Added:
component in the main content. It is a polite live region; the client shows
822
Added:
it briefly then dismisses it. Without script it stays visible as a quiet
823
Added:
fixed notice, so a no-JS visitor is still told. *)
819
824
let override_note =
820
825
match Recovery.basis (Evidence.Workout.clearance workout) with
821
826
| Recovery.Recovered -> []
822
827
| Recovery.Overridden _ ->
823
828
[
824
Removed:
tag "div" [ class_ "warn" ] [ txt "Begun before recovery finished." ];
829
Added:
tag "div"
830
Added:
[
831
Added:
class_ "toast";
832
Added:
Dream_html.attr "data-hito-toast";
833
Added:
Dream_html.string_attr "role" "status";
834
Added:
Dream_html.string_attr "aria-live" "polite";
835
Added:
]
836
Added:
[ txt "Begun before recovery finished." ];
825
837
]
826
838
in
827
839
(* One entry per filled slot, the first fill in performance order — the fill
lib/web/workout_client.ml
@@ -30,6 +30,30 @@
30
30
| Some status -> status##.textContent := Js.some (Js.string message)
31
31
| None -> ()
32
32
33
Added:
(* The early-workout toast. The server renders it visible so a no-JS visitor
34
Added:
still reads it. With script it is a transient notice: mark it shown, then
35
Added:
remove it after three seconds (WCAG 2.2.1 gives users time to read a brief
36
Added:
status). Idempotent: a toast already dismissed carries [data-hito-toast-done]
37
Added:
and is left alone, so a content swap never re-arms the same notice. *)
38
Added:
let dismiss_toast () =
39
Added:
match query_one document "[data-hito-toast]" with
40
Added:
| None -> ()
41
Added:
| Some toast -> (
42
Added:
match
43
Added:
Js.Opt.to_option
44
Added:
(toast##getAttribute (Js.string "data-hito-toast-done"))
45
Added:
with
46
Added:
| Some _ -> ()
47
Added:
| None ->
48
Added:
toast##setAttribute (Js.string "data-hito-toast-done") (Js.string "");
49
Added:
ignore
50
Added:
(Dom_html.window##setTimeout
51
Added:
(Js.wrap_callback (fun () ->
52
Added:
match Js.Opt.to_option toast##.parentNode with
53
Added:
| Some parent -> Dom.removeChild parent toast
54
Added:
| None -> ()))
55
Added:
(Js.number_of_float 3000.)))
56
Added:
33
57
(* The workout timer. The sticky bar carries the workout's start time as an
34
58
epoch in [data-started]; the elapsed time is computed from it, so a content
35
59
swap that re-renders the bar never resets the count. A single interval runs
@@ -101,6 +125,7 @@
101
125
would be redundant noise. Transient "Loading"/"Saving" still clear when
102
126
the swap lands, because the fresh status node starts empty. *)
103
127
start_timer ();
128
Added:
dismiss_toast ();
104
129
true
105
130
| _ -> false
106
131
@@ -348,4 +373,5 @@
348
373
a no-JS visitor keeps every fallback. *)
349
374
document##.documentElement##setAttribute
350
375
(Js.string "data-hito-js") (Js.string "on");
351
Removed:
start_timer ()
376
Added:
start_timer ();
377
Added:
dismiss_toast ()
test/test_web.ml
@@ -1055,6 +1055,39 @@
1055
1055
Alcotest.(check bool)
1056
1056
"still submits the override without script" true
1057
1057
(contains ~substring:"value=\"true\"" home) );
1058
Added:
( "an overridden workout shows the early notice as a toast",
1059
Added:
`Quick,
1060
Added:
fun () ->
1061
Added:
let c = client () in
1062
Added:
let _ = sign_in_new c in
1063
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
1064
Added:
let _ = post c "/routines/ideal/select" [ ("dream.csrf", token) ] in
1065
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
1066
Added:
let _ =
1067
Added:
post c "/workout" [ ("dream.csrf", token); ("override", "false") ]
1068
Added:
in
1069
Added:
let token = Option.get (csrf_token (body (get c "/workout"))) in
1070
Added:
let _ = post c "/workout/finish" [ ("dream.csrf", token) ] in
1071
Added:
(* Recovery has not elapsed, so the next workout must be started
1072
Added:
under override. *)
1073
Added:
let token = Option.get (csrf_token (body (get c "/"))) in
1074
Added:
let _ =
1075
Added:
post c "/workout" [ ("dream.csrf", token); ("override", "true") ]
1076
Added:
in
1077
Added:
let workout = body (get c "/workout") in
1078
Added:
Alcotest.(check bool)
1079
Added:
"renders the notice as a toast" true
1080
Added:
(contains ~substring:"data-hito-toast" workout);
1081
Added:
Alcotest.(check bool)
1082
Added:
"announces the toast politely" true
1083
Added:
(contains ~substring:"role=\"status\"" workout);
1084
Added:
Alcotest.(check bool)
1085
Added:
"keeps the early-workout wording" true
1086
Added:
(contains ~substring:"Begun before recovery finished." workout);
1087
Added:
Alcotest.(check bool)
1088
Added:
"no longer renders the notice as a plain warn block" false
1089
Added:
(contains ~substring:"<div class=\"warn\">Begun before recovery"
1090
Added:
workout) );
1058
1091
] );
1059
1092
( "web.app_feedback",
1060
1093
[