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

@@ -207,10 +207,20 @@ mod tests {
body.contains("href=\"/static/app.css\""),
"{path} is missing the shared stylesheet"
);
assert!(
!body.contains("<style>"),
// The shell carries exactly two <style> elements, and neither is
// page styling: the cascade-order declaration that puts app.css
// between Tailwind's preflight and its utilities, and Penguin UI's
// @theme. Any third one is a page inlining CSS instead of
// linking the stylesheet.
assert_eq!(
body.matches("<style").count(),
2,
"{path} inlines CSS instead of linking the stylesheet"
);
assert!(
body.contains("@layer theme, base, app, components, utilities;"),
"{path} lost the cascade order app.css depends on"
);
}
}

View File

@@ -51,6 +51,8 @@ pub(crate) fn render_builder(page: &AddTablePageState) -> String {
}
/// Used when the page itself cannot be loaded (auth or backend failure).
/// There is no draft left to render, so this replaces the builder — the dialog
/// is what tells the user why the form just emptied.
pub(crate) fn render_submission_error(message: &str) -> String {
render(&Alert::error("Could not create the table", message))
}
@@ -145,6 +147,41 @@ mod tests {
assert!(html.contains(r#"name="accounting_currency" value="EUR" list="currency-codes""#));
}
/// The dialog only exists when there is a failure, and it carries the same
/// message as the inline alert behind it.
#[test]
fn a_failure_is_shown_as_a_dialog_as_well_as_an_alert() {
let mut state = page();
assert!(!render_builder(&state).contains(r#"role="dialog""#));
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_eq!(html.matches("That column already exists.").count(), 2);
}
/// The dialog is Tailwind + Alpine, so the page has to load them; the
/// `head` block lives two levels up, in `ui/base.html`.
#[test]
fn the_page_loads_what_the_dialog_needs() {
let html = render_page(&page());
assert!(html.contains("@tailwindcss/browser@4"));
assert!(html.contains("@alpinejs/focus@3"));
assert!(html.contains("htmx:beforeSwap"));
}
/// A load failure has no draft to render, so the dialog is the response.
#[test]
fn a_load_failure_answers_with_the_dialog() {
let html = render_submission_error("The backend is unreachable.");
assert!(!html.contains("Template error"), "{html}");
assert!(html.contains(r#"role="dialog""#));
assert!(html.contains("The backend is unreachable."));
}
#[test]
fn a_link_mode_is_shown_on_the_button_that_cycles_it() {
let html = render_builder(&page());

View File

@@ -47,7 +47,8 @@ impl Default for Nav {
}
}
/// The swap target every form POST answers with.
/// The swap target every form POST answers with. An error carries the dialog
/// that puts the message in front of the user; see `ui/alert_fragment.html`.
#[derive(Template)]
#[template(path = "ui/alert_fragment.html")]
pub(crate) struct Alert<'a> {
@@ -117,3 +118,24 @@ pub(crate) fn render<T: Template>(template: &T) -> String {
.render()
.unwrap_or_else(|error| format!("<p class=\"error\">Template error: {error}</p>"))
}
#[cfg(test)]
mod tests {
use super::*;
/// Every form on the site answers through `Alert`, so this is the one
/// place that decides a failure cannot go unnoticed.
#[test]
fn a_failed_form_gets_a_dialog_and_a_successful_one_does_not() {
let failure = render(&Alert::error("Could not save", "A name is required."));
assert!(!failure.contains("Template error"), "{failure}");
assert!(failure.contains(r#"role="alert""#));
assert!(failure.contains(r#"role="dialog""#));
// Once in the inline alert, once in the dialog.
assert_eq!(failure.matches("A name is required.").count(), 2);
let success = render(&Alert::success("Saved", "Two rows written."));
assert!(success.contains(r#"role="alert""#));
assert!(!success.contains(r#"role="dialog""#));
}
}