Build out the asset page to match the Domus Asset comp
Follow Direction A of the comp: a breadcrumb + actions bar, the title with
tags, then a two-column catalog — description and maintenance log on the
left; photos, info and documents on the right. Title, description and
attached photos are wired to data; tags, the log, info and documents are
placeholders (held space) matching the comp until those features land.

Adds the comp's chev-left, dots, pin and plus icons, and ports the
catalog atoms from the comp's stylesheet, mapping fixed px type/spacing
onto the --step-*/--space-* tokens.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N92HLTepkg3AtRpDUyZ54o
change
commit 7aa973753e16bb47c4b38d35af3d56e6406c5193
author Claude <noreply@anthropic.com>
date
parent 7bed66cc
diff --git a/lib/views/asset.rb b/lib/views/asset.rb
index 29730a7..4db4359 100644
--- a/lib/views/asset.rb
+++ b/lib/views/asset.rb
@@ -2,14 +2,21 @@
 
 require "phlex"
 require_relative "layout"
+require_relative "icons"
 
 module Domus
   module Views
-    # The asset detail page — the catalog card for a single tracked thing.
-    # First version: the identifying title block (eyebrow + name), the
-    # free-text description, and the attached photos. Tags, the maintenance
-    # log, info and documents are held for later.
+    # The asset detail page — the catalog card for a single tracked thing,
+    # following the "Domus Asset" comp (Direction A, simplified). A breadcrumb
+    # and the title/tags head, then a two-column catalog: description and the
+    # maintenance log on the left; photos, info and documents on the right.
+    #
+    # Only the title, description and attached photos are wired to data yet.
+    # The tags, maintenance log, info and documents are held space — rendered
+    # as placeholders matching the comp until those features land.
     class Asset < Phlex::HTML
+      include Icons
+
       def initialize(asset:, images: [])
         @asset = asset
         @images = images
@@ -17,19 +24,54 @@ module Domus
 
       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] }
+          main(class: "asset") do
+            topbar
+            head
+            div(class: "asset-grid") do
+              div(class: "col-main") do
+                description
+                maintenance_log
+              end
+              div(class: "col-side") do
+                photos
+                info
+                documents
+              end
             end
-            description
-            photos
           end
         end
       end
 
       private
 
+      def topbar
+        div(class: "asset-top") do
+          div(class: "crumbs") do
+            a(href: "/") { icon("chev-left"); plain "Assets" }
+          end
+          div(class: "asset-actions") do
+            button(type: "button", class: "iconbtn", "aria-label": "Asset actions") { icon("dots") }
+          end
+        end
+      end
+
+      def head
+        header(class: "asset-head") do
+          p(class: "eyebrow") { plain "Asset" }
+          h1(class: "title") { plain @asset[:name] }
+          tags
+        end
+      end
+
+      # Placeholder tags until tagging is wired.
+      def tags
+        div(class: "tags") do
+          span(class: "tag loc") { icon("pin"); plain "kitchen" }
+          span(class: "tag") { plain "appliance" }
+          span(class: "tag add") { plain "+ tag" }
+        end
+      end
+
       # Free-text description, rendered as paragraphs split on blank lines.
       # Omitted entirely when the asset has none yet.
       def description
@@ -37,25 +79,62 @@ module Domus
         return if text.empty?
 
         div(class: "desc") do
-          text.split(/\n{2,}/).each do |para|
-            p { plain para.strip }
+          text.split(/\n{2,}/).each { |para| p { plain para.strip } }
+        end
+      end
+
+      # Held space: the date+text log. Shows only the add row until logging
+      # is wired.
+      def maintenance_log
+        section(class: "panel") do
+          div(class: "panel-h") do
+            h2(class: "sec") { plain "Maintenance log" }
+            span(class: "note mono") { plain "0 ENTRIES" }
+          end
+          div(class: "card log") do
+            div(class: "logrow add") do
+              span(class: "d") { plain "today" }
+              div(class: "t") { plain "Log maintenance…" }
+            end
           end
         end
       end
 
-      # The captionless photo grid. Each tile is an <img> served by the
-      # GET /files/:id route. Omitted entirely when nothing is attached.
+      # The captionless photo grid: each attached file plus an add affordance.
       def photos
-        return if @images.empty?
-
-        section(class: "asset-photos") do
-          h2(class: "photos-h") { plain "Photos" }
+        section(class: "panel") do
+          div(class: "panel-h") { h3(class: "sub") { plain "Photos" } }
           div(class: "photos") do
             @images.each do |file|
               div(class: "shot") do
                 img(src: "/files/#{file[:id]}#{file[:extension]}", alt: "Photo of #{@asset[:name]}", loading: "lazy")
               end
             end
+            button(type: "button", class: "addphoto") { icon("camera"); plain "add" }
+          end
+        end
+      end
+
+      # Held space for purchase / model / serial details.
+      def info
+        section(class: "panel") do
+          div(class: "panel-h") { h3(class: "sub") { plain "Info" } }
+          div(class: "card empty") do
+            div(class: "msg") { plain "Purchase, model, serial and more — add details when you have them." }
+            button(type: "button", class: "ghostadd") { icon("plus"); plain "Add details" }
+          end
+        end
+      end
+
+      # Held space for attached documents.
+      def documents
+        section(class: "panel") do
+          div(class: "panel-h") { h3(class: "sub") { plain "Documents" } }
+          div(class: "card") do
+            div(class: "doc addrow") do
+              span(class: "dico") { icon("plus") }
+              span(class: "nm") { plain "Attach a document…" }
+            end
           end
         end
       end
diff --git a/public/app.css b/public/app.css
index f6ac49b..3736578 100644
--- a/public/app.css
+++ b/public/app.css
@@ -538,13 +538,60 @@ body {
 }
 
 /* ════════════════════════════════════════════════════════════════════════
-   Asset detail — the catalog card for a single tracked thing
+   Asset detail — two-column catalog (the "Domus Asset" comp, Direction A)
    ════════════════════════════════════════════════════════════════════════ */
+.asset {
+  --pad: var(--space-m);     /* card inset */
+  --row-y: var(--space-xs);  /* list row vertical padding */
+  --sec-gap: var(--space-l); /* gap between stacked sections */
 
-/* ---- title block: catalog eyebrow + name ---- */
-.asset-head {
-  margin-bottom: var(--space-l);
+  width: 100%;
+  max-width: 1080px;
+  margin: 0 auto;
+  padding: var(--space-l);
+}
+.asset svg { flex: none; }
+
+/* ---- top bar: breadcrumb + actions ---- */
+.asset-top {
+  display: flex;
+  align-items: center;
+  margin-bottom: var(--space-s);
+}
+.asset-actions { margin-left: auto; }
+
+.asset .crumbs { display: flex; align-items: center; gap: var(--space-2xs); }
+.asset .crumbs a {
+  display: inline-flex;
+  align-items: center;
+  gap: var(--space-3xs);
+  font-family: var(--font-mono);
+  font-size: var(--step--2);
+  letter-spacing: 0.06em;
+  text-transform: uppercase;
+  color: var(--w-ink-3);
+  text-decoration: none;
 }
+.asset .crumbs a:hover { color: var(--w-accent-ink); }
+.asset .crumbs svg { width: 13px; height: 13px; }
+
+.asset .iconbtn {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 36px; height: 36px;
+  border: 1px solid var(--w-line-2);
+  border-radius: calc(var(--radius) - 5px);
+  background: var(--w-surface);
+  color: var(--w-ink);
+  cursor: pointer;
+  transition: background .14s ease;
+}
+.asset .iconbtn:hover { background: var(--w-fill); }
+.asset .iconbtn svg { width: 16px; height: 16px; }
+
+/* ---- title block: catalog eyebrow + name + tags ---- */
+.asset-head { margin-bottom: var(--space-s); }
 
 .asset-head .eyebrow {
   margin: 0 0 var(--space-2xs);
@@ -566,43 +613,204 @@ body {
   line-height: 1.04;
 }
 
-/* ---- free-text description ---- */
-.desc {
-  max-width: 54ch;
-  line-height: 1.62;
-  color: var(--w-ink);
+/* ---- tags ---- */
+.asset .tags {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: var(--space-2xs);
+  margin-top: var(--space-xs);
+}
+.asset .tag {
+  display: inline-flex;
+  align-items: center;
+  gap: var(--space-3xs);
+  font-family: var(--font-mono);
+  font-size: var(--step--2);
+  padding: var(--space-3xs) var(--space-2xs);
+  border-radius: 7px;
+  background: var(--w-fill);
+  border: 1px solid var(--w-line);
+  color: var(--w-ink-2);
 }
-.desc p { margin: 0; }
-.desc p + p { margin-top: var(--space-3xs); }
+.asset .tag svg { width: 11px; height: 11px; }
+.asset .tag.loc {
+  background: var(--w-accent-soft);
+  border-color: color-mix(in oklch, var(--w-accent) 20%, var(--w-line));
+  color: var(--w-accent-ink);
+}
+.asset .tag.add {
+  background: none;
+  border: 1px dashed var(--w-line-2);
+  color: var(--w-ink-3);
+  cursor: pointer;
+}
+.asset .tag.add:hover { color: var(--w-accent-ink); border-color: var(--w-accent); }
 
-/* ---- attached photos ---- */
-.asset-photos { margin-top: var(--space-l); }
+/* ---- two-column catalog ---- */
+.asset-grid {
+  display: grid;
+  grid-template-columns: 1.55fr 1fr;
+  gap: var(--space-l);
+  margin-top: var(--sec-gap);
+  align-items: start;
+}
+.col-main,
+.col-side {
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  gap: var(--sec-gap);
+}
 
-.asset-photos .photos-h {
-  margin: 0 0 var(--space-s);
+/* ---- section heads ---- */
+.asset .panel-h {
+  display: flex;
+  align-items: baseline;
+  gap: var(--space-2xs);
+  margin-bottom: var(--space-xs);
+}
+.asset .panel-h .note { margin-left: auto; }
+.asset .sec {
+  margin: 0;
+  font-family: var(--font-serif);
+  font-weight: 700;
+  font-size: var(--step-1);
+  letter-spacing: -0.012em;
+}
+.asset .sub {
+  margin: 0;
   font-family: var(--font-serif);
   font-weight: 700;
   font-size: var(--step-0);
   letter-spacing: -0.01em;
 }
+.asset .mono {
+  font-family: var(--font-mono);
+  font-size: var(--step--2);
+  letter-spacing: 0.04em;
+  color: var(--w-ink-3);
+}
 
-.photos {
+/* ---- free-text description ---- */
+.asset .desc { max-width: 54ch; line-height: 1.62; color: var(--w-ink); }
+.asset .desc p { margin: 0; }
+.asset .desc p + p { margin-top: var(--space-3xs); }
+
+/* ---- card surface ---- */
+.asset .card {
+  background: var(--w-surface);
+  border: 1px solid var(--w-line);
+  border-radius: var(--radius);
+}
+
+/* ---- maintenance log (date + text rows) ---- */
+.asset .logrow {
+  display: flex;
+  align-items: baseline;
+  gap: var(--space-s);
+  padding: var(--row-y) var(--pad);
+  border-top: 1px solid var(--w-line);
+}
+.asset .logrow:first-child { border-top: 0; }
+.asset .logrow .d {
+  flex: none;
+  width: 96px;
+  font-family: var(--font-mono);
+  font-size: var(--step--2);
+  letter-spacing: 0.02em;
+  color: var(--w-ink-2);
+}
+.asset .logrow .t { flex: 1; min-width: 0; }
+.asset .logrow.add { color: var(--w-ink-3); cursor: pointer; }
+.asset .logrow.add:hover { background: var(--w-fill); }
+
+/* ---- photo grid ---- */
+.asset .photos {
   display: grid;
   grid-template-columns: repeat(3, 1fr);
   gap: var(--space-2xs);
 }
-
-.photos .shot {
+.asset .photos .shot {
   aspect-ratio: 4 / 3;
   overflow: hidden;
   border: 1px solid var(--w-line-2);
   border-radius: calc(var(--radius) - 4px);
   background: var(--w-fill);
 }
-
-.photos .shot img {
+.asset .photos .shot img {
   width: 100%;
   height: 100%;
   object-fit: cover;
   display: block;
 }
+.asset .photos .addphoto {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: var(--space-3xs);
+  aspect-ratio: 4 / 3;
+  border: 1px dashed var(--w-line-2);
+  border-radius: calc(var(--radius) - 4px);
+  background: none;
+  color: var(--w-ink-3);
+  cursor: pointer;
+  font-family: var(--font-mono);
+  font-size: var(--step--2);
+  letter-spacing: 0.06em;
+  text-transform: uppercase;
+}
+.asset .photos .addphoto:hover { color: var(--w-accent-ink); border-color: var(--w-accent); }
+.asset .photos .addphoto svg { width: 18px; height: 18px; }
+
+/* ---- info empty state ---- */
+.asset .empty { padding: var(--pad); }
+.asset .empty .msg { max-width: 34ch; line-height: 1.5; color: var(--w-ink-3); }
+.asset .ghostadd {
+  display: inline-flex;
+  align-items: center;
+  gap: var(--space-3xs);
+  margin-top: var(--space-2xs);
+  padding: 0;
+  border: 0;
+  background: none;
+  font-family: var(--font-ui);
+  font-size: var(--step--1);
+  font-weight: 550;
+  letter-spacing: -0.01em;
+  color: var(--w-ink-2);
+  cursor: pointer;
+}
+.asset .ghostadd:hover { color: var(--w-accent-ink); }
+.asset .ghostadd svg { width: 16px; height: 16px; color: var(--w-accent); }
+
+/* ---- document rows ---- */
+.asset .doc {
+  display: flex;
+  align-items: center;
+  gap: var(--space-2xs);
+  padding: var(--row-y) var(--pad);
+}
+.asset .doc .dico {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 34px; height: 34px;
+  flex: none;
+  border-radius: 9px;
+  background: var(--w-fill);
+  border: 1px solid var(--w-line);
+  color: var(--w-ink-2);
+}
+.asset .doc .dico svg { width: 16px; height: 16px; }
+.asset .doc .nm { flex: 1; min-width: 0; font-size: var(--step--1); }
+.asset .doc.addrow { color: var(--w-ink-3); cursor: pointer; }
+.asset .doc.addrow .dico { background: none; border: 1px dashed var(--w-line-2); }
+.asset .doc.addrow:hover { background: var(--w-fill); }
+
+/* ---- single column on narrow viewports ---- */
+@media (max-width: 760px) {
+  .asset { padding: var(--space-m) var(--space-s); }
+  .asset-grid { grid-template-columns: 1fr; gap: var(--sec-gap); }
+}
diff --git a/public/icons/chev-left.svg b/public/icons/chev-left.svg
new file mode 100644
index 0000000..716087f
--- /dev/null
+++ b/public/icons/chev-left.svg
@@ -0,0 +1,3 @@
+<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="M15 6l-6 6 6 6"/>
+</svg>
diff --git a/public/icons/dots.svg b/public/icons/dots.svg
new file mode 100644
index 0000000..807f5e9
--- /dev/null
+++ b/public/icons/dots.svg
@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true">
+  <circle cx="5" cy="12" r="1.5"/>
+  <circle cx="12" cy="12" r="1.5"/>
+  <circle cx="19" cy="12" r="1.5"/>
+</svg>
diff --git a/public/icons/pin.svg b/public/icons/pin.svg
new file mode 100644
index 0000000..b9645dc
--- /dev/null
+++ b/public/icons/pin.svg
@@ -0,0 +1,4 @@
+<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 21s6-5.3 6-10a6 6 0 1 0-12 0c0 4.7 6 10 6 10z"/>
+  <circle cx="12" cy="11" r="2.2"/>
+</svg>
diff --git a/public/icons/plus.svg b/public/icons/plus.svg
new file mode 100644
index 0000000..7f3b417
--- /dev/null
+++ b/public/icons/plus.svg
@@ -0,0 +1,3 @@
+<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 5v14M5 12h14"/>
+</svg>
diff --git a/test/test_app.rb b/test/test_app.rb
index 3cea785..4d70625 100644
--- a/test/test_app.rb
+++ b/test/test_app.rb
@@ -110,12 +110,15 @@ class TestApp < Minitest::Test
     assert_includes last_response.body, %(src="/files/#{file_id}.png")
   end
 
-  def test_asset_detail_without_images_omits_photos
+  def test_asset_detail_without_images_shows_photos_add_affordance
     id = domus.db[:assets].insert(name: "Bare", created_at: Time.now)
 
     get "/assets/#{id}"
     assert_equal 200, last_response.status
-    refute_includes last_response.body, 'class="photos"'
+    # The photos section always renders (with the add affordance); there
+    # just aren't any <img> tiles when nothing is attached.
+    assert_includes last_response.body, "addphoto"
+    refute_includes last_response.body, "<img"
   end
 
   def test_get_file_serves_stored_image