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
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