Add asset detail page with title and description
Implements the first slice of the "Domus Asset" design (Calm Archive):
the catalog title block (eyebrow + asset name) and the free-text
description. Tags, photos, the maintenance log, info and documents are
held for a later pass.

- Add nullable `description` text column to assets (migration 005)
- Add GET /assets/:id route, 404 when the asset is missing
- Add Views::Asset Phlex view rendering the title block and paragraphs
- Map the design's px type/spacing onto --step-* / --space-* tokens

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N92HLTepkg3AtRpDUyZ54o
change
commit c1ee8c0aea2b7f87cc5a4b9be3c1be5925a10366
author Claude <noreply@anthropic.com>
date
parent lmxsrqzu
diff --git a/db/migrate/005_add_description_to_assets.rb b/db/migrate/005_add_description_to_assets.rb
new file mode 100644
index 0000000..07bd7b6
--- /dev/null
+++ b/db/migrate/005_add_description_to_assets.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+Sequel.migration do
+  change do
+    alter_table(:assets) do
+      add_column :description, String, text: true
+    end
+  end
+end
diff --git a/lib/views/asset.rb b/lib/views/asset.rb
new file mode 100644
index 0000000..3ec7cef
--- /dev/null
+++ b/lib/views/asset.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require "phlex"
+require_relative "layout"
+
+module Domus
+  module Views
+    # The asset detail page — the catalog card for a single tracked thing.
+    # First version: only the identifying title block (eyebrow + name) and the
+    # free-text description. Tags, photos, the maintenance log, info and
+    # documents are held for later.
+    class Asset < Phlex::HTML
+      def initialize(asset:)
+        @asset = asset
+      end
+
+      def view_template
+        render Layout.new(title: "#{@asset[:name]} — Domus") do
+          main(class: "wrap") do
+            header(class: "asset-head") do
+              p(class: "eyebrow") { plain "Asset" }
+              h1(class: "title") { plain @asset[:name] }
+            end
+            description
+          end
+        end
+      end
+
+      private
+
+      # Free-text description, rendered as paragraphs split on blank lines.
+      # Omitted entirely when the asset has none yet.
+      def description
+        text = @asset[:description].to_s.strip
+        return if text.empty?
+
+        div(class: "desc") do
+          text.split(/\n{2,}/).each do |para|
+            p { plain para.strip }
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/lib/web.rb b/lib/web.rb
index fe417a7..ad4a177 100644
--- a/lib/web.rb
+++ b/lib/web.rb
@@ -4,6 +4,7 @@ require "roda"
 require "fileutils"
 require_relative "views/layout"
 require_relative "views/home"
+require_relative "views/asset"
 
 module Domus
   # Raised when a request can't be processed because of client input. The
@@ -53,6 +54,17 @@ module Domus
           r.redirect "/"
         end
       end
+
+      r.on "assets" do
+        r.is Integer do |id|
+          r.get do
+            asset = db[:assets].first(id:)
+            raise ClientError.new("Asset not found.", status: 404) unless asset
+
+            Views::Asset.new(asset:).call
+          end
+        end
+      end
     end
 
     private
diff --git a/public/app.css b/public/app.css
index 22f5cf1..2c2e332 100644
--- a/public/app.css
+++ b/public/app.css
@@ -534,3 +534,41 @@ body {
   }
   .menu { left: 0; right: 0; min-width: 0; }
 }
+
+/* ════════════════════════════════════════════════════════════════════════
+   Asset detail — the catalog card for a single tracked thing
+   ════════════════════════════════════════════════════════════════════════ */
+
+/* ---- title block: catalog eyebrow + name ---- */
+.asset-head {
+  margin-bottom: var(--space-l);
+}
+
+.asset-head .eyebrow {
+  margin: 0 0 var(--space-2xs);
+  font-family: var(--font-mono);
+  font-size: var(--step--2);
+  font-weight: 500;
+  line-height: 1;
+  text-transform: uppercase;
+  letter-spacing: 0.16em;
+  color: var(--w-ink-3);
+}
+
+.asset-head .title {
+  margin: 0;
+  font-family: var(--font-serif);
+  font-weight: 700;
+  font-size: var(--step-3);
+  letter-spacing: -0.018em;
+  line-height: 1.04;
+}
+
+/* ---- free-text description ---- */
+.desc {
+  max-width: 54ch;
+  line-height: 1.62;
+  color: var(--w-ink);
+}
+.desc p { margin: 0; }
+.desc p + p { margin-top: var(--space-3xs); }
diff --git a/test/test_app.rb b/test/test_app.rb
index b4478c7..0461087 100644
--- a/test/test_app.rb
+++ b/test/test_app.rb
@@ -54,6 +54,43 @@ class TestApp < Minitest::Test
     refute_includes body, 'href="/capture"'
   end
 
+  def test_asset_detail_renders_title
+    id = domus.db[:assets].insert(name: "Bosch 800 dishwasher", created_at: Time.now)
+
+    get "/assets/#{id}"
+    assert_equal 200, last_response.status
+    body = last_response.body
+    assert_includes body, "Bosch 800 dishwasher"
+    assert_includes body, "Asset"
+  end
+
+  def test_asset_detail_renders_description
+    id = domus.db[:assets].insert(
+      name: "Bosch 800 dishwasher",
+      description: "Stainless interior, third rack.\n\nReplaces the GE that flooded.",
+      created_at: Time.now
+    )
+
+    get "/assets/#{id}"
+    assert_equal 200, last_response.status
+    body = last_response.body
+    assert_includes body, "Stainless interior, third rack."
+    assert_includes body, "Replaces the GE that flooded."
+  end
+
+  def test_asset_detail_omits_description_when_absent
+    id = domus.db[:assets].insert(name: "Untitled", created_at: Time.now)
+
+    get "/assets/#{id}"
+    assert_equal 200, last_response.status
+    refute_includes last_response.body, 'class="desc"'
+  end
+
+  def test_asset_detail_missing_is_404
+    get "/assets/999999"
+    assert_equal 404, last_response.status
+  end
+
   def test_upload_image_saves_file_and_redirects
     post "/files", "file" => upload("photo.png", "image/png", "fake-png-bytes")