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