Implement the home page
Add the archive front door from the Calm Archive design: a header
wordmark with capture actions (Take a photo / Upload a file), a
single-column "Recent assets" list wired to real asset data, and a
quiet v1.0 footer. Capture moves to /capture; the home page is now the
root. On small screens the capture actions move to a thumb-reachable
bottom dock, matching the mobile design.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0147qRctyhELxJgGs3nHizEM
diff --git a/lib/views/home.rb b/lib/views/home.rb
new file mode 100644
index 0000000..a61a719
--- /dev/null
+++ b/lib/views/home.rb
@@ -0,0 +1,144 @@
+# frozen_string_literal: true
+
+require "phlex"
+
+module Domus
+ module Views
+ # The home page — the archive's front door. Capture actions live in the
+ # header (and a thumb-reachable dock on small screens); the recent assets
+ # run as a single-column list below. Calm Archive language throughout.
+ class Home < Phlex::HTML
+ ICONS_DIR = File.expand_path("../../public/icons", __dir__)
+
+ def initialize(assets:, total:)
+ @assets = assets
+ @total = total
+ end
+
+ def view_template
+ doctype
+ html(lang: "en") do
+ head do
+ meta(charset: "utf-8")
+ meta(name: "viewport", content: "width=device-width, initial-scale=1")
+ title { "Domus" }
+ link(rel: "stylesheet", href: "/app.css")
+ end
+ body do
+ div(class: "page") do
+ render_header
+ render_main
+ render_footer
+ render_dock
+ end
+ end
+ end
+ end
+
+ private
+
+ def render_header
+ header(class: "topbar") do
+ a(href: "/", class: "logo") do
+ span(class: "logo-mark")
+ plain "domus"
+ end
+ render_actions(class: "actions")
+ end
+ end
+
+ # The capture entry points. Both lead to the capture flow, where the
+ # camera and file pickers live.
+ def render_actions(**attrs)
+ div(**attrs) do
+ a(href: "/capture", class: "browse") { plain "Upload a file" }
+ a(href: "/capture", class: "add-btn") do
+ icon("camera")
+ plain "Take a photo"
+ end
+ end
+ end
+
+ def render_main
+ main(class: "wrap") do
+ section do
+ div(class: "sec-h") do
+ h3 { plain "Recent assets" }
+ span(class: "sub") { plain "#{@total} tracked" } if @total.positive?
+ end
+ if @assets.empty?
+ render_empty
+ else
+ render_archive
+ end
+ end
+ end
+ end
+
+ def render_archive
+ div(class: "archive") do
+ @assets.each do |asset|
+ div(class: "entry") do
+ span(class: "chip") { icon("box") }
+ div(class: "body") do
+ div(class: "nm") { plain asset[:name] }
+ end
+ span(class: "time") { plain relative_time(asset[:created_at]) }
+ end
+ end
+ end
+ end
+
+ def render_empty
+ div(class: "archive empty") do
+ p(class: "empty-line") { plain "Nothing tracked yet." }
+ end
+ end
+
+ def render_footer
+ footer(class: "foot") do
+ span(class: "fm") { plain "v1.0" }
+ end
+ end
+
+ # On small screens the capture actions move to a thumb-reachable dock
+ # fixed to the bottom of the viewport; it's hidden on wider screens.
+ def render_dock
+ render_actions(class: "dock")
+ end
+
+ # Compact, archival relative time — "now", "5m", "3h", "2d", "1w", "4mo".
+ def relative_time(at)
+ return "" unless at
+
+ seconds = (Time.now - at).to_i
+ return "now" if seconds < 60
+
+ minutes = seconds / 60
+ return "#{minutes}m" if minutes < 60
+
+ hours = minutes / 60
+ return "#{hours}h" if hours < 24
+
+ days = hours / 24
+ return "#{days}d" if days < 7
+
+ weeks = days / 7
+ return "#{weeks}w" if days < 30
+
+ months = days / 30
+ return "#{months}mo" if months < 12
+
+ "#{days / 365}y"
+ end
+
+ ICONS = Hash.new do |cache, name|
+ cache[name] = File.read(File.join(ICONS_DIR, "#{name}.svg")).freeze
+ end
+
+ def icon(name)
+ raw safe(ICONS[name])
+ end
+ end
+ end
+end
diff --git a/lib/web.rb b/lib/web.rb
index f1cff2c..15ff078 100644
--- a/lib/web.rb
+++ b/lib/web.rb
@@ -4,6 +4,7 @@ require "roda"
require "fileutils"
require_relative "views/layout"
require_relative "views/capture"
+require_relative "views/home"
module Domus
# Raised when a request can't be processed because of client input. The
@@ -34,6 +35,13 @@ module Domus
r.public
r.root do
+ r.get do
+ assets = db[:assets].order(Sequel.desc(:created_at), Sequel.desc(:id)).limit(12).all
+ Views::Home.new(assets:, total: db[:assets].count).call
+ end
+ end
+
+ r.on "capture" do
r.get do
Views::Capture.new.call
end
diff --git a/public/app.css b/public/app.css
index 9874692..624df19 100644
--- a/public/app.css
+++ b/public/app.css
@@ -347,3 +347,198 @@ body {
padding-top: var(--space-l);
}
}
+
+/* ════════════════════════════════════════════════════════════════════════
+ Home — the archive front door
+ ════════════════════════════════════════════════════════════════════════ */
+
+/* ---- header capture actions ---- */
+.topbar .actions {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: var(--space-s);
+}
+
+.add-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 9px;
+ padding: 10px 18px;
+ border: 1px solid var(--w-accent);
+ border-radius: calc(var(--radius) - 6px);
+ background: var(--w-accent);
+ color: #fff;
+ cursor: pointer;
+ text-decoration: none;
+ font-family: var(--font-ui);
+ font-weight: 600;
+ font-size: var(--step--1);
+ letter-spacing: -0.01em;
+ white-space: nowrap;
+ transition: background .14s ease, border-color .14s ease;
+}
+.add-btn:hover { background: var(--w-accent-ink); border-color: var(--w-accent-ink); }
+.add-btn svg { width: 17px; height: 17px; }
+
+.browse {
+ font-family: var(--font-ui);
+ font-size: var(--step--1);
+ font-weight: 550;
+ color: var(--w-ink-2);
+ background: none;
+ border: 0;
+ cursor: pointer;
+ padding: 2px 0;
+ text-decoration: none;
+ white-space: nowrap;
+ border-bottom: 1.5px solid var(--w-line-2);
+ transition: color .14s ease, border-color .14s ease;
+}
+.browse:hover { color: var(--w-accent-ink); border-color: var(--w-accent); }
+
+/* ---- page shell ---- */
+.wrap {
+ width: 100%;
+ max-width: 760px;
+ margin: 0 auto;
+ padding: var(--space-xl) var(--space-l) var(--space-2xl);
+}
+
+.sec-h {
+ display: flex;
+ align-items: baseline;
+ gap: var(--space-s);
+ margin-bottom: var(--space-s);
+}
+.sec-h h3 {
+ margin: 0;
+ font-family: var(--font-serif);
+ font-weight: 700;
+ font-size: var(--step-1);
+ letter-spacing: -0.012em;
+}
+.sec-h .sub {
+ margin-left: auto;
+ font-family: var(--font-mono);
+ font-size: var(--step--2);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--w-ink-3);
+}
+
+/* ---- recent assets list ---- */
+.archive {
+ border: 1px solid var(--w-line);
+ border-radius: var(--radius);
+ background: var(--w-surface);
+ overflow: hidden;
+}
+
+.entry {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 13px 16px;
+ border-top: 1px solid var(--w-line);
+ cursor: pointer;
+ transition: background .12s ease;
+}
+.entry:first-child { border-top: 0; }
+.entry:hover { background: var(--w-fill); }
+
+.entry .chip {
+ width: 38px; height: 38px;
+ border-radius: 10px;
+ display: flex; align-items: center; justify-content: center;
+ background: var(--w-accent-soft);
+ border: 1px solid color-mix(in oklch, var(--w-accent) 22%, var(--w-line));
+ color: var(--w-accent-ink);
+ flex: none;
+}
+.entry .body { flex: 1; min-width: 0; }
+.entry .nm {
+ font-size: var(--step--1);
+ font-weight: 550;
+ letter-spacing: -0.01em;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.entry .meta {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: var(--w-ink-3);
+ margin-top: 2px;
+}
+.entry .time {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ color: var(--w-ink-3);
+ flex: none;
+}
+
+.archive.empty {
+ padding: var(--space-l);
+ text-align: center;
+}
+.empty-line {
+ margin: 0;
+ font-family: var(--font-mono);
+ font-size: var(--step--2);
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--w-ink-3);
+}
+
+/* ---- footer ---- */
+.foot {
+ width: 100%;
+ max-width: 760px;
+ margin: 0 auto;
+ padding: var(--space-l);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ border-top: 1px solid var(--w-line);
+}
+.foot .fm {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ letter-spacing: 0.04em;
+ color: var(--w-ink-3);
+}
+
+/* ---- mobile: capture moves to a thumb-reachable bottom dock ---- */
+.dock { display: none; }
+
+@media (max-width: 560px) {
+ .topbar .actions { display: none; }
+
+ .wrap { padding: var(--space-l) var(--space-s) 150px; }
+ .foot { display: none; }
+
+ .dock {
+ position: fixed;
+ left: 0; right: 0; bottom: 0;
+ z-index: 20;
+ display: flex;
+ flex-direction: column-reverse;
+ align-items: center;
+ gap: 12px;
+ padding: 16px 20px calc(16px + env(safe-area-inset-bottom, 8px));
+ background: linear-gradient(to top, var(--w-bg) 72%, transparent);
+ }
+ .dock .add-btn {
+ width: 100%;
+ height: 52px;
+ justify-content: center;
+ border-radius: calc(var(--radius) - 2px);
+ font-weight: 650;
+ font-size: 16px;
+ box-shadow: 0 1px 2px rgba(60,52,36,.10), 0 10px 24px -12px rgba(154,90,60,.6);
+ }
+ .dock .browse { font-size: 14px; }
+}
diff --git a/public/icons/box.svg b/public/icons/box.svg
new file mode 100644
index 0000000..c081ac1
--- /dev/null
+++ b/public/icons/box.svg
@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
+ <path d="M12 3.5 20 7v10l-8 3.5L4 17V7z"/>
+ <path d="M4 7l8 3.5L20 7"/>
+ <path d="M12 10.5V20.5"/>
+</svg>
diff --git a/test/test_app.rb b/test/test_app.rb
index dd1fd41..3108256 100644
--- a/test/test_app.rb
+++ b/test/test_app.rb
@@ -19,10 +19,35 @@ class TestApp < Minitest::Test
domus.db[:files].delete
end
- def test_root
+ def test_root_renders_home
get "/"
assert_equal 200, last_response.status
assert_includes last_response.body, "Domus"
+ assert_includes last_response.body, "Recent assets"
+ end
+
+ def test_root_lists_recent_assets_newest_first
+ now = Time.now
+ domus.db[:assets].insert(name: "Older asset", created_at: now - 86_400)
+ domus.db[:assets].insert(name: "Newer asset", created_at: now)
+
+ get "/"
+ assert_equal 200, last_response.status
+ body = last_response.body
+ assert_includes body, "Older asset"
+ assert_includes body, "Newer asset"
+ assert_operator body.index("Newer asset"), :<, body.index("Older asset")
+ end
+
+ def test_root_empty_state
+ get "/"
+ assert_equal 200, last_response.status
+ assert_includes last_response.body, "Nothing tracked yet."
+ end
+
+ def test_capture_page
+ get "/capture"
+ assert_equal 200, last_response.status
assert_includes last_response.body, "Add an image"
end