feat Serve the toast from the top edge, dismissable, sliding out

The notice sat a rem below the top, had no way to close it, and after three seconds vanished with a transition that changed no property. Now one shared toast builder renders the message with a Dismiss button. It is anchored to the top edge and slides down into view; after five seconds, or on Dismiss, the client marks it done and it slides back up and fades before it is removed. A manual dismiss cancels the pending timer so it cannot act on a later notice. Reduced motion keeps the fade and drops the movement. Without script the toast stays and the button is inert, so a no-JS visitor is still told.

Commit
e5835fc264254bce31297b036587f6cae71d4eae
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
lib/presenter/page_kit.ml
index ba15e51a..532ba630 100644..100644
@@ -28,3 +28,27 @@
28 28
29 29 let errors_to_text errors =
30 30 errors |> List.map (fun (_, key) -> error_text key) |> String.concat " "
31 Added:
32 Added: (* A transient notice served from the top of the screen. It is a polite live
33 Added: region so assistive technology reads it. The client slides it away after five
34 Added: seconds or when Dismiss is pressed; without script it stays, so a no-JS
35 Added: visitor is still told and the button is inert. *)
36 Added: let toast message =
37 Added: tag "div"
38 Added: [
39 Added: class_ "toast";
40 Added: Dream_html.attr "data-hito-toast";
41 Added: Dream_html.string_attr "role" "status";
42 Added: Dream_html.string_attr "aria-live" "polite";
43 Added: ]
44 Added: [
45 Added: tag "span" [ class_ "toast-message" ] [ Dream_html.txt "%s" message ];
46 Added: tag "button"
47 Added: [
48 Added: type_ "button";
49 Added: class_ "toast-dismiss";
50 Added: Dream_html.string_attr "aria-label" "Dismiss notice";
51 Added: Dream_html.attr "data-hito-toast-dismiss";
52 Added: ]
53 Added: [ Dream_html.txt "×" ];
54 Added: ]
lib/presenter/page_shell.ml
index f3f91c21..c166c9f0 100644..100644
@@ -164,17 +164,7 @@
164 164 match request with
165 165 | Some request -> (
166 166 match Dream.session_field request flash_key with
167 Removed: | Some message ->
168 Removed: [
169 Removed: tag "div"
170 Removed: [
171 Removed: class_ "toast";
172 Removed: Dream_html.attr "data-hito-toast";
173 Removed: Dream_html.string_attr "role" "status";
174 Removed: Dream_html.string_attr "aria-live" "polite";
175 Removed: ]
176 Removed: [ txt "%s" message ];
177 Removed: ]
167 Added: | Some message -> [ toast message ]
178 168 | None -> [])
179 169 | None -> []
180 170 in
lib/presenter/pages_workout.ml
index e81aa343..b6fc3514 100644..100644
@@ -220,18 +220,7 @@
220 220 it briefly then dismisses it. Without script it stays visible as a quiet
221 221 fixed notice, so a no-JS visitor is still told. *)
222 222 let override_note =
223 Removed: if vm.overridden then
224 Removed: [
225 Removed: tag "div"
226 Removed: [
227 Removed: class_ "toast";
228 Removed: Dream_html.attr "data-hito-toast";
229 Removed: Dream_html.string_attr "role" "status";
230 Removed: Dream_html.string_attr "aria-live" "polite";
231 Removed: ]
232 Removed: [ txt "Begun before recovery finished." ];
233 Removed: ]
234 Removed: else []
223 Added: if vm.overridden then [ toast "Begun before recovery finished." ] else []
235 224 in
236 225 (* The exercise accordion: one <details> per prescription slot, all sharing a
237 226 [name], which the browser treats as an exclusive group — opening one closes
lib/web/assets/hito.css
index 3952b246..92f1db9f 100644..100644
@@ -1178,28 +1178,58 @@
1178 1178 block in the flow. Rendered visible so a no-JS visitor reads it; the client
1179 1179 marks it done and removes it after three seconds. The brass wash keeps it
1180 1180 distinct from an oxblood error. */
1181 Added: /* The transient notice. Served from the top edge: it slides down into view on
1182 Added: arrival and, once the client marks it done — after five seconds or on
1183 Added: Dismiss — slides back up out of view before it is removed. Without script it
1184 Added: simply stays. */
1181 1185 .toast {
1182 1186 position: fixed;
1187 Added: top: 0;
1183 1188 left: 50%;
1184 Removed: top: 1rem;
1185 Removed: transform: translateX(-50%);
1186 1189 z-index: 20;
1187 Removed: max-width: 30rem;
1190 Added: display: flex;
1191 Added: align-items: center;
1192 Added: gap: 0.75rem;
1193 Added: max-width: min(30rem, calc(100% - 2rem));
1194 Added: padding: 0.7rem 0.6rem 0.7rem 1rem;
1188 1195 border: 1px solid var(--brass);
1189 Removed: border-radius: var(--radius);
1196 Added: border-top: none;
1197 Added: border-radius: 0 0 var(--radius) var(--radius);
1190 1198 color: var(--ink);
1191 1199 background: var(--brass-wash);
1192 Removed: padding: 0.7rem 1rem;
1193 1200 box-shadow: 0 6px 18px rgba(26, 26, 22, 0.18);
1194 1201 font-weight: 600;
1202 Added: transform: translate(-50%, 0);
1203 Added: animation: toast-in 260ms ease both;
1204 Added: transition: transform 320ms ease, opacity 320ms ease;
1195 1205 }
1196 Removed: /* Once the client marks the toast done it begins a short fade before removal,
1197 Removed: so its disappearance is not abrupt. */
1206 Added: @keyframes toast-in {
1207 Added: from { transform: translate(-50%, -100%); }
1208 Added: to { transform: translate(-50%, 0); }
1209 Added: }
1198 1210 .toast[data-hito-toast-done] {
1199 Removed: transition: opacity 400ms ease;
1211 Added: transform: translate(-50%, -100%);
1212 Added: opacity: 0;
1200 1213 }
1214 Added: .toast-message { flex: 1 1 auto; }
1215 Added: .toast-dismiss {
1216 Added: flex: none;
1217 Added: width: 2rem;
1218 Added: height: 2rem;
1219 Added: padding: 0;
1220 Added: border: none;
1221 Added: border-radius: 50%;
1222 Added: color: var(--ink);
1223 Added: background: transparent;
1224 Added: font-size: 1.25rem;
1225 Added: line-height: 1;
1226 Added: cursor: pointer;
1227 Added: }
1228 Added: .toast-dismiss:hover { background: rgba(26, 26, 22, 0.08); }
1201 1229 @media (prefers-reduced-motion: reduce) {
1202 Removed: .toast[data-hito-toast-done] { transition: none; }
1230 Added: .toast { animation: none; transition: opacity 320ms ease; }
1231 Added: .toast[data-hito-toast-done] { transform: translate(-50%, 0); }
1232 Added: }
1203 1233 }
1204 1234
1205 1235 /* Subjective feedback uses one inline three-choice group per signal. The radio
lib/web/workout_client.ml
index 9742eaa9..120940a6 100644..100644
@@ -30,30 +30,62 @@
30 30 | Some status -> status##.textContent := Js.some (Js.string message)
31 31 | None -> ()
32 32
33 Removed: (* The early-workout toast. The server renders it visible so a no-JS visitor
34 Removed: still reads it. With script it is a transient notice: mark it shown, then
35 Removed: remove it after three seconds (WCAG 2.2.1 gives users time to read a brief
36 Removed: status). Idempotent: a toast already dismissed carries [data-hito-toast-done]
37 Removed: and is left alone, so a content swap never re-arms the same notice. *)
33 Added: (* The transient notice. Without script it stays, so a no-JS visitor still
34 Added: reads it. With script it slides away after five seconds (WCAG 2.2.1 gives
35 Added: users time to read a brief status) or when its Dismiss button is pressed.
36 Added: Marking it done starts the slide-out; the node is removed once that has
37 Added: played. Idempotent: a toast already marked done is left alone, so a content
38 Added: swap never re-arms the same notice, and the pending timer is cancelled on a
39 Added: manual dismiss so it cannot act on a later toast. *)
40 Added: let toast_timer = ref None
41 Added:
42 Added: let finish_toast toast =
43 Added: match
44 Added: Js.Opt.to_option (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 400.))
56 Added:
38 57 let dismiss_toast () =
39 Removed: match query_one document "[data-hito-toast]" with
58 Added: (match !toast_timer with
59 Added: | Some id ->
60 Added: Dom_html.window##clearTimeout id;
61 Added: toast_timer := None
62 Added: | None -> ());
63 Added: match query_one document "[data-hito-toast]:not([data-hito-toast-done])" with
40 64 | None -> ()
41 Removed: | Some toast -> (
42 Removed: match
43 Removed: Js.Opt.to_option
44 Removed: (toast##getAttribute (Js.string "data-hito-toast-done"))
45 Removed: with
46 Removed: | Some _ -> ()
47 Removed: | None ->
48 Removed: toast##setAttribute (Js.string "data-hito-toast-done") (Js.string "");
49 Removed: ignore
50 Removed: (Dom_html.window##setTimeout
51 Removed: (Js.wrap_callback (fun () ->
52 Removed: match Js.Opt.to_option toast##.parentNode with
53 Removed: | Some parent -> Dom.removeChild parent toast
54 Removed: | None -> ()))
55 Removed: (Js.number_of_float 3000.)))
65 Added: | Some toast ->
66 Added: toast_timer :=
67 Added: Some
68 Added: (Dom_html.window##setTimeout
69 Added: (Js.wrap_callback (fun () ->
70 Added: toast_timer := None;
71 Added: finish_toast toast))
72 Added: (Js.number_of_float 5000.))
56 73
74 Added: let toast_click event =
75 Added: match closest "[data-hito-toast-dismiss]" (Dom_html.eventTarget event) with
76 Added: | None -> Js._true
77 Added: | Some button -> (
78 Added: match closest "[data-hito-toast]" button with
79 Added: | None -> Js._true
80 Added: | Some toast ->
81 Added: (match !toast_timer with
82 Added: | Some id ->
83 Added: Dom_html.window##clearTimeout id;
84 Added: toast_timer := None
85 Added: | None -> ());
86 Added: finish_toast toast;
87 Added: Js._false)
88 Added:
57 89 (* The workout timer. The sticky bar carries the workout's start time as an
58 90 epoch in [data-started]. The elapsed time is computed from it, so a content
59 91 swap that re-renders the bar never resets the count. A single interval runs
@@ -423,6 +455,10 @@
423 455 |> ignore;
424 456 Dom_html.addEventListener document Dom_html.Event.click
425 457 (Dom_html.handler dialog_click)
458 Added: Js._false
459 Added: |> ignore;
460 Added: Dom_html.addEventListener document Dom_html.Event.click
461 Added: (Dom_html.handler toast_click)
426 462 Js._false
427 463 |> ignore;
428 464 Dom_html.addEventListener document Dom_html.Event.click