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