Move flash into header nav slot to avoid layout reflow
The flash bar now replaces the +New nav link in the site header
instead of being a separate element. This eliminates layout shift
when the flash appears. Dismissing (or the 8s auto-dismiss) swaps
the +New nav back in via replaceWith.

- Move render_flash and flash_script from Dashboard to Layout
- Layout accepts optional flash: param
- Remove .flash-wrap; .flash-bar uses flex:1 to fill the nav slot
- Update tests to verify flash replaces <nav> in header

https://claude.ai/code/session_01NLGBcJgbiyWGDBGFh8DRas
change
commit d6fb0d9feb881d36f4334298f39cfaac1c7661b6
author Claude <noreply@anthropic.com>
date
parent 4e7f6c29
diff --git a/lib/ketchup/views/dashboard.rb b/lib/ketchup/views/dashboard.rb
index 3e5a106..e784314 100644
--- a/lib/ketchup/views/dashboard.rb
+++ b/lib/ketchup/views/dashboard.rb
@@ -21,8 +21,7 @@ module Views
       overdue = @current_user.overdue_tasks.all.sort_by { |t| -t.urgency }
       upcoming = @current_user.upcoming_tasks.all
 
-      render Layout.new(current_user: @current_user) do
-        render_flash
+      render Layout.new(current_user: @current_user, flash: @flash) do
         div(class: "dashboard") do
           div(class: "column-overdue") do
             render_focus(overdue)
@@ -37,38 +36,6 @@ module Views
 
     private
 
-    def render_flash
-      return unless @flash
-
-      undo_path = @flash["undo_path"]
-
-      div(class: "flash-wrap", data: { undo_path: undo_path }.compact) do
-        div(class: "flash-bar") do
-          span(class: "flash-message") { @flash["message"] }
-          button(class: "flash-undo-btn", hidden: !undo_path) { "Undo" }
-          button(class: "flash-close-btn", **{ "aria-label": "Dismiss" }) { "\u00d7" }
-        end
-        script { raw safe(flash_script) }
-      end
-    end
-
-    def flash_script
-      <<~JS
-        (function() {
-          var wrap = document.currentScript.parentElement;
-          var undo = wrap.querySelector('.flash-undo-btn');
-          var close = wrap.querySelector('.flash-close-btn');
-          var path = wrap.dataset.undoPath;
-          if (undo && path) {
-            undo.addEventListener('click', function() {
-              fetch(path, {method: 'DELETE'}).then(function() { location.reload(); });
-            });
-          }
-          close.addEventListener('click', function() { wrap.remove(); });
-        })();
-      JS
-    end
-
     def render_focus(overdue)
       if overdue.empty?
         section(class: "section section--focus") do
diff --git a/lib/ketchup/views/layout.rb b/lib/ketchup/views/layout.rb
index 134c379..ac9edae 100644
--- a/lib/ketchup/views/layout.rb
+++ b/lib/ketchup/views/layout.rb
@@ -17,10 +17,11 @@ module Views
       }.freeze
     end
 
-    def initialize(current_user:, title: "Ketchup", active_view: nil)
+    def initialize(current_user:, title: "Ketchup", active_view: nil, flash: nil)
       @current_user = current_user
       @title = title
       @active_view = active_view
+      @flash = flash
     end
 
     def view_template(&)
@@ -52,11 +53,15 @@ module Views
         body do
           header(class: "site-header") do
             a(href: "/", class: "site-name") { "Ketchup" }
-            nav(class: "site-nav") do
-              a(
-                href: "/series/new",
-                class: ["header-action", ("header-action--active" if @active_view == :new)]
-              ) { "+New" }
+            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
             end
             a(href: "/users/#{@current_user[:id]}", class: "header-user") { @current_user[:login] }
           end
@@ -68,6 +73,39 @@ module Views
 
     private
 
+    def render_flash
+      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" }
+        button(class: "flash-close-btn", **{ "aria-label": "Dismiss" }) { "\u00d7" }
+        script { raw safe(flash_script) }
+      end
+    end
+
+    def flash_script
+      <<~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); }
+          if (undo && path) {
+            undo.addEventListener('click', function() {
+              fetch(path, {method: 'DELETE'}).then(function() { location.reload(); });
+            });
+          }
+          close.addEventListener('click', dismiss);
+          setTimeout(dismiss, 8000);
+        })();
+      JS
+    end
+
     def asset_path(path)
       version = ASSET_VERSIONS[path]
       version ? "#{path}?v=#{version}" : path
diff --git a/public/css/app.css b/public/css/app.css
index bd5e33d..1a37957 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -106,24 +106,13 @@ h2 { font-size: var(--step-0); font-weight: 600; }
 /* Flash bar */
 /* -------------------- */
 
-.flash-wrap {
-  position: fixed;
-  bottom: var(--space-m);
-  left: 50%;
-  transform: translateX(-50%);
-  z-index: 100;
-}
-
 .flash-bar {
-  display: inline-flex;
-  align-items: center;
+  flex: 1;
+  display: flex;
+  align-items: baseline;
+  justify-content: center;
   gap: var(--space-xs);
-  padding: var(--space-2xs) var(--space-s);
-  font-size: var(--step--1);
   color: #666;
-  background: #f5f5f5;
-  border: 1px solid #e0e0e0;
-  border-radius: 4px;
 }
 
 .flash-undo-btn {
diff --git a/test/test_web.rb b/test/test_web.rb
index 5cf11f7..f5cc8f8 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -602,9 +602,14 @@ class TestWeb < Minitest::Test
     assert_includes body, "flash-undo-btn"
     assert_includes body, "flash-close-btn"
 
-    # Inline script wires up click handlers (no Alpine dependency)
+    # Flash replaces +New nav in the header
+    assert_includes body, "site-header"
+    refute_match(/<nav\b/, body)
+
+    # Inline script wires up click handlers and restores nav on dismiss
     assert_includes body, "addEventListener"
     assert_includes body, "fetch(path"
+    assert_includes body, "replaceWith(nav)"
   end
 
   def test_complete_flash_without_undo_path