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

@@ -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 %}