refactor Rationalize DOM and CSS

Remove duplicate navigation markup and stale selectors. Consolidate shared controls and update web assertions for the current DOM.

Commit
ccc44b0ae6f57210b605e29869935846c2f421e0
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
lib/presenter/page_shell.ml
index f8b3ace2..22221d4f 100644..100644
@@ -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
index 7e37c077..9c3d39fb 100644..100644
@@ -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
index 17a446a2..6c250639 100644..100644
@@ -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
index 9d89d4e9..0ee0a108 100644..100644
@@ -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
index a3ca266c..f454ca28 100644..100644
@@ -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
index 624660dc..4f1eb336 100644..100644
@@ -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
index 2561ef5c..c26f2569 100644..100644
@@ -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
index 06c5c306..8ac761f2 100644..100644
@@ -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
index 2711322f..07b0cda4 100644..100644
@@ -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
index 2ce2bd90..f9498a98 100644..100644
@@ -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,