open Dream_html
open Page_kit
(* The shell. [viewer] and [request] identify authenticated pages. Auth pages
omit the application navigation. *)
let theme_session_key = "hito.theme"
let deployment_version () =
match Sys.getenv_opt "HITO_VERSION" with
| Some version when version <> "" -> version
| _ -> "development"
let footer () =
tag "footer"
[ class_ "site-footer" ]
[
tag "div" [] [ txt "© 2026 Marius PETER. All rights reserved." ];
tag "div" []
[
txt "Version: ";
tag "code"
[ class_ "deployment-version" ]
[ txt "%s" (deployment_version ()) ];
];
]
let html_page ?viewer ?request ?(active = "") ?(show_nav = false)
?(show_footer = false) ?workout_started title content =
let spa_client = true in
(* The trainee is logging a workout exactly when its start epoch is present.
The middle nav slot then shows the elapsed timer on every page, so the
running workout is always visible; otherwise it is the routine link. *)
let theme =
match request with
| Some request -> (
match Dream.session_field request theme_session_key with
| Some "dark" -> "dark"
| _ -> "light")
| None -> "light"
in
let nav_link page path label =
let attrs = [ href path ] in
let attrs =
if spa_client then Dream_html.attr "data-hito-app-link" :: attrs
else attrs
in
let attrs =
if String.equal page active then
Dream_html.string_attr "aria-current" "page" :: attrs
else attrs
in
tag "a" attrs [ txt "%s" label ]
in
(* Three always-available actions. The middle slot points to the routine
normally. While logging it becomes the elapsed workout timer. The link
still carries the trainee to the current workout, so a running workout
stays one tap away without adding a fourth destination. The client ticks
the value from [data-started]. Without script the link shows a static
start. *)
let workout_timer_link started =
let attrs =
[ href Routes.workout; class_ "nav-timer" ]
@ (if spa_client then [ Dream_html.attr "data-hito-app-link" ] else [])
@ (if String.equal "workout" active then
[ Dream_html.string_attr "aria-current" "page" ]
else [])
@ [
Dream_html.attr "data-hito-workout-timer";
Dream_html.string_attr "data-started" "%d" started;
Dream_html.string_attr "role" "timer";
Dream_html.string_attr "aria-label" "Elapsed workout time";
]
in
tag "a" attrs
[
tag "span"
[
class_ "nav-timer-value";
Dream_html.string_attr "aria-live" "off";
Dream_html.attr "data-hito-workout-timer-value";
]
[ txt "0:00" ];
]
in
let navigation_links () =
[
nav_link "home" Routes.home "Home";
(match workout_started with
| Some started -> workout_timer_link started
| None -> nav_link "routine" Routes.routine "Routine");
nav_link "logbook" Routes.logbook "Logbook";
]
in
let application_navigation () =
let visible =
match (viewer, request) with
| Some (_ : View_model.Viewer.t), Some _ -> true
| _ -> show_nav
in
if visible then
[
tag "nav"
[
class_ "app-nav";
Dream_html.string_attr "aria-label" "Application navigation";
]
(navigation_links ());
]
else []
in
tag "html"
[
Dream_html.string_attr "lang" "en";
Dream_html.string_attr "data-theme" "%s" theme;
]
[
tag "head" []
[
tag "title" [] [ txt "Hito — %s" title ];
void "meta" [ Dream_html.string_attr "charset" "utf-8" ];
void "meta"
[
Dream_html.string_attr "name" "viewport";
Dream_html.string_attr "content"
"width=device-width,initial-scale=1";
];
void "link" [ rel "stylesheet"; href Routes.stylesheet ];
void "link"
[
rel "icon";
Dream_html.string_attr "type" "image/svg+xml";
href Routes.favicon;
];
];
tag "body"
[ Dream_html.string_attr "class" "hito-app" ]
([
tag "a"
[
class_ "skip-link"; Dream_html.string_attr "href" "#main-content";
]
[ txt "Skip to main content" ];
tag "div"
([
Dream_html.string_attr "class" "app-shell page-%s" active;
Dream_html.string_attr "data-hito-page-title" "Hito — %s" title;
]
@
if spa_client then [ Dream_html.attr "data-hito-app-shell" ]
else [])
([
tag "header"
[ class_ "masthead" ]
([
tag "a"
([ class_ "brand"; href Routes.home ]
@
if spa_client then
[ Dream_html.attr "data-hito-app-link" ]
else [])
[ txt "hito" ];
]
@ application_navigation ());
tag "main"
([ id "main-content"; tabindex "-1" ]
@
if spa_client then [ Dream_html.attr "data-hito-app-content" ]
else [])
content;
]
@ if show_footer then [ footer () ] else []);
]
@
if spa_client then
[
tag "script"
[ src Routes.workout_client; Dream_html.attr "defer" ]
[];
]
else []);
]