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.

Commit
9702a02f111c1790b3ba93ce82dbcef26ba3fe83
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
ARCHITECTURE.org
index 2194b40f..6ffc0536 100644..100644
@@ -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
index c2ac06a0..3de19b85 100644..100644
@@ -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
index 1c50e51a..7fd77276 100644..100644
@@ -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
index b4fa0655..3debc6ba 100644..100644
@@ -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
index bc866f11..ad8202f1 100644..100644
@@ -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 [