Redesign UX: single-column layout with slide-over panel
Three-column grid replaced with a single task list (overdue cards
on top, upcoming grouped by date below) and a slide-over panel for
series detail and user settings. Overdue cards now show interval
and relative time inline so the triage decision is visible without
clicking through. Complete buttons enlarged from 14px squares to
28-32px circles for easier targeting.

New series creation moved to standalone /series/new page with
OverType editor — the always-visible sidebar form is gone.

Assisted-by: Claude Opus 4.6 via pi
change klvoulsyrrwrrzxkyqzvwkrnmsoqssso
commit c6ea692eecf577df97f6017a41816b9f7ce57249
author Alpha Chen <alpha@kejadlen.dev>
date
parent tlrsyrqr
diff --git a/lib/ketchup/snapshots.rb b/lib/ketchup/snapshots.rb
index 6135905..1b987f1 100644
--- a/lib/ketchup/snapshots.rb
+++ b/lib/ketchup/snapshots.rb
@@ -123,142 +123,90 @@ module Ketchup
 
       private
 
-      # Snapshots, grouped by flow. Indentation means "then, without
-      # navigating away." Each leaf is one screenshot.
+      # Snapshots grouped by flow:
       #
       # Dashboard
-      #   full page
-      #   complete a task, return to dashboard, full page
-      #   overdue column only
-      #   switch overdue sort to date, overdue column only
-      #   upcoming column only
-      #   toggle calendar view, upcoming column only
-      #   scroll to bottom of calendar, upcoming column only
+      #   full page (overdue + upcoming, single column)
+      #   complete a task, return to dashboard
+      #   overdue sorted by urgency (main column)
+      #   overdue sorted by date (main column)
       #
-      # New series
-      #   empty sidebar form
-      #   fill in note (markdown) and interval, sidebar
-      #   submit, sidebar after redirect to series detail
-      #   click Edit, sidebar with note in editing mode
+      # New series (panel)
+      #   panel with empty form
+      #   panel with filled-in note + interval
+      #   panel after submission (series detail)
+      #   panel in editing mode
       #
-      # Existing series (one with noted + un-noted completed tasks)
-      #   series detail with task history
-      #   TODO: hover over un-noted task to reveal "add a note...", sidebar
-      #   click "add a note", type markdown, sidebar
-      #   focus an existing note's editor, sidebar
+      # Series detail (panel)
+      #   series with task history
+      #   add a task note
+      #   edit existing task note
+      #
+      # User settings (panel)
+      #   user detail
+      #   user editing
       def run_capture(width:, height:)
         entries = []
 
-        # 1. Whole dashboard
+        # 1. Dashboard — full page
         entries << snap("dashboard") do
           goto @base
-          wait_for(".home")
+          wait_for(".dashboard")
         end
 
-        # 2. Whole dashboard after completing a task
+        # 2. Dashboard after completing a task
         wait_for(".complete-btn").click
         wait_for("#series-note-detail")
         goto @base
-        wait_for(".home")
+        wait_for(".dashboard")
         entries << snap("dashboard-after-complete")
 
-        # 3. Overdue column, sorted by urgency
+        # 3. Overdue sorted by urgency
         # Explicitly select Urgency — Alpine.$persist may carry "date"
         # from a previous viewport run.
-        overdue_sel = '[x-data="sortable"]'
-        wait_for("#{overdue_sel} .sort-toggle button:last-child").click
-        entries << snap("overdue-urgency", selector: overdue_sel)
-
-        # 4. Overdue column, sorted by date
-        wait_for("#{overdue_sel} .sort-toggle button:first-child").click
-        entries << snap("overdue-date", selector: overdue_sel)
-
-        # Toggle back to urgency so the persist doesn't bleed into
-        # the next viewport run.
-        wait_for("#{overdue_sel} .sort-toggle button:last-child").click
-
-        # 5. Upcoming column (list view)
-        # Resize viewport to page height so headless Chrome renders
-        # content below the fold (it won't paint outside the viewport).
-        upcoming_sel = '[x-data="upcoming"]'
-        with_rendered_page(width, height) do
-          entries << snap("upcoming", selector: upcoming_sel)
+        sortable_sel = '[x-data="sortable"]'
+        urgency_btn = @browser.at_css("#{sortable_sel} .sort-toggle button:last-child") ||
+                      @browser.at_css("#{sortable_sel} .sort-toggle button:nth-child(1)")
+        urgency_btn.click if urgency_btn
+        entries << snap("overdue-urgency", selector: ".main-column")
+
+        # 4. Overdue sorted by date
+        date_btn = @browser.at_css("#{sortable_sel} .sort-toggle button:first-child") ||
+                   @browser.at_css("#{sortable_sel} .sort-toggle button:nth-child(2)")
+        date_btn.click if date_btn
+        entries << snap("overdue-date", selector: ".main-column")
+
+        # Reset to urgency sort
+        urgency_btn = @browser.at_css("#{sortable_sel} .sort-toggle button:last-child") ||
+                      @browser.at_css("#{sortable_sel} .sort-toggle button:nth-child(1)")
+        urgency_btn.click if urgency_btn
+
+        # 5. New series — navigate to standalone page
+        entries << snap("new-series") do
+          goto "#{@base}/series/new"
+          wait_for("form[action='/series']")
         end
 
-        # 6. Upcoming column, calendar view (top)
-        # Toggle calendar on, then temporarily hide items past one
-        # viewport-height so the selector capture stays compact.
-        wait_for("#{upcoming_sel} .sort-toggle button").click
-        wait_for(".calendar-day-empty")
-        hide_upcoming_items_past(upcoming_sel, height)
-        with_rendered_page(width, height) do
-          entries << snap("upcoming-calendar", selector: upcoming_sel)
-        end
-        restore_hidden_items
-
-        # 7. Upcoming column, calendar view (bottom)
-        # Hide items before the horizon marker, keeping a few rows of
-        # context above it so the screenshot shows the transition from
-        # regular calendar into the far-future section.
-        hide_upcoming_items_before_horizon(upcoming_sel, height)
-        with_rendered_page(width, height) do
-          entries << snap("upcoming-calendar-bottom", selector: upcoming_sel)
-        end
-        restore_hidden_items
-
-        # Toggle calendar off — Alpine.$persist keeps showEmpty in
-        # localStorage, which would bleed into the next viewport run.
-        wait_for("#{upcoming_sel} .sort-toggle button").click
-
-        # Steps 8-11: on desktop, these use the inline sidebar on the
-        # dashboard. On mobile, series creation uses the standalone
-        # /series/new page and the detail view stacks at the top.
-        if element_visible?(".column-aside")
-          # 8. Sidebar (new series)
-          entries << snap("new-series", selector: ".column-aside")
-
-          # 9. New series form filled in (with markdown), not created yet
-          fill_new_series(
-            note: "Call the vet\n\nAsk about *vaccination schedule*\n- Bring **shot records**\n- Check flea meds",
-            interval_count: 2,
-            interval_unit: "week"
-          )
-          entries << snap("new-series-editing", selector: ".column-aside")
-
-          # 10. Series detail, post-creation
-          wait_for("#create-series-btn").click
-          wait_for("#series-note-detail")
-          entries << snap("series-created", selector: ".column-aside")
-
-          # 11. Series detail, editing note, post-creation
-          wait_for("button.aside-heading-action").click
-          entries << snap("series-editing", selector: ".column-aside")
-        else
-          # 8. Standalone new-series page
-          entries << snap("new-series") do
-            goto "#{@base}/series/new"
-            wait_for("form[action='/series']")
-          end
+        # 6. New series form filled in
+        fill_new_series_standalone(
+          note: "Call the vet\n\nAsk about *vaccination schedule*\n- Bring **shot records**\n- Check flea meds",
+          interval_count: 2,
+          interval_unit: "week"
+        )
+        entries << snap("new-series-editing")
 
-          # 9. New series form filled in
-          fill_new_series_standalone(
-            note: "Call the vet\n\nAsk about *vaccination schedule*\n- Bring **shot records**\n- Check flea meds",
-            interval_count: 2,
-            interval_unit: "week"
-          )
-          entries << snap("new-series-editing")
-
-          # 10. Series detail, post-creation
-          wait_for("button[type='submit']").click
-          wait_for("#series-note-detail")
-          entries << snap("series-created")
-
-          # 11. Series detail, editing note, post-creation
-          wait_for("button.aside-heading-action").click
-          entries << snap("series-editing")
-        end
+        # 7. Series detail after creation — submit the form
+        wait_for("#create-series-btn:not([disabled])").click
+        wait_for("#series-note-detail")
+        # Wait for panel to be visible
+        wait_for(".panel-inner")
+        entries << snap("series-created")
+
+        # 8. Series detail in editing mode
+        wait_for("button.panel-action").click
+        entries << snap("series-editing")
 
-        # 12. Series detail of a series with multiple finished tasks
+        # 9. Series detail with task history
         noted_ids = Task.exclude(completed_at: nil).where(Sequel.like(:note, "%*%")).select(:series_id)
         unnoted_ids = Task.exclude(completed_at: nil).where(note: nil).select(:series_id)
         series_with_history = Series.first(
@@ -269,10 +217,7 @@ module Ketchup
           wait_for(".task-history")
         end
 
-        # TODO: hover snapshot — CSS :hover doesn't trigger in headless Chrome
-        #   via mouse.move; need another approach to reveal ".task-history-add-note"
-
-        # 13. Task: Add task note (with markdown)
+        # 10. Add a task note
         @browser.evaluate(<<~JS)
           document.querySelector('.task-history-item:has(.task-history-note-editor[data-value=""]) .task-history-add-note').click()
         JS
@@ -280,24 +225,24 @@ module Ketchup
         note_text = "Checked *both* lines\n- Front needs **new filter**\n- Back is fine"
         textarea.evaluate("this.value = #{note_text.to_json}")
         textarea.evaluate('this.dispatchEvent(new Event("input", { bubbles: true }))')
-        entries << snap("task-add-note", selector: ".column-aside")
+        entries << snap("task-add-note", selector: ".panel-content")
 
-        # 14. Task: Edit task note (with markdown)
+        # 11. Edit existing task note
         existing = @browser.at_css('.task-history-note-editor:not([data-value=""]) textarea')
         existing.focus
-        entries << snap("task-edit-note", selector: ".column-aside")
+        entries << snap("task-edit-note", selector: ".panel-content")
 
-        # 15. User detail sidebar
+        # 12. User detail
         user = User.first(login: "snapshot@example.com")
-        entries << snap("user-detail", selector: ".column-aside") do
+        entries << snap("user-detail") do
           goto "#{@base}/users/#{user.id}"
-          wait_for(".column-aside")
+          wait_for(".panel-inner")
         end
 
-        # 16. User detail editing
+        # 13. User editing
         @browser.at_css('[x-on\\:click="editing = true"]').click
         wait_for('input[name="email"]')
-        entries << snap("user-editing", selector: ".column-aside")
+        entries << snap("user-editing")
 
         entries
       end
@@ -380,23 +325,8 @@ module Ketchup
           &.then { |rect| rect["width"] > 0 && rect["height"] > 0 } || false
       end
 
-      def fill_new_series(note:, interval_count: 1, interval_unit: "day")
-        textarea = @browser.at_css("#series-note-editor textarea")
-        textarea.focus
-        textarea.evaluate("this.value = #{note.to_json}")
-        textarea.evaluate('this.dispatchEvent(new Event("input", { bubbles: true }))')
-
-        count_input = @browser.at_css("input[name='interval_count']")
-        count_input.focus
-        count_input.evaluate("this.value = ''")
-        count_input.type(interval_count.to_s)
-
-        unit_select = @browser.at_css("select[name='interval_unit']")
-        unit_select.select(interval_unit)
-      end
-
       def fill_new_series_standalone(note:, interval_count: 1, interval_unit: "day")
-        textarea = @browser.at_css("textarea#note")
+        textarea = wait_for("#series-note-editor textarea")
         textarea.focus
         textarea.evaluate("this.value = #{note.to_json}")
         textarea.evaluate('this.dispatchEvent(new Event("input", { bubbles: true }))')
@@ -410,47 +340,6 @@ module Ketchup
         unit_select.select(interval_unit)
       end
 
-      # Hide calendar list items whose top edge exceeds +cutoff_px+ from
-      # the column's top, so a selector capture stays compact.
-      def hide_upcoming_items_past(selector, cutoff_px)
-        @browser.evaluate(<<~JS)
-          (function() {
-            var col = document.querySelector(#{selector.to_json});
-            var cutoff = col.getBoundingClientRect().top + #{cutoff_px};
-            var items = col.querySelectorAll('.task-list > li');
-            var toHide = [];
-            items.forEach(function(li) {
-              if (li.getBoundingClientRect().top > cutoff) toHide.push(li);
-            });
-            toHide.forEach(function(li) {
-              li.dataset.snapshotHidden = '1';
-              li.style.display = 'none';
-            });
-          })()
-        JS
-      end
-
-      # Hide calendar list items above the "3 months" horizon marker,
-      # keeping roughly 30 % of a viewport's worth of context above it.
-      def hide_upcoming_items_before_horizon(selector, viewport_h)
-        @browser.evaluate(<<~JS)
-          (function() {
-            var horizon = document.querySelector('.calendar-horizon');
-            if (!horizon) return;
-            var cutoff = horizon.getBoundingClientRect().top - #{(viewport_h * 0.3).to_i};
-            var items = document.querySelector(#{selector.to_json}).querySelectorAll('.task-list > li');
-            var toHide = [];
-            items.forEach(function(li) {
-              if (li.getBoundingClientRect().bottom < cutoff) toHide.push(li);
-            });
-            toHide.forEach(function(li) {
-              li.dataset.snapshotHidden = '1';
-              li.style.display = 'none';
-            });
-          })()
-        JS
-      end
-
       # Temporarily resize the viewport to the full page height so
       # headless Chrome paints everything, then restore after the block.
       def with_rendered_page(width, height)
@@ -460,15 +349,6 @@ module Ketchup
       ensure
         @browser.resize(width: width, height: height) if page_h > height
       end
-
-      def restore_hidden_items
-        @browser.evaluate(<<~JS)
-          document.querySelectorAll('[data-snapshot-hidden]').forEach(function(el) {
-            el.style.display = '';
-            delete el.dataset.snapshotHidden;
-          });
-        JS
-      end
     end
   end
 end
diff --git a/lib/ketchup/views/dashboard.rb b/lib/ketchup/views/dashboard.rb
index e6afd5a..aa8405f 100644
--- a/lib/ketchup/views/dashboard.rb
+++ b/lib/ketchup/views/dashboard.rb
@@ -3,10 +3,8 @@
 require "phlex"
 
 require_relative "layout"
-require_relative "overdue_column"
-require_relative "upcoming_column"
+require_relative "task_list"
 require_relative "series_detail"
-require_relative "new_series_form"
 require_relative "user_form"
 
 module Views
@@ -21,25 +19,45 @@ module Views
     end
 
     def view_template
-      render Layout.new(current_user: @current_user) do
-        div(class: @series ? "home home--series" : "home") do
-          render OverdueColumn.new(
-            tasks: @current_user.overdue_tasks.all.sort_by { |t| -t.urgency },
-            selected_series: @series,
-            csrf: @csrf
-          )
-          render UpcomingColumn.new(
-            tasks: @current_user.upcoming_tasks.all,
-            selected_series: @series,
-            csrf: @csrf
-          )
+      has_panel = @series || @panel == :user
+      render Layout.new(current_user: @current_user, panel_open: has_panel) do
+        div(class: "dashboard") do
+          render_main_column
+          render_panel if has_panel
+        end
+      end
+    end
+
+    private
+
+    def render_main_column
+      overdue = @current_user.overdue_tasks.all.sort_by { |t| -t.urgency }
+      upcoming = @current_user.upcoming_tasks.all
+
+      div(class: "main-column") do
+        render TaskList.new(
+          overdue: overdue,
+          upcoming: upcoming,
+          selected_series: @series,
+          csrf: @csrf
+        )
+      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)
+            render SeriesDetail.new(series: @series, csrf: @csrf)
           elsif @panel == :user
             render UserForm.new(current_user: @current_user, csrf: @csrf)
-          else
-            render NewSeriesForm.new(csrf: @csrf)
           end
         end
       end
diff --git a/lib/ketchup/views/layout.rb b/lib/ketchup/views/layout.rb
index b534bad..76be6fd 100644
--- a/lib/ketchup/views/layout.rb
+++ b/lib/ketchup/views/layout.rb
@@ -4,9 +4,10 @@ require "phlex"
 
 module Views
   class Layout < Phlex::HTML
-    def initialize(current_user:, title: "Ketchup")
+    def initialize(current_user:, title: "Ketchup", panel_open: false)
       @current_user = current_user
       @title = title
+      @panel_open = panel_open
     end
 
     def view_template(&)
@@ -31,11 +32,13 @@ module Views
                  integrity: "sha384-LXWjKwDZz29o7TduNe+r/UxaolHh5FsSvy2W7bDHSZ8jJeGgDeuNnsDNHoxpSgDi",
                  crossorigin: "anonymous", defer: true)
         end
-        body do
+        body(class: @panel_open ? "has-panel" : nil) do
           header(class: "site-header") do
             a(href: "/", class: "site-name") { "Ketchup" }
-            a(href: "/series/new", class: "mobile-new-link") { "+ New" }
-            a(href: "/users/#{@current_user[:id]}", class: "user") { @current_user[:login] }
+            nav(class: "site-nav") do
+              a(href: "/series/new", class: "header-action") { "+ New" }
+              a(href: "/users/#{@current_user[:id]}", class: "header-user") { @current_user[:login] }
+            end
           end
           yield
           render_footer
diff --git a/lib/ketchup/views/new_series_form.rb b/lib/ketchup/views/new_series_form.rb
deleted file mode 100644
index 12574c7..0000000
--- a/lib/ketchup/views/new_series_form.rb
+++ /dev/null
@@ -1,48 +0,0 @@
-# frozen_string_literal: true
-
-require "phlex"
-
-module Views
-  class NewSeriesForm < Phlex::HTML
-    def initialize(csrf:)
-      @csrf = csrf
-    end
-
-    def view_template
-      div(class: "column column-aside column-aside--new") do
-        div(class: "column-header") do
-          h2 { "New Series" }
-          button(type: "submit", form: "new-series-form", id: "create-series-btn", class: "aside-heading-action", disabled: true) { "Create" }
-        end
-
-        form(method: "post", action: "/series", id: "new-series-form", class: "task-detail") do
-          input(type: "hidden", name: "_csrf", value: @csrf.call("/series"))
-          div(id: "series-note-editor", class: "task-detail-note")
-
-          dl(class: "task-detail-fields") do
-            dt { "Repeat every" }
-            dd(class: "detail-edit-interval") do
-              input(
-                type: "number", name: "interval_count",
-                class: "detail-input detail-input-count",
-                min: 1, value: 1, required: true
-              )
-              select(name: "interval_unit", class: "detail-input detail-input-unit", required: true) do
-                INTERVAL_OPTIONS.each_with_index { |(val, label), i| option(value: val, selected: i == 0) { label } }
-              end
-            end
-
-            dt { "First due date" }
-            dd do
-              input(
-                type: "date", name: "first_due_date",
-                class: "detail-input detail-input-date",
-                value: Date.today.to_s, required: true
-              )
-            end
-          end
-        end
-      end
-    end
-  end
-end
diff --git a/lib/ketchup/views/overdue_column.rb b/lib/ketchup/views/overdue_column.rb
deleted file mode 100644
index 6d457e5..0000000
--- a/lib/ketchup/views/overdue_column.rb
+++ /dev/null
@@ -1,57 +0,0 @@
-# frozen_string_literal: true
-
-require "phlex"
-
-require_relative "task_card"
-
-module Views
-  class OverdueColumn < Phlex::HTML
-    def initialize(tasks:, csrf:, selected_series: nil)
-      @tasks = tasks
-      @csrf = csrf
-      @selected_series = selected_series
-    end
-
-    def view_template
-      div(class: "column", "x-data": "sortable") do
-        div(class: "column-header") do
-          h2 { "Overdue" }
-          nav(class: "sort-toggle") do
-            button(
-              "x-on:click": "sort = 'date'",
-              "x-bind:class": "sort === 'date' && 'sort-active'",
-              "x-bind:disabled": "sort === 'date'"
-            ) { "Date" }
-            button(
-              "x-on:click": "sort = 'urgency'",
-              "x-bind:class": "sort === 'urgency' && 'sort-active'",
-              "x-bind:disabled": "sort === 'urgency'"
-            ) { "Urgency" }
-          end
-        end
-
-        if @tasks.empty?
-          p(class: "empty") { "Nothing overdue." }
-        else
-          ul(class: "task-list") do
-            @tasks.each do |task|
-              li(
-                class: "task-item",
-                "data-urgency": format("%.4f", task.urgency),
-                "data-due-date": task[:due_date].to_s
-              ) do
-                render TaskCard.new(task: task, csrf: @csrf, selected: selected?(task), sortable: true)
-              end
-            end
-          end
-        end
-      end
-    end
-
-    private
-
-    def selected?(task)
-      @selected_series && @selected_series.id == task[:series_id]
-    end
-  end
-end
diff --git a/lib/ketchup/views/series/new.rb b/lib/ketchup/views/series/new.rb
index 2ed3546..155de2e 100644
--- a/lib/ketchup/views/series/new.rb
+++ b/lib/ketchup/views/series/new.rb
@@ -20,8 +20,8 @@ module Views
             form(method: "post", action: "/series") do
               input(type: "hidden", name: "_csrf", value: @csrf.call("/series"))
               div(class: "field") do
-                label(for: "note") { "Note" }
-                textarea(id: "note", name: "note", rows: 3, required: true)
+                label(for: "series-note-editor") { "Note" }
+                div(id: "series-note-editor", class: "series-note series-note--editable")
               end
 
               div(class: "field") do
@@ -37,7 +37,6 @@ module Views
                 end
               end
 
-              # Maybe we should update this if we change the interval?
               div(class: "field") do
                 label(for: "first_due_date") { "First due date" }
                 input(
@@ -46,7 +45,7 @@ module Views
                 )
               end
 
-              button(type: "submit") { "Create" }
+              button(type: "submit", id: "create-series-btn", disabled: true) { "Create" }
             end
           end
         end
diff --git a/lib/ketchup/views/series_detail.rb b/lib/ketchup/views/series_detail.rb
index 1fbb3c7..cbc8bd2 100644
--- a/lib/ketchup/views/series_detail.rb
+++ b/lib/ketchup/views/series_detail.rb
@@ -4,37 +4,39 @@ require "phlex"
 
 module Views
   class SeriesDetail < Phlex::HTML
-    def initialize(series:)
+    def initialize(series:, csrf: nil)
       @series = series
+      @csrf = csrf
     end
 
     def view_template
       active_task = @series.active_task
-      div(class: "column column-aside", "x-data": "{ editing: false }") do
-        div(class: "column-header") do
-          h2(class: "aside-heading") do
-            a(href: "/", class: "aside-heading-action") { "New" }
+      div(class: "panel-inner", "x-data": "{ editing: false }") do
+        div(class: "panel-header") do
+          a(href: "/", class: "panel-close", "aria-label": "Close") { "←" }
+          div(class: "panel-actions") do
+            button(
+              class: "panel-action",
+              "x-show": "!editing",
+              "x-on:click": "editing = true; $dispatch('start-editing')"
+            ) { "Edit" }
+            button(
+              class: "panel-action",
+              "x-show": "editing",
+              "x-on:click": "editing = false; $dispatch('stop-editing')"
+            ) { "Done" }
           end
-          button(
-            class: "aside-heading-action",
-            "x-show": "!editing",
-            "x-on:click": "editing = true; $dispatch('start-editing')"
-          ) { "Edit" }
-          button(
-            class: "aside-heading-action",
-            "x-show": "editing",
-            "x-on:click": "editing = false; $dispatch('stop-editing')"
-          ) { "Done" }
         end
 
-        div(class: "task-detail") do
+        div(class: "panel-body") do
           div(
             id: "series-note-detail",
-            class: "task-detail-note",
+            class: "series-note",
             "data-value": @series.note || "",
             "data-series-id": @series.id.to_s
           )
-          dl(class: "task-detail-fields") do
+
+          dl(class: "detail-fields") do
             dt { "Repeat every" }
             dd("x-show": "!editing") do
               plain interval_text(@series.interval_count, @series.interval_unit)
@@ -82,7 +84,7 @@ module Views
 
               if active_task.urgency > 0
                 dt { "Urgency" }
-                dd { "#{format("%.1f", active_task.urgency)}x" }
+                dd { "#{format("%.1f", active_task.urgency)}×" }
               end
             end
           end
diff --git a/lib/ketchup/views/task_card.rb b/lib/ketchup/views/task_card.rb
index 0074cae..51b6d7c 100644
--- a/lib/ketchup/views/task_card.rb
+++ b/lib/ketchup/views/task_card.rb
@@ -4,19 +4,18 @@ require "phlex"
 
 module Views
   class TaskCard < Phlex::HTML
-    def initialize(task:, csrf:, selected: false, sortable: false)
+    def initialize(task:, csrf:, selected: false, overdue: false)
       @task = task
       @csrf = csrf
       @selected = selected
-      @sortable = sortable
+      @overdue = overdue
     end
 
     def view_template
       name = @task[:note].lines.first&.strip || @task[:note]
-      overdue = @task[:due_date] < Date.today
       complete_path = "/series/#{@task[:series_id]}/tasks/#{@task[:id]}/complete"
 
-      div(class: ["task-card", ("task-overdue" if overdue), ("task-selected" if @selected)]) do
+      div(class: task_classes) do
         form(method: "post", action: complete_path, class: "complete-form") do
           input(type: "hidden", name: "_csrf", value: @csrf.call(complete_path))
           button(
@@ -25,12 +24,49 @@ module Views
             **{ "aria-label": "Complete #{name}" }
           ) { "✓" }
         end
-        a(href: "/series/#{@task[:series_id]}", class: "task-name") { name }
-        if @sortable && overdue
-          span(class: "task-secondary task-urgency", "x-show": "sort === 'urgency'") { "#{format("%.1f", @task.urgency)}x" } if @task.urgency > 0
-          span(class: "task-secondary", "x-show": "sort === 'date'") { @task[:due_date].to_s }
+        div(class: "task-body") do
+          a(href: "/series/#{@task[:series_id]}", class: "task-name") { name }
+          if @overdue
+            span(class: "task-meta") do
+              plain meta_text
+            end
+          end
+        end
+        if @overdue && @task.urgency > 0
+          span(class: "task-urgency", "x-show": "sort === 'urgency'") { "#{format("%.1f", @task.urgency)}×" }
+          span(class: "task-date", "x-show": "sort === 'date'") { @task[:due_date].to_s }
         end
       end
     end
+
+    private
+
+    def task_classes
+      classes = ["task-card"]
+      classes << "task-card--overdue" if @overdue
+      classes << "task-card--selected" if @selected
+      classes
+    end
+
+    def meta_text
+      days = (Date.today - @task[:due_date]).to_i
+      interval_count = @task[:interval_count] || @task.series.interval_count
+      interval_unit = @task[:interval_unit] || @task.series.interval_unit
+      interval = "#{interval_count} #{interval_count == 1 ? interval_unit : "#{interval_unit}s"}"
+
+      ago = if days == 1
+              "yesterday"
+            elsif days < 7
+              "#{days} days ago"
+            elsif days < 30
+              weeks = days / 7
+              "#{weeks} #{weeks == 1 ? "week" : "weeks"} ago"
+            else
+              months = days / 30
+              "#{months} #{months == 1 ? "month" : "months"} ago"
+            end
+
+      "every #{interval} · #{ago}"
+    end
   end
 end
diff --git a/lib/ketchup/views/task_list.rb b/lib/ketchup/views/task_list.rb
new file mode 100644
index 0000000..7d84e37
--- /dev/null
+++ b/lib/ketchup/views/task_list.rb
@@ -0,0 +1,116 @@
+# frozen_string_literal: true
+
+require "phlex"
+
+require_relative "task_card"
+
+module Views
+  class TaskList < Phlex::HTML
+    def initialize(overdue:, upcoming:, csrf:, selected_series: nil)
+      @overdue = overdue
+      @upcoming = upcoming
+      @csrf = csrf
+      @selected_series = selected_series
+    end
+
+    def view_template
+      div(class: "task-list-container", "x-data": "sortable") do
+        render_overdue
+        render_upcoming
+        render_completed_today
+      end
+    end
+
+    private
+
+    def render_overdue
+      section(class: "section section--overdue") do
+        div(class: "section-header") do
+          h2(class: "section-title") do
+            span(class: "section-title-text") { "Overdue" }
+            span(class: "section-count") { @overdue.size.to_s } unless @overdue.empty?
+          end
+          nav(class: "sort-toggle") do
+            button(
+              "x-on:click": "sort = 'urgency'",
+              "x-bind:class": "sort === 'urgency' && 'sort-active'",
+              "x-bind:disabled": "sort === 'urgency'"
+            ) { "Urgency" }
+            button(
+              "x-on:click": "sort = 'date'",
+              "x-bind:class": "sort === 'date' && 'sort-active'",
+              "x-bind:disabled": "sort === 'date'"
+            ) { "Date" }
+          end unless @overdue.empty?
+        end
+
+        if @overdue.empty?
+          p(class: "empty") { "All caught up!" }
+        else
+          ul(class: "task-list") do
+            @overdue.each do |task|
+              li(
+                class: "task-item",
+                "data-urgency": format("%.4f", task.urgency),
+                "data-due-date": task[:due_date].to_s
+              ) do
+                render TaskCard.new(task: task, csrf: @csrf, selected: selected?(task), overdue: true)
+              end
+            end
+          end
+        end
+      end
+    end
+
+    def render_upcoming
+      section(class: "section section--upcoming") do
+        div(class: "section-header") do
+          h2(class: "section-title") do
+            span(class: "section-title-text") { "Coming up" }
+          end
+        end
+
+        if @upcoming.empty?
+          p(class: "empty") { "Nothing upcoming." }
+        else
+          tasks_by_date = @upcoming.group_by { |t| t[:due_date] }
+
+          ul(class: "task-list") do
+            tasks_by_date.each do |date, tasks|
+              li(class: "date-header") do
+                span(class: "date-label") { friendly_date(date) }
+              end
+              tasks.each do |task|
+                li(class: "task-item") do
+                  render TaskCard.new(task: task, csrf: @csrf, selected: selected?(task), overdue: false)
+                end
+              end
+            end
+          end
+        end
+      end
+    end
+
+    def render_completed_today
+      # Placeholder for future "completed today" counter
+    end
+
+    def friendly_date(date)
+      today = Date.today
+      case date
+      when today then "Today"
+      when today + 1 then "Tomorrow"
+      else
+        if date < today + 7
+          date.strftime("%A")
+        else
+          date.strftime("%b %-d")
+        end
+      end
+    end
+
+    def selected?(task)
+      @selected_series && @selected_series.id == task[:series_id]
+    end
+  end
+end
diff --git a/lib/ketchup/views/upcoming_column.rb b/lib/ketchup/views/upcoming_column.rb
deleted file mode 100644
index 48caba7..0000000
--- a/lib/ketchup/views/upcoming_column.rb
+++ /dev/null
@@ -1,97 +0,0 @@
-# frozen_string_literal: true
-
-require "phlex"
-
-require_relative "task_card"
-
-module Views
-  class UpcomingColumn < Phlex::HTML
-    def initialize(tasks:, csrf:, selected_series: nil)
-      @tasks = tasks
-      @csrf = csrf
-      @selected_series = selected_series
-    end
-
-    def view_template
-      div(class: "column", "x-data": "upcoming") do
-        div(class: "column-header") do
-          h2 { "Upcoming" }
-          nav(class: "sort-toggle") do
-            button(
-              "x-on:click": "showEmpty = !showEmpty",
-              "x-bind:class": "showEmpty && 'sort-active'"
-            ) { "Calendar" }
-          end
-        end
-
-        if @tasks.empty?
-          p(class: "empty") { "Nothing upcoming." }
-        else
-          render_calendar
-        end
-      end
-    end
-
-    private
-
-    def render_calendar
-      tasks_by_date = @tasks.group_by { |t| t[:due_date] }
-
-      ul(class: "task-list") do
-        current_month = Date.today.month
-        horizon = Date.today + 91
-        past_horizon = false
-        last_date = @tasks.last[:due_date]
-        last_task_in_window = tasks_by_date.keys.select { |d| d <= horizon }.max || (Date.today - 1)
-        (Date.today..last_date).each do |date|
-          day_tasks = tasks_by_date[date]
-          empty = day_tasks.nil?
-          next if empty && date > last_task_in_window && date <= horizon
-          if date.month != current_month
-            current_month = date.month
-            li(class: "calendar-month", "x-show": "showEmpty") if date <= horizon
-          end
-          if empty && date > horizon
-            unless past_horizon
-              past_horizon = true
-              li(class: "calendar-horizon", "x-show": "showEmpty") do
-                span { "3 months" }
-              end
-            end
-            next
-          end
-          weekend = date.saturday? || date.sunday?
-          day_attrs = { class: ["calendar-day", ("calendar-day-empty" if empty), ("calendar-day-weekend" if weekend)] }
-          day_attrs[:"x-show"] = "showEmpty" if empty
-          li(**day_attrs) do
-            span(class: "calendar-date") { friendly_date(date) }
-          end
-          next if empty
-          day_tasks.each do |task|
-            li(class: ["task-item", ("calendar-day-weekend" if weekend)]) do
-              render TaskCard.new(task: task, csrf: @csrf, selected: selected?(task))
-            end
-          end
-        end
-      end
-    end
-
-    def friendly_date(date)
-      today = Date.today
-      case date
-      when today then "Today"
-      when today + 1 then "Tomorrow"
-      else
-        if date < today + 7
-          date.strftime("%A")
-        else
-          date.strftime("%b %-d")
-        end
-      end
-    end
-
-    def selected?(task)
-      @selected_series && @selected_series.id == task[:series_id]
-    end
-  end
-end
diff --git a/lib/ketchup/views/user_form.rb b/lib/ketchup/views/user_form.rb
index cf976ed..79eee2b 100644
--- a/lib/ketchup/views/user_form.rb
+++ b/lib/ketchup/views/user_form.rb
@@ -13,42 +13,48 @@ module Views
       email_path = "/users/#{@current_user[:id]}/email"
       email = @current_user[:email]
 
-      div(class: "column column-aside", "x-data": "{ editing: false }") do
-        div(class: "column-header") do
-          h2 { @current_user[:login] }
-          button(
-            class: "aside-heading-action",
-            "x-show": "!editing",
-            "x-on:click": "editing = true"
-          ) { "Edit" }
-          button(
-            type: "submit",
-            form: "user-form",
-            class: "aside-heading-action",
-            "x-show": "editing",
-            "x-cloak": true
-          ) { "Save" }
+      div(class: "panel-inner", "x-data": "{ editing: false }") do
+        div(class: "panel-header") do
+          a(href: "/", class: "panel-close", "aria-label": "Close") { "←" }
+          div(class: "panel-actions") do
+            button(
+              class: "panel-action",
+              "x-show": "!editing",
+              "x-on:click": "editing = true"
+            ) { "Edit" }
+            button(
+              type: "submit",
+              form: "user-form",
+              class: "panel-action",
+              "x-show": "editing",
+              "x-cloak": true
+            ) { "Save" }
+          end
         end
 
-        form(method: "post", action: email_path, id: "user-form", class: "task-detail") do
-          input(type: "hidden", name: "_csrf", value: @csrf.call(email_path))
+        div(class: "panel-body") do
+          h2(class: "panel-body-title") { @current_user[:login] }
+
+          form(method: "post", action: email_path, id: "user-form") do
+            input(type: "hidden", name: "_csrf", value: @csrf.call(email_path))
 
-          dl(class: "task-detail-fields") do
-            dt { "Email" }
-            dd("x-show": "!editing") do
-              if email
-                plain email
-              else
-                span(class: "detail-placeholder") { "not set" }
+            dl(class: "detail-fields") do
+              dt { "Email" }
+              dd("x-show": "!editing") do
+                if email
+                  plain email
+                else
+                  span(class: "detail-placeholder") { "not set" }
+                end
+              end
+              dd("x-show": "editing", "x-cloak": true) do
+                input(
+                  type: "email", name: "email",
+                  class: "detail-input",
+                  value: email,
+                  placeholder: "for notifications"
+                )
               end
-            end
-            dd("x-show": "editing", "x-cloak": true) do
-              input(
-                type: "email", name: "email",
-                class: "detail-input",
-                value: email,
-                placeholder: "for notifications"
-              )
             end
           end
         end
diff --git a/public/css/app.css b/public/css/app.css
index ba9ccde..0bd1d2e 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -34,6 +34,8 @@ a {
 h1 { font-size: var(--step-2); }
 h2 { font-size: var(--step-0); font-weight: 600; }
 
+[x-cloak] { display: none !important; }
+
 /* -------------------- */
 /* Site header */
 /* -------------------- */
@@ -44,20 +46,37 @@ h2 { font-size: var(--step-0); font-weight: 600; }
   align-items: center;
   gap: var(--space-s);
   padding: var(--space-xs) var(--grid-gutter);
-  border-block-end: 1px dotted #ccc;
+  border-block-end: 1px solid #e5e5e5;
 }
 
 .site-name {
   font-weight: 700;
 }
 
-.user {
+.site-nav {
+  display: flex;
+  align-items: center;
+  gap: var(--space-s);
+}
+
+.header-action {
   font-size: var(--step--1);
-  color: #777;
+  color: #555;
+  text-decoration: none;
 }
 
-.mobile-new-link {
-  display: none;
+.header-action:hover {
+  color: #1a1a1a;
+}
+
+.header-user {
+  font-size: var(--step--1);
+  color: #999;
+  text-decoration: none;
+}
+
+.header-user:hover {
+  color: #555;
 }
 
 /* -------------------- */
@@ -66,7 +85,7 @@ h2 { font-size: var(--step-0); font-weight: 600; }
 
 .site-footer {
   margin-block-start: auto;
-  border-block-start: 1px dotted #ccc;
+  border-block-start: 1px solid #e5e5e5;
   padding: var(--space-xs) var(--grid-gutter);
   font-size: var(--step--2);
   color: #999;
@@ -74,139 +93,66 @@ h2 { font-size: var(--step-0); font-weight: 600; }
 }
 
 /* -------------------- */
-/* Page wrapper */
+/* Dashboard layout */
 /* -------------------- */
 
-.wrapper {
-  max-width: 28rem;
-  margin-inline: auto;
-  padding: var(--space-l) var(--grid-gutter);
+.dashboard {
+  display: flex;
+  min-height: 0;
+  flex: 1;
 }
 
-/* -------------------- */
-/* Home layout */
-/* -------------------- */
-
-.home {
-  display: grid;
-  grid-template-columns: minmax(18rem, 1fr) minmax(18rem, 1fr) auto;
-  align-items: start;
-  gap: var(--space-xl);
-  padding: var(--space-m) var(--grid-gutter);
-  max-width: 80rem;
+.main-column {
+  flex: 1;
+  max-width: 36rem;
   margin-inline: auto;
+  padding: var(--space-m) var(--grid-gutter);
 }
 
-@media (max-width: 48rem) {
-  .site-header,
-  .site-footer,
-  .wrapper {
-    padding-inline: var(--space-xs);
-  }
-
-  .home {
-    grid-template-columns: 1fr;
-    gap: var(--space-l);
-    padding: var(--space-m) var(--space-m);
-    max-width: none;
-    margin-inline: 0;
-  }
-
-  .column-aside {
-    width: auto;
-  }
-
-  .column-aside--new {
-    display: none;
-  }
-
-  .home--series .column-aside {
-    order: -1;
-  }
-
-  .mobile-new-link {
-    display: inline;
-    margin-left: auto;
-    font-size: var(--step--1);
-    color: #777;
-  }
-}
+/* -------------------- */
+/* Sections (overdue / upcoming) */
+/* -------------------- */
 
-.column {
-  min-width: 0;
+.section + .section {
+  margin-block-start: var(--space-l);
 }
 
-.column-header {
+.section-header {
   display: flex;
   justify-content: space-between;
   align-items: baseline;
-  gap: var(--space-2xs);
-  margin-block-end: var(--space-xs);
-  padding-block-end: var(--space-3xs);
-  border-block-end: 1px dotted #ccc;
-}
-
-.column h2 {
   margin-block-end: var(--space-xs);
   padding-block-end: var(--space-3xs);
-  border-block-end: 1px dotted #ccc;
-}
-
-.column-header h2 {
-  margin-block-end: 0;
-  padding-block-end: 0;
-  border-block-end: none;
-}
-
-.column-aside {
-  width: 16rem;
-}
-
-.aside-heading {
-  flex: 1;
-  color: #999;
-}
-
-.aside-heading::before {
-  content: "\200b";
-}
-
-.aside-heading-action {
-  all: unset;
-  font-size: var(--step--1);
-  font-weight: 400;
-  color: #999;
-  cursor: default;
-}
-
-.aside-heading-action:hover {
-  color: #1a1a1a;
+  border-block-end: 1px solid #e5e5e5;
 }
 
-.aside-heading-action:disabled {
-  color: #ccc;
-  cursor: default;
-}
-
-#series-note-editor {
-  border: 1px solid #ccc;
-  border-radius: 3px;
-}
-
-#series-note-editor:focus-within {
-  border-color: #999;
+.section-title {
+  display: flex;
+  align-items: baseline;
+  gap: var(--space-2xs);
 }
 
-.column-aside form {
-  font-size: var(--step--1);
+.section-title-text {
+  font-weight: 600;
 }
 
-.column-aside button[type="submit"] {
-  font-size: var(--step--1);
+.section-count {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  min-width: 1.4em;
+  height: 1.4em;
+  padding: 0 0.35em;
+  font-size: var(--step--2);
+  font-weight: 600;
+  line-height: 1;
+  color: #fff;
+  background: #c0392b;
+  border-radius: 999px;
 }
 
 /* -------------------- */
-/* Task list */
+/* Sort toggle */
 /* -------------------- */
 
 .sort-toggle {
@@ -230,9 +176,9 @@ h2 { font-size: var(--step-0); font-weight: 600; }
   font-weight: 600;
 }
 
-.sort-active {
-  font-weight: 600;
-}
+/* -------------------- */
+/* Task list */
+/* -------------------- */
 
 .task-list {
   list-style: none;
@@ -240,109 +186,318 @@ h2 { font-size: var(--step-0); font-weight: 600; }
 }
 
 .task-item {
+  padding-block: var(--space-3xs);
+}
+
+.date-header {
+  padding-block-start: var(--space-s);
+  padding-block-end: var(--space-3xs);
+}
+
+.date-header:first-child {
+  padding-block-start: var(--space-2xs);
+}
+
+.date-label {
+  font-size: var(--step--1);
+  font-weight: 600;
+  color: #555;
+}
+
+.empty {
+  color: #999;
+  font-size: var(--step--1);
   padding-block: var(--space-2xs);
 }
 
+/* -------------------- */
+/* Task card */
+/* -------------------- */
+
 .task-card {
   display: grid;
   grid-template-columns: auto 1fr auto;
   align-items: center;
-  gap: var(--space-2xs);
+  gap: var(--space-xs);
+  padding: var(--space-2xs) var(--space-2xs);
+  border-radius: 6px;
   cursor: pointer;
-  margin-inline: calc(-1 * var(--space-3xs));
-  padding-inline: var(--space-3xs);
+  transition: background 0.1s;
 }
 
 .task-card:hover {
-  background: #f8f8f8;
+  background: #f5f5f5;
 }
 
-.task-selected {
-  box-shadow: -2px 0 0 #1a1a1a;
+.task-card--selected {
+  background: #f0f0f0;
+  box-shadow: inset 3px 0 0 #1a1a1a;
+}
+
+.task-card--overdue {
+  padding: var(--space-xs) var(--space-s);
+  margin-inline: calc(-1 * var(--space-2xs));
+  border: 1px solid #f0e0de;
+  border-radius: 8px;
+  background: #fefafa;
+}
+
+.task-card--overdue:hover {
+  background: #fdf5f5;
+  border-color: #e8d0cd;
+}
+
+.task-card--overdue + .task-item .task-card--overdue,
+.task-item + .task-item .task-card--overdue {
+  margin-block-start: var(--space-3xs);
+}
+
+.task-body {
+  min-width: 0;
 }
 
 .task-name {
   font-weight: 600;
   font-size: var(--step--1);
+  display: block;
 }
 
-.task-overdue .task-name {
+.task-card--overdue .task-name {
   color: #c0392b;
+  font-size: var(--step-0);
 }
 
-
-.task-secondary {
+.task-meta {
+  display: block;
   font-size: var(--step--2);
-  color: #777;
-  white-space: nowrap;
-  min-width: 6em;
+  color: #888;
+  margin-block-start: 1px;
 }
 
 .task-urgency {
+  font-size: var(--step--1);
+  font-weight: 700;
   color: #c0392b;
+  white-space: nowrap;
+}
+
+.task-date {
+  font-size: var(--step--2);
+  color: #888;
+  white-space: nowrap;
+}
+
+/* -------------------- */
+/* Complete button */
+/* -------------------- */
+
+.complete-form {
+  display: contents;
+}
+
+.complete-btn {
+  width: 28px;
+  height: 28px;
+  padding: 0;
+  border: 2px solid #ccc;
+  border-radius: 50%;
+  background: transparent;
+  color: transparent;
+  font-size: 14px;
+  line-height: 1;
+  cursor: pointer;
+  transition: all 0.15s;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+}
+
+.complete-btn:hover {
+  border-color: #27ae60;
+  color: #27ae60;
+  background: #eafaf1;
+}
+
+.task-card--overdue .complete-btn {
+  width: 32px;
+  height: 32px;
+  font-size: 16px;
+  border-color: #e0a9a5;
+}
+
+.task-card--overdue .complete-btn:hover {
+  border-color: #27ae60;
+  color: #27ae60;
+  background: #eafaf1;
+}
+
+/* -------------------- */
+/* Panel (slide-over) */
+/* -------------------- */
+
+.panel {
+  position: fixed;
+  inset-block: 0;
+  inset-inline-end: 0;
+  width: 22rem;
+  max-width: 100vw;
+  transform: translateX(100%);
+  transition: transform 0.25s ease;
+  z-index: 100;
+  display: flex;
+  flex-direction: column;
+  background: #fff;
+  border-inline-start: 1px solid #e5e5e5;
+  box-shadow: -4px 0 24px rgba(0, 0, 0, 0.06);
+}
+
+.panel--open {
+  transform: translateX(0);
+}
+
+.panel-backdrop {
+  position: fixed;
+  inset: 0;
+  background: rgba(0, 0, 0, 0.15);
+  z-index: 99;
+}
+
+.panel-content {
+  flex: 1;
+  overflow-y: auto;
+  display: flex;
+  flex-direction: column;
+}
+
+.panel-inner {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+}
+
+.panel-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: var(--space-s) var(--space-m);
+  border-block-end: 1px solid #e5e5e5;
+  flex-shrink: 0;
+}
+
+.panel-close {
+  all: unset;
+  font-size: var(--step-0);
+  color: #555;
+  cursor: pointer;
+  padding: var(--space-3xs) var(--space-2xs);
+  border-radius: 4px;
+}
+
+.panel-close:hover {
+  color: #1a1a1a;
+  background: #f5f5f5;
+}
+
+.panel-title {
   font-weight: 600;
-  min-width: 3em;
 }
 
-.task-detail {
+.panel-actions {
+  display: flex;
+  gap: var(--space-2xs);
+}
+
+.panel-action {
+  all: unset;
   font-size: var(--step--1);
+  color: #555;
+  cursor: pointer;
+  padding: var(--space-3xs) var(--space-2xs);
+  border-radius: 4px;
 }
 
-.task-detail-note {
-  color: #444;
+.panel-action:hover {
+  color: #1a1a1a;
+  background: #f5f5f5;
+}
+
+.panel-action:disabled {
+  color: #ccc;
+  cursor: default;
+  background: none;
+}
+
+.panel-body {
+  padding: var(--space-m);
+  flex: 1;
+}
+
+.panel-body-title {
   margin-block-end: var(--space-s);
-  line-height: 1.3;
+}
+
+/* -------------------- */
+/* Series note */
+/* -------------------- */
+
+.series-note {
+  color: #444;
+  margin-block-end: var(--space-m);
+  line-height: 1.4;
   border: 1px solid transparent;
-  border-radius: 3px;
+  border-radius: 4px;
 }
 
-#series-note-editor {
-  margin-block-end: 0;
-  border-color: #ccc;
+.series-note--editable {
+  border-color: #ddd;
 }
 
-#series-note-editor:focus-within {
+.series-note--editable:focus-within {
   border-color: #999;
 }
 
-.task-detail-note:hover {
+.series-note:hover {
   border-color: transparent;
 }
 
-.task-detail-note:focus-within {
+.series-note:focus-within {
   border-color: #999;
 }
 
+/* -------------------- */
+/* Detail fields */
+/* -------------------- */
 
-.task-detail-fields {
+.detail-fields {
   display: grid;
   grid-template-columns: auto 1fr;
   gap: var(--space-3xs) var(--space-s);
-  font-size: var(--step--2);
+  font-size: var(--step--1);
 }
 
-.task-detail-fields dt {
+.detail-fields dt {
   color: #999;
 }
 
-.task-detail-fields dd {
+.detail-fields dd {
   margin: 0;
   color: #444;
 }
 
 .detail-input {
-  font-size: var(--step--2);
-  padding: 1px 4px;
-  margin: -2px -5px;
+  font-size: var(--step--1);
+  padding: 2px 6px;
+  margin: -3px -7px;
   border: 1px solid #ddd;
-  border-radius: 3px;
+  border-radius: 4px;
   background: transparent;
   color: #444;
   font-family: inherit;
 }
 
 .detail-input:hover {
-  border-color: #ddd;
+  border-color: #ccc;
 }
 
 .detail-input:focus {
@@ -353,7 +508,6 @@ h2 { font-size: var(--step-0); font-weight: 600; }
 
 .detail-placeholder {
   color: #999;
-  font-size: var(--step--2);
 }
 
 .detail-edit-interval {
@@ -381,12 +535,16 @@ h2 { font-size: var(--step-0); font-weight: 600; }
   width: 100%;
 }
 
+/* -------------------- */
+/* Task history */
+/* -------------------- */
+
 .task-history {
-  margin-block-start: var(--space-s);
+  margin-block-start: var(--space-l);
 }
 
 .task-history h3 {
-  font-size: var(--step--2);
+  font-size: var(--step--1);
   font-weight: 600;
   color: #999;
   margin-block-end: var(--space-3xs);
@@ -402,7 +560,7 @@ h2 { font-size: var(--step-0); font-weight: 600; }
 }
 
 .task-history-item + .task-history-item {
-  border-top: 1px dotted #ccc;
+  border-top: 1px solid #f0f0f0;
 }
 
 .task-history-row {
@@ -414,7 +572,7 @@ h2 { font-size: var(--step-0); font-weight: 600; }
 }
 
 .task-history-check {
-  color: #999;
+  color: #27ae60;
 }
 
 .task-history-add-note {
@@ -430,76 +588,29 @@ h2 { font-size: var(--step-0); font-weight: 600; }
 }
 
 .task-history-note-editor {
-  margin-top: 1px;
-  margin-left: 14px;
+  margin-top: 2px;
+  margin-left: 16px;
   border: 1px solid transparent;
-  border-radius: 3px;
-  line-height: 1.3;
+  border-radius: 4px;
+  line-height: 1.4;
 }
 
 .task-history-note-editor:hover {
-  border-color: #ddd;
+  border-color: #eee;
 }
 
 .task-history-note-editor:focus-within {
   border-color: #999;
 }
 
+/* -------------------- */
+/* Page wrapper (standalone forms) */
+/* -------------------- */
 
-
-
-
-
-
-.calendar-horizon {
-  padding-block: var(--space-s) var(--space-2xs);
-  border-block-end: 1px solid #ccc;
-  font-size: var(--step--2);
-  color: #999;
-  text-align: center;
-}
-
-.calendar-month {
-  padding-block: var(--space-2xs) 0;
-  border-block-end: 1px dotted #ccc;
-}
-
-.calendar-day {
-  padding-block: var(--space-2xs);
-  border-block-end: none;
-}
-
-.calendar-day-empty + .calendar-day-empty,
-.calendar-day-empty:first-child {
-  padding-block: var(--space-3xs);
-}
-
-.calendar-date {
-  font-size: var(--step--1);
-  font-weight: 600;
-  color: #444;
-}
-
-.calendar-day-empty .calendar-date {
-  font-weight: 400;
-  color: #bbb;
-}
-
-.calendar-day-weekend {
-  background: #f8f8f8;
-  margin-inline: calc(-1 * var(--space-2xs));
-  padding-inline: var(--space-2xs);
-}
-
-.calendar-day-weekend + .calendar-month {
-  background: #f8f8f8;
-  margin-inline: calc(-1 * var(--space-2xs));
-  padding-inline: var(--space-2xs);
-}
-
-.empty {
-  color: #999;
-  font-size: var(--step--1);
+.wrapper {
+  max-width: 28rem;
+  margin-inline: auto;
+  padding: var(--space-l) var(--grid-gutter);
 }
 
 /* -------------------- */
@@ -527,7 +638,7 @@ textarea,
 select {
   padding: var(--space-3xs) var(--space-2xs);
   border: 1px solid #ccc;
-  border-radius: 3px;
+  border-radius: 4px;
   font: inherit;
   background: #fff;
 }
@@ -545,9 +656,9 @@ input[type="number"]::-webkit-outer-spin-button {
 input:focus,
 textarea:focus,
 select:focus {
-  outline: 1px dotted #aaa;
-  outline-offset: 1px;
+  outline: none;
   border-color: #999;
+  box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.05);
 }
 
 textarea {
@@ -560,10 +671,10 @@ textarea {
   gap: var(--space-3xs);
 }
 
-button[type="submit"]:not(.complete-btn):not(.aside-heading-action) {
+button[type="submit"]:not(.complete-btn):not(.panel-action) {
   padding: var(--space-3xs) var(--space-s);
   border: 1px solid #1a1a1a;
-  border-radius: 3px;
+  border-radius: 4px;
   background: #1a1a1a;
   color: #fff;
   font: inherit;
@@ -571,40 +682,39 @@ button[type="submit"]:not(.complete-btn):not(.aside-heading-action) {
   cursor: pointer;
 }
 
-button[type="submit"]:not(.complete-btn):not(.aside-heading-action):hover {
+button[type="submit"]:not(.complete-btn):not(.panel-action):hover {
   background: #333;
 }
 
-.complete-form {
-  display: contents;
-}
+/* -------------------- */
+/* Mobile responsive */
+/* -------------------- */
 
-.complete-btn {
-  width: 14px;
-  height: 14px;
-  padding: 0;
-  border: 1.5px solid #ccc;
-  border-radius: 2px;
-  background: transparent;
-  color: transparent;
-  font-size: 10px;
-  line-height: 1;
-  cursor: pointer;
-  transition: border-color 0.15s, color 0.15s, background 0.15s;
-}
+@media (max-width: 48rem) {
+  .site-header {
+    padding-inline: var(--space-s);
+  }
 
-.complete-btn:hover {
-  border-color: #1a1a1a;
-  color: #1a1a1a;
-  background: #f0f0f0;
-}
+  .main-column {
+    padding-inline: var(--space-s);
+  }
 
-.task-overdue .complete-btn {
-  border-color: #e0a9a5;
+  .panel {
+    width: 100vw;
+  }
+
+  .task-card--overdue {
+    margin-inline: 0;
+  }
 }
 
-.task-overdue .complete-btn:hover {
-  border-color: #c0392b;
-  color: #c0392b;
-  background: #fce8e8;
+@media (min-width: 60rem) {
+  /* On wide screens, panel sits beside the main column */
+  .panel {
+    position: fixed;
+  }
+
+  .has-panel .main-column {
+    margin-inline-end: 22rem;
+  }
 }
diff --git a/public/js/app.js b/public/js/app.js
index 8e19a4f..492cae1 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -59,6 +59,24 @@ function saveSeriesField(seriesId, field, value) {
 OverType.setTheme({ name: "ketchup", colors: { text: "#1a1a1a" } })
 
 document.addEventListener("alpine:init", () => {
+  // Panel open/close
+  Alpine.data("panel", () => ({
+    open: false,
+
+    init() {
+      // Open panel if server rendered content into it
+      if (this.$el.querySelector(".panel-inner")) {
+        this.$nextTick(() => { this.open = true })
+      }
+    },
+
+    close() {
+      this.open = false
+      // Navigate back to root after close animation
+      setTimeout(() => { window.location.href = "/" }, 250)
+    },
+  }))
+
   Alpine.data("sortable", () => ({
     sort: Alpine.$persist("urgency"),
 
@@ -221,8 +239,4 @@ document.addEventListener("alpine:init", () => {
       })
     }
   }
-
-  Alpine.data("upcoming", () => ({
-    showEmpty: Alpine.$persist(false),
-  }))
 })
diff --git a/test/test_web.rb b/test/test_web.rb
index 5563b4f..260b0df 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -18,16 +18,22 @@ class TestWeb < Minitest::Test
     DB[:users].delete
   end
 
-  def test_root_shows_new_series_form
-    get "/", {}, auth_headers
+  def test_new_series_page
+    get "/series/new", {}, auth_headers
     assert last_response.ok?
     assert_includes last_response.body, "New Series"
     assert_includes last_response.body, 'action="/series"'
   end
 
+  def test_root_links_to_new_series
+    get "/", {}, auth_headers
+    assert last_response.ok?
+    assert_includes last_response.body, "/series/new"
+  end
+
   def test_root_shows_empty_state
     get "/", {}, auth_headers
-    assert_includes last_response.body, "Nothing overdue."
+    assert_includes last_response.body, "All caught up!"
     assert_includes last_response.body, "Nothing upcoming."
   end
 
@@ -274,7 +280,7 @@ class TestWeb < Minitest::Test
     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-selected"
+    assert_includes last_response.body, "task-card--selected"
   end
 
   def test_get_series_shows_completed_history
@@ -500,13 +506,13 @@ class TestWeb < Minitest::Test
   private
 
   def csrf_post(path, params = {}, headers = auth_headers)
-    get "/", {}, headers  # establish session
+    get "/series/new", {}, headers  # establish session and get CSRF token
     token = last_response.body[/name="_csrf" value="([^"]+)"/, 1]
     post path, params.merge("_csrf" => token), headers
   end
 
   def create_series(note:, interval_unit:, interval_count:, first_due_date:, headers: auth_headers)
-    get "/", {}, headers  # establish session and get tokens
+    get "/series/new", {}, headers  # establish session and get CSRF token
     token = last_response.body[/name="_csrf" value="([^"]+)"/, 1]
     post "/series", {
       _csrf: token,