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
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")