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
change
commit a7bf56b02bc0a3a128adeba29dc0e2e1dae19c4c
author Claude <noreply@anthropic.com>
date
parent 1a30ef12
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