penguinui

This commit is contained in:
Priec
2026-08-03 17:25:12 +02:00
parent 0e6d764702
commit 00121874a7
9 changed files with 383 additions and 149 deletions

View File

@@ -8,9 +8,17 @@
sections are what carry the draft across requests.
#}
{% import "ui/alert.html" as alert %}
{% import "ui/dialog.html" as dialog %}
{#
A failure is a dialog, not an inline alert: the builder is taller than the
viewport, so an alert at the top is off screen for anyone who has scrolled
down to the column list or the Create table button. The alert stays too, as
what is left on the page after the dialog is dismissed.
#}
{%- if let Some(message) = page.error %}{% call alert::error("Could not continue", message) %}{% endcall %}{% endif -%}
{%- if let Some(message) = page.status %}{% call alert::success("Draft updated", message) %}{% endcall %}{% endif -%}
{%- if let Some(message) = page.error %}{% call dialog::error("Could not continue", message) %}{% endcall %}{% endif -%}
<section class="builder-section">
<h2>Table</h2>

View File

@@ -1,13 +1,47 @@
{#
Inline status blocks. Imported wherever a form reports its outcome:
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.
Imported wherever a form reports its outcome:
{% import "ui/alert.html" as alert %}
{% call alert::error("Could not create the table", message) %}{% endcall %}
An error is normally paired with ui/dialog.html, which is what actually puts
the message in front of someone who has scrolled away from the top of a form.
#}
{% macro error(title, message) %}
<div class="form-error"><strong>{{ title }}</strong><p>{{ message }}</p></div>
<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>
<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 %}
{% macro success(title, message) %}
<div class="form-success"><strong>{{ title }}</strong><p>{{ message }}</p></div>
<div class="relative mt-4 w-full overflow-hidden rounded-md border border-success bg-surface text-onSurface" role="alert">
<div class="flex items-center gap-2 bg-success/10 p-4">
<div class="shrink-0 rounded-full bg-success/15 p-1 text-success">
<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="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" />
</svg>
</div>
<div class="ml-2">
<h3 class="text-sm font-semibold text-success">{{ title }}</h3>
<p class="text-sm font-medium whitespace-pre-wrap">{{ message }}</p>
</div>
</div>
</div>
{% endmacro %}

View File

@@ -1,6 +1,17 @@
{#
Standalone swap target for every form POST — crate::ui::Alert.
Rendered into the page's #submission-status / #login-status div.
A success is an inline alert and nothing more. A failure also gets the
dialog: these divs sit at the bottom of their form, so on any page long
enough to scroll — the validation forms, the import form, the table builder
— the alert alone lands off screen and the page looks like it did nothing.
#}
{% import "ui/alert.html" as alert %}
{%- if success %}{% call alert::success(title, message) %}{% endcall %}{% else %}{% call alert::error(title, message) %}{% endcall %}{% endif -%}
{% import "ui/dialog.html" as dialog %}
{%- if success -%}
{% call alert::success(title, message) %}{% endcall %}
{%- else -%}
{% call alert::error(title, message) %}{% endcall %}
{% call dialog::error(title, message) %}{% endcall %}
{%- endif -%}

View File

@@ -11,8 +11,57 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Komp Accounting{% endblock %} · Komp Accounting</title>
{#
Cascade order, declared before anything ships CSS so that it is this line
that decides it. `app` is static/app.css, which is wrapped in that layer:
it sits above Tailwind's preflight — so the reset cannot touch the site's
own look — and below Tailwind's utilities, so a utility class put on an
element still wins. That is what makes Tailwind usable here one element at
a time, without converting the stylesheet.
#}
<style>@layer theme, base, app, components, utilities;</style>
<link rel="stylesheet" href="/static/app.css">
<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.
#}
<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;
}
</style>
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/focus@3/dist/cdn.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
<script>
// htmx only swaps 2xx responses. Every form in this app answers a failure
// with a status that says so — 422 for a rejected draft, 403 for a lost
// session, 502 for an unreachable backend — so the markup explaining the
// failure was rendered, returned, and then thrown away, and the page did
// nothing at all. Swap it instead; the response body is the explanation.
document.addEventListener('htmx:beforeSwap', function (event) {
if (event.detail.xhr.status >= 400) {
event.detail.shouldSwap = true;
event.detail.isError = false;
}
});
</script>
{% block head %}{% endblock %}
</head>
<body>

View File

@@ -0,0 +1,54 @@
{#
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.
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`.
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.
{% 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>
</div>
</div>
{% endmacro %}