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""#));
}
}

View File

@@ -1,168 +1,177 @@
/* One stylesheet for every page. Served at /static/app.css and linked from
templates/base.html, so no page inlines its own <style> block. */
templates/ui/base.html, so no page inlines its own <style> block.
/* ---------- Foundations ---------- */
Everything below sits in the `app` cascade layer, whose position is fixed by
the @layer line in ui/base.html: above Tailwind's preflight, below Tailwind's
utilities. So this file is still the site's look, and a Tailwind class on one
element still overrides it. Do not unwrap it — unlayered CSS beats every
layer, which would put this file above the utilities again. */
* { box-sizing: border-box; }
: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; }
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; }
main { width: min(1500px, calc(100% - 40px)); margin: 34px auto; }
@layer app {
/* ---------- Navbar (components/navbar.html) ---------- */
/* ---------- Foundations ---------- */
.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; }
* { box-sizing: border-box; }
: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; }
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; }
main { width: min(1500px, calc(100% - 40px)); margin: 34px auto; }
/* ---------- Page heading (components/heading.html) ---------- */
/* ---------- Navbar (components/navbar.html) ---------- */
.heading { display: flex; justify-content: space-between; align-items: end; gap: 24px; margin-bottom: 22px; }
.heading h1 { margin: 0; font-size: 28px; }
.heading p { margin: 5px 0 0; color: #687384; }
.eyebrow { color: #2563eb !important; font-size: 11px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
.actions { display: flex; flex-wrap: wrap; gap: 8px; }
.actions a, .actions span { padding: 8px 12px; border: 1px solid #cfd7e1; border-radius: 7px; color: #283548; background: white; text-decoration: none; }
.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; }
/* ---------- Admin three-pane browser ---------- */
/* ---------- Page heading (components/heading.html) ---------- */
.workspace { display: grid; grid-template-columns: minmax(210px, .75fr) minmax(280px, 1.15fr) minmax(300px, 1.25fr); min-height: 560px; border: 1px solid #d9dfe7; border-radius: 11px; overflow: hidden; background: white; box-shadow: 0 10px 26px rgb(31 43 58 / 7%); }
.pane + .pane { border-left: 1px solid #e1e5eb; }
.pane-title { height: 54px; padding: 0 17px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #e5e8ed; background: #fafbfc; }
.pane-title h2 { margin: 0; font-size: 14px; }
.pane-title span { color: #7c8796; font-size: 12px; }
.pane-list { padding: 10px; }
.pane-list form { margin: 0; }
.browser-item { width: 100%; display: grid; gap: 3px; padding: 11px 12px; text-align: left; border: 1px solid transparent; border-radius: 7px; color: #253246; background: transparent; cursor: pointer; }
.browser-item:hover { background: #f2f6fc; }
.browser-item.selected { border-color: #a9c7f6; background: #eaf2ff; }
.browser-item small, .column small { color: #788495; }
.column { display: grid; grid-template-columns: minmax(100px, 1fr) auto; gap: 3px 12px; padding: 10px 11px; border-bottom: 1px solid #edf0f3; }
.column code { color: #315078; font-size: 12px; }
.column small { grid-column: 1 / -1; }
.empty { margin: 8px; color: #7c8796; }
.heading { display: flex; justify-content: space-between; align-items: end; gap: 24px; margin-bottom: 22px; }
.heading h1 { margin: 0; font-size: 28px; }
.heading p { margin: 5px 0 0; color: #687384; }
.eyebrow { color: #2563eb !important; font-size: 11px; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
.actions { display: flex; flex-wrap: wrap; gap: 8px; }
.actions a, .actions span { padding: 8px 12px; border: 1px solid #cfd7e1; border-radius: 7px; color: #283548; background: white; text-decoration: none; }
/* ---------- Forms (components/form_card.html) ---------- */
/* ---------- Admin three-pane browser ---------- */
.form-main { width: min(900px, calc(100% - 32px)); }
.back-link { display: inline-block; margin-bottom: 14px; color: #315f9d; text-decoration: none; }
.form-card { padding: 25px; border: 1px solid #d9dfe7; border-radius: 11px; background: white; box-shadow: 0 10px 26px rgb(31 43 58 / 7%); }
.form-card h1 { margin: 0; }
.form-card > p { color: #687384; }
.form-grid { margin-top: 22px; display: grid; grid-template-columns: 1fr 1fr; gap: 17px; }
.form-grid label { display: grid; align-content: start; gap: 6px; color: #465267; font-size: 12px; }
.form-grid label.wide { grid-column: 1 / -1; }
.form-grid small { color: #7c8796; }
.form-actions { margin-top: 20px; display: flex; justify-content: end; align-items: center; gap: 14px; }
.form-actions a { color: #59677a; }
.form-actions button { padding: 9px 16px; border: 0; border-radius: 6px; color: white; background: #2563eb; cursor: pointer; }
.check-group { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 12px 20px; }
.check { display: flex !important; grid-template-columns: auto 1fr; align-items: center; gap: 6px; }
.check input { width: auto; }
.workspace { display: grid; grid-template-columns: minmax(210px, .75fr) minmax(280px, 1.15fr) minmax(300px, 1.25fr); min-height: 560px; border: 1px solid #d9dfe7; border-radius: 11px; overflow: hidden; background: white; box-shadow: 0 10px 26px rgb(31 43 58 / 7%); }
.pane + .pane { border-left: 1px solid #e1e5eb; }
.pane-title { height: 54px; padding: 0 17px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #e5e8ed; background: #fafbfc; }
.pane-title h2 { margin: 0; font-size: 14px; }
.pane-title span { color: #7c8796; font-size: 12px; }
.pane-list { padding: 10px; }
.pane-list form { margin: 0; }
.browser-item { width: 100%; display: grid; gap: 3px; padding: 11px 12px; text-align: left; border: 1px solid transparent; border-radius: 7px; color: #253246; background: transparent; cursor: pointer; }
.browser-item:hover { background: #f2f6fc; }
.browser-item.selected { border-color: #a9c7f6; background: #eaf2ff; }
.browser-item small, .column small { color: #788495; }
.column { display: grid; grid-template-columns: minmax(100px, 1fr) auto; gap: 3px 12px; padding: 10px 11px; border-bottom: 1px solid #edf0f3; }
.column code { color: #315078; font-size: 12px; }
.column small { grid-column: 1 / -1; }
.empty { margin: 8px; color: #7c8796; }
/* ---------- Alerts (components/alert.html) ---------- */
/* ---------- Forms (components/form_card.html) ---------- */
.form-error { margin-top: 16px; padding: 11px 13px; border: 1px solid #efc3bf; border-radius: 6px; color: #9d342d; background: #fff5f4; }
.form-error p { margin: 3px 0 0; white-space: pre-wrap; }
.form-success { margin-top: 16px; padding: 11px 13px; border: 1px solid #b9dec6; border-radius: 6px; color: #21643a; background: #f1fbf4; }
.form-success p { margin: 3px 0 0; }
.error { color: #b42318; }
.success { color: #067647; }
.hint, .result-meta { color: #667385; }
.error-page { width: min(600px, calc(100% - 32px)); margin: 15vh auto; padding: 24px; border: 1px solid #f1c4c0; border-radius: 10px; background: white; }
.error-page h1 { margin-top: 0; }
.error-page p { color: #a33a31; }
.form-main { width: min(900px, calc(100% - 32px)); }
.back-link { display: inline-block; margin-bottom: 14px; color: #315f9d; text-decoration: none; }
.form-card { padding: 25px; border: 1px solid #d9dfe7; border-radius: 11px; background: white; box-shadow: 0 10px 26px rgb(31 43 58 / 7%); }
.form-card h1 { margin: 0; }
.form-card > p { color: #687384; }
.form-grid { margin-top: 22px; display: grid; grid-template-columns: 1fr 1fr; gap: 17px; }
.form-grid label { display: grid; align-content: start; gap: 6px; color: #465267; font-size: 12px; }
.form-grid label.wide { grid-column: 1 / -1; }
.form-grid small { color: #7c8796; }
.form-actions { margin-top: 20px; display: flex; justify-content: end; align-items: center; gap: 14px; }
.form-actions a { color: #59677a; }
.form-actions button { padding: 9px 16px; border: 0; border-radius: 6px; color: white; background: #2563eb; cursor: pointer; }
.check-group { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 12px 20px; }
.check { display: flex !important; grid-template-columns: auto 1fr; align-items: center; gap: 6px; }
.check input { width: auto; }
/* ---------- Login (pages/login.html) ---------- */
/* ---------- Alerts ---------- */
.login-main { width: min(420px, calc(100% - 32px)); margin: 12vh auto; }
.login-card { padding: 25px; border: 1px solid #d9dfe7; border-radius: 11px; background: white; box-shadow: 0 10px 26px rgb(31 43 58 / 7%); }
.login-card h1 { margin: 0 0 18px; font-size: 24px; }
.login-card label { display: grid; gap: 5px; margin-bottom: 14px; color: #465267; font-size: 12px; }
.login-card button { width: 100%; border: 0; border-radius: 6px; padding: 10px 18px; color: white; background: #2563eb; cursor: pointer; }
.login-card button:disabled, .login-card button.htmx-request { opacity: .55; cursor: wait; }
/* The alert cards themselves are Penguin UI now — see ui/alert.html. What is
left here is the plain coloured text that ui/notice.html and the error page
use. */
.error { color: #b42318; }
.success { color: #067647; }
.hint, .result-meta { color: #667385; }
.error-page { width: min(600px, calc(100% - 32px)); margin: 15vh auto; padding: 24px; border: 1px solid #f1c4c0; border-radius: 10px; background: white; }
.error-page h1 { margin-top: 0; }
.error-page p { color: #a33a31; }
/* ---------- Analytics (pages/analytics.html) ---------- */
/* ---------- Login (pages/login.html) ---------- */
.analytics { display: grid; grid-template-columns: minmax(290px, 380px) minmax(0, 1fr); align-items: start; gap: 16px; }
.panel { margin-bottom: 16px; padding: 18px; border: 1px solid #dfe4ea; border-radius: 10px; background: white; }
.panel h2 { margin: 0 0 14px; font-size: 17px; }
.sidebar { position: sticky; top: 16px; max-height: calc(100vh - 32px); overflow: auto; }
.sidebar h2 { margin-bottom: 6px; }
.catalog-load { display: grid; grid-template-columns: 1fr auto; align-items: end; gap: 8px; margin: 14px 0; }
.catalog-load label { margin: 0; }
.schema-summary { margin: 8px 0; }
.catalog-table { border-top: 1px solid #e4e7ec; padding: 9px 0; }
.catalog-table summary { display: flex; align-items: center; gap: 8px; cursor: pointer; }
.currency { color: #667385; font-size: 11px; }
.starter-query, .secondary { margin-top: 9px; padding: 6px 9px; width: auto; border: 0; border-radius: 6px; color: #344054; background: #eef2f6; cursor: pointer; }
.columns, .links ul { margin: 8px 0; padding: 0; list-style: none; }
.columns li { display: flex; justify-content: space-between; gap: 8px; padding: 3px 0; }
.columns li span { color: #667385; font-size: 11px; text-align: right; }
.column-name { width: auto; padding: 0; border: 0; color: #2563eb; background: none; font-family: ui-monospace, monospace; text-align: left; cursor: pointer; }
.links { color: #667385; font-size: 12px; }
.links li { padding: 2px 0; }
.llm-context { border-top: 1px solid #e4e7ec; margin-top: 10px; padding-top: 10px; }
.llm-context summary { cursor: pointer; font-weight: 600; }
.llm-context textarea { min-height: 320px; margin-top: 8px; font-size: 11px; }
.query-row { display: grid; grid-template-columns: 180px 120px; gap: 12px; margin-bottom: 12px; }
.query-form label { display: grid; gap: 5px; color: #465267; font-size: 12px; }
.query-form textarea { min-height: 130px; }
.query-form .actions { margin-top: 12px; align-items: center; gap: 12px; }
.query-form button { border: 0; border-radius: 6px; padding: 10px 18px; width: auto; color: white; background: #2563eb; cursor: pointer; }
.query-form button:disabled, .htmx-request button, button.htmx-request { opacity: .55; cursor: wait; }
.chart { height: 560px; }
.table-wrap { max-height: 560px; overflow: auto; }
table { width: 100%; border-collapse: collapse; background: white; }
th, td { padding: 9px 11px; border: 1px solid #e4e7ec; text-align: left; white-space: nowrap; }
th { position: sticky; top: 0; background: #f9fafb; }
.login-main { width: min(420px, calc(100% - 32px)); margin: 12vh auto; }
.login-card { padding: 25px; border: 1px solid #d9dfe7; border-radius: 11px; background: white; box-shadow: 0 10px 26px rgb(31 43 58 / 7%); }
.login-card h1 { margin: 0 0 18px; font-size: 24px; }
.login-card label { display: grid; gap: 5px; margin-bottom: 14px; color: #465267; font-size: 12px; }
.login-card button { width: 100%; border: 0; border-radius: 6px; padding: 10px 18px; color: white; background: #2563eb; cursor: pointer; }
.login-card button:disabled, .login-card button.htmx-request { opacity: .55; cursor: wait; }
/* ---------- Table builder (Add table) ---------- */
/* ---------- Analytics (pages/analytics.html) ---------- */
.builder-section { margin-top: 26px; padding-top: 20px; border-top: 1px solid #e8ebf0; }
.builder-section:first-of-type { margin-top: 0; padding-top: 0; border-top: 0; }
.builder-section h2 { margin: 0; font-size: 15px; color: #33415c; }
.builder-section > .hint { margin: 8px 0 0; font-size: 13px; }
.builder-section .count { margin-left: 6px; padding: 1px 7px; border-radius: 9px; font-size: 12px; color: #4b5563; background: #eef1f6; }
.builder-section > button.secondary { margin-top: 16px; padding: 9px 16px; border: 1px solid #c9d2de; border-radius: 6px; color: #24324a; background: #f4f6fa; cursor: pointer; }
.builder-section > button.secondary:hover { background: #e9edf4; }
.analytics { display: grid; grid-template-columns: minmax(290px, 380px) minmax(0, 1fr); align-items: start; gap: 16px; }
.panel { margin-bottom: 16px; padding: 18px; border: 1px solid #dfe4ea; border-radius: 10px; background: white; }
.panel h2 { margin: 0 0 14px; font-size: 17px; }
.sidebar { position: sticky; top: 16px; max-height: calc(100vh - 32px); overflow: auto; }
.sidebar h2 { margin-bottom: 6px; }
.catalog-load { display: grid; grid-template-columns: 1fr auto; align-items: end; gap: 8px; margin: 14px 0; }
.catalog-load label { margin: 0; }
.schema-summary { margin: 8px 0; }
.catalog-table { border-top: 1px solid #e4e7ec; padding: 9px 0; }
.catalog-table summary { display: flex; align-items: center; gap: 8px; cursor: pointer; }
.currency { color: #667385; font-size: 11px; }
.starter-query, .secondary { margin-top: 9px; padding: 6px 9px; width: auto; border: 0; border-radius: 6px; color: #344054; background: #eef2f6; cursor: pointer; }
.columns, .links ul { margin: 8px 0; padding: 0; list-style: none; }
.columns li { display: flex; justify-content: space-between; gap: 8px; padding: 3px 0; }
.columns li span { color: #667385; font-size: 11px; text-align: right; }
.column-name { width: auto; padding: 0; border: 0; color: #2563eb; background: none; font-family: ui-monospace, monospace; text-align: left; cursor: pointer; }
.links { color: #667385; font-size: 12px; }
.links li { padding: 2px 0; }
.llm-context { border-top: 1px solid #e4e7ec; margin-top: 10px; padding-top: 10px; }
.llm-context summary { cursor: pointer; font-weight: 600; }
.llm-context textarea { min-height: 320px; margin-top: 8px; font-size: 11px; }
.query-row { display: grid; grid-template-columns: 180px 120px; gap: 12px; margin-bottom: 12px; }
.query-form label { display: grid; gap: 5px; color: #465267; font-size: 12px; }
.query-form textarea { min-height: 130px; }
.query-form .actions { margin-top: 12px; align-items: center; gap: 12px; }
.query-form button { border: 0; border-radius: 6px; padding: 10px 18px; width: auto; color: white; background: #2563eb; cursor: pointer; }
.query-form button:disabled, .htmx-request button, button.htmx-request { opacity: .55; cursor: wait; }
.chart { height: 560px; }
.table-wrap { max-height: 560px; overflow: auto; }
table { width: 100%; border-collapse: collapse; background: white; }
th, td { padding: 9px 11px; border: 1px solid #e4e7ec; text-align: left; white-space: nowrap; }
th { position: sticky; top: 0; background: #f9fafb; }
.builder-table { margin-top: 14px; }
.builder-table th { position: static; font-size: 12px; color: #5b6678; }
.builder-table td { font-size: 13px; }
.builder-table .mark { width: 34px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: #4a5568; }
.builder-table.preview .source-system td { color: #7a8496; background: #fbfcfd; }
.builder-table.preview .source-relation td { color: #3c5a86; }
/* ---------- Table builder (Add table) ---------- */
.builder-list { margin: 12px 0 0; padding: 0; list-style: none; display: flex; flex-wrap: wrap; gap: 8px; }
.builder-list li { display: flex; align-items: center; gap: 8px; padding: 5px 10px; border: 1px solid #e1e6ee; border-radius: 7px; background: white; }
.builder-section { margin-top: 26px; padding-top: 20px; border-top: 1px solid #e8ebf0; }
.builder-section:first-of-type { margin-top: 0; padding-top: 0; border-top: 0; }
.builder-section h2 { margin: 0; font-size: 15px; color: #33415c; }
.builder-section > .hint { margin: 8px 0 0; font-size: 13px; }
.builder-section .count { margin-left: 6px; padding: 1px 7px; border-radius: 9px; font-size: 12px; color: #4b5563; background: #eef1f6; }
.builder-section > button.secondary { margin-top: 16px; padding: 9px 16px; border: 1px solid #c9d2de; border-radius: 6px; color: #24324a; background: #f4f6fa; cursor: pointer; }
.builder-section > button.secondary:hover { background: #e9edf4; }
button.toggle { min-width: 34px; padding: 3px 7px; border: 1px solid #cdd5e0; border-radius: 5px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: #24324a; background: #f6f8fb; cursor: pointer; }
button.toggle:hover { background: #e9edf4; }
button.toggle.mode-optional { color: #1d4ed8; border-color: #bfd0f5; background: #eef3fe; }
button.toggle.mode-required { color: #a1410a; border-color: #f0cfae; background: #fdf3e9; }
button.danger { padding: 4px 10px; border: 1px solid #eccfcf; border-radius: 5px; font-size: 12px; color: #a12b2b; background: #fdf3f3; cursor: pointer; }
button.danger:hover { background: #f8e5e5; }
.builder-table { margin-top: 14px; }
.builder-table th { position: static; font-size: 12px; color: #5b6678; }
.builder-table td { font-size: 13px; }
.builder-table .mark { width: 34px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: #4a5568; }
.builder-table.preview .source-system td { color: #7a8496; background: #fbfcfd; }
.builder-table.preview .source-relation td { color: #3c5a86; }
.tag { display: inline-block; padding: 1px 7px; border-radius: 9px; font-size: 11px; color: #4b5563; background: #eef1f6; }
.tag + .tag { margin-left: 4px; }
.builder-list { margin: 12px 0 0; padding: 0; list-style: none; display: flex; flex-wrap: wrap; gap: 8px; }
.builder-list li { display: flex; align-items: center; gap: 8px; padding: 5px 10px; border: 1px solid #e1e6ee; border-radius: 7px; background: white; }
/* ---------- Narrow screens ---------- */
button.toggle { min-width: 34px; padding: 3px 7px; border: 1px solid #cdd5e0; border-radius: 5px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: #24324a; background: #f6f8fb; cursor: pointer; }
button.toggle:hover { background: #e9edf4; }
button.toggle.mode-optional { color: #1d4ed8; border-color: #bfd0f5; background: #eef3fe; }
button.toggle.mode-required { color: #a1410a; border-color: #f0cfae; background: #fdf3e9; }
button.danger { padding: 4px 10px; border: 1px solid #eccfcf; border-radius: 5px; font-size: 12px; color: #a12b2b; background: #fdf3f3; cursor: pointer; }
button.danger:hover { background: #f8e5e5; }
.tag { display: inline-block; padding: 1px 7px; border-radius: 9px; font-size: 11px; color: #4b5563; background: #eef1f6; }
.tag + .tag { margin-left: 4px; }
/* ---------- Narrow screens ---------- */
@media (max-width: 850px) {
.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; }
}
@media (max-width: 850px) {
.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

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