[OCaml] High Intensity Training Online
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.
Changed files
lib/presenter/page_kit.ml
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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