web using penguin-ui

This commit is contained in:
Priec
2026-08-07 21:37:40 +02:00
parent 7cda7ea833
commit c47e236713
8 changed files with 183 additions and 120 deletions

View File

@@ -202,8 +202,10 @@ mod tests {
for path in ["/", "/login", "/register"] { for path in ["/", "/login", "/register"] {
let (status, body) = get(path).await; let (status, body) = get(path).await;
assert!(status.is_success(), "{path} returned {status}"); assert!(status.is_success(), "{path} returned {status}");
// Penguin UI's navbar, so what identifies it is its Alpine
// state rather than a class this app wrote.
assert!( assert!(
body.contains("<header class=\"topbar\">"), body.contains(r#"x-data="{ mobileMenuIsOpen: false }""#),
"{path} is missing the shared navbar" "{path} is missing the shared navbar"
); );
assert!( assert!(
@@ -312,6 +314,8 @@ mod tests {
async fn stylesheet_is_served_once_for_every_page() { async fn stylesheet_is_served_once_for_every_page() {
let (status, body) = get("/static/app.css").await; let (status, body) = get("/static/app.css").await;
assert!(status.is_success()); assert!(status.is_success());
assert!(body.contains(".topbar")); // The navbar and the alerts are Penguin UI's own Tailwind classes now,
// so what is left in this file is the site's layout.
assert!(body.contains(".form-card"));
} }
} }

View File

@@ -331,6 +331,7 @@ impl TableDraft {
.map(|link| ProtoTableLink { .map(|link| ProtoTableLink {
linked_table_name: link.linked_table_name.clone(), linked_table_name: link.linked_table_name.clone(),
required: link.mode.is_required(), required: link.mode.is_required(),
name: link.linked_table_name.clone(),
}) })
.collect(), .collect(),
accounting_currency: if self.creating_new_profile { accounting_currency: if self.creating_new_profile {

View File

@@ -183,7 +183,7 @@ mod tests {
state.error = Some("That column already exists.".to_string()); state.error = Some("That column already exists.".to_string());
let html = render_builder(&state); let html = render_builder(&state);
assert!(html.contains(r#"role="dialog""#)); assert!(html.contains(r#"role="dialog""#));
assert!(html.contains("x-data=\"{ modalIsOpen: true }\"")); assert!(html.contains("x-data=\"{ dangerModalIsOpen: true }\""));
assert_eq!(html.matches("That column already exists.").count(), 2); assert_eq!(html.matches("That column already exists.").count(), 2);
} }

View File

@@ -25,15 +25,10 @@
textarea { resize: vertical; font: 13px/1.5 ui-monospace, monospace; } textarea { resize: vertical; font: 13px/1.5 ui-monospace, monospace; }
main { width: min(1500px, calc(100% - 40px)); margin: 34px auto; } main { width: min(1500px, calc(100% - 40px)); margin: 34px auto; }
/* ---------- Navbar (components/navbar.html) ---------- */ /* ---------- Navbar ----------
Nothing here: the navbar is Penguin UI's, styled by its own Tailwind
.topbar { min-height: 58px; padding: 0 28px; display: flex; align-items: center; justify-content: space-between; color: #eef4ff; background: #152238; } classes in ui/navbar.html. The dark `.topbar` bar this replaced was the
.topbar > div, .topbar nav { display: flex; align-items: center; gap: 18px; } last hand-written component on the site. */
.topbar a, .link-button { color: #cbd7e8; text-decoration: none; background: transparent; border: 0; cursor: pointer; padding: 8px 2px; width: auto; }
.topbar a:hover, .link-button:hover, .topbar a.active { color: white; }
.topbar a.active { border-bottom: 2px solid #68a4ff; }
.topbar form { margin: 0; }
.role { color: #9fb0c8; font-size: 12px; }
/* ---------- Page heading (components/heading.html) ---------- */ /* ---------- Page heading (components/heading.html) ---------- */
@@ -189,8 +184,6 @@
.heading { align-items: start; flex-direction: column; } .heading { align-items: start; flex-direction: column; }
.workspace { grid-template-columns: 1fr; } .workspace { grid-template-columns: 1fr; }
.pane + .pane { border-left: 0; border-top: 1px solid #e1e5eb; } .pane + .pane { border-left: 0; border-top: 1px solid #e1e5eb; }
.topbar { padding: 10px 16px; align-items: start; gap: 10px; }
.topbar, .topbar nav { flex-wrap: wrap; }
.analytics { grid-template-columns: 1fr; } .analytics { grid-template-columns: 1fr; }
.sidebar { position: static; max-height: none; } .sidebar { position: static; max-height: none; }
.query-row { grid-template-columns: 1fr; } .query-row { grid-template-columns: 1fr; }

View File

@@ -1,15 +1,20 @@
{# {#
Inline status blocks — Penguin UI's "alert" component Inline failure alert — Penguin UI's danger alert, taken from
(https://www.penguinui.com/components/alert, modern style, with icon), minus penguinui-components/alert/alert-dismiss-functionality.html.
the dark variants because the app has no dark theme. The colours come from
the @theme block in ui/base.html.
Failures only: a success is ui/toast.html, which says its piece and then Unlike the toast, this component carries its copy in the markup, so it cannot
removes itself rather than staying in the layout. An error has to stay until be `{% include %}`d from the library the way ui/toast.html's stack is: the
library file says "Invalid Email Address". This is that file's danger variant
with its two text nodes replaced by `{{ title }}` and `{{ message }}` and
nothing else touched — every class, the x-data, the leave transition and the
dismiss button are the library's. Two additions, both about this app's
content rather than the component's look: `mt-4` for the gap from the form
above, and `whitespace-pre-wrap` because the server's messages are multi-line.
Failures only. A success is ui/toast.html, which says its piece and then
removes itself rather than staying in the layout; an error has to stay until
it is read, so it is this. it is read, so it is this.
Imported wherever a form reports a failure:
{% import "ui/alert.html" as alert %} {% import "ui/alert.html" as alert %}
{% call alert::error("Could not create the table", message) %}{% endcall %} {% call alert::error("Could not create the table", message) %}{% endcall %}
@@ -17,18 +22,29 @@
the message in front of someone who has scrolled away from the top of a form. the message in front of someone who has scrolled away from the top of a form.
#} #}
{% macro error(title, message) %} {% macro error(title, message) %}
<div class="relative mt-4 w-full overflow-hidden rounded-md border border-danger bg-surface text-onSurface" role="alert"> <div x-data="{ alertIsVisible: true }" x-show="alertIsVisible"
<div class="flex items-center gap-2 bg-danger/10 p-4"> class="relative mt-4 w-full overflow-hidden rounded-radius border border-danger bg-surface text-on-surface dark:bg-surface-dark dark:text-on-surface-dark"
<div class="shrink-0 rounded-full bg-danger/15 p-1 text-danger"> role="alert" x-transition:leave="transition ease-in duration-300" x-transition:leave-start="opacity-100 scale-100"
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-6" aria-hidden="true"> x-transition:leave-end="opacity-0 scale-90">
<path fill-rule="evenodd" clip-rule="evenodd" <div class="flex w-full items-center gap-2 bg-danger/10 p-4">
d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" /> <div class="bg-danger/15 text-danger rounded-full p-1" aria-hidden="true">
</svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-6"
aria-hidden="true">
<path fill-rule="evenodd"
d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16ZM8.28 7.22a.75.75 0 0 0-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 1 0 1.06 1.06L10 11.06l1.72 1.72a.75.75 0 1 0 1.06-1.06L11.06 10l1.72-1.72a.75.75 0 0 0-1.06-1.06L10 8.94 8.28 7.22Z"
clip-rule="evenodd" />
</svg>
</div>
<div class="ml-2">
<h3 class="text-sm font-semibold text-danger">{{ title }}</h3>
<p class="text-xs font-medium whitespace-pre-wrap sm:text-sm">{{ message }}</p>
</div>
<button type="button" @click="alertIsVisible = false" class="ml-auto" aria-label="dismiss alert">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" stroke="currentColor"
fill="none" stroke-width="2.5" class="w-4 h-4 shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div> </div>
<div class="ml-2">
<h3 class="text-sm font-semibold text-danger">{{ title }}</h3>
<p class="text-sm font-medium whitespace-pre-wrap">{{ message }}</p>
</div>
</div>
</div> </div>
{% endmacro %} {% endmacro %}

View File

@@ -25,43 +25,62 @@
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2/dist/htmx.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/htmx.org@2/dist/htmx.min.js"></script>
{# {#
Penguin UI (https://www.penguinui.com) is Tailwind + Alpine, and its Penguin UI (https://www.penguinui.com) is Tailwind + Alpine, and every
components are what this app uses for anything the user has to be told: component on this site is one of its components: the navbar, the failure
ui/alert.html and ui/dialog.html. The focus plugin is what x-trap in the alert and modal, and the toast, which is included straight out of
dialog needs, and it has to load before Alpine itself. penguinui-components. The focus plugin is what x-trap in the modal needs,
and it has to load before Alpine itself.
#} #}
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script> <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<style type="text/tailwindcss"> <style type="text/tailwindcss">
/* Penguin UI's "modern" theme setup: its semantic names, this app's palette. */ /*
@theme { The theme penguinui-components/style.css declares, with this app's palette
--color-surface: #ffffff; in the light slots. The names are the library's and are not renamed: the
--color-surfaceAlt: #f3f5f7; component markup here is the library's markup, so it asks for
--color-surfaceDark: #152238; `text-on-surface` and `rounded-radius` and has to find them.
--color-onSurface: #465267; */
--color-onSurfaceStrong: #17202a; @custom-variant dark (&:where(.dark, .dark *));
--color-outline: #d9dfe7;
--color-primary: #2563eb;
--color-onPrimary: #ffffff;
--color-danger: #9d342d;
--color-success: #21643a;
/* @theme {
The same palette again under the names penguinui-components/style.css /* Light theme — this app's palette. */
declares, for the component files this app includes from the library --color-surface: #ffffff;
unmodified rather than copying — currently the toast. The copies above
renamed these; a file used as the library ships it cannot be renamed,
so the theme answers to both spellings.
*/
--color-surface-alt: #f3f5f7; --color-surface-alt: #f3f5f7;
--color-on-surface: #465267; --color-on-surface: #465267;
--color-on-surface-strong: #17202a; --color-on-surface-strong: #17202a;
--color-primary: #2563eb;
--color-on-primary: #ffffff; --color-on-primary: #ffffff;
--color-secondary: #152238;
--color-on-secondary: #ffffff;
--color-outline: #d9dfe7;
--color-outline-strong: #8f9bad;
/*
Dark theme. Nothing sets the `.dark` class the custom variant above
scopes these to, so they are inert — they exist because the library's
markup carries `dark:` classes and is used as it ships. Scoping the
variant to a class rather than leaving Tailwind's prefers-color-scheme
default is what keeps them from firing on an OS set to dark.
*/
--color-surface-dark: #0f1722;
--color-surface-dark-alt: #152238;
--color-on-surface-dark: #cbd7e8;
--color-on-surface-dark-strong: #ffffff;
--color-primary-dark: #68a4ff;
--color-on-primary-dark: #0f1722;
--color-outline-dark: #33415a;
--color-outline-dark-strong: #cbd7e8;
/* Shared colours. */
--color-info: #1d4e89; --color-info: #1d4e89;
--color-on-info: #ffffff; --color-on-info: #ffffff;
--color-success: #21643a;
--color-on-success: #ffffff; --color-on-success: #ffffff;
--color-warning: #8a5a08; --color-warning: #8a5a08;
--color-on-warning: #ffffff; --color-on-warning: #ffffff;
--color-danger: #9d342d;
--color-on-danger: #ffffff; --color-on-danger: #ffffff;
/* Border radius. */
--radius-radius: 6px; --radius-radius: 6px;
} }
</style> </style>

View File

@@ -1,54 +1,52 @@
{# {#
Blocking modal dialog — Penguin UI's "modal" component Blocking failure modal — Penguin UI's danger modal, taken from
(https://www.penguinui.com/components/modal, modern style), with the dark penguinui-components/modal/modal-alerts.html.
variants dropped because the app has no dark theme.
It is Tailwind + Alpine markup, so a page that calls this macro must load Same deal as ui/alert.html: the component's copy lives in its markup, so this
both in its `head` block; `pages/add_table/add_table.html` shows the set. is the library file's danger variant with its text nodes replaced and nothing
The `@theme` block there maps Penguin UI's semantic colour names onto the else changed. Two structural edits, both because the server, not a click,
palette in `static/app.css`. decides when this appears:
Rendered already open (`modalIsOpen: true`): the server only sends the - the library's trigger button is dropped, and `dangerModalIsOpen` starts
dialog when there is something the user has to be told, so there is no `true`. The server only sends the dialog when there is something the user
trigger button — the response itself is the trigger. has to be told, so the response itself is the trigger.
- the footer button dismisses instead of acting ("Back to the form"): a
failure has nothing to confirm.
It is Tailwind + Alpine markup; x-trap needs the focus plugin, which
ui/base.html loads before Alpine itself.
{% import "ui/dialog.html" as dialog %} {% import "ui/dialog.html" as dialog %}
{% call dialog::error("Could not add the column", message) %}{% endcall %} {% call dialog::error("Could not add the column", message) %}{% endcall %}
#} #}
{% macro error(title, message) %} {% macro error(title, message) %}
<div x-data="{ modalIsOpen: true }"> <div x-data="{ dangerModalIsOpen: true }">
<div x-cloak x-show="modalIsOpen" x-transition.opacity.duration.200ms <div x-cloak x-show="dangerModalIsOpen" x-transition.opacity.duration.200ms x-trap.inert.noscroll="dangerModalIsOpen" x-on:keydown.esc.window="dangerModalIsOpen = false" x-on:click.self="dangerModalIsOpen = false" class="fixed inset-0 z-30 flex items-end justify-center bg-black/20 p-4 pb-8 backdrop-blur-md sm:items-center lg:p-8" role="dialog" aria-modal="true" aria-labelledby="dangerModalTitle">
x-trap.inert.noscroll="modalIsOpen" <!-- Modal Dialog -->
x-on:keydown.esc.window="modalIsOpen = false" x-on:click.self="modalIsOpen = false" <div x-show="dangerModalIsOpen" x-transition:enter="transition ease-out duration-200 delay-100 motion-reduce:transition-opacity" x-transition:enter-start="opacity-0 scale-50" x-transition:enter-end="opacity-100 scale-100" class="flex max-w-lg flex-col gap-4 overflow-hidden rounded-radius border border-outline bg-surface text-on-surface dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark">
class="fixed inset-0 z-30 flex items-end justify-center bg-surfaceDark/20 p-4 pb-8 backdrop-blur-xs sm:items-center lg:p-8" <!-- Dialog Header -->
role="dialog" aria-modal="true" aria-labelledby="dialogTitle"> <div class="flex items-center justify-between border-b border-outline bg-surface-alt/60 px-4 py-2 dark:border-outline-dark dark:bg-surface-dark/20">
<!-- Modal Dialog --> <div class="flex items-center justify-center rounded-full bg-danger/20 text-danger p-1">
<div x-show="modalIsOpen" <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-6" aria-hidden="true">
x-transition:enter="transition delay-100 duration-200 ease-out motion-reduce:transition-opacity" <path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16ZM8.28 7.22a.75.75 0 0 0-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 1 0 1.06 1.06L10 11.06l1.72 1.72a.75.75 0 1 0 1.06-1.06L11.06 10l1.72-1.72a.75.75 0 0 0-1.06-1.06L10 8.94 8.28 7.22Z" clip-rule="evenodd" />
x-transition:enter-start="scale-50 opacity-0" x-transition:enter-end="scale-100 opacity-100" </svg>
class="flex max-w-lg flex-col gap-4 overflow-hidden rounded-lg border border-outline bg-surface text-onSurface"> </div>
<!-- Dialog Header --> <button x-on:click="dangerModalIsOpen = false" aria-label="close modal">
<div class="flex items-center justify-between border-b border-outline bg-surfaceAlt/60 p-4"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" stroke="currentColor" fill="none" stroke-width="1.4" class="w-5 h-5">
<h3 id="dialogTitle" class="font-semibold tracking-wide text-danger">{{ title }}</h3> <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
<button type="button" x-on:click="modalIsOpen = false" class="cursor-pointer text-onSurface" aria-label="close dialog"> </svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" stroke="currentColor" </button>
fill="none" stroke-width="1.4" class="h-5 w-5"> </div>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> <!-- Dialog Body -->
</svg> <div class="px-4 text-center">
</button> <h3 id="dangerModalTitle" class="mb-2 font-semibold tracking-wide text-on-surface-strong dark:text-on-surface-dark-strong">{{ title }}</h3>
</div> <p class="whitespace-pre-wrap">{{ message }}</p>
<!-- Dialog Body --> </div>
<div class="px-4 py-4"> <!-- Dialog Footer -->
<p class="text-pretty whitespace-pre-wrap">{{ message }}</p> <div class="flex items-center justify-center border-outline p-4 dark:border-outline-dark">
</div> <button x-on:click="dangerModalIsOpen = false" type="button" class="w-full whitespace-nowrap rounded-radius border border-danger bg-danger px-4 py-2 text-center text-sm font-semibold tracking-wide text-on-danger transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-danger active:opacity-100 active:outline-offset-0">Back to the form</button>
<!-- Dialog Footer --> </div>
<div class="flex flex-col-reverse justify-between gap-2 border-t border-outline bg-surfaceAlt/60 p-4 sm:flex-row sm:items-center md:justify-end"> </div>
<button type="button" x-on:click="modalIsOpen = false"
class="cursor-pointer rounded-md whitespace-nowrap bg-primary px-4 py-2 text-center text-sm font-medium tracking-wide text-onPrimary transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary active:opacity-100 active:outline-offset-0">
Back to the form
</button>
</div>
</div> </div>
</div>
</div> </div>
{% endmacro %} {% endmacro %}

View File

@@ -1,22 +1,54 @@
{# {#
Shared navbar, rendered on every page by base.html. Shared navbar, rendered on every page by base.html. Reads the `nav` field of
Reads the `nav` field of the surrounding page struct (crate::ui::Nav). the surrounding page struct (crate::ui::Nav).
Penguin UI's default navbar, from
penguinui-components/navbar/default-navbar.html: the library's markup with
this app's brand and links in place of the demo's, and the same link list
repeated in the mobile menu exactly as the library repeats it. The active
link is the library's own "current page" styling (`font-bold text-primary`
plus aria-current) rather than the underline the old hand-written `.topbar`
drew, and logging out is a form posting to /logout, styled as a link so it
sits in the list with the others.
This replaced the hand-written dark `.topbar` bar, so the site chrome is now
the library's: white, bordered underneath, with a hamburger menu below `md`.
#} #}
<header class="topbar"> <nav x-data="{ mobileMenuIsOpen: false }" x-on:click.away="mobileMenuIsOpen = false" class="flex items-center justify-between border-b border-outline px-6 py-4 dark:border-outline-dark" aria-label="main menu">
<div> <!-- Brand -->
<strong>Komp Accounting</strong> <a href="/" class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
{% if !nav.role.is_empty() %}<span class="role">{{ nav.role }}</span>{% endif %} <span>Komp <span class="text-primary dark:text-primary-dark">Accounting</span></span>
</div> {% if !nav.role.is_empty() %}<span class="ml-2 align-middle text-xs font-medium text-on-surface dark:text-on-surface-dark">{{ nav.role }}</span>{% endif %}
<nav> </a>
<a href="/admin" {% if nav.active == "admin" %}class="active"{% endif %}>Admin</a> <!-- Desktop Menu -->
<a href="/" {% if nav.active == "analytics" %}class="active"{% endif %}>Analytics</a> <ul class="hidden items-center gap-4 md:flex">
{% if nav.authenticated %} <li><a href="/admin" class="{% if nav.active == "admin" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark dark:hover:text-primary-dark{% endif %} underline-offset-2 hover:text-primary focus:outline-hidden focus:underline" {% if nav.active == "admin" %}aria-current="page"{% endif %}>Admin</a></li>
<form hx-post="/logout" hx-swap="none"> <li><a href="/" class="{% if nav.active == "analytics" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark dark:hover:text-primary-dark{% endif %} underline-offset-2 hover:text-primary focus:outline-hidden focus:underline" {% if nav.active == "analytics" %}aria-current="page"{% endif %}>Analytics</a></li>
<button class="link-button" type="submit">Log out</button> {% if nav.authenticated %}
</form> <li><form hx-post="/logout" hx-swap="none"><button type="submit" class="font-medium text-on-surface underline-offset-2 hover:text-primary focus:outline-hidden focus:underline dark:text-on-surface-dark dark:hover:text-primary-dark">Log out</button></form></li>
{% else %} {% else %}
<a href="/login" {% if nav.active == "login" %}class="active"{% endif %}>Login</a> <li><a href="/login" class="{% if nav.active == "login" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark dark:hover:text-primary-dark{% endif %} underline-offset-2 hover:text-primary focus:outline-hidden focus:underline" {% if nav.active == "login" %}aria-current="page"{% endif %}>Login</a></li>
<a href="/register" {% if nav.active == "register" %}class="active"{% endif %}>Register</a> <li><a href="/register" class="{% if nav.active == "register" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark dark:hover:text-primary-dark{% endif %} underline-offset-2 hover:text-primary focus:outline-hidden focus:underline" {% if nav.active == "register" %}aria-current="page"{% endif %}>Register</a></li>
{% endif %} {% endif %}
</nav> </ul>
</header> <!-- Mobile Menu Button -->
<button x-on:click="mobileMenuIsOpen = !mobileMenuIsOpen" x-bind:aria-expanded="mobileMenuIsOpen" x-bind:class="mobileMenuIsOpen ? 'fixed top-6 right-6 z-20' : null" type="button" class="flex text-on-surface dark:text-on-surface-dark md:hidden" aria-label="mobile menu" aria-controls="mobileMenu">
<svg x-cloak x-show="!mobileMenuIsOpen" xmlns="http://www.w3.org/2000/svg" fill="none" aria-hidden="true" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
<svg x-cloak x-show="mobileMenuIsOpen" xmlns="http://www.w3.org/2000/svg" fill="none" aria-hidden="true" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
<!-- Mobile Menu -->
<ul x-cloak x-show="mobileMenuIsOpen" x-transition:enter="transition motion-reduce:transition-none ease-out duration-300" x-transition:enter-start="-translate-y-full" x-transition:enter-end="translate-y-0" x-transition:leave="transition motion-reduce:transition-none ease-out duration-300" x-transition:leave-start="translate-y-0" x-transition:leave-end="-translate-y-full" id="mobileMenu" class="fixed max-h-svh overflow-y-auto inset-x-0 top-0 z-10 flex flex-col divide-y divide-outline rounded-b-radius border-b border-outline bg-surface-alt px-6 pb-6 pt-20 dark:divide-outline-dark dark:border-outline-dark dark:bg-surface-dark-alt md:hidden">
<li class="py-4"><a href="/admin" class="w-full text-lg {% if nav.active == "admin" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark{% endif %} focus:underline" {% if nav.active == "admin" %}aria-current="page"{% endif %}>Admin</a></li>
<li class="py-4"><a href="/" class="w-full text-lg {% if nav.active == "analytics" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark{% endif %} focus:underline" {% if nav.active == "analytics" %}aria-current="page"{% endif %}>Analytics</a></li>
{% if nav.authenticated %}
<li class="py-4"><form hx-post="/logout" hx-swap="none"><button type="submit" class="w-full text-left text-lg font-medium text-on-surface focus:underline dark:text-on-surface-dark">Log out</button></form></li>
{% else %}
<li class="py-4"><a href="/login" class="w-full text-lg {% if nav.active == "login" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark{% endif %} focus:underline" {% if nav.active == "login" %}aria-current="page"{% endif %}>Login</a></li>
<li class="py-4"><a href="/register" class="w-full text-lg {% if nav.active == "register" %}font-bold text-primary dark:text-primary-dark{% else %}font-medium text-on-surface dark:text-on-surface-dark{% endif %} focus:underline" {% if nav.active == "register" %}aria-current="page"{% endif %}>Register</a></li>
{% endif %}
</ul>
</nav>