Move flash from header to fixed bottom toast
The flash as an inline header element fought with nav layout — it
changed header height, required nav replacement on dismiss, and
lacked visual distinction as a notification. A fixed-position toast
separates the notification from header concerns entirely.
Also fix mobile layout: override .dashboard:has(.column-overdue)
specificity so columns stretch to full width on small screens.
Assisted-by: Claude Opus 4.6 via Claude Code
diff --git a/lib/ketchup/views/layout.rb b/lib/ketchup/views/layout.rb
index ac9edae..daa1a6f 100644
--- a/lib/ketchup/views/layout.rb
+++ b/lib/ketchup/views/layout.rb
@@ -53,19 +53,16 @@ module Views
body do
header(class: "site-header") do
a(href: "/", class: "site-name") { "Ketchup" }
- if @flash
- render_flash
- else
- nav(class: "site-nav") do
- a(
- href: "/series/new",
- class: ["header-action", ("header-action--active" if @active_view == :new)]
- ) { "+New" }
- end
+ nav(class: "site-nav") do
+ a(
+ href: "/series/new",
+ class: ["header-action", ("header-action--active" if @active_view == :new)]
+ ) { "+New" }
end
a(href: "/users/#{@current_user[:id]}", class: "header-user") { @current_user[:login] }
end
yield
+ render_flash if @flash
render_footer
end
end
@@ -77,8 +74,8 @@ module Views
undo_path = @flash["undo_path"]
div(class: "flash-bar", data: { undo_path: undo_path }.compact) do
- span(class: "flash-message") { @flash["message"] }
button(class: "flash-undo-btn", hidden: !undo_path) { "Undo" }
+ span(class: "flash-message") { @flash["message"] }
button(class: "flash-close-btn", **{ "aria-label": "Dismiss" }) { "\u00d7" }
script { raw safe(flash_script) }
end
@@ -88,13 +85,10 @@ module Views
<<~JS
(function() {
var bar = document.currentScript.parentElement;
- var nav = document.createElement('nav');
- nav.className = 'site-nav';
- nav.innerHTML = '<a href="/series/new" class="header-action">+New</a>';
var undo = bar.querySelector('.flash-undo-btn');
var close = bar.querySelector('.flash-close-btn');
var path = bar.dataset.undoPath;
- function dismiss() { bar.replaceWith(nav); }
+ function dismiss() { bar.remove(); }
if (undo && path) {
undo.addEventListener('click', function() {
fetch(path, {method: 'DELETE'}).then(function() { location.reload(); });
diff --git a/public/css/app.css b/public/css/app.css
index 1a37957..58a06f6 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -107,14 +107,23 @@ h2 { font-size: var(--step-0); font-weight: 600; }
/* -------------------- */
.flash-bar {
- flex: 1;
+ position: fixed;
+ bottom: var(--space-s);
+ right: var(--space-s);
+ z-index: 100;
display: flex;
align-items: baseline;
- justify-content: center;
gap: var(--space-xs);
- color: #666;
+ padding: var(--space-xs) var(--space-l);
+ color: #333;
+ font-size: var(--step--2);
+ background: #fff;
+ border: 1px solid #ddd;
+ border-radius: 6px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
+
.flash-undo-btn {
all: unset;
color: #c00;
@@ -130,7 +139,8 @@ h2 { font-size: var(--step-0); font-weight: 600; }
color: #aaa;
cursor: pointer;
font-size: var(--step-0);
- line-height: 1;
+ vertical-align: middle;
+ line-height: 0;
}
.flash-close-btn:hover {
@@ -269,7 +279,7 @@ h2 { font-size: var(--step-0); font-weight: 600; }
/* -------------------- */
.section + .section {
- margin-block-start: var(--space-l);
+ margin-block-start: var(--space-s);
}
.section-header {
@@ -731,11 +741,16 @@ button[type="submit"]:not(.complete-btn):hover {
.main-column {
flex: none;
align-self: stretch;
+ max-width: none;
+ margin-inline: 0;
padding-inline: var(--space-s);
}
- .dashboard {
+ .dashboard,
+ .dashboard:has(.column-overdue) {
flex-direction: column;
+ max-width: none;
+ margin-inline: 0;
}
.column-overdue {
@@ -747,13 +762,29 @@ button[type="submit"]:not(.complete-btn):hover {
.column-agenda {
width: auto;
+ align-self: stretch;
border-inline-start: none;
- padding: 0 var(--space-s) var(--space-m);
+ padding: var(--space-xs) var(--space-s) var(--space-m);
}
.task-card--overdue {
margin-inline: 0;
}
+
+ .flash-bar {
+ bottom: 0;
+ right: 0;
+ left: 0;
+ padding: var(--space-s) var(--space-s);
+ border-radius: 0;
+ border-inline: none;
+ border-block-end: none;
+ }
+
+ .flash-message {
+ flex: 1;
+ text-align: center;
+ }
}
diff --git a/test/test_web.rb b/test/test_web.rb
index f5cc8f8..02cbf7e 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -602,14 +602,14 @@ class TestWeb < Minitest::Test
assert_includes body, "flash-undo-btn"
assert_includes body, "flash-close-btn"
- # Flash replaces +New nav in the header
+ # Flash toast appears after the main content
assert_includes body, "site-header"
- refute_match(/<nav\b/, body)
+ assert body.index("flash-bar") > body.index("site-header")
- # Inline script wires up click handlers and restores nav on dismiss
+ # Inline script wires up click handlers and removes flash on dismiss
assert_includes body, "addEventListener"
assert_includes body, "fetch(path"
- assert_includes body, "replaceWith(nav)"
+ assert_includes body, "bar.remove()"
end
def test_complete_flash_without_undo_path