From 7cda7ea833adb64275579a258c4e3685d16020ba Mon Sep 17 00:00:00 2001 From: Priec Date: Fri, 7 Aug 2026 21:30:32 +0200 Subject: [PATCH] toats notifications in the web --- server | 2 +- web/askama.toml | 7 ++++ web/src/lib.rs | 7 ++++ web/src/ui/mod.rs | 8 ++++- web/static/app.css | 5 +++ web/templates/pages/add_table/builder.html | 3 +- .../admin/table_definition/column_panel.html | 3 +- .../admin/table_definition/workspace.html | 3 +- web/templates/ui/alert.html | 23 +++---------- web/templates/ui/alert_fragment.html | 8 +++-- web/templates/ui/base.html | 32 +++++++++++++++++++ web/templates/ui/toast.html | 26 +++++++++++++++ 12 files changed, 102 insertions(+), 25 deletions(-) create mode 100644 web/askama.toml create mode 100644 web/templates/ui/toast.html diff --git a/server b/server index aebe3581..8ed66653 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit aebe3581b6eccef34ca3e14ebece7d665736728e +Subproject commit 8ed666531ba2d5745f8b3c0019333ce1b7f7785c diff --git a/web/askama.toml b/web/askama.toml new file mode 100644 index 00000000..76a6ca47 --- /dev/null +++ b/web/askama.toml @@ -0,0 +1,7 @@ +# Two template roots. `templates` is this app's own; `penguinui-components` is +# the Penguin UI library checked in beside it, included from templates by its +# own path (e.g. "toast-notification/stacking-toast-notification.html") so the +# component files stay exactly as the library ships them — no vendored copies +# to re-sync when the library is updated. +[general] +dirs = ["templates", "penguinui-components"] diff --git a/web/src/lib.rs b/web/src/lib.rs index 22c35719..9229ee34 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -224,6 +224,13 @@ mod tests { body.contains("@layer theme, base, app, components, utilities;"), "{path} lost the cascade order app.css depends on" ); + // Penguin UI's toast stack, included straight from + // penguinui-components. A success anywhere on the page dispatches + // `notify` at it, so a page without it swallows the message. + assert!( + body.contains(r#"x-on:notify.window="addNotification("#), + "{path} is missing the toast stack" + ); } } diff --git a/web/src/ui/mod.rs b/web/src/ui/mod.rs index 733148f6..30f94090 100644 --- a/web/src/ui/mod.rs +++ b/web/src/ui/mod.rs @@ -134,8 +134,14 @@ mod tests { // Once in the inline alert, once in the dialog. assert_eq!(failure.matches("A name is required.").count(), 2); + // A success only asks the toast stack in `ui/base.html` to show it: it + // dismisses itself, so it neither blocks the page nor stays behind in + // the layout, and it renders nothing of its own here. let success = render(&Alert::success("Saved", "Two rows written.")); - assert!(success.contains(r#"role="alert""#)); + assert!(!success.contains("Template error"), "{success}"); + assert!(success.contains("$dispatch('notify'")); + assert!(success.contains(r#"data-message="Two rows written.""#)); assert!(!success.contains(r#"role="dialog""#)); + assert!(!success.contains(r#"role="alert""#)); } } diff --git a/web/static/app.css b/web/static/app.css index a7038baa..972022eb 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -15,6 +15,11 @@ :root { color: #17202a; background: #f3f5f7; font: 14px/1.45 Inter, ui-sans-serif, system-ui, sans-serif; } body { margin: 0; } [x-cloak] { display: none !important; } + /* Penguin UI's toast component file (included by ui/base.html) opens with the + demo buttons that trigger it in the library's own preview. The file is used + unmodified, so those buttons come along; this is what hides them. The app's + own trigger is the dispatch in ui/toast.html. */ + .penguin-toast > button { display: none; } button, input, select, textarea { font: inherit; } select, input, textarea { width: 100%; padding: 9px 10px; border: 1px solid #cbd3dd; border-radius: 6px; color: #1e2938; background: white; } textarea { resize: vertical; font: 13px/1.5 ui-monospace, monospace; } diff --git a/web/templates/pages/add_table/builder.html b/web/templates/pages/add_table/builder.html index 9e3c56e9..3fe6e298 100644 --- a/web/templates/pages/add_table/builder.html +++ b/web/templates/pages/add_table/builder.html @@ -9,6 +9,7 @@ #} {% import "ui/alert.html" as alert %} {% import "ui/dialog.html" as dialog %} +{% import "ui/toast.html" as toast %} {# A failure is a dialog, not an inline alert: the builder is taller than the @@ -17,7 +18,7 @@ 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.status %}{% call toast::success("Draft updated", message) %}{% endcall %}{% endif -%} {%- if let Some(message) = page.error %}{% call dialog::error("Could not continue", message) %}{% endcall %}{% endif -%}
diff --git a/web/templates/pages/admin/table_definition/column_panel.html b/web/templates/pages/admin/table_definition/column_panel.html index 65b86733..7272e781 100644 --- a/web/templates/pages/admin/table_definition/column_panel.html +++ b/web/templates/pages/admin/table_definition/column_panel.html @@ -15,9 +15,10 @@ #} {% import "ui/alert.html" as panel_alert %} {% import "ui/dialog.html" as panel_dialog %} +{% import "ui/toast.html" as panel_toast %} {% if standalone_column_panel %} {%- if let Some(message) = page.error %}{% call panel_alert::error("Could not add the column", message) %}{% endcall %}{% endif -%} - {%- if let Some(message) = page.status %}{% call panel_alert::success("Staged", message) %}{% endcall %}{% endif -%} + {%- if let Some(message) = page.status %}{% call panel_toast::success("Staged", message) %}{% endcall %}{% endif -%} {%- if let Some(message) = page.error %}{% call panel_dialog::error("Could not add the column", message) %}{% endcall %}{% endif -%} {% endif %} diff --git a/web/templates/pages/admin/table_definition/workspace.html b/web/templates/pages/admin/table_definition/workspace.html index 642164c1..e43d94a7 100644 --- a/web/templates/pages/admin/table_definition/workspace.html +++ b/web/templates/pages/admin/table_definition/workspace.html @@ -9,9 +9,10 @@ #} {% import "ui/alert.html" as alert %} {% import "ui/dialog.html" as dialog %} +{% import "ui/toast.html" as toast %} {%- if let Some(message) = page.error %}{% call alert::error("Could not continue", message) %}{% endcall %}{% endif -%} -{%- if let Some(message) = page.status %}{% call alert::success("Done", message) %}{% endcall %}{% endif -%} +{%- if let Some(message) = page.status %}{% call toast::success("Done", message) %}{% endcall %}{% endif -%} {%- if let Some(message) = page.error %}{% call dialog::error("Could not continue", message) %}{% endcall %}{% endif -%} {% if let Some(sql) = page.sql %} diff --git a/web/templates/ui/alert.html b/web/templates/ui/alert.html index 9450f7b0..c582cdd8 100644 --- a/web/templates/ui/alert.html +++ b/web/templates/ui/alert.html @@ -4,7 +4,11 @@ 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: + 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 %} @@ -28,20 +32,3 @@ {% endmacro %} - -{% macro success(title, message) %} - -{% endmacro %} diff --git a/web/templates/ui/alert_fragment.html b/web/templates/ui/alert_fragment.html index 17c94e76..9973dfcf 100644 --- a/web/templates/ui/alert_fragment.html +++ b/web/templates/ui/alert_fragment.html @@ -2,15 +2,19 @@ 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 + A success is a toast: it floats over the page, says its piece and removes + itself, so it neither pushes the form around nor stays behind as a stale + claim about a draft that has since moved on. A failure is the opposite — + it has to stay until it is read — so it keeps the inline alert, plus 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 %} {% import "ui/dialog.html" as dialog %} +{% import "ui/toast.html" as toast %} {%- if success -%} -{% call alert::success(title, message) %}{% endcall %} +{% call toast::success(title, message) %}{% endcall %} {%- else -%} {% call alert::error(title, message) %}{% endcall %} {% call dialog::error(title, message) %}{% endcall %} diff --git a/web/templates/ui/base.html b/web/templates/ui/base.html index 45b31f3b..cfe634b7 100644 --- a/web/templates/ui/base.html +++ b/web/templates/ui/base.html @@ -44,6 +44,25 @@ --color-onPrimary: #ffffff; --color-danger: #9d342d; --color-success: #21643a; + + /* + 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. + */ + --color-surface-alt: #f3f5f7; + --color-on-surface: #465267; + --color-on-surface-strong: #17202a; + --color-on-primary: #ffffff; + --color-info: #1d4e89; + --color-on-info: #ffffff; + --color-on-success: #ffffff; + --color-warning: #8a5a08; + --color-on-warning: #ffffff; + --color-on-danger: #ffffff; + --radius-radius: 6px; } @@ -67,5 +86,18 @@ {% include "ui/navbar.html" %} {% block content %}{% endblock %} + {# + Penguin UI's stacking toast notification, straight out of the library and + unmodified — askama.toml puts penguinui-components on the template path, so + this is the library's file, not a copy of it. + + Mounted once per page and deliberately outside the block every fragment + swaps into: a success is announced by a fragment dispatching `notify` (see + ui/toast.html), and the toast showing it has to outlive the swap that + follows. The component file also ships the demo buttons that trigger it in + the library's own preview; `.penguin-toast > button` in static/app.css is + what keeps those off the page. + #} +
{% include "toast-notification/stacking-toast-notification.html" %}
diff --git a/web/templates/ui/toast.html b/web/templates/ui/toast.html new file mode 100644 index 00000000..6393588e --- /dev/null +++ b/web/templates/ui/toast.html @@ -0,0 +1,26 @@ +{# + Transient success toast. + + The toast itself is Penguin UI's stacking toast notification, used straight + from the library — `penguinui-components/toast-notification/`, included + unmodified by ui/base.html, which is also where the demo trigger buttons that + ship in that file are dealt with. It listens for a `notify` window event. + + This file is only the trigger. The library fires `notify` from a button's + x-on:click; here the server decides, so the trigger is an empty element that + dispatches the same event as Alpine initialises it. Alpine initialises + htmx-swapped markup too, so a fragment carrying this shows a toast when it + lands. + + {% import "ui/toast.html" as toast %} + {% call toast::success("Draft updated", message) %}{% endcall %} + + `title` and `message` travel as data attributes rather than interpolated into + the JS, because Askama escapes an attribute value correctly and a message + containing a quote or an apostrophe would otherwise break the expression. +#} +{% macro success(title, message) %} + +{% endmacro %}