feat Add polished toolkit interface

Give the server-rendered logbook a responsive, sober interface with\nOcsigen Toolkit components and a separately authored CSS asset.\n\nEmbed the stylesheet at build time so the local server remains\nself-contained while serving it as a typed CSS route.

Commit
b16f900601853075a3eb5468b1ad8aae5c78cc9c
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
dune-project
index 283b65aa..84948381 100644..100644
@@ -32,6 +32,12 @@
32 32 (>= 7.0))
33 33 (ocsipersist-sqlite
34 34 (>= 2.1))
35 Added: (ocsigen-toolkit
36 Added: (= 4.3.0))
37 Added: (crunch
38 Added: (and
39 Added: (= 4.1.0)
40 Added: :build))
35 41 (ocamlformat
36 42 (and
37 43 (= 0.29.0)
hito.opam
index 56298e0a..0ce2f892 100644..100644
@@ -16,6 +16,8 @@
16 16 "eliom" {>= "12.0"}
17 17 "ocsigenserver" {>= "7.0"}
18 18 "ocsipersist-sqlite" {>= "2.1"}
19 Added: "ocsigen-toolkit" {= "4.3.0"}
20 Added: "crunch" {= "4.1.0" & build}
19 21 "ocamlformat" {= "0.29.0" & with-dev-setup}
20 22 "odoc" {with-doc}
21 23 ]
lib/web/assets/hito.css
index 00000000..c9942357 000000..100644
@@ -0,0 +1,472 @@
1 Added: :root {
2 Added: color-scheme: dark;
3 Added: --ink: #17191d;
4 Added: --panel: #202328;
5 Added: --panel-raised: #292d33;
6 Added: --line: #464b54;
7 Added: --muted: #afb2b8;
8 Added: --bone: #f1eee8;
9 Added: --accent: #d9877b;
10 Added: --accent-strong: #a84438;
11 Added: --brass: #c69a56;
12 Added: --danger: #d77b70;
13 Added: --shadow: 0 1.5rem 4rem rgba(0, 0, 0, 0.28);
14 Added: }
15 Added:
16 Added: * {
17 Added: box-sizing: border-box;
18 Added: }
19 Added:
20 Added: body {
21 Added: min-width: 20rem;
22 Added: margin: 0;
23 Added: color: var(--bone);
24 Added: background:
25 Added: radial-gradient(circle at 14% -10%, #343840 0, transparent 27rem),
26 Added: radial-gradient(circle at 94% 12%, #39343a 0, transparent 24rem),
27 Added: var(--ink);
28 Added: font:
29 Added: 16px/1.55 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
30 Added: "Segoe UI", sans-serif;
31 Added: }
32 Added:
33 Added: a {
34 Added: color: var(--accent);
35 Added: text-decoration-thickness: 0.09em;
36 Added: text-underline-offset: 0.18em;
37 Added: }
38 Added:
39 Added: a:hover {
40 Added: color: #efb0a7;
41 Added: }
42 Added:
43 Added: .app-shell {
44 Added: width: min(100%, 68rem);
45 Added: margin: 0 auto;
46 Added: padding: 1.25rem;
47 Added: }
48 Added:
49 Added: .masthead {
50 Added: display: flex;
51 Added: align-items: center;
52 Added: justify-content: space-between;
53 Added: gap: 1rem;
54 Added: padding: 1rem 0 1.25rem;
55 Added: border-bottom: 1px solid rgba(241, 238, 227, 0.18);
56 Added: }
57 Added:
58 Added: .brand {
59 Added: display: flex;
60 Added: align-items: center;
61 Added: gap: 0.75rem;
62 Added: color: var(--bone);
63 Added: text-decoration: none;
64 Added: }
65 Added:
66 Added: .brand-mark {
67 Added: display: grid;
68 Added: width: 2.5rem;
69 Added: height: 2.5rem;
70 Added: place-items: center;
71 Added: border: 1px solid var(--accent);
72 Added: border-radius: 0.7rem 1.15rem 0.7rem 1.15rem;
73 Added: color: var(--accent);
74 Added: font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
75 Added: font-size: 0.72rem;
76 Added: font-weight: 800;
77 Added: letter-spacing: 0.08em;
78 Added: }
79 Added:
80 Added: .brand-copy {
81 Added: display: grid;
82 Added: line-height: 1.05;
83 Added: }
84 Added:
85 Added: .brand-copy strong {
86 Added: font-size: 1.2rem;
87 Added: letter-spacing: -0.03em;
88 Added: }
89 Added:
90 Added: .brand-copy small,
91 Added: .eyebrow {
92 Added: color: var(--accent);
93 Added: font-size: 0.68rem;
94 Added: font-weight: 800;
95 Added: letter-spacing: 0.14em;
96 Added: text-transform: uppercase;
97 Added: }
98 Added:
99 Added: .primary-nav {
100 Added: display: flex;
101 Added: align-items: center;
102 Added: gap: 0.2rem;
103 Added: }
104 Added:
105 Added: .primary-nav > a,
106 Added: .ot-dropdown-button {
107 Added: display: inline-flex;
108 Added: align-items: center;
109 Added: gap: 0.45rem;
110 Added: min-height: 2.5rem;
111 Added: padding: 0.55rem 0.7rem;
112 Added: border-radius: 0.55rem;
113 Added: color: var(--bone);
114 Added: font-size: 0.86rem;
115 Added: font-weight: 700;
116 Added: text-decoration: none;
117 Added: }
118 Added:
119 Added: .primary-nav > a:hover,
120 Added: .ot-dropdown:hover {
121 Added: background: rgba(217, 135, 123, 0.12);
122 Added: color: var(--accent);
123 Added: }
124 Added:
125 Added: .nav-menu {
126 Added: display: none;
127 Added: }
128 Added:
129 Added: /* Ocsigen Toolkit's CSS-only dropdown: usable without a client OCaml tier. */
130 Added: .ot-dropdown {
131 Added: position: relative;
132 Added: transition: background-color 0.2s ease;
133 Added: }
134 Added:
135 Added: .ot-dropdown-button {
136 Added: position: relative;
137 Added: z-index: 5;
138 Added: cursor: default;
139 Added: }
140 Added:
141 Added: .ot-dropdown-background {
142 Added: position: fixed;
143 Added: inset: 0;
144 Added: z-index: 1;
145 Added: visibility: hidden;
146 Added: background: #000;
147 Added: opacity: 0;
148 Added: transition: opacity 0.15s, visibility 0.15s;
149 Added: }
150 Added:
151 Added: .ot-dropdown-menu {
152 Added: position: absolute;
153 Added: top: calc(100% + 0.35rem);
154 Added: right: 0;
155 Added: z-index: 10;
156 Added: display: grid;
157 Added: min-width: 12rem;
158 Added: overflow: hidden;
159 Added: visibility: hidden;
160 Added: border: 1px solid var(--line);
161 Added: border-radius: 0.7rem;
162 Added: background: #262a30;
163 Added: box-shadow: var(--shadow);
164 Added: opacity: 0;
165 Added: transition: opacity 0.2s, visibility 0.2s;
166 Added: }
167 Added:
168 Added: .ot-dropdown:hover > .ot-dropdown-background {
169 Added: visibility: visible;
170 Added: opacity: 0.42;
171 Added: }
172 Added:
173 Added: .ot-dropdown:hover > .ot-dropdown-menu {
174 Added: visibility: visible;
175 Added: opacity: 1;
176 Added: }
177 Added:
178 Added: .ot-dropdown-menu a {
179 Added: padding: 0.8rem 1rem;
180 Added: color: var(--bone);
181 Added: font-size: 0.9rem;
182 Added: font-weight: 700;
183 Added: text-decoration: none;
184 Added: }
185 Added:
186 Added: .ot-dropdown-menu a + a {
187 Added: border-top: 1px solid var(--line);
188 Added: }
189 Added:
190 Added: .ot-dropdown-menu a:hover {
191 Added: background: rgba(217, 135, 123, 0.13);
192 Added: color: var(--accent);
193 Added: }
194 Added:
195 Added: /* Ocsigen Toolkit icon styles for the settings/navigation glyph. */
196 Added: .ot-icon {
197 Added: display: inline-block;
198 Added: width: 1em;
199 Added: height: 1em;
200 Added: overflow: hidden;
201 Added: position: relative;
202 Added: }
203 Added:
204 Added: .ot-icon::before,
205 Added: .ot-icon::after {
206 Added: box-sizing: border-box;
207 Added: display: inline-block;
208 Added: content: "";
209 Added: }
210 Added:
211 Added: .ot-icon-gear::before {
212 Added: width: 1em;
213 Added: height: 1em;
214 Added: border: 0.18em dotted currentColor;
215 Added: border-radius: 50%;
216 Added: }
217 Added:
218 Added: .ot-icon-gear::after {
219 Added: position: absolute;
220 Added: top: 0.22em;
221 Added: left: 0.22em;
222 Added: width: 0.56em;
223 Added: height: 0.56em;
224 Added: border: 0.17em solid currentColor;
225 Added: border-radius: 50%;
226 Added: }
227 Added:
228 Added: main {
229 Added: padding: 1.65rem 0 2rem;
230 Added: }
231 Added:
232 Added: /* Ot_popup.hcf supplies the structural header/content/footer card wrapper. */
233 Added: .ot-hcf {
234 Added: display: flex;
235 Added: width: 100%;
236 Added: flex-direction: column;
237 Added: overflow: hidden;
238 Added: }
239 Added:
240 Added: .ot-hcf-header,
241 Added: .ot-hcf-footer {
242 Added: flex-shrink: 0;
243 Added: }
244 Added:
245 Added: .ot-hcf-header:empty,
246 Added: .ot-hcf-footer:empty {
247 Added: display: none;
248 Added: }
249 Added:
250 Added: .ot-hcf-content {
251 Added: flex-grow: 1;
252 Added: overflow: auto;
253 Added: }
254 Added:
255 Added: .page-surface {
256 Added: min-height: 30rem;
257 Added: border: 1px solid var(--line);
258 Added: border-radius: 1.15rem;
259 Added: background: linear-gradient(145deg, rgba(42, 45, 51, 0.97), rgba(25, 27, 31, 0.98));
260 Added: box-shadow: var(--shadow);
261 Added: }
262 Added:
263 Added: .page-surface .ot-hcf-content {
264 Added: padding: clamp(1.2rem, 4vw, 2.4rem);
265 Added: }
266 Added:
267 Added: h2 {
268 Added: max-width: 35rem;
269 Added: margin: 0 0 0.4rem;
270 Added: font-size: clamp(1.65rem, 5vw, 2.55rem);
271 Added: line-height: 1.04;
272 Added: letter-spacing: -0.05em;
273 Added: }
274 Added:
275 Added: h3 {
276 Added: margin: 0;
277 Added: color: var(--bone);
278 Added: font-size: 1rem;
279 Added: }
280 Added:
281 Added: p {
282 Added: max-width: 45rem;
283 Added: margin: 0.55rem 0;
284 Added: color: var(--muted);
285 Added: }
286 Added:
287 Added: h2 + p {
288 Added: max-width: 39rem;
289 Added: font-size: 1.03rem;
290 Added: }
291 Added:
292 Added: fieldset {
293 Added: margin: 1.15rem 0 0;
294 Added: padding: 1rem;
295 Added: border: 1px solid var(--line);
296 Added: border-radius: 0.8rem;
297 Added: background: rgba(9, 10, 12, 0.3);
298 Added: }
299 Added:
300 Added: legend {
301 Added: padding: 0 0.45rem;
302 Added: color: var(--accent);
303 Added: font-size: 0.79rem;
304 Added: font-weight: 800;
305 Added: letter-spacing: 0.05em;
306 Added: }
307 Added:
308 Added: label {
309 Added: display: block;
310 Added: margin: 0.85rem 0 0.3rem;
311 Added: color: var(--bone);
312 Added: font-size: 0.78rem;
313 Added: font-weight: 800;
314 Added: letter-spacing: 0.04em;
315 Added: text-transform: uppercase;
316 Added: }
317 Added:
318 Added: input,
319 Added: select {
320 Added: width: 100%;
321 Added: min-height: 2.7rem;
322 Added: border: 1px solid #5c626c;
323 Added: border-radius: 0.55rem;
324 Added: background: rgba(8, 9, 11, 0.5);
325 Added: color: var(--bone);
326 Added: font: inherit;
327 Added: outline: none;
328 Added: padding: 0.55rem 0.7rem;
329 Added: }
330 Added:
331 Added: input:focus,
332 Added: select:focus {
333 Added: border-color: var(--accent);
334 Added: box-shadow: 0 0 0 3px rgba(217, 135, 123, 0.16);
335 Added: }
336 Added:
337 Added: input[type="checkbox"] {
338 Added: width: auto;
339 Added: min-height: auto;
340 Added: margin-right: 0.45rem;
341 Added: accent-color: var(--accent-strong);
342 Added: }
343 Added:
344 Added: input[type="submit"] {
345 Added: width: auto;
346 Added: min-height: 2.8rem;
347 Added: margin-top: 1rem;
348 Added: border: 0;
349 Added: border-radius: 0.6rem;
350 Added: background: var(--accent-strong);
351 Added: color: #fffaf7;
352 Added: cursor: pointer;
353 Added: font-weight: 900;
354 Added: letter-spacing: 0.01em;
355 Added: padding: 0.65rem 1rem;
356 Added: transition: transform 0.15s ease, background 0.15s ease;
357 Added: }
358 Added:
359 Added: input[type="submit"]:hover {
360 Added: background: #c45b4e;
361 Added: transform: translateY(-1px);
362 Added: }
363 Added:
364 Added: input[type="submit"]:disabled {
365 Added: background: #5a5d63;
366 Added: color: #ddd9d2;
367 Added: cursor: not-allowed;
368 Added: transform: none;
369 Added: }
370 Added:
371 Added: .row {
372 Added: display: flex;
373 Added: gap: 0.75rem;
374 Added: }
375 Added:
376 Added: .row > div {
377 Added: flex: 1;
378 Added: min-width: 0;
379 Added: }
380 Added:
381 Added: .warn {
382 Added: max-width: 47rem;
383 Added: margin: 1rem 0;
384 Added: border: 1px solid rgba(198, 154, 86, 0.58);
385 Added: border-left: 0.28rem solid var(--brass);
386 Added: border-radius: 0.65rem;
387 Added: background: rgba(198, 154, 86, 0.1);
388 Added: color: #eed5aa;
389 Added: padding: 0.85rem 1rem;
390 Added: }
391 Added:
392 Added: .done {
393 Added: color: #d8d5ce;
394 Added: font-size: 0.86rem;
395 Added: font-weight: 700;
396 Added: }
397 Added:
398 Added: ul {
399 Added: margin: 1rem 0;
400 Added: padding: 0;
401 Added: list-style: none;
402 Added: }
403 Added:
404 Added: li {
405 Added: position: relative;
406 Added: margin: 0.45rem 0;
407 Added: padding: 0.75rem 0.85rem 0.75rem 1.35rem;
408 Added: border-left: 1px solid var(--line);
409 Added: background: rgba(255, 255, 255, 0.025);
410 Added: }
411 Added:
412 Added: li::before {
413 Added: position: absolute;
414 Added: top: 1.28rem;
415 Added: left: 0.55rem;
416 Added: width: 0.3rem;
417 Added: height: 0.3rem;
418 Added: border-radius: 50%;
419 Added: background: var(--accent);
420 Added: content: "";
421 Added: }
422 Added:
423 Added: li ul {
424 Added: margin: 0.55rem 0 0;
425 Added: }
426 Added:
427 Added: li li {
428 Added: border-left-color: rgba(164, 180, 170, 0.28);
429 Added: color: var(--muted);
430 Added: font-size: 0.92rem;
431 Added: }
432 Added:
433 Added: @media (max-width: 42rem) {
434 Added: .app-shell {
435 Added: padding: 0.85rem;
436 Added: }
437 Added:
438 Added: .masthead {
439 Added: align-items: flex-start;
440 Added: }
441 Added:
442 Added: .primary-nav > a {
443 Added: display: none;
444 Added: }
445 Added:
446 Added: .nav-menu {
447 Added: display: block;
448 Added: }
449 Added:
450 Added: .page-surface {
451 Added: min-height: calc(100vh - 8.25rem);
452 Added: border-radius: 0.9rem;
453 Added: }
454 Added:
455 Added: .page-surface .ot-hcf-content {
456 Added: padding: 1.1rem;
457 Added: }
458 Added:
459 Added: .row {
460 Added: gap: 0.55rem;
461 Added: }
462 Added: }
463 Added:
464 Added: @media (max-width: 26rem) {
465 Added: .row {
466 Added: display: block;
467 Added: }
468 Added:
469 Added: .row > div + div {
470 Added: margin-top: 0.75rem;
471 Added: }
472 Added: }
lib/web/dune
index 661bb445..89273626 100644..100644
@@ -1,8 +1,15 @@
1 1 ; Server-rendered only: no client-side OCaml, so no eliom ppx and no
2 2 ; js_of_ocaml. Pages are plain HTML from Eliom_registration.Html.
3 3
4 Added: (rule
5 Added: (targets stylesheet.ml)
6 Added: (deps
7 Added: (source_tree assets))
8 Added: (action
9 Added: (run %{bin:ocaml-crunch} --silent --mode plain --output %{targets} assets)))
10 Added:
4 11 (library
5 12 (name hito_web)
6 13 (public_name hito.web)
7 Removed: (modules routes pages services)
8 Removed: (libraries hito.core hito.app eliom.server))
14 Added: (modules routes pages services stylesheet)
15 Added: (libraries hito.core hito.app eliom.server ocsigen-toolkit.server))
lib/web/pages.ml
index 437dd537..b3595778 100644..100644
@@ -7,24 +7,34 @@
7 7 use sites. *)
8 8 let id_string (id : Repository.routine_id) = (id :> string)
9 9
10 Removed: (* Kept inline so the server needs no static file extension. *)
11 Removed: let stylesheet =
12 Removed: "*{box-sizing:border-box} body{font:16px/1.5 system-ui,sans-serif;margin:0 \
13 Removed: auto;max-width:34rem;padding:1rem;color:#191919;background:#fafafa} \
14 Removed: h1{font-size:1.4rem;margin:0 0 .25rem} h2{font-size:1.1rem;margin:1.5rem 0 \
15 Removed: .5rem} p{margin:.4rem 0} a{color:#0b5} nav{margin:.5rem 0 \
16 Removed: 1.5rem;font-size:.9rem} nav a{margin-right:1rem} fieldset{border:1px solid \
17 Removed: #ddd;border-radius:6px;margin:0 0 1rem;padding:.75rem} \
18 Removed: legend{font-weight:600;padding:0 .35rem} label{display:block;margin:.4rem 0 \
19 Removed: .1rem;font-size:.85rem;color:#555} \
20 Removed: input,select{font:inherit;padding:.4rem;width:100%;border:1px solid \
21 Removed: #ccc;border-radius:4px;background:#fff} \
22 Removed: input[type=submit]{background:#191919;color:#fff;border:0;cursor:pointer;margin-top:.6rem;width:auto;padding:.5rem \
23 Removed: 1rem} .row{display:flex;gap:.5rem} .row>div{flex:1} \
24 Removed: .warn{background:#fff6e5;border-left:3px solid #e59b00;padding:.6rem \
25 Removed: .8rem;margin:1rem 0} .done{color:#666;font-size:.9rem} \
26 Removed: ul{padding-left:1.2rem} li{margin:.2rem 0}"
10 Added: (* The stylesheet is a separately authored CSS asset. Dune embeds it in the
11 Added: executable and the explicit Eliom CSS service serves it at runtime. *)
27 12
13 Added: let nav_link service label = a ~service [ txt label ] ()
14 Added:
15 Added: let navigation () =
16 Added: let menu =
17 Added: Ot_buttons.dropdown
18 Added: ~a:[ a_class [ "nav-menu" ] ]
19 Added: ~menu:
20 Added: [
21 Added: nav_link Routes.home "Overview";
22 Added: nav_link Routes.routine "Routine";
23 Added: nav_link Routes.log "Current workout";
24 Added: nav_link Routes.history "History";
25 Added: ]
26 Added: [ span [ txt "Menu" ]; Ot_icons.F.config () ]
27 Added: in
28 Added: nav
29 Added: ~a:[ a_class [ "primary-nav" ] ]
30 Added: [
31 Added: nav_link Routes.home "Overview";
32 Added: nav_link Routes.routine "Routine";
33 Added: nav_link Routes.log "Current workout";
34 Added: nav_link Routes.history "History";
35 Added: menu;
36 Added: ]
37 Added:
28 38 let shell ~title:t content =
29 39 html
30 40 (head
@@ -37,23 +47,44 @@
37 47 a_name "viewport"; a_content "width=device-width,initial-scale=1";
38 48 ]
39 49 ();
40 Removed: style [ txt stylesheet ];
50 Added: meta ~a:[ a_name "theme-color"; a_content "#111815" ] ();
51 Added: css_link ~uri:(make_uri ~service:Routes.stylesheet ()) ();
41 52 ])
42 53 (body
43 Removed: ([
44 Removed: h1 [ txt "hito" ];
45 Removed: nav
46 Removed: [
47 Removed: a ~service:Routes.home [ txt "Home" ] ();
48 Removed: a ~service:Routes.routine [ txt "Routine" ] ();
49 Removed: a ~service:Routes.log [ txt "Current workout" ] ();
50 Removed: a ~service:Routes.history [ txt "History" ] ();
51 Removed: ];
52 Removed: ]
53 Removed: @ content))
54 Added: ~a:[ a_class [ "hito-app" ] ]
55 Added: [
56 Added: div
57 Added: ~a:[ a_class [ "app-shell" ] ]
58 Added: [
59 Added: header
60 Added: ~a:[ a_class [ "masthead" ] ]
61 Added: [
62 Added: a
63 Added: ~a:[ a_class [ "brand" ] ]
64 Added: ~service:Routes.home
65 Added: [
66 Added: span ~a:[ a_class [ "brand-mark" ] ] [ txt "HD" ];
67 Added: span
68 Added: ~a:[ a_class [ "brand-copy" ] ]
69 Added: [
70 Added: strong [ txt "hito" ];
71 Added: small [ txt "Heavy Duty logbook" ];
72 Added: ];
73 Added: ]
74 Added: ();
75 Added: navigation ();
76 Added: ];
77 Added: main [ Ot_popup.hcf ~a:[ a_class [ "page-surface" ] ] content ];
78 Added: ];
79 Added: ])
54 80
55 81 let problem ~title:t ~detail =
56 Removed: shell ~title:t [ h2 [ txt t ]; p ~a:[ a_class [ "warn" ] ] [ txt detail ] ]
82 Added: shell ~title:t
83 Added: [
84 Added: p ~a:[ a_class [ "eyebrow" ] ] [ txt "Attention required" ];
85 Added: h2 [ txt t ];
86 Added: p ~a:[ a_class [ "warn" ] ] [ txt detail ];
87 Added: ]
57 88
58 89 (* A prescribed stimulus described in words: the movements, and the rep window
59 90 that calibrates the load. *)
lib/web/routes.ml
index 489e9184..cacfa059 100644..100644
@@ -30,6 +30,12 @@
30 30 Eliom_service.create ~path:(Eliom_service.Path [ "" ])
31 31 ~meth:(Eliom_service.Get Eliom_parameter.unit) ()
32 32
33 Added: (* GET /assets/hito.css — the application stylesheet. *)
34 Added: let stylesheet =
35 Added: Eliom_service.create
36 Added: ~path:(Eliom_service.Path [ "assets"; "hito.css" ])
37 Added: ~meth:(Eliom_service.Get Eliom_parameter.unit) ()
38 Added:
33 39 (* GET /log — the workout in progress. *)
34 40 let log =
35 41 Eliom_service.create ~path:(Eliom_service.Path [ "log" ])
lib/web/services.ml
index f17dc2c0..4be4afcd 100644..100644
@@ -29,7 +29,15 @@
29 29 Pages.problem ~title:"Routine unavailable"
30 30 ~detail:(Format.asprintf "%a" Service.pp_error error)))
31 31
32 Added: let stylesheet =
33 Added: match Stylesheet.read "hito.css" with
34 Added: | Some stylesheet -> stylesheet
35 Added: | None -> failwith "Embedded stylesheet hito.css is missing"
36 Added:
32 37 let register () =
38 Added: Eliom_registration.CssText.register ~service:Routes.stylesheet (fun () () ->
39 Added: Lwt.return stylesheet);
40 Added:
33 41 Eliom_registration.Html.register ~service:Routes.home (fun () () ->
34 42 Lwt.return (home_page ()));
35 43