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
change lvnxsqsmxzlvyoxwmspywrpwumlvxylo
commit cfa9e737f20c37a2b12405a504bcc2ac220ccb0b
author Alpha Chen <alpha@kejadlen.dev>
date
parent d6fb0d9f
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