Move panel shell into Layout, serve panel content via fragment fetch
Panel markup was conditional in Dashboard. Moving the shell into Layout
makes it available on every page, letting Alpine fetch content on demand
rather than server-rendering it inline.

Assisted-by: Claude Opus 4.6 via Claude Code
change yymxqorowmoospkvykxlvlsryrknspms
commit 320a91fb4b5fedae9b5db2902573488985d158c4
author Alpha Chen <alpha@kejadlen.dev>
date
parent sklmvkuy
diff --git a/lib/ketchup/views/dashboard.rb b/lib/ketchup/views/dashboard.rb
index aa8405f..520eaa9 100644
--- a/lib/ketchup/views/dashboard.rb
+++ b/lib/ketchup/views/dashboard.rb
@@ -4,8 +4,6 @@ require "phlex"
 
 require_relative "layout"
 require_relative "task_list"
-require_relative "series_detail"
-require_relative "user_form"
 
 module Views
   INTERVAL_OPTIONS = Series::INTERVAL_UNITS.map { |u| [u, "#{u}(s)"] }.freeze
@@ -19,17 +17,28 @@ module Views
     end
 
     def view_template
-      has_panel = @series || @panel == :user
-      render Layout.new(current_user: @current_user, panel_open: has_panel) do
-        div(class: "dashboard") do
+      render Layout.new(current_user: @current_user) do
+        div(
+          class: "dashboard",
+          **dashboard_data_attrs
+        ) do
           render_main_column
-          render_panel if has_panel
         end
       end
     end
 
     private
 
+    def dashboard_data_attrs
+      if @series
+        { "data-open-series": @series.id.to_s }
+      elsif @panel == :user
+        { "data-open-user": @current_user[:id].to_s }
+      else
+        {}
+      end
+    end
+
     def render_main_column
       overdue = @current_user.overdue_tasks.all.sort_by { |t| -t.urgency }
       upcoming = @current_user.upcoming_tasks.all
@@ -43,24 +52,5 @@ module Views
         )
       end
     end
-
-    def render_panel
-      div(
-        class: "panel",
-        id: "panel",
-        "x-data": "panel",
-        "x-bind:class": "open && 'panel--open'"
-      ) do
-        div(class: "panel-backdrop", "x-on:click": "close()")
-
-        div(class: "panel-content") do
-          if @series
-            render SeriesDetail.new(series: @series, csrf: @csrf)
-          elsif @panel == :user
-            render UserForm.new(current_user: @current_user, csrf: @csrf)
-          end
-        end
-      end
-    end
   end
 end
diff --git a/lib/ketchup/views/layout.rb b/lib/ketchup/views/layout.rb
index 76be6fd..129f0cd 100644
--- a/lib/ketchup/views/layout.rb
+++ b/lib/ketchup/views/layout.rb
@@ -4,10 +4,9 @@ require "phlex"
 
 module Views
   class Layout < Phlex::HTML
-    def initialize(current_user:, title: "Ketchup", panel_open: false)
+    def initialize(current_user:, title: "Ketchup")
       @current_user = current_user
       @title = title
-      @panel_open = panel_open
     end
 
     def view_template(&)
@@ -32,7 +31,7 @@ module Views
                  integrity: "sha384-LXWjKwDZz29o7TduNe+r/UxaolHh5FsSvy2W7bDHSZ8jJeGgDeuNnsDNHoxpSgDi",
                  crossorigin: "anonymous", defer: true)
         end
-        body(class: @panel_open ? "has-panel" : nil) do
+        body do
           header(class: "site-header") do
             a(href: "/", class: "site-name") { "Ketchup" }
             nav(class: "site-nav") do
@@ -41,6 +40,15 @@ module Views
             end
           end
           yield
+          div(
+            id: "panel",
+            class: "panel",
+            "x-data": "panel",
+            "x-bind:class": "open && 'panel--open'"
+          ) do
+            div(class: "panel-backdrop", "x-show": "open", "x-on:click": "close()")
+            div(class: "panel-content", "x-ref": "content")
+          end
           render_footer
         end
       end
diff --git a/lib/ketchup/views/user_panel.rb b/lib/ketchup/views/user_panel.rb
new file mode 100644
index 0000000..405c0e2
--- /dev/null
+++ b/lib/ketchup/views/user_panel.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+require "phlex"
+
+require_relative "user_form"
+
+module Views
+  class UserPanel < Phlex::HTML
+    def initialize(current_user:, csrf:)
+      @current_user = current_user
+      @csrf = csrf
+    end
+
+    def view_template
+      render UserForm.new(current_user: @current_user, csrf: @csrf)
+    end
+  end
+end
diff --git a/lib/ketchup/web.rb b/lib/ketchup/web.rb
index 71ad83f..40ca6ba 100644
--- a/lib/ketchup/web.rb
+++ b/lib/ketchup/web.rb
@@ -7,6 +7,7 @@ require_relative "models"
 require_relative "views/dashboard"
 require_relative "views/series/new"
 require_relative "views/series_panel"
+require_relative "views/user_panel"
 
 class Web < Roda
   plugin :halt
@@ -45,6 +46,10 @@ class Web < Roda
     r.on "users", Integer do |user_id|
       r.halt 404 unless @user.id == user_id
 
+      r.get "panel" do
+        Views::UserPanel.new(current_user: @user, csrf: method(:csrf_token)).call
+      end
+
       r.get do
         Views::Dashboard.new(current_user: @user, csrf: method(:csrf_token), panel: :user).call
       end
diff --git a/test/test_web.rb b/test/test_web.rb
index 50c5e4a..5e98971 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -261,14 +261,14 @@ class TestWeb < Minitest::Test
     assert_includes last_response.body, 'name="_csrf"'
   end
 
-  def test_series_sidebar_has_new_link
+  def test_get_series_page_has_data_attribute
     create_series(note: "Call Mom", interval_unit: "week", interval_count: "2",
                   first_due_date: "2026-03-01")
 
     series = DB[:series].first
     get "/series/#{series[:id]}", {}, auth_headers
-    assert_includes last_response.body, "New"
-    assert_includes last_response.body, 'href="/"'
+    assert last_response.ok?
+    assert_includes last_response.body, "data-open-series"
   end
 
   def test_get_series_shows_sidebar
@@ -278,12 +278,11 @@ class TestWeb < Minitest::Test
     series = DB[:series].first
     get "/series/#{series[:id]}", {}, auth_headers
     assert last_response.ok?
-    assert_includes last_response.body, "2 weeks"
-    assert_includes last_response.body, "2026-03-01"
     assert_includes last_response.body, "task-card--selected"
+    assert_includes last_response.body, "data-open-series=\"#{series[:id]}\""
   end
 
-  def test_get_series_shows_completed_history
+  def test_get_series_panel_shows_completed_history
     create_series(note: "Call Mom", interval_unit: "week", interval_count: "1",
                   first_due_date: "2026-03-01")
 
@@ -293,7 +292,8 @@ class TestWeb < Minitest::Test
 
     completed_task = DB[:tasks].first(id: task[:id])
     patch "/series/#{series[:id]}/tasks/#{completed_task[:id]}/note", { note: "Left a message" }, auth_headers
-    get "/series/#{series[:id]}", {}, auth_headers
+
+    get "/series/#{series[:id]}/panel", {}, auth_headers
     assert last_response.ok?
     assert_includes last_response.body, "Left a message"
     assert_includes last_response.body, "task-history"
@@ -450,21 +450,31 @@ class TestWeb < Minitest::Test
     assert_equal Date.new(2026, 3, 1), task[:due_date]
   end
 
-  def test_get_user_shows_email_form
+  def test_get_user_page_has_data_attribute
     get "/", {}, auth_headers  # create user
     user_id = DB[:users].first(login: "alice@example.com")[:id]
 
     get "/users/#{user_id}", {}, auth_headers
     assert last_response.ok?
+    assert_includes last_response.body, "data-open-user=\"#{user_id}\""
+  end
+
+  def test_get_user_panel_shows_email_form
+    get "/", {}, auth_headers  # create user
+    user_id = DB[:users].first(login: "alice@example.com")[:id]
+
+    get "/users/#{user_id}/panel", {}, auth_headers
+    assert last_response.ok?
     assert_includes last_response.body, "alice@example.com"
     assert_includes last_response.body, 'name="email"'
+    refute_includes last_response.body, "<!DOCTYPE"
   end
 
   def test_post_user_email
     get "/", {}, auth_headers  # create user
     user_id = DB[:users].first(login: "alice@example.com")[:id]
 
-    get "/users/#{user_id}", {}, auth_headers
+    get "/users/#{user_id}/panel", {}, auth_headers
     token = last_response.body[/name="_csrf" value="([^"]+)"/, 1]
     post "/users/#{user_id}/email", { "_csrf" => token, email: "alice@example.org" }, auth_headers
     assert last_response.redirect?
@@ -477,7 +487,7 @@ class TestWeb < Minitest::Test
     get "/", {}, auth_headers
     user_id = DB[:users].first(login: "alice@example.com")[:id]
 
-    get "/users/#{user_id}", {}, auth_headers
+    get "/users/#{user_id}/panel", {}, auth_headers
     token = last_response.body[/name="_csrf" value="([^"]+)"/, 1]
     post "/users/#{user_id}/email", { "_csrf" => token, email: "" }, auth_headers
 
@@ -519,6 +529,12 @@ class TestWeb < Minitest::Test
     assert_equal 404, last_response.status
   end
 
+  def test_layout_includes_panel_shell
+    get "/", {}, auth_headers
+    assert_includes last_response.body, 'id="panel"'
+    assert_includes last_response.body, 'x-data="panel"'
+  end
+
   def test_csrf_rejects_post_without_token
     get "/", {}, auth_headers  # establish session
     post "/series", {