Replace slide-over panel with full series detail page
Series detail is now a standalone page at /series/:id using the
same dashboard/main-column/section layout. Removes the panel
component, backdrop, and all panel-related Alpine JS. Task card
links are plain <a> tags again. Agenda and calendar links also
simplified to regular navigation.

Assisted-by: Claude Opus 4.6 via Claude Code
change wptltomlwlkrzyrnpnqrpmttvmmxzmxp
commit 6d6fbac261d94a22ca846a672ccfaf913958006d
author Alpha Chen <alpha@kejadlen.dev>
date
parent yupqzyrx
diff --git a/lib/ketchup/views/agenda.rb b/lib/ketchup/views/agenda.rb
index 26ee417..41be289 100644
--- a/lib/ketchup/views/agenda.rb
+++ b/lib/ketchup/views/agenda.rb
@@ -70,8 +70,7 @@ module Views
       name = task[:note].lines.first&.strip || task[:note]
       a(
         href: "/series/#{task[:series_id]}",
-        class: ["agenda-pill", ("agenda-pill--overdue" if overdue)],
-        "x-on:click.prevent": "$dispatch('open-panel', { seriesId: #{task[:series_id]} })"
+        class: ["agenda-pill", ("agenda-pill--overdue" if overdue)]
       ) { name }
     end
   end
diff --git a/lib/ketchup/views/calendar.rb b/lib/ketchup/views/calendar.rb
index a76e88c..5ca8e3d 100644
--- a/lib/ketchup/views/calendar.rb
+++ b/lib/ketchup/views/calendar.rb
@@ -55,8 +55,7 @@ module Views
                   task_name = task[:note].lines.first&.strip || task[:note]
                   a(
                     href: "/series/#{task[:series_id]}",
-                    class: ["calendar-pill", ("calendar-pill--overdue" if is_overdue)],
-                    "x-on:click.prevent": "$dispatch('open-panel', { seriesId: #{task[:series_id]} })"
+                    class: ["calendar-pill", ("calendar-pill--overdue" if is_overdue)]
                   ) { task_name }
                 end
               end
diff --git a/lib/ketchup/views/dashboard.rb b/lib/ketchup/views/dashboard.rb
index 7f41e02..e307772 100644
--- a/lib/ketchup/views/dashboard.rb
+++ b/lib/ketchup/views/dashboard.rb
@@ -9,19 +9,14 @@ module Views
   INTERVAL_OPTIONS = Series::INTERVAL_UNITS.map { |u| [u, "#{u}(s)"] }.freeze
 
   class Dashboard < Phlex::HTML
-    def initialize(current_user:, csrf:, series: nil, open_user: false)
+    def initialize(current_user:, csrf:)
       @current_user = current_user
       @csrf = csrf
-      @series = series
-      @open_user = open_user
     end
 
     def view_template
       render Layout.new(current_user: @current_user) do
-        div(
-          class: "dashboard",
-          **dashboard_data_attrs
-        ) do
+        div(class: "dashboard") do
           render_main_column
         end
       end
@@ -29,16 +24,6 @@ module Views
 
     private
 
-    def dashboard_data_attrs
-      if @series
-        { "data-open-series": @series.id.to_s }
-      elsif @open_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
@@ -47,7 +32,6 @@ module Views
         render TaskList.new(
           overdue: overdue,
           upcoming: upcoming,
-          selected_series: @series,
           csrf: @csrf
         )
       end
diff --git a/lib/ketchup/views/layout.rb b/lib/ketchup/views/layout.rb
index a00f570..3c85f78 100644
--- a/lib/ketchup/views/layout.rb
+++ b/lib/ketchup/views/layout.rb
@@ -54,15 +54,6 @@ module Views
             a(href: "/users/#{@current_user[:id]}", class: "header-user") { @current_user[:login] }
           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/series/show.rb b/lib/ketchup/views/series/show.rb
new file mode 100644
index 0000000..23e72b8
--- /dev/null
+++ b/lib/ketchup/views/series/show.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+require "phlex"
+
+require_relative "../layout"
+
+module Views
+  module Series
+    class Show < Phlex::HTML
+      def initialize(series:, current_user:, csrf:)
+        @series = series
+        @current_user = current_user
+        @csrf = csrf
+      end
+
+      def view_template
+        active_task = @series.active_task
+
+        render Layout.new(current_user: @current_user, title: "#{note_title} — Ketchup", active_view: nil) do
+          div(class: "dashboard") do
+            div(class: "main-column") do
+              section(class: "section") do
+                div(class: "section-header") do
+                  h2(class: "section-title") do
+                    span(class: "section-title-text") { note_title }
+                  end
+                end
+
+                div(class: "series-note", id: "series-note-detail",
+                    "data-value": @series.note || "",
+                    "data-series-id": @series.id.to_s)
+
+                dl(class: "detail-fields") do
+                  dt { "Repeat every" }
+                  dd { interval_text(@series.interval_count, @series.interval_unit) }
+
+                  if active_task
+                    dt { "Due date" }
+                    dd { active_task[:due_date].to_s }
+
+                    if active_task.urgency > 0
+                      dt { "Urgency" }
+                      dd { "#{format("%.1f", active_task.urgency)}×" }
+                    end
+                  end
+                end
+
+                unless @series.completed_tasks.empty?
+                  stats = @series.completion_stats
+                  dl(class: "detail-fields") do
+                    dt { "Streak" }
+                    dd { stats[:streak].to_s }
+                    dt { "On-time" }
+                    dd { "#{stats[:on_time_pct]}%" }
+                  end
+
+                  div(class: "task-history") do
+                    div(class: "section-header") do
+                      h2(class: "section-title") do
+                        span(class: "section-title-text") { "History" }
+                      end
+                    end
+                    ul do
+                      @series.completed_tasks.each do |ct|
+                        li(
+                          class: "task-history-item",
+                          "x-data": "historyNote(#{@series.id}, #{ct[:id]}, #{ct[:note] ? "true" : "false"})"
+                        ) do
+                          div(class: "task-history-row") do
+                            span(class: "task-history-check") { "✓" }
+                            span(class: "task-history-date") { ct[:completed_at].strftime("%Y-%m-%d") }
+                            span(
+                              class: "task-history-add-note",
+                              "x-show": "!hasNote && !editing",
+                              "x-on:click": "edit()"
+                            ) { "add a note..." }
+                          end
+                          div(
+                            class: "task-history-note-editor",
+                            "data-value": ct[:note] || "",
+                            "x-show": "hasNote || editing",
+                            "x-ref": "editor"
+                          )
+                        end
+                      end
+                    end
+                  end
+                end
+              end
+            end
+          end
+        end
+      end
+
+      private
+
+      def note_title
+        @series.note&.lines&.first&.strip || "Series"
+      end
+
+      def interval_text(count, unit)
+        "#{count} #{count == 1 ? unit : "#{unit}s"}"
+      end
+    end
+  end
+end
diff --git a/lib/ketchup/views/task_card.rb b/lib/ketchup/views/task_card.rb
index 9acfbe0..43831a6 100644
--- a/lib/ketchup/views/task_card.rb
+++ b/lib/ketchup/views/task_card.rb
@@ -4,10 +4,9 @@ require "phlex"
 
 module Views
   class TaskCard < Phlex::HTML
-    def initialize(task:, csrf:, selected: false, overdue: false, date_label: nil)
+    def initialize(task:, csrf:, overdue: false, date_label: nil)
       @task = task
       @csrf = csrf
-      @selected = selected
       @overdue = overdue
       @date_label = date_label
     end
@@ -28,19 +27,7 @@ module Views
         div(class: "task-body") do
           a(
             href: "/series/#{@task[:series_id]}",
-            class: "task-name stretched-link",
-            "x-on:click.prevent": "
-              const panel = Alpine.$data(document.getElementById('panel'));
-              if (panel.open && panel.currentSeriesId === '#{@task[:series_id]}') {
-                panel.close();
-                history.pushState(null, '', '/');
-              } else {
-                panel.show('#{@task[:series_id]}');
-                history.pushState(null, '', '/series/#{@task[:series_id]}');
-              }
-              document.querySelectorAll('.task-card--selected').forEach(el => el.classList.remove('task-card--selected'));
-              if (panel.open) $el.closest('.task-card').classList.add('task-card--selected');
-            "
+            class: "task-name stretched-link"
           ) { name }
           if @overdue
             span(class: "task-meta") do
@@ -61,7 +48,7 @@ module Views
     def task_classes
       classes = ["task-card"]
       classes << "task-card--overdue" if @overdue
-      classes << "task-card--selected" if @selected
+
       classes
     end
 
diff --git a/lib/ketchup/views/task_list.rb b/lib/ketchup/views/task_list.rb
index 8e1b741..25e0669 100644
--- a/lib/ketchup/views/task_list.rb
+++ b/lib/ketchup/views/task_list.rb
@@ -6,15 +6,14 @@ require_relative "task_card"
 
 module Views
   class TaskList < Phlex::HTML
-    def initialize(overdue:, upcoming:, csrf:, selected_series: nil)
+    def initialize(overdue:, upcoming:, csrf:)
       @overdue = overdue
       @upcoming = upcoming
       @csrf = csrf
-      @selected_series = selected_series
     end
 
     def view_template
-      div(class: "task-list-container", "x-data": "") do
+      div(class: "task-list-container") do
         render_overdue
         render_upcoming
         render_completed_today
@@ -42,7 +41,7 @@ module Views
           ul(class: "task-list") do
             @overdue.each do |task|
               li(class: "task-item") do
-                render TaskCard.new(task: task, csrf: @csrf, selected: selected?(task), overdue: true)
+                render TaskCard.new(task: task, csrf: @csrf, overdue: true)
               end
             end
           end
@@ -66,7 +65,7 @@ module Views
               li(class: "task-item") do
                 render TaskCard.new(
                   task: task, csrf: @csrf,
-                  selected: selected?(task), overdue: false,
+                  overdue: false,
                   date_label: friendly_date(task[:due_date])
                 )
               end
@@ -94,8 +93,6 @@ module Views
       end
     end
 
-    def selected?(task)
-      @selected_series && @selected_series.id == task[:series_id]
-    end
+
   end
 end
diff --git a/lib/ketchup/web.rb b/lib/ketchup/web.rb
index e66fad9..4c43e7c 100644
--- a/lib/ketchup/web.rb
+++ b/lib/ketchup/web.rb
@@ -9,6 +9,7 @@ require_relative "views/focus"
 require_relative "views/calendar"
 require_relative "views/agenda"
 require_relative "views/series/new"
+require_relative "views/series/show"
 require_relative "views/series_panel"
 require_relative "views/user_panel"
 
@@ -82,7 +83,7 @@ class Web < Roda
       end
 
       r.get do
-        Views::Dashboard.new(current_user: @user, csrf: method(:csrf_token), open_user: true).call
+        Views::Dashboard.new(current_user: @user, csrf: method(:csrf_token)).call
       end
 
       r.post "email" do
@@ -135,7 +136,7 @@ class Web < Roda
 
         r.is do
           r.get do
-            Views::Dashboard.new(current_user: @user, series: @series, csrf: method(:csrf_token)).call
+            Views::Series::Show.new(series: @series, current_user: @user, csrf: method(:csrf_token)).call
           end
 
           r.patch do
diff --git a/public/css/app.css b/public/css/app.css
index eb95c95..8999f7f 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -130,8 +130,6 @@ h2 { font-size: var(--step-0); font-weight: 600; }
   max-width: 32rem;
   margin-inline: auto;
   padding: var(--space-m) var(--space-l);
-  position: relative;
-  z-index: 100;
 }
 
 /* -------------------- */
@@ -587,7 +585,7 @@ h2 { font-size: var(--step-0); font-weight: 600; }
 }
 
 .task-history-note-editor {
-  margin-top: 2px;
+  margin-top: var(--space-3xs);
   margin-left: 16px;
   border: 1px solid transparent;
   border-radius: 4px;
diff --git a/public/js/app.js b/public/js/app.js
index f82a75a..f42c173 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -56,7 +56,32 @@ function saveSeriesField(seriesId, field, value) {
   })
 }
 
-OverType.setTheme({ name: "ketchup", colors: { text: "#1a1a1a" } })
+OverType.setTheme({
+  name: "ketchup",
+  colors: {
+    bgPrimary: "transparent",
+    bgSecondary: "transparent",
+    text: "#1a1a1a",
+    textPrimary: "#1a1a1a",
+    textSecondary: "#888",
+    h1: "#c00",
+    h2: "#c00",
+    h3: "#c00",
+    strong: "#1a1a1a",
+    em: "#666",
+    link: "#c00",
+    code: "#888",
+    codeBg: "rgba(0, 0, 0, 0.05)",
+    blockquote: "#666",
+    hr: "#ddd",
+    syntaxMarker: "rgba(0, 0, 0, 0.3)",
+    syntax: "#999",
+    cursor: "#c00",
+    selection: "rgba(204, 0, 0, 0.15)",
+    listMarker: "#c00",
+    border: "#ddd",
+  },
+})
 
 function initPanelEditors(container) {
   const noteDetail = container.querySelector("#series-note-detail")
@@ -107,68 +132,6 @@ function initPanelEditors(container) {
 }
 
 document.addEventListener("alpine:init", () => {
-  // Panel open/close
-  Alpine.data("panel", () => ({
-    open: false,
-    loading: false,
-
-    init() {
-      // Auto-open panel if server set data-open-series on the dashboard
-      const dashboard = document.querySelector("[data-open-series]")
-      if (dashboard) {
-        const seriesId = dashboard.dataset.openSeries
-        if (seriesId) this.show(seriesId)
-      }
-
-      // Auto-open user panel
-      const userDash = document.querySelector("[data-open-user]")
-      if (userDash) {
-        const userId = userDash.dataset.openUser
-        if (userId) this.showUser(userId)
-      }
-
-      // Listen for custom event from other views (agenda, calendar)
-      window.addEventListener("open-panel", (e) => {
-        this.show(e.detail.seriesId)
-      })
-    },
-
-    async show(seriesId) {
-      this.currentSeriesId = String(seriesId)
-      this.loading = true
-      this.open = true
-      try {
-        const resp = await fetch(`/series/${seriesId}/panel`)
-        if (!resp.ok) return
-        this.$refs.content.innerHTML = await resp.text()
-        initPanelEditors(this.$refs.content)
-      } finally {
-        this.loading = false
-      }
-    },
-
-    async showUser(userId) {
-      this.currentSeriesId = null
-      this.loading = true
-      this.open = true
-      try {
-        const resp = await fetch(`/users/${userId}/panel`)
-        if (!resp.ok) return
-        this.$refs.content.innerHTML = await resp.text()
-      } finally {
-        this.loading = false
-      }
-    },
-
-    close() {
-      this.currentSeriesId = null
-      this.open = false
-      setTimeout(() => {
-        if (this.$refs.content) this.$refs.content.innerHTML = ""
-      }, 250)
-    },
-  }))
-
   Alpine.data("intervalEditor", (seriesId, initialCount, initialUnit) => ({
     count: initialCount,
     unit: initialUnit,
@@ -215,7 +178,7 @@ document.addEventListener("alpine:init", () => {
         placeholder: "Add a note...",
         autoResize: true,
         minHeight: 14,
-        fontSize: "11px",
+        fontSize: "14px",
         padding: "0 4px",
       })
 
@@ -245,6 +208,9 @@ document.addEventListener("alpine:init", () => {
     },
   }))
 
+  // Series detail page editor
+  initPanelEditors(document)
+
   // New series form editor
   const newNoteEl = document.getElementById("series-note-editor")
   if (newNoteEl) {
diff --git a/test/test_web.rb b/test/test_web.rb
index 0429233..0831db2 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -261,25 +261,15 @@ class TestWeb < Minitest::Test
     assert_includes last_response.body, 'name="_csrf"'
   end
 
-  def test_get_series_page_has_data_attribute
+  def test_get_series_page_shows_detail
     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 last_response.ok?
-    assert_includes last_response.body, "data-open-series"
-  end
-
-  def test_get_series_shows_sidebar
-    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 last_response.ok?
-    assert_includes last_response.body, "task-card--selected"
-    assert_includes last_response.body, "data-open-series=\"#{series[:id]}\""
+    assert_includes last_response.body, "Call Mom"
+    assert_includes last_response.body, "2 weeks"
   end
 
   def test_get_series_panel_shows_completed_history
@@ -450,13 +440,13 @@ class TestWeb < Minitest::Test
     assert_equal Date.new(2026, 3, 1), task[:due_date]
   end
 
-  def test_get_user_page_has_data_attribute
+  def test_get_user_page_shows_dashboard
     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}\""
+    assert_includes last_response.body, "alice@example.com"
   end
 
   def test_get_user_panel_shows_email_form
@@ -529,10 +519,9 @@ class TestWeb < Minitest::Test
     assert_equal 404, last_response.status
   end
 
-  def test_layout_includes_panel_shell
+  def test_layout_includes_footer
     get "/", {}, auth_headers
-    assert_includes last_response.body, 'id="panel"'
-    assert_includes last_response.body, 'x-data="panel"'
+    assert_includes last_response.body, "site-footer"
   end
 
   def test_header_shows_view_nav