[OCaml] High Intensity Training Online
refactor Rationalize DOM and CSS
Remove duplicate navigation markup and stale selectors. Consolidate shared controls and update web assertions for the current DOM.
Changed files
lib/presenter/page_shell.ml
@@ -1,9 +1,8 @@
1
1
open Dream_html
2
2
open Page_kit
3
3
4
Removed:
(* The shell. [viewer] and [request] are present on authenticated pages, which
5
Removed:
then show the profile link and the username on the history link. Auth pages
6
Removed:
omit both. *)
4
Added:
(* The shell. [viewer] and [request] identify authenticated pages. Auth pages
5
Added:
omit the application navigation. *)
7
6
let theme_session_key = "hito.theme"
8
7
9
8
let deployment_version () =
@@ -53,12 +52,11 @@
53
52
tag "a" attrs [ txt "%s" label ]
54
53
in
55
54
(* Three always-available actions. The middle slot points to the routine
56
Removed:
normally. While logging it becomes the elapsed workout timer — a link that
55
Added:
normally. While logging it becomes the elapsed workout timer. The link
57
56
still carries the trainee to the current workout, so a running workout
58
Removed:
stays one tap away without adding a fourth destination, and the timer needs
59
Removed:
no separate bar. The client ticks the value from [data-started]; without
60
Removed:
script the link shows a static start. The logbook tab always reads
61
Removed:
"Logbook". The username lives on its own profile control. *)
57
Added:
stays one tap away without adding a fourth destination. The client ticks
58
Added:
the value from [data-started]. Without script the link shows a static
59
Added:
start. *)
62
60
let workout_timer_link started =
63
61
let attrs =
64
62
[ href Routes.workout; class_ "nav-timer" ]
@@ -93,40 +91,23 @@
93
91
nav_link "logbook" Routes.logbook "Logbook";
94
92
]
95
93
in
96
Removed:
(* Profile access lives in the logbook action bar. The masthead contains only
97
Removed:
the three primary navigation links on desktop, and only the brand on
98
Removed:
mobile. *)
99
Removed:
let profile_area = [] in
100
Removed:
let primary_nav =
101
Removed:
match (viewer, request) with
102
Removed:
| Some (_ : View_model.Viewer.t), Some _ ->
103
Removed:
[ tag "nav" [ class_ "primary-nav" ] (navigation_links ()) ]
104
Removed:
| _ when show_nav ->
105
Removed:
[ tag "nav" [ class_ "primary-nav" ] (navigation_links ()) ]
106
Removed:
| _ -> []
94
Added:
let application_navigation () =
95
Added:
let visible =
96
Added:
match (viewer, request) with
97
Added:
| Some (_ : View_model.Viewer.t), Some _ -> true
98
Added:
| _ -> show_nav
99
Added:
in
100
Added:
if visible then
101
Added:
[
102
Added:
tag "nav"
103
Added:
[
104
Added:
class_ "app-nav";
105
Added:
Dream_html.string_attr "aria-label" "Application navigation";
106
Added:
]
107
Added:
(navigation_links ());
108
Added:
]
109
Added:
else []
107
110
in
108
Removed:
let bottom_nav =
109
Removed:
match (viewer, request) with
110
Removed:
| Some (_ : View_model.Viewer.t), Some _ ->
111
Removed:
[
112
Removed:
tag "nav"
113
Removed:
[
114
Removed:
class_ "bottom-nav";
115
Removed:
Dream_html.string_attr "aria-label" "Mobile navigation";
116
Removed:
]
117
Removed:
(navigation_links ());
118
Removed:
]
119
Removed:
| _ when show_nav ->
120
Removed:
[
121
Removed:
tag "nav"
122
Removed:
[
123
Removed:
class_ "bottom-nav";
124
Removed:
Dream_html.string_attr "aria-label" "Mobile navigation";
125
Removed:
]
126
Removed:
(navigation_links ());
127
Removed:
]
128
Removed:
| _ -> []
129
Removed:
in
130
111
tag "html"
131
112
[
132
113
Dream_html.string_attr "lang" "en";
@@ -167,28 +148,27 @@
167
148
@
168
149
if spa_client then [ Dream_html.attr "data-hito-app-shell" ]
169
150
else [])
170
Removed:
([
171
Removed:
tag "header"
172
Removed:
[ class_ "masthead" ]
173
Removed:
([
174
Removed:
tag "a"
175
Removed:
([ class_ "brand"; href Routes.home ]
176
Removed:
@
177
Removed:
if spa_client then
178
Removed:
[ Dream_html.attr "data-hito-app-link" ]
179
Removed:
else [])
180
Removed:
[ txt "hito" ];
181
Removed:
]
182
Removed:
@ primary_nav @ profile_area);
183
Removed:
tag "main"
184
Removed:
([ id "main-content"; tabindex "-1" ]
185
Removed:
@
186
Removed:
if spa_client then [ Dream_html.attr "data-hito-app-content" ]
187
Removed:
else [])
188
Removed:
content;
189
Removed:
footer ();
190
Removed:
]
191
Removed:
@ bottom_nav);
151
Added:
[
152
Added:
tag "header"
153
Added:
[ class_ "masthead" ]
154
Added:
([
155
Added:
tag "a"
156
Added:
([ class_ "brand"; href Routes.home ]
157
Added:
@
158
Added:
if spa_client then
159
Added:
[ Dream_html.attr "data-hito-app-link" ]
160
Added:
else [])
161
Added:
[ txt "hito" ];
162
Added:
]
163
Added:
@ application_navigation ());
164
Added:
tag "main"
165
Added:
([ id "main-content"; tabindex "-1" ]
166
Added:
@
167
Added:
if spa_client then [ Dream_html.attr "data-hito-app-content" ]
168
Added:
else [])
169
Added:
content;
170
Added:
footer ();
171
Added:
];
192
172
]
193
173
@
194
174
if spa_client then
lib/presenter/pages_app_feedback.ml
@@ -179,22 +179,18 @@
179
179
in
180
180
html_page ~viewer ~request ~active:"app-feedback" ?workout_started
181
181
"App feedback"
182
Removed:
[
183
Removed:
tag "div" []
184
Removed:
([
185
Removed:
tag "h1" [] [ txt "App feedback" ];
186
Removed:
tag "p" [] [ txt "Help us improve hito by sharing your experience." ];
187
Removed:
tag "nav"
188
Removed:
[
189
Removed:
class_ "feedback-tabs";
190
Removed:
Dream_html.string_attr "aria-label" "App feedback tabs";
191
Removed:
]
192
Removed:
[
193
Removed:
app_feedback_tab ~selected:tab ~tab:`Write
194
Removed:
~label:"Write feedback";
195
Removed:
app_feedback_tab ~selected:tab ~tab:`Submitted
196
Removed:
~label:"Submitted feedback";
197
Removed:
];
182
Added:
([
183
Added:
tag "h1" [] [ txt "App feedback" ];
184
Added:
tag "p" [] [ txt "Help us improve hito by sharing your experience." ];
185
Added:
tag "nav"
186
Added:
[
187
Added:
class_ "feedback-tabs";
188
Added:
Dream_html.string_attr "aria-label" "App feedback tabs";
198
189
]
199
Removed:
@ panel);
200
Removed:
]
190
Added:
[
191
Added:
app_feedback_tab ~selected:tab ~tab:`Write ~label:"Write feedback";
192
Added:
app_feedback_tab ~selected:tab ~tab:`Submitted
193
Added:
~label:"Submitted feedback";
194
Added:
];
195
Added:
]
196
Added:
@ panel)
lib/presenter/pages_profile.ml
@@ -50,53 +50,49 @@
50
50
@
51
51
if Option.is_some username_error then [ Dream_html.attr "open" ]
52
52
else [])
53
Removed:
[
54
Removed:
tag "div"
55
Removed:
[ class_ "profile-dialog-content" ]
56
Removed:
([
57
Removed:
tag "h3" [] [ txt "Change username" ];
58
Removed:
tag "form"
53
Added:
([
54
Added:
tag "h3" [] [ txt "Change username" ];
55
Added:
tag "form"
56
Added:
[
57
Added:
action Routes.profile_username;
58
Added:
post_form;
59
Added:
class_ "profile-form";
60
Added:
Dream_html.attr "data-hito-app-form";
61
Added:
]
62
Added:
[
63
Added:
Dream_html.csrf_tag request;
64
Added:
tag "div"
65
Added:
[ class_ "field" ]
59
66
[
60
Removed:
action Routes.profile_username;
61
Removed:
post_form;
62
Removed:
class_ "profile-form";
63
Removed:
Dream_html.attr "data-hito-app-form";
64
Removed:
]
65
Removed:
[
66
Removed:
Dream_html.csrf_tag request;
67
Removed:
tag "div"
68
Removed:
[ class_ "field" ]
67
Added:
tag "label"
68
Added:
[ Dream_html.string_attr "for" "username" ]
69
Added:
[ txt "Username" ];
70
Added:
void "input"
69
71
[
70
Removed:
tag "label"
71
Removed:
[ Dream_html.string_attr "for" "username" ]
72
Removed:
[ txt "Username" ];
73
Removed:
void "input"
74
Removed:
[
75
Removed:
type_ "text";
76
Removed:
name "username";
77
Removed:
Dream_html.string_attr "id" "username";
78
Removed:
Dream_html.string_attr "value" "%s"
79
Removed:
viewer.View_model.Viewer.username;
80
Removed:
required;
81
Removed:
];
72
Added:
type_ "text";
73
Added:
name "username";
74
Added:
Dream_html.string_attr "id" "username";
75
Added:
Dream_html.string_attr "value" "%s"
76
Added:
viewer.View_model.Viewer.username;
77
Added:
required;
82
78
];
83
Removed:
tag "div"
84
Removed:
[ class_ "button-group" ]
79
Added:
];
80
Added:
tag "div"
81
Added:
[ class_ "button-group" ]
82
Added:
[
83
Added:
void "input"
84
Added:
[ type_ "submit"; value "Change username" ];
85
Added:
tag "button"
85
86
[
86
Removed:
void "input"
87
Removed:
[ type_ "submit"; value "Change username" ];
88
Removed:
tag "button"
89
Removed:
[
90
Removed:
type_ "button";
91
Removed:
class_ "secondary";
92
Removed:
Dream_html.attr "data-hito-dialog-close";
93
Removed:
]
94
Removed:
[ txt "Cancel" ];
95
Removed:
];
87
Added:
type_ "button";
88
Added:
class_ "secondary";
89
Added:
Dream_html.attr "data-hito-dialog-close";
90
Added:
]
91
Added:
[ txt "Cancel" ];
96
92
];
97
Removed:
]
98
Removed:
@ error_block username_error);
99
Removed:
];
93
Added:
];
94
Added:
]
95
Added:
@ error_block username_error);
100
96
];
101
97
(* Password: a button opens the change-password modal. *)
102
98
tag "section"
@@ -125,65 +121,61 @@
125
121
@
126
122
if Option.is_some password_error then [ Dream_html.attr "open" ]
127
123
else [])
128
Removed:
[
129
Removed:
tag "div"
130
Removed:
[ class_ "profile-dialog-content" ]
131
Removed:
([
132
Removed:
tag "h3" [] [ txt "Change password" ];
133
Removed:
tag "form"
124
Added:
([
125
Added:
tag "h3" [] [ txt "Change password" ];
126
Added:
tag "form"
127
Added:
[
128
Added:
action Routes.profile_password;
129
Added:
post_form;
130
Added:
class_ "profile-form";
131
Added:
Dream_html.attr "data-hito-app-form";
132
Added:
]
133
Added:
[
134
Added:
Dream_html.csrf_tag request;
135
Added:
tag "div"
136
Added:
[ class_ "field" ]
134
137
[
135
Removed:
action Routes.profile_password;
136
Removed:
post_form;
137
Removed:
class_ "profile-form";
138
Removed:
Dream_html.attr "data-hito-app-form";
139
Removed:
]
140
Removed:
[
141
Removed:
Dream_html.csrf_tag request;
142
Removed:
tag "div"
143
Removed:
[ class_ "field" ]
138
Added:
tag "label"
139
Added:
[ Dream_html.string_attr "for" "current" ]
140
Added:
[ txt "Current password" ];
141
Added:
void "input"
144
142
[
145
Removed:
tag "label"
146
Removed:
[ Dream_html.string_attr "for" "current" ]
147
Removed:
[ txt "Current password" ];
148
Removed:
void "input"
149
Removed:
[
150
Removed:
type_ "password";
151
Removed:
name "current";
152
Removed:
Dream_html.string_attr "id" "current";
153
Removed:
required;
154
Removed:
];
143
Added:
type_ "password";
144
Added:
name "current";
145
Added:
Dream_html.string_attr "id" "current";
146
Added:
required;
155
147
];
156
Removed:
tag "div"
157
Removed:
[ class_ "field" ]
148
Added:
];
149
Added:
tag "div"
150
Added:
[ class_ "field" ]
151
Added:
[
152
Added:
tag "label"
153
Added:
[ Dream_html.string_attr "for" "next" ]
154
Added:
[ txt "New password" ];
155
Added:
void "input"
158
156
[
159
Removed:
tag "label"
160
Removed:
[ Dream_html.string_attr "for" "next" ]
161
Removed:
[ txt "New password" ];
162
Removed:
void "input"
163
Removed:
[
164
Removed:
type_ "password";
165
Removed:
name "next";
166
Removed:
Dream_html.string_attr "id" "next";
167
Removed:
required;
168
Removed:
];
157
Added:
type_ "password";
158
Added:
name "next";
159
Added:
Dream_html.string_attr "id" "next";
160
Added:
required;
169
161
];
170
Removed:
tag "div"
171
Removed:
[ class_ "button-group" ]
162
Added:
];
163
Added:
tag "div"
164
Added:
[ class_ "button-group" ]
165
Added:
[
166
Added:
void "input"
167
Added:
[ type_ "submit"; value "Change password" ];
168
Added:
tag "button"
172
169
[
173
Removed:
void "input"
174
Removed:
[ type_ "submit"; value "Change password" ];
175
Removed:
tag "button"
176
Removed:
[
177
Removed:
type_ "button";
178
Removed:
class_ "secondary";
179
Removed:
Dream_html.attr "data-hito-dialog-close";
180
Removed:
]
181
Removed:
[ txt "Cancel" ];
182
Removed:
];
170
Added:
type_ "button";
171
Added:
class_ "secondary";
172
Added:
Dream_html.attr "data-hito-dialog-close";
173
Added:
]
174
Added:
[ txt "Cancel" ];
183
175
];
184
Removed:
]
185
Removed:
@ error_block password_error);
186
Removed:
];
176
Added:
];
177
Added:
]
178
Added:
@ error_block password_error);
187
179
];
188
180
(* Import: a multipart form uploads a native Hevy CSV export. The file
189
181
field is [csv], the wall-clock offset is [utc_offset_seconds], and an
lib/presenter/pages_routine.ml
@@ -5,29 +5,26 @@
5
5
let choose_routine request ~workout_started ~viewer
6
6
~(routines : View_model.Routine_choice.t list) =
7
7
html_page ~viewer ~request ~active:"home" ?workout_started "Choose a routine"
8
Removed:
[
9
Removed:
tag "h1" [] [ txt "Routines" ];
10
Removed:
tag "div" []
11
Removed:
(List.map
12
Removed:
(fun (choice : View_model.Routine_choice.t) ->
13
Removed:
tag "form"
14
Removed:
[
15
Removed:
action Routes.select_routine choice.id;
16
Removed:
post_form;
17
Removed:
Dream_html.attr "data-hito-app-form";
18
Removed:
]
19
Removed:
[
20
Removed:
Dream_html.csrf_tag request;
21
Removed:
tag "fieldset" []
22
Removed:
[
23
Removed:
tag "legend" [] [ txt "%s" choice.name ];
24
Removed:
tag "p" []
25
Removed:
[ txt "%d workouts in the cycle." choice.workout_count ];
26
Removed:
void "input" [ type_ "submit"; value "Use this routine" ];
27
Removed:
];
28
Removed:
])
29
Removed:
routines);
30
Removed:
]
8
Added:
([ tag "h1" [] [ txt "Routines" ] ]
9
Added:
@ List.map
10
Added:
(fun (choice : View_model.Routine_choice.t) ->
11
Added:
tag "form"
12
Added:
[
13
Added:
action Routes.select_routine choice.id;
14
Added:
post_form;
15
Added:
Dream_html.attr "data-hito-app-form";
16
Added:
]
17
Added:
[
18
Added:
Dream_html.csrf_tag request;
19
Added:
tag "fieldset" []
20
Added:
[
21
Added:
tag "legend" [] [ txt "%s" choice.name ];
22
Added:
tag "p" []
23
Added:
[ txt "%d workouts in the cycle." choice.workout_count ];
24
Added:
void "input" [ type_ "submit"; value "Use this routine" ];
25
Added:
];
26
Added:
])
27
Added:
routines)
31
28
32
29
let begin_form request ~routine_id ~override label =
33
30
tag "form"
lib/web/assets/hito.css
@@ -65,7 +65,6 @@
65
65
--font-size-h3: 1.333rem;
66
66
--font-size-h2: 1.777rem;
67
67
--font-size-h1: 2.369rem;
68
Removed:
--font-size-heading: 2.369rem;
69
68
--radius: 0.4em;
70
69
--sans: "Work Sans", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
71
70
--mono: "Courier Prime", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
@@ -140,7 +139,7 @@
140
139
.app-shell {
141
140
width: min(100%, 45rem);
142
141
margin: 0 auto;
143
Removed:
padding: 0 1rem 2.5rem;
142
Added:
padding: 0 0.75rem calc(6.75rem + env(safe-area-inset-bottom));
144
143
}
145
144
146
145
/* The blood bar is the header's base edge and the brand marker. It belongs
@@ -182,30 +181,6 @@
182
181
text-decoration: none;
183
182
}
184
183
185
Removed:
/* The profile control shows the signed-in username and links to the logbook.
186
Removed:
It sits right of the brand. On mobile it is the top-bar profile button. At
187
Removed:
desktop widths it joins the inline navigation. It reads as a compact pill. */
188
Removed:
.profile {
189
Removed:
display: inline-flex;
190
Removed:
align-items: center;
191
Removed:
min-height: 2.75rem;
192
Removed:
padding: 0.5rem 0.9rem;
193
Removed:
border: 1px solid var(--rule-strong);
194
Removed:
border-radius: var(--radius);
195
Removed:
color: var(--ink);
196
Removed:
font-weight: 700;
197
Removed:
text-decoration: none;
198
Removed:
}
199
Removed:
.profile:hover {
200
Removed:
color: var(--blood-dark);
201
Removed:
background: var(--hover-surface);
202
Removed:
}
203
Removed:
.profile[aria-current] {
204
Removed:
color: var(--on-primary);
205
Removed:
background: var(--blood);
206
Removed:
border-color: var(--blood-dark);
207
Removed:
}
208
Removed:
209
184
.eyebrow {
210
185
font: 700 var(--font-size-small)/1.35 var(--sans);
211
186
letter-spacing: 0.09em;
@@ -213,50 +188,19 @@
213
188
}
214
189
.eyebrow { color: var(--blood-dark); }
215
190
216
Removed:
.primary-nav {
217
Removed:
display: flex;
218
Removed:
flex-wrap: wrap;
219
Removed:
justify-content: flex-end;
220
Removed:
gap: 0.2rem;
221
Removed:
}
222
191
223
Removed:
.primary-nav > a {
224
Removed:
display: inline-flex;
225
Removed:
align-items: center;
226
Removed:
min-height: 2.75rem;
227
Removed:
padding: 0.55rem 0.7rem;
228
Removed:
border-radius: var(--radius);
229
Removed:
color: var(--ink);
230
Removed:
font-size: 0.88rem;
231
Removed:
text-decoration: none;
232
Removed:
}
233
Removed:
234
Removed:
.primary-nav > a:hover {
235
Removed:
background: var(--hover-surface);
236
Removed:
color: var(--blood-dark);
237
Removed:
}
238
Removed:
239
Removed:
/* The current page's navigation link fills solid. The inline navigation serves
240
Removed:
desktop and the bottom bar serves mobile, so both carry the same active
241
Removed:
treatment. */
242
Removed:
.page-home .primary-nav > a[href="/"],
243
Removed:
.page-routine .primary-nav > a[href="/routine"],
244
Removed:
.page-workout .primary-nav > a[href="/workout"],
245
Removed:
.page-logbook .primary-nav > a[href="/logbook"],
246
Removed:
.page-home .bottom-nav > a[href="/"],
247
Removed:
.page-routine .bottom-nav > a[href="/routine"],
248
Removed:
.page-workout .bottom-nav > a[href="/workout"],
249
Removed:
.page-logbook .bottom-nav > a[href="/logbook"] {
192
Added:
.page-home .app-nav > a[href="/"],
193
Added:
.page-routine .app-nav > a[href="/routine"],
194
Added:
.page-workout .app-nav > a[href="/workout"],
195
Added:
.page-logbook .app-nav > a[href="/logbook"] {
250
196
color: var(--on-primary);
251
197
background: var(--blood);
252
198
}
253
199
254
Removed:
.primary-nav > a[aria-current] {
200
Added:
.app-nav > a[aria-current] {
255
201
font-weight: 800;
256
202
}
257
203
258
Removed:
/* The profile link uses the same compact pill as the old account control. */
259
Removed:
260
204
/* The content starts below the masthead. The main element is also the
261
205
replaceable SPA content node, so the page needs no extra surface wrapper. */
262
206
main {
@@ -264,7 +208,6 @@
264
208
padding: 1.25rem 0 1.5rem;
265
209
}
266
210
267
Removed:
.bottom-nav { display: none; }
268
211
269
212
/* Page transitions. The SPA client swaps the content node when the user moves
270
213
between Home, Current Workout, and Logbook. A freshly inserted node runs
@@ -279,13 +222,6 @@
279
222
animation: page-enter 220ms ease both;
280
223
}
281
224
282
Removed:
/* On desktop the shell settles at a compact, readable width. */
283
Removed:
@media (min-width: 42.0625rem) {
284
Removed:
.app-shell {
285
Removed:
width: min(100%, 50rem);
286
Removed:
}
287
Removed:
}
288
Removed:
289
225
/* One coherent heading scale across the app. h1 is the page title, h2 a
290
226
section within the page, and h3 a sub-section. They share colour, weight,
291
227
and rhythm. Only the size steps down, so the hierarchy reads the same on
@@ -865,26 +801,27 @@
865
801
font: 700 var(--font-size-lead)/1.2 var(--sans);
866
802
letter-spacing: -0.01em;
867
803
}
868
Removed:
/* The record to beat for one input: the exercise's most recent prior load or
869
Removed:
reps, integrated into the input as a Bootstrap-style leading group. It is
870
Removed:
italic and monospaced so it stays distinct from the value being entered. */
871
Removed:
.exercise-previous {
804
Added:
/* The record to beat and the unit are leading and trailing input addons. */
805
Added:
.exercise-previous,
806
Added:
.input-unit {
872
807
display: flex;
873
808
align-items: center;
874
809
flex: 0 0 auto;
875
810
min-height: 3rem;
876
Removed:
margin: 0;
877
811
padding: 0.65rem 0.75rem;
878
812
border: 1px solid var(--rule-strong);
879
Removed:
border-right: 0;
880
Removed:
border-radius: var(--radius) 0 0 var(--radius);
881
813
color: var(--muted-ink);
882
814
background: var(--hover-surface);
883
815
font: 500 1rem/1.3 var(--mono);
884
Removed:
font-style: italic;
885
816
font-variant-numeric: tabular-nums;
886
817
white-space: nowrap;
887
818
}
819
Added:
.exercise-previous {
820
Added:
margin: 0;
821
Added:
border-right: 0;
822
Added:
border-radius: var(--radius) 0 0 var(--radius);
823
Added:
font-style: italic;
824
Added:
}
888
825
.input-group {
889
826
display: flex;
890
827
align-items: stretch;
@@ -899,19 +836,8 @@
899
836
border-radius: var(--radius) 0 0 var(--radius);
900
837
}
901
838
.input-unit {
902
Removed:
display: flex;
903
Removed:
align-items: center;
904
Removed:
flex: 0 0 auto;
905
Removed:
min-height: 3rem;
906
Removed:
padding: 0.65rem 0.75rem;
907
Removed:
border: 1px solid var(--rule-strong);
908
839
border-left: 0;
909
840
border-radius: 0 var(--radius) var(--radius) 0;
910
Removed:
color: var(--muted-ink);
911
Removed:
background: var(--hover-surface);
912
Removed:
font: 500 1rem/1.3 var(--mono);
913
Removed:
font-variant-numeric: tabular-nums;
914
Removed:
white-space: nowrap;
915
841
}
916
842
917
843
.logging-fieldset {
@@ -1124,31 +1050,6 @@
1124
1050
.confirm-dialog::backdrop,
1125
1051
.feedback-modal::backdrop,
1126
1052
.profile-dialog::backdrop { background: rgba(26, 26, 22, 0.45); }
1127
Removed:
.confirm-dialog button {
1128
Removed:
width: 100%;
1129
Removed:
min-height: 3rem;
1130
Removed:
border: 2px solid var(--blood-dark);
1131
Removed:
border-radius: var(--radius);
1132
Removed:
color: var(--on-primary);
1133
Removed:
background: var(--blood);
1134
Removed:
cursor: pointer;
1135
Removed:
font: 700 var(--font-size-small)/1 var(--sans);
1136
Removed:
transition: background-color 150ms ease, transform 150ms ease;
1137
Removed:
}
1138
Removed:
.confirm-dialog button:hover {
1139
Removed:
background: var(--blood-hover);
1140
Removed:
transform: translateY(-2px);
1141
Removed:
}
1142
Removed:
.confirm-dialog button.secondary {
1143
Removed:
color: var(--brass);
1144
Removed:
background: transparent;
1145
Removed:
border-color: var(--brass);
1146
Removed:
}
1147
Removed:
.confirm-dialog button.secondary:hover {
1148
Removed:
color: var(--on-brass);
1149
Removed:
background: var(--brass);
1150
Removed:
}
1151
Removed:
1152
1053
/* The sequential feedback flow opens in a native modal. The [open] fallback
1153
1054
keeps the flow usable when script is disabled, while the client upgrades it
1154
1055
to [showModal] and supplies the backdrop. It runs wider than the other
@@ -1266,10 +1167,6 @@
1266
1167
.routine-exercise { color: var(--ink); }
1267
1168
1268
1169
@media (max-width: 42rem) {
1269
Removed:
.app-shell {
1270
Removed:
padding: 0 0.75rem calc(6.75rem + env(safe-area-inset-bottom));
1271
Removed:
}
1272
Removed:
1273
1170
.masthead {
1274
1171
min-height: 4.5rem;
1275
1172
padding: 0.9rem 0 0.7rem;
@@ -1328,13 +1225,9 @@
1328
1225
outline-offset: 0.3rem;
1329
1226
}
1330
1227
1331
Removed:
/* Mobile-first navigation with a single desktop breakpoint at 42.0625rem.
1332
Removed:
Below that width the layout is mobile: a fixed bottom navigation for the
1333
Removed:
three actions, with the sign-out control hidden. At or above that width the
1334
Removed:
inline primary navigation joins the header sign-out, and the bottom
1335
Removed:
navigation is hidden. */
1336
Removed:
.primary-nav { display: none; }
1337
Removed:
.bottom-nav {
1228
Added:
/* Responsive application navigation. The same navigation sits in the masthead
1229
Added:
on wide screens and becomes the fixed bottom bar on narrow screens. */
1230
Added:
.app-nav {
1338
1231
position: fixed;
1339
1232
z-index: 20;
1340
1233
right: 0;
@@ -1348,10 +1241,7 @@
1348
1241
box-shadow: 0 -0.35rem 1.2rem rgba(26, 26, 22, 0.15);
1349
1242
padding: 0.5rem 0.6rem calc(0.5rem + env(safe-area-inset-bottom));
1350
1243
}
1351
Removed:
/* The tabs are round-corner buttons on the paper bar. Mobile and desktop
1352
Removed:
activity buttons therefore share one appearance: the same radius, hover, and
1353
Removed:
active blood fill. The bar is slightly larger than the buttons it holds. */
1354
Removed:
.bottom-nav > a {
1244
Added:
.app-nav > a {
1355
1245
display: grid;
1356
1246
min-height: 3rem;
1357
1247
place-items: center;
@@ -1363,23 +1253,40 @@
1363
1253
text-align: center;
1364
1254
text-decoration: none;
1365
1255
}
1366
Removed:
.bottom-nav > a:hover {
1256
Added:
.app-nav > a:hover {
1367
1257
color: var(--blood-dark);
1368
1258
background: var(--hover-surface);
1369
1259
}
1370
Removed:
.app-shell { padding: 0 0.75rem calc(6.75rem + env(safe-area-inset-bottom)); }
1371
1260
1372
Removed:
@media (max-width: 42.0624rem) {
1261
Added:
@media (max-width: 42rem) {
1373
1262
.masthead { justify-content: center; }
1374
1263
.masthead .brand { margin-right: 0; }
1375
1264
}
1376
1265
1377
1266
@media (min-width: 42.0625rem) {
1378
Removed:
.app-shell { padding: 0 1rem 2.5rem; }
1379
Removed:
.bottom-nav { display: none; }
1380
Removed:
.primary-nav { display: flex; }
1267
Added:
.app-shell {
1268
Added:
width: min(100%, 50rem);
1269
Added:
padding: 0 1rem 2.5rem;
1270
Added:
}
1271
Added:
.app-nav {
1272
Added:
position: static;
1273
Added:
display: flex;
1274
Added:
flex-wrap: wrap;
1275
Added:
justify-content: flex-end;
1276
Added:
gap: 0.2rem;
1277
Added:
border-top: 0;
1278
Added:
background: transparent;
1279
Added:
box-shadow: none;
1280
Added:
padding: 0;
1281
Added:
}
1282
Added:
.app-nav > a {
1283
Added:
display: inline-flex;
1284
Added:
align-items: center;
1285
Added:
min-height: 2.75rem;
1286
Added:
padding: 0.55rem 0.7rem;
1287
Added:
font-size: 0.88rem;
1288
Added:
}
1381
1289
}
1382
Removed:
1383
1290
1384
1291
/* The app-feedback page presents one active panel at a time through native
1385
1292
links, so both tabs remain usable without JavaScript. */
lib/web/workout_client.ml
@@ -11,23 +11,6 @@
11
11
| None -> None
12
12
| Some node -> Js.Opt.to_option (Dom_html.CoerceTo.element node)
13
13
14
Removed:
let query_all root selector =
15
Removed:
let nodes = root##querySelectorAll (Js.string selector) in
16
Removed:
let rec collect i acc =
17
Removed:
if i < 0 then acc
18
Removed:
else
19
Removed:
let acc =
20
Removed:
match Js.Opt.to_option (nodes##item i) with
21
Removed:
| None -> acc
22
Removed:
| Some node -> (
23
Removed:
match Js.Opt.to_option (Dom_html.CoerceTo.element node) with
24
Removed:
| None -> acc
25
Removed:
| Some el -> el :: acc)
26
Removed:
in
27
Removed:
collect (i - 1) acc
28
Removed:
in
29
Removed:
collect (nodes##.length - 1) []
30
Removed:
31
14
let closest selector element =
32
15
match Js.Opt.to_option (element##closest (Js.string selector)) with
33
16
| None -> None
@@ -47,13 +30,10 @@
47
30
| Some status -> status##.textContent := Js.some (Js.string message)
48
31
| None -> ()
49
32
50
Removed:
(* The workout timer. The middle nav slot carries the workout's start time as an
51
Removed:
epoch in [data-started] while logging. The elapsed time is computed from it,
52
Removed:
so a content swap never resets the count. The mobile and desktop navs each
53
Removed:
hold a timer, so every timer element is updated from its own [data-started]
54
Removed:
and its own value span. A single interval runs at a time: [start_timer]
55
Removed:
clears the previous one before it reads the timers the latest render
56
Removed:
produced. When no timer is present, the timer stops. *)
33
Added:
(* The workout timer. The middle navigation slot carries the workout's start
34
Added:
time in [data-started] while logging. The elapsed time is computed from it,
35
Added:
so a content swap never resets the count. One timer element is present in
36
Added:
the shared navigation. *)
57
37
let timer_interval = ref None
58
38
59
39
let format_elapsed seconds =
@@ -63,9 +43,10 @@
63
43
Printf.sprintf "%d:%02d" minutes secs
64
44
65
45
let tick () =
66
Removed:
let now = Js.to_float (new%js Js.date_now)##getTime /. 1000. in
67
Removed:
List.iter
68
Removed:
(fun timer ->
46
Added:
match query_one document "[data-hito-workout-timer]" with
47
Added:
| None -> ()
48
Added:
| Some timer -> (
49
Added:
let now = Js.to_float (new%js Js.date_now)##getTime /. 1000. in
69
50
match
70
51
Js.Opt.to_option (timer##getAttribute (Js.string "data-started"))
71
52
with
@@ -85,7 +66,6 @@
85
66
| Some value ->
86
67
value##.textContent :=
87
68
Js.some (Js.string (format_elapsed elapsed))))
88
Removed:
(query_all document "[data-hito-workout-timer]")
89
69
90
70
let start_timer () =
91
71
(match !timer_interval with
test/web/logbook.ml
@@ -18,11 +18,8 @@
18
18
Alcotest.(check bool)
19
19
"Record feedback action shown" true
20
20
(Option.is_some (index_from ~needle:"Record feedback" page 0));
21
Removed:
(* No workout table, just the empty-state line. *)
21
Added:
(* No workout entries exist, so the empty-state line is shown. *)
22
22
Alcotest.(check bool)
23
Removed:
"no workout table" false
24
Removed:
(has_class page "logbook-table");
25
Removed:
Alcotest.(check bool)
26
23
"empty state when nothing is logged" true
27
24
(Option.is_some (index_from ~needle:"Nothing logged yet." page 0))
28
25
);
@@ -37,14 +34,10 @@
37
34
let _ = post c "/workout/finish" [ ("dream.csrf", token) ] in
38
35
let page = body (get c "/logbook") in
39
36
(* The logbook is an accordion, one collapsible <details> per
40
Removed:
workout. There is no table; the Duration sits in the panel, not
41
Removed:
the summary. *)
37
Added:
workout. The duration sits in the panel, not the summary. *)
42
38
Alcotest.(check bool)
43
39
"the accordion is present" true
44
40
(has_class page "logbook-entry");
45
Removed:
Alcotest.(check bool)
46
Removed:
"no workout table" false
47
Removed:
(has_class page "logbook-table");
48
41
Alcotest.(check bool)
49
42
"the entry summary carries the date" true
50
43
(Option.is_some (index_from ~needle:"logbook-entry-date" page 0));
test/web/profile.ml
@@ -28,10 +28,8 @@
28
28
"logbook has a Profile action" true
29
29
(contains_substring ~needle:"href=\"/profile\"" logbook_page);
30
30
Alcotest.(check bool)
31
Removed:
"masthead has no Profile control" true
32
Removed:
(not
33
Removed:
(contains_substring ~needle:"class=\"profile\"" logbook_page))
34
Removed:
);
31
Added:
"masthead has no profile control" true
32
Added:
(not (has_class logbook_page "profile")) );
35
33
( "changing the username updates the stored account",
36
34
`Quick,
37
35
fun () ->
test/web/support.ml
@@ -140,6 +140,16 @@
140
140
in
141
141
if start < 0 then None else at start
142
142
143
Added:
let count_occurrences ~needle haystack =
144
Added:
if needle = "" then 0
145
Added:
else
146
Added:
let rec count from total =
147
Added:
match index_from ~needle haystack from with
148
Added:
| None -> total
149
Added:
| Some index -> count (index + String.length needle) (total + 1)
150
Added:
in
151
Added:
count 0 0
152
Added:
143
153
(* {2 Markup queries}
144
154
145
155
Assertions on rendered HTML should survive an attribute being reordered or a
test/web/workout.ml
@@ -557,25 +557,25 @@
557
557
let _ = register c ~username:"lifter" ~password:"heavyduty1" in
558
558
(* Before a workout, no page shows a nav timer. *)
559
559
let home = body (get c "/") in
560
Removed:
Alcotest.(check bool)
561
Removed:
"no nav timer before a workout" true
562
Removed:
(Option.is_none (index_from ~needle:"nav-timer" home 0));
560
Added:
Alcotest.(check int)
561
Added:
"no nav timer before a workout" 0
562
Added:
(count_occurrences ~needle:"data-hito-workout-timer" home);
563
563
let _ = start_day_one c in
564
564
let page = body (get c "/workout") in
565
Removed:
Alcotest.(check bool)
566
Removed:
"the workout page shows the nav timer" true
567
Removed:
(Option.is_some (index_from ~needle:"nav-timer" page 0));
568
Removed:
Alcotest.(check bool)
569
Removed:
"the timer carries its start epoch" true
570
Removed:
(Option.is_some (index_from ~needle:"data-started" page 0));
565
Added:
Alcotest.(check int)
566
Added:
"the workout page has one nav timer" 1
567
Added:
(count_occurrences ~needle:"data-hito-workout-timer" page);
568
Added:
Alcotest.(check int)
569
Added:
"the timer has one start epoch" 1
570
Added:
(count_occurrences ~needle:"data-started" page);
571
571
(* The reminder shows away from the workout page too: the logbook. *)
572
572
let logbook = body (get c "/logbook") in
573
Removed:
Alcotest.(check bool)
574
Removed:
"the logbook shows the nav timer while logging" true
575
Removed:
(Option.is_some (index_from ~needle:"nav-timer" logbook 0));
576
Removed:
Alcotest.(check bool)
577
Removed:
"the logbook nav timer carries its start epoch" true
578
Removed:
(Option.is_some (index_from ~needle:"data-started" logbook 0)) );
573
Added:
Alcotest.(check int)
574
Added:
"the logbook has one nav timer while logging" 1
575
Added:
(count_occurrences ~needle:"data-hito-workout-timer" logbook);
576
Added:
Alcotest.(check int)
577
Added:
"the logbook timer has one start epoch" 1
578
Added:
(count_occurrences ~needle:"data-started" logbook) );
579
579
( "the recovery gate blocks the next workout until enough rest, then \
580
580
Day 2 begins",
581
581
`Quick,