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"] {
let (status, body) = get(path).await;
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!(
body.contains("<header class=\"topbar\">"),
body.contains(r#"x-data="{ mobileMenuIsOpen: false }""#),
"{path} is missing the shared navbar"
);
assert!(
@@ -312,6 +314,8 @@ mod tests {
async fn stylesheet_is_served_once_for_every_page() {
let (status, body) = get("/static/app.css").await;
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 {
linked_table_name: link.linked_table_name.clone(),
required: link.mode.is_required(),
name: link.linked_table_name.clone(),
})
.collect(),
accounting_currency: if self.creating_new_profile {

View File

@@ -183,7 +183,7 @@ mod tests {
state.error = Some("That column already exists.".to_string());
let html = render_builder(&state);
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);
}

View File

@@ -25,15 +25,10 @@
textarea { resize: vertical; font: 13px/1.5 ui-monospace, monospace; }
main { width: min(1500px, calc(100% - 40px)); margin: 34px auto; }
/* ---------- Navbar (components/navbar.html) ---------- */
.topbar { min-height: 58px; padding: 0 28px; display: flex; align-items: center; justify-content: space-between; color: #eef4ff; background: #152238; }
.topbar > div, .topbar nav { display: flex; align-items: center; gap: 18px; }
.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; }
/* ---------- Navbar ----------
Nothing here: the navbar is Penguin UI's, styled by its own Tailwind
classes in ui/navbar.html. The dark `.topbar` bar this replaced was the
last hand-written component on the site. */
/* ---------- Page heading (components/heading.html) ---------- */
@@ -189,8 +184,6 @@
.heading { align-items: start; flex-direction: column; }
.workspace { grid-template-columns: 1fr; }
.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; }
.sidebar { position: static; max-height: none; }
.query-row { grid-template-columns: 1fr; }

View File

@@ -1,15 +1,20 @@
{#
Inline status blocks — Penguin UI's "alert" component
(https://www.penguinui.com/components/alert, modern style, with icon), minus
the dark variants because the app has no dark theme. The colours come from
the @theme block in ui/base.html.
Inline failure alert — Penguin UI's danger alert, taken from
penguinui-components/alert/alert-dismiss-functionality.html.
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
Unlike the toast, this component carries its copy in the markup, so it cannot
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.
Imported wherever a form reports a failure:
{% import "ui/alert.html" as alert %}
{% 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.
#}
{% 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 class="flex items-center gap-2 bg-danger/10 p-4">
<div class="shrink-0 rounded-full bg-danger/15 p-1 text-danger">
<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" clip-rule="evenodd"
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" />
</svg>
<div x-data="{ alertIsVisible: true }" x-show="alertIsVisible"
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"
role="alert" x-transition:leave="transition ease-in duration-300" x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-90">
<div class="flex w-full items-center gap-2 bg-danger/10 p-4">
<div class="bg-danger/15 text-danger rounded-full p-1" aria-hidden="true">
<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 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>
{% endmacro %}

View File

@@ -25,43 +25,62 @@
<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
components are what this app uses for anything the user has to be told:
ui/alert.html and ui/dialog.html. The focus plugin is what x-trap in the
dialog needs, and it has to load before Alpine itself.
Penguin UI (https://www.penguinui.com) is Tailwind + Alpine, and every
component on this site is one of its components: the navbar, the failure
alert and modal, and the toast, which is included straight out of
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>
<style type="text/tailwindcss">
/* Penguin UI's "modern" theme setup: its semantic names, this app's palette. */
@theme {
--color-surface: #ffffff;
--color-surfaceAlt: #f3f5f7;
--color-surfaceDark: #152238;
--color-onSurface: #465267;
--color-onSurfaceStrong: #17202a;
--color-outline: #d9dfe7;
--color-primary: #2563eb;
--color-onPrimary: #ffffff;
--color-danger: #9d342d;
--color-success: #21643a;
/*
The theme penguinui-components/style.css declares, with this app's palette
in the light slots. The names are the library's and are not renamed: the
component markup here is the library's markup, so it asks for
`text-on-surface` and `rounded-radius` and has to find them.
*/
@custom-variant dark (&:where(.dark, .dark *));
/*
The same palette again under the names penguinui-components/style.css
declares, for the component files this app includes from the library
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.
*/
@theme {
/* Light theme — this app's palette. */
--color-surface: #ffffff;
--color-surface-alt: #f3f5f7;
--color-on-surface: #465267;
--color-on-surface-strong: #17202a;
--color-primary: #2563eb;
--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-on-info: #ffffff;
--color-success: #21643a;
--color-on-success: #ffffff;
--color-warning: #8a5a08;
--color-on-warning: #ffffff;
--color-danger: #9d342d;
--color-on-danger: #ffffff;
/* Border radius. */
--radius-radius: 6px;
}
</style>

View File

@@ -1,54 +1,52 @@
{#
Blocking modal dialog — Penguin UI's "modal" component
(https://www.penguinui.com/components/modal, modern style), with the dark
variants dropped because the app has no dark theme.
Blocking failure modal — Penguin UI's danger modal, taken from
penguinui-components/modal/modal-alerts.html.
It is Tailwind + Alpine markup, so a page that calls this macro must load
both in its `head` block; `pages/add_table/add_table.html` shows the set.
The `@theme` block there maps Penguin UI's semantic colour names onto the
palette in `static/app.css`.
Same deal as ui/alert.html: the component's copy lives in its markup, so this
is the library file's danger variant with its text nodes replaced and nothing
else changed. Two structural edits, both because the server, not a click,
decides when this appears:
Rendered already open (`modalIsOpen: true`): the server only sends the
dialog when there is something the user has to be told, so there is no
trigger button — the response itself is the trigger.
- the library's trigger button is dropped, and `dangerModalIsOpen` starts
`true`. The server only sends the dialog when there is something the user
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 %}
{% call dialog::error("Could not add the column", message) %}{% endcall %}
#}
{% macro error(title, message) %}
<div x-data="{ modalIsOpen: true }">
<div x-cloak x-show="modalIsOpen" x-transition.opacity.duration.200ms
x-trap.inert.noscroll="modalIsOpen"
x-on:keydown.esc.window="modalIsOpen = false" x-on:click.self="modalIsOpen = false"
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"
role="dialog" aria-modal="true" aria-labelledby="dialogTitle">
<!-- Modal Dialog -->
<div x-show="modalIsOpen"
x-transition:enter="transition delay-100 duration-200 ease-out motion-reduce:transition-opacity"
x-transition:enter-start="scale-50 opacity-0" x-transition:enter-end="scale-100 opacity-100"
class="flex max-w-lg flex-col gap-4 overflow-hidden rounded-lg border border-outline bg-surface text-onSurface">
<!-- Dialog Header -->
<div class="flex items-center justify-between border-b border-outline bg-surfaceAlt/60 p-4">
<h3 id="dialogTitle" class="font-semibold tracking-wide text-danger">{{ title }}</h3>
<button type="button" x-on:click="modalIsOpen = false" class="cursor-pointer text-onSurface" aria-label="close dialog">
<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="h-5 w-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Dialog Body -->
<div class="px-4 py-4">
<p class="text-pretty whitespace-pre-wrap">{{ message }}</p>
</div>
<!-- Dialog Footer -->
<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">
<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 x-data="{ dangerModalIsOpen: true }">
<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">
<!-- Modal Dialog -->
<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">
<!-- Dialog Header -->
<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">
<div class="flex items-center justify-center rounded-full bg-danger/20 text-danger p-1">
<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>
<button x-on:click="dangerModalIsOpen = false" aria-label="close modal">
<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">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Dialog Body -->
<div class="px-4 text-center">
<h3 id="dangerModalTitle" class="mb-2 font-semibold tracking-wide text-on-surface-strong dark:text-on-surface-dark-strong">{{ title }}</h3>
<p class="whitespace-pre-wrap">{{ message }}</p>
</div>
<!-- Dialog Footer -->
<div class="flex items-center justify-center border-outline p-4 dark:border-outline-dark">
<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>
</div>
</div>
</div>
</div>
</div>
{% endmacro %}

View File

@@ -1,22 +1,54 @@
{#
Shared navbar, rendered on every page by base.html.
Reads the `nav` field of the surrounding page struct (crate::ui::Nav).
Shared navbar, rendered on every page by base.html. Reads the `nav` field of
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">
<div>
<strong>Komp Accounting</strong>
{% if !nav.role.is_empty() %}<span class="role">{{ nav.role }}</span>{% endif %}
</div>
<nav>
<a href="/admin" {% if nav.active == "admin" %}class="active"{% endif %}>Admin</a>
<a href="/" {% if nav.active == "analytics" %}class="active"{% endif %}>Analytics</a>
{% if nav.authenticated %}
<form hx-post="/logout" hx-swap="none">
<button class="link-button" type="submit">Log out</button>
</form>
{% else %}
<a href="/login" {% if nav.active == "login" %}class="active"{% endif %}>Login</a>
<a href="/register" {% if nav.active == "register" %}class="active"{% endif %}>Register</a>
{% endif %}
</nav>
</header>
<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">
<!-- Brand -->
<a href="/" class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
<span>Komp <span class="text-primary dark:text-primary-dark">Accounting</span></span>
{% 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 %}
</a>
<!-- Desktop Menu -->
<ul class="hidden items-center gap-4 md:flex">
<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>
<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>
{% if nav.authenticated %}
<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 %}
<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>
<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 %}
</ul>
<!-- 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>