← back to Lifestyle Asset Intel
server + console + valuation API + smoke tests
cfa428ebdfb6adc4e22bfe894fc0959bfb3bb86b · 2026-05-09 21:36:58 -0700 · Steve Abrams
Express bootstrap (server.js) follows NPH middleware order: helmet CSP →
compression → morgan → body parsers → static → locals → routes → 404 →
error. Three route surfaces:
GET / internal market-ops console (EJS)
GET /asset/:slug detail view w/ value band + comps + Schema.org Product
GET /indices/:slug benchmark detail
GET /api/health liveness + db_ok
GET /api/sources tiered source registry
GET /api/assets catalog list
GET /api/valuation/:slug blueprint-shaped { asset, latest_snapshot, comps[], confidence_breakdown }
GET /api/index/:slug time-series points
GET /api/portfolio?owner=… stub holdings list
lib/valuation.js is a SQL-only stub — its file-level docstring locks down
that the response shape is the future-API contract; only its body changes
when real ML lands in v0.5.
Console enforces every standing-rule grid control: 8-option sort <select>,
3–8 column density slider, live grid-search input, sun/moon dark/light
toggle (anti-flash inline + CSS-var inversion), hamburger nav, and
Schema.org JSON-LD (WebSite root + Product on detail). All persisted to
localStorage. Footer renders an explicit TODO until BRAND_DOMAIN is set,
so v0.1 (info@<domain> Purelymail provisioning) can't be silently skipped.
7 smoke tests via node --test cover health, sources, valuation contract
shape, index time-series, console controls, and asset-page schema.org.
All green against the seeded database.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
A lib/valuation.jsA public/css/app.cssA public/js/app.jsA routes/api.jsA routes/console.jsA server.jsA tests/smoke.test.jsA views/asset.ejsA views/error.ejsA views/index.ejsA views/index_detail.ejsA views/partials/foot.ejsA views/partials/head.ejs
Diff
commit cfa428ebdfb6adc4e22bfe894fc0959bfb3bb86b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat May 9 21:36:58 2026 -0700
server + console + valuation API + smoke tests
Express bootstrap (server.js) follows NPH middleware order: helmet CSP →
compression → morgan → body parsers → static → locals → routes → 404 →
error. Three route surfaces:
GET / internal market-ops console (EJS)
GET /asset/:slug detail view w/ value band + comps + Schema.org Product
GET /indices/:slug benchmark detail
GET /api/health liveness + db_ok
GET /api/sources tiered source registry
GET /api/assets catalog list
GET /api/valuation/:slug blueprint-shaped { asset, latest_snapshot, comps[], confidence_breakdown }
GET /api/index/:slug time-series points
GET /api/portfolio?owner=… stub holdings list
lib/valuation.js is a SQL-only stub — its file-level docstring locks down
that the response shape is the future-API contract; only its body changes
when real ML lands in v0.5.
Console enforces every standing-rule grid control: 8-option sort <select>,
3–8 column density slider, live grid-search input, sun/moon dark/light
toggle (anti-flash inline + CSS-var inversion), hamburger nav, and
Schema.org JSON-LD (WebSite root + Product on detail). All persisted to
localStorage. Footer renders an explicit TODO until BRAND_DOMAIN is set,
so v0.1 (info@<domain> Purelymail provisioning) can't be silently skipped.
7 smoke tests via node --test cover health, sources, valuation contract
shape, index time-series, console controls, and asset-page schema.org.
All green against the seeded database.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
lib/valuation.js | 136 ++++++++++++++++++++++++++++++++++++++++++++++++
public/css/app.css | 135 +++++++++++++++++++++++++++++++++++++++++++++++
public/js/app.js | 93 +++++++++++++++++++++++++++++++++
routes/api.js | 68 ++++++++++++++++++++++++
routes/console.js | 45 ++++++++++++++++
server.js | 94 +++++++++++++++++++++++++++++++++
tests/smoke.test.js | 115 ++++++++++++++++++++++++++++++++++++++++
views/asset.ejs | 95 +++++++++++++++++++++++++++++++++
views/error.ejs | 7 +++
views/index.ejs | 70 +++++++++++++++++++++++++
views/index_detail.ejs | 32 ++++++++++++
views/partials/foot.ejs | 13 +++++
views/partials/head.ejs | 41 +++++++++++++++
13 files changed, 944 insertions(+)
diff --git a/lib/valuation.js b/lib/valuation.js
new file mode 100644
index 0000000..1224652
--- /dev/null
+++ b/lib/valuation.js
@@ -0,0 +1,136 @@
+// valuation.js — v0 stub valuation engine.
+//
+// This file is the contract that future ML replaces in v0.5+. It computes a
+// blueprint-shaped { asset, latest_snapshot, comps, confidence_breakdown }
+// response from the seeded transactions table using simple SQL aggregations
+// — no models, no embeddings, no calibration.
+//
+// When real valuation lands, ONLY the body of `valueAsset()` should change;
+// the response shape is the public API contract.
+
+const { many, one } = require('./db');
+
+async function valueAsset(slug) {
+ const asset = await one(
+ `SELECT ca.*, mf.name AS family_name, mf.slug AS family_slug,
+ b.name AS brand_name, b.slug AS brand_slug
+ FROM canonical_assets ca
+ JOIN model_families mf ON mf.id = ca.model_family_id
+ JOIN brands b ON b.id = mf.brand_id
+ WHERE ca.slug = $1`,
+ [slug]
+ );
+ if (!asset) return null;
+
+ const comps = await many(
+ `SELECT t.*, s.slug AS source_slug, s.name AS source_name, s.tier AS source_tier
+ FROM transactions t
+ JOIN sources s ON s.id = t.source_id
+ WHERE t.canonical_asset_id = $1
+ ORDER BY t.transacted_at DESC
+ LIMIT 50`,
+ [asset.id]
+ );
+
+ const snapshot = await one(
+ `SELECT *
+ FROM valuation_snapshots
+ WHERE canonical_asset_id = $1
+ ORDER BY as_of DESC
+ LIMIT 1`,
+ [asset.id]
+ );
+
+ return {
+ asset: {
+ slug: asset.slug,
+ brand: asset.brand_name,
+ family: asset.family_name,
+ size: asset.size,
+ material: asset.material,
+ color: asset.color,
+ hardware: asset.hardware,
+ construction: asset.construction,
+ region: asset.region,
+ attrs: asset.attrs
+ },
+ latest_snapshot: snapshot ? {
+ as_of: snapshot.as_of,
+ q10: numberOrNull(snapshot.q10),
+ q50: numberOrNull(snapshot.q50),
+ q90: numberOrNull(snapshot.q90),
+ liquidity_score: numberOrNull(snapshot.liquidity_score),
+ expected_dts: snapshot.expected_dts,
+ confidence: numberOrNull(snapshot.confidence),
+ auth_risk: numberOrNull(snapshot.auth_risk),
+ comp_count: snapshot.comp_count,
+ methodology_version: snapshot.methodology_version
+ } : null,
+ comps: comps.map((c) => ({
+ source: c.source_slug,
+ source_tier: c.source_tier,
+ transacted_at: c.transacted_at,
+ gross: numberOrNull(c.gross_transaction_price),
+ net: numberOrNull(c.expected_net_seller_proceeds),
+ condition_grade: c.condition_grade,
+ condition_facets: c.condition_facets,
+ currency: c.currency,
+ region: c.region
+ })),
+ confidence_breakdown: snapshot && snapshot.breakdown ? snapshot.breakdown : {}
+ };
+}
+
+async function listAssets() {
+ // Joins back to the latest snapshot per asset for the console grid.
+ return many(
+ `SELECT ca.id, ca.slug,
+ mf.name AS family_name, mf.slug AS family_slug,
+ b.name AS brand_name, b.slug AS brand_slug,
+ ca.size, ca.material, ca.color, ca.hardware, ca.region,
+ vs.q10, vs.q50, vs.q90, vs.liquidity_score, vs.confidence,
+ vs.expected_dts, vs.comp_count, vs.as_of
+ FROM canonical_assets ca
+ JOIN model_families mf ON mf.id = ca.model_family_id
+ JOIN brands b ON b.id = mf.brand_id
+ LEFT JOIN LATERAL (
+ SELECT * FROM valuation_snapshots vs
+ WHERE vs.canonical_asset_id = ca.id
+ ORDER BY vs.as_of DESC LIMIT 1
+ ) vs ON true
+ ORDER BY b.name, mf.name, ca.size, ca.material, ca.color`
+ );
+}
+
+async function getIndex(slug) {
+ const idx = await one(`SELECT * FROM indices WHERE slug = $1`, [slug]);
+ if (!idx) return null;
+ const points = await many(
+ `SELECT as_of, value, change_pct
+ FROM index_points
+ WHERE index_id = $1
+ ORDER BY as_of`,
+ [idx.id]
+ );
+ return {
+ index: {
+ slug: idx.slug,
+ name: idx.name,
+ definition: idx.definition,
+ methodology_version: idx.methodology_version
+ },
+ points: points.map((p) => ({
+ as_of: p.as_of,
+ value: numberOrNull(p.value),
+ change_pct: numberOrNull(p.change_pct)
+ }))
+ };
+}
+
+function numberOrNull(v) {
+ if (v === null || v === undefined) return null;
+ const n = typeof v === 'string' ? parseFloat(v) : v;
+ return Number.isFinite(n) ? n : null;
+}
+
+module.exports = { valueAsset, listAssets, getIndex };
diff --git a/public/css/app.css b/public/css/app.css
new file mode 100644
index 0000000..a2d3a21
--- /dev/null
+++ b/public/css/app.css
@@ -0,0 +1,135 @@
+:root {
+ --bg: #fafafa;
+ --fg: #111;
+ --muted: #666;
+ --line: #e5e5e5;
+ --card: #fff;
+ --accent: #0e7490;
+ --accent-fg: #fff;
+ --good: #0f766e;
+ --warn: #b45309;
+ --bad: #b91c1c;
+ --cols: 5;
+ --radius: 10px;
+ --shadow: 0 1px 2px rgba(0,0,0,0.04), 0 4px 12px rgba(0,0,0,0.04);
+ --mono: ui-monospace, "SF Mono", Menlo, monospace;
+ --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif;
+}
+:root[data-theme="dark"] {
+ --bg: #0b0d10;
+ --fg: #e8eaee;
+ --muted: #8a93a0;
+ --line: #1f242b;
+ --card: #14181d;
+ --accent: #22d3ee;
+ --accent-fg: #06121a;
+ --shadow: 0 1px 2px rgba(0,0,0,0.5), 0 6px 18px rgba(0,0,0,0.4);
+}
+* { box-sizing: border-box; }
+html, body { margin: 0; background: var(--bg); color: var(--fg); font-family: var(--sans); -webkit-font-smoothing: antialiased; }
+a { color: var(--accent); text-decoration: none; }
+a:hover { text-decoration: underline; }
+code, .mono { font-family: var(--mono); font-size: 0.9em; }
+.muted { color: var(--muted); }
+
+.topbar {
+ display: flex; align-items: center; gap: 1rem;
+ padding: 0.6rem 1rem; border-bottom: 1px solid var(--line);
+ background: var(--card); position: sticky; top: 0; z-index: 10;
+}
+.brand { font-weight: 700; letter-spacing: -0.01em; color: var(--fg); }
+.brand-sub { font-weight: 400; color: var(--muted); margin-left: 0.4rem; font-size: 0.85em; }
+.topnav { display: flex; gap: 1rem; margin-left: auto; }
+.topnav a { color: var(--muted); padding: 0.3rem 0.6rem; border-radius: 6px; }
+.topnav a:hover, .topnav a.active { color: var(--fg); background: var(--bg); text-decoration: none; }
+
+.hamburger {
+ display: none; width: 36px; height: 36px; padding: 0;
+ background: transparent; border: 1px solid var(--line); border-radius: 6px;
+ flex-direction: column; align-items: center; justify-content: center; gap: 4px; cursor: pointer;
+}
+.hamburger span { display: block; width: 18px; height: 2px; background: var(--fg); }
+
+.theme-toggle {
+ background: transparent; border: 1px solid var(--line); border-radius: 6px;
+ width: 36px; height: 36px; cursor: pointer; color: var(--fg);
+ display: inline-flex; align-items: center; justify-content: center;
+}
+.theme-toggle .icon-moon { display: none; }
+:root[data-theme="dark"] .theme-toggle .icon-sun { display: none; }
+:root[data-theme="dark"] .theme-toggle .icon-moon { display: inline; }
+
+.container { max-width: 1400px; margin: 0 auto; padding: 1.5rem 1rem 4rem; }
+h1 { margin: 0.4rem 0 0.2rem; font-size: 1.6rem; letter-spacing: -0.01em; }
+h2 { margin: 1.6rem 0 0.6rem; font-size: 1.15rem; }
+.crumbs { margin: 0 0 0.4rem; font-size: 0.9em; }
+
+.controls {
+ display: flex; flex-wrap: wrap; gap: 0.8rem; align-items: center;
+ padding: 0.8rem; border: 1px solid var(--line); border-radius: var(--radius);
+ background: var(--card); margin: 1rem 0;
+}
+.controls label { display: inline-flex; align-items: center; gap: 0.4rem; font-size: 0.9em; color: var(--muted); }
+.controls .grow { flex: 1; min-width: 180px; }
+.controls .grow input[type="search"] { width: 100%; }
+.controls select, .controls input[type="search"] {
+ background: var(--bg); color: var(--fg); border: 1px solid var(--line);
+ padding: 0.4rem 0.6rem; border-radius: 6px; font: inherit;
+}
+.controls input[type="range"] { accent-color: var(--accent); }
+
+.grid {
+ display: grid;
+ grid-template-columns: repeat(var(--cols), minmax(0, 1fr));
+ gap: 0.9rem;
+}
+.card {
+ display: block; padding: 0.85rem; border: 1px solid var(--line); border-radius: var(--radius);
+ background: var(--card); color: var(--fg); box-shadow: var(--shadow);
+ transition: transform 0.08s ease, box-shadow 0.12s ease;
+}
+.card:hover { transform: translateY(-1px); box-shadow: 0 8px 24px rgba(0,0,0,0.08); text-decoration: none; }
+.card-head { display: flex; justify-content: space-between; gap: 0.5rem; font-size: 0.8em; color: var(--muted); }
+.brand-tag { font-weight: 600; color: var(--fg); }
+.family-tag { font-family: var(--mono); }
+.card-body { margin-top: 0.5rem; }
+.material { font-size: 0.85em; color: var(--muted); margin-bottom: 0.5rem; }
+.value-band { display: flex; gap: 0.4rem; align-items: baseline; font-variant-numeric: tabular-nums; }
+.value-band .q10, .value-band .q90 { color: var(--muted); font-size: 0.85em; }
+.value-band .q50 { color: var(--fg); font-size: 1.1em; font-weight: 700; }
+.value-band.big { gap: 1.2rem; align-items: stretch; padding: 0.8rem 0; }
+.value-band.big > div { display: flex; flex-direction: column; padding: 0.4rem 0.8rem; border-radius: 8px; background: var(--bg); }
+.value-band.big > div.mid { background: var(--accent); color: var(--accent-fg); }
+.value-band.big .lbl { font-size: 0.75em; color: var(--muted); letter-spacing: 0.05em; text-transform: uppercase; }
+.value-band.big > div.mid .lbl { color: var(--accent-fg); opacity: 0.85; }
+.value-band.big .val { font-size: 1.3em; font-weight: 700; font-variant-numeric: tabular-nums; }
+.meta { display: flex; gap: 0.7rem; margin-top: 0.5rem; font-size: 0.78em; color: var(--muted); font-variant-numeric: tabular-nums; }
+
+.kpis { list-style: none; padding: 0; margin: 0.6rem 0 0; display: flex; flex-wrap: wrap; gap: 0.6rem; }
+.kpis li { padding: 0.4rem 0.7rem; border: 1px solid var(--line); border-radius: 8px; display: flex; flex-direction: column; min-width: 90px; }
+.kpis .lbl { font-size: 0.7em; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; }
+.kpis .val { font-weight: 600; font-variant-numeric: tabular-nums; }
+
+table { width: 100%; border-collapse: collapse; margin-top: 0.4rem; font-size: 0.92em; }
+th, td { text-align: left; padding: 0.45rem 0.6rem; border-bottom: 1px solid var(--line); }
+th { font-weight: 600; color: var(--muted); font-size: 0.85em; text-transform: uppercase; letter-spacing: 0.04em; }
+td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
+
+pre.json { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 0.8rem; overflow: auto; font-size: 0.85em; }
+
+.empty { grid-column: 1 / -1; padding: 2rem; text-align: center; color: var(--muted); border: 1px dashed var(--line); border-radius: var(--radius); }
+
+.sitefoot {
+ margin-top: 3rem; padding: 1.5rem 1rem; border-top: 1px solid var(--line);
+ display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap;
+ color: var(--muted); font-size: 0.85em;
+}
+.sitefoot .todo { color: var(--warn); font-family: var(--mono); }
+
+@media (max-width: 720px) {
+ .topnav { display: none; flex-direction: column; gap: 0.2rem; position: absolute; top: 100%; left: 0; right: 0;
+ background: var(--card); border-bottom: 1px solid var(--line); padding: 0.5rem 1rem; }
+ .topnav.open { display: flex; }
+ .hamburger { display: inline-flex; }
+ .grid { grid-template-columns: repeat(min(var(--cols), 2), minmax(0, 1fr)); }
+}
diff --git a/public/js/app.js b/public/js/app.js
new file mode 100644
index 0000000..ef6d0d4
--- /dev/null
+++ b/public/js/app.js
@@ -0,0 +1,93 @@
+(function () {
+ 'use strict';
+
+ // -- theme toggle ------------------------------------------------------
+ var themeBtn = document.getElementById('themeToggle');
+ if (themeBtn) {
+ themeBtn.addEventListener('click', function () {
+ var cur = document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light';
+ var next = cur === 'dark' ? 'light' : 'dark';
+ if (next === 'dark') document.documentElement.dataset.theme = 'dark';
+ else delete document.documentElement.dataset.theme;
+ try { localStorage.setItem('lai-theme', next); } catch (e) {}
+ });
+ }
+
+ // -- hamburger nav ----------------------------------------------------
+ var navBtn = document.getElementById('navToggle');
+ var nav = document.getElementById('topnav');
+ if (navBtn && nav) {
+ navBtn.addEventListener('click', function () {
+ var open = nav.classList.toggle('open');
+ navBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
+ });
+ }
+
+ // -- grid: density + sort + search ------------------------------------
+ var grid = document.getElementById('grid');
+ if (!grid) return;
+
+ var density = document.getElementById('density');
+ var densityOut = document.getElementById('densityOut');
+ var sort = document.getElementById('sort');
+ var search = document.getElementById('search');
+
+ var initial = {
+ density: parseInt(localStorage.getItem('lai-density') || '5', 10),
+ sort: localStorage.getItem('lai-sort') || 'newest',
+ search: localStorage.getItem('lai-search') || ''
+ };
+
+ function applyDensity(n) {
+ document.documentElement.style.setProperty('--cols', String(n));
+ if (densityOut) densityOut.textContent = String(n);
+ try { localStorage.setItem('lai-density', String(n)); } catch (e) {}
+ }
+ function applySort(mode) {
+ var cards = Array.prototype.slice.call(grid.querySelectorAll('.card'));
+ var num = function (el, key) { return parseFloat(el.getAttribute('data-' + key) || '0') || 0; };
+ var str = function (el, key) { return (el.getAttribute('data-' + key) || '').toLowerCase(); };
+ var cmp;
+ switch (mode) {
+ case 'brand': cmp = function (a, b) { return str(a,'brand').localeCompare(str(b,'brand')); }; break;
+ case 'family': cmp = function (a, b) { return str(a,'family').localeCompare(str(b,'family')); }; break;
+ case 'q50_asc': cmp = function (a, b) { return num(a,'q50') - num(b,'q50'); }; break;
+ case 'q50_desc': cmp = function (a, b) { return num(b,'q50') - num(a,'q50'); }; break;
+ case 'liquidity_desc': cmp = function (a, b) { return num(b,'liquidity') - num(a,'liquidity'); }; break;
+ case 'confidence_desc': cmp = function (a, b) { return num(b,'confidence') - num(a,'confidence'); }; break;
+ case 'recency_desc': cmp = function (a, b) { return str(b,'asof').localeCompare(str(a,'asof')); }; break;
+ case 'newest':
+ default: cmp = function (a, b) { return str(b,'asof').localeCompare(str(a,'asof')); };
+ }
+ cards.sort(cmp).forEach(function (c) { grid.appendChild(c); });
+ try { localStorage.setItem('lai-sort', mode); } catch (e) {}
+ }
+ function applySearch(q) {
+ var query = (q || '').toLowerCase().trim();
+ var cards = grid.querySelectorAll('.card');
+ cards.forEach(function (c) {
+ if (!query) { c.style.display = ''; return; }
+ var hay = ['brand','family','material','color']
+ .map(function (k) { return (c.getAttribute('data-' + k) || '').toLowerCase(); })
+ .join(' ');
+ c.style.display = hay.indexOf(query) >= 0 ? '' : 'none';
+ });
+ try { localStorage.setItem('lai-search', query); } catch (e) {}
+ }
+
+ if (density) {
+ density.value = String(initial.density);
+ applyDensity(initial.density);
+ density.addEventListener('input', function () { applyDensity(parseInt(density.value, 10)); });
+ }
+ if (sort) {
+ sort.value = initial.sort;
+ applySort(initial.sort);
+ sort.addEventListener('change', function () { applySort(sort.value); });
+ }
+ if (search) {
+ search.value = initial.search;
+ if (initial.search) applySearch(initial.search);
+ search.addEventListener('input', function () { applySearch(search.value); });
+ }
+})();
diff --git a/routes/api.js b/routes/api.js
new file mode 100644
index 0000000..b71c8ee
--- /dev/null
+++ b/routes/api.js
@@ -0,0 +1,68 @@
+const express = require('express');
+const { pool } = require('../lib/db');
+const { valueAsset, listAssets, getIndex } = require('../lib/valuation');
+
+const router = express.Router();
+
+router.get('/health', async (req, res) => {
+ let dbOk = false;
+ try {
+ await pool.query('SELECT 1');
+ dbOk = true;
+ } catch (err) {
+ console.error('[health] db check failed:', err.message);
+ }
+ res.json({ ok: true, version: '0.0.1', db_ok: dbOk });
+});
+
+router.get('/sources', async (req, res, next) => {
+ try {
+ const r = await pool.query(`SELECT slug, name, tier, kind, weight, enabled, url, notes
+ FROM sources
+ ORDER BY tier, name`);
+ res.json({ sources: r.rows });
+ } catch (e) { next(e); }
+});
+
+router.get('/assets', async (req, res, next) => {
+ try {
+ const rows = await listAssets();
+ res.json({ assets: rows });
+ } catch (e) { next(e); }
+});
+
+router.get('/valuation/:slug', async (req, res, next) => {
+ try {
+ const out = await valueAsset(req.params.slug);
+ if (!out) return res.status(404).json({ error: 'asset_not_found' });
+ res.json(out);
+ } catch (e) { next(e); }
+});
+
+router.get('/index/:slug', async (req, res, next) => {
+ try {
+ const out = await getIndex(req.params.slug);
+ if (!out) return res.status(404).json({ error: 'index_not_found' });
+ res.json(out);
+ } catch (e) { next(e); }
+});
+
+router.get('/portfolio', async (req, res, next) => {
+ try {
+ const owner = req.query.owner;
+ if (!owner) return res.status(400).json({ error: 'owner_query_required' });
+ const r = await pool.query(
+ `SELECT ph.*, ca.slug AS asset_slug, mf.name AS family, b.name AS brand
+ FROM portfolio_holdings ph
+ JOIN canonical_assets ca ON ca.id = ph.canonical_asset_id
+ JOIN model_families mf ON mf.id = ca.model_family_id
+ JOIN brands b ON b.id = mf.brand_id
+ WHERE ph.owner_email = $1
+ ORDER BY ph.acquired_at DESC NULLS LAST`,
+ [owner]
+ );
+ res.json({ owner, holdings: r.rows });
+ } catch (e) { next(e); }
+});
+
+module.exports = router;
diff --git a/routes/console.js b/routes/console.js
new file mode 100644
index 0000000..e68be7d
--- /dev/null
+++ b/routes/console.js
@@ -0,0 +1,45 @@
+const express = require('express');
+const { listAssets, valueAsset, getIndex } = require('../lib/valuation');
+
+const router = express.Router();
+
+router.get('/', async (req, res, next) => {
+ try {
+ const assets = await listAssets();
+ res.render('index', {
+ title: 'Lifestyle Asset Intelligence — Market-Ops Console',
+ assets,
+ path: '/'
+ });
+ } catch (e) { next(e); }
+});
+
+router.get('/asset/:slug', async (req, res, next) => {
+ try {
+ const data = await valueAsset(req.params.slug);
+ if (!data) return res.status(404).render('error', {
+ title: 'Asset not found', message: `No canonical asset with slug "${req.params.slug}"`, path: req.path
+ });
+ res.render('asset', {
+ title: `${data.asset.brand} ${data.asset.family} ${data.asset.size || ''} — ${data.asset.material || ''}`.trim(),
+ data,
+ path: req.path
+ });
+ } catch (e) { next(e); }
+});
+
+router.get('/indices/:slug', async (req, res, next) => {
+ try {
+ const out = await getIndex(req.params.slug);
+ if (!out) return res.status(404).render('error', {
+ title: 'Index not found', message: `No index with slug "${req.params.slug}"`, path: req.path
+ });
+ res.render('index_detail', {
+ title: out.index.name,
+ out,
+ path: req.path
+ });
+ } catch (e) { next(e); }
+});
+
+module.exports = router;
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..194dfd0
--- /dev/null
+++ b/server.js
@@ -0,0 +1,94 @@
+require('dotenv').config();
+
+const express = require('express');
+const path = require('path');
+const morgan = require('morgan');
+const helmet = require('helmet');
+const compression = require('compression');
+
+const apiRoutes = require('./routes/api');
+const consoleRoutes = require('./routes/console');
+
+const app = express();
+const PORT = parseInt(process.env.PORT || '9820', 10);
+const PUBLIC_URL = process.env.PUBLIC_URL || `http://localhost:${PORT}`;
+const IS_PROD = process.env.NODE_ENV === 'production';
+const HTTPS_PUBLIC = PUBLIC_URL.startsWith('https://');
+
+// TODO (v0.1): once a brand domain is chosen, set CONTACT_EMAIL=info@<domain>
+// in .env and provision the mailbox at Purelymail per Steve's standing rule.
+const CONTACT_EMAIL = process.env.CONTACT_EMAIL || '';
+const BRAND_DOMAIN = process.env.BRAND_DOMAIN || '';
+
+app.set('trust proxy', 1);
+app.set('view engine', 'ejs');
+app.set('views', path.join(__dirname, 'views'));
+
+app.use(helmet({
+ contentSecurityPolicy: {
+ directives: {
+ defaultSrc: ["'self'"],
+ styleSrc: ["'self'", "'unsafe-inline'"],
+ scriptSrc: ["'self'", "'unsafe-inline'"],
+ imgSrc: ["'self'", 'data:', 'https:'],
+ connectSrc: ["'self'"],
+ frameAncestors: ["'none'"],
+ formAction: ["'self'"],
+ baseUri: ["'self'"]
+ }
+ },
+ hsts: HTTPS_PUBLIC ? { maxAge: 63072000, includeSubDomains: true, preload: true } : false,
+ referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
+ crossOriginEmbedderPolicy: false
+}));
+
+app.use(compression());
+
+const morganFormat = IS_PROD
+ ? ':remote-addr - :remote-user [:date[clf]] ":method :url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent"'
+ : ':method :url :status :response-time ms - :res[content-length]';
+app.use(morgan(morganFormat));
+
+app.use(express.json({ limit: '512kb' }));
+app.use(express.urlencoded({ extended: true, limit: '512kb' }));
+app.use(express.static(path.join(__dirname, 'public'), { maxAge: '1h' }));
+
+app.use((req, res, next) => {
+ res.locals.publicUrl = PUBLIC_URL;
+ res.locals.path = req.path;
+ res.locals.contactEmail = CONTACT_EMAIL;
+ res.locals.brandDomain = BRAND_DOMAIN;
+ next();
+});
+
+app.use('/api', apiRoutes);
+app.use('/', consoleRoutes);
+
+app.use((req, res) => {
+ res.status(404);
+ if (req.accepts('html')) {
+ return res.render('error', { title: 'Not Found', message: `No route for ${req.path}`, path: req.path });
+ }
+ res.json({ error: 'not_found', path: req.path });
+});
+
+app.use((err, req, res, next) => {
+ console.error('[error]', err);
+ res.status(err.status || 500);
+ if (req.accepts('html')) {
+ return res.render('error', {
+ title: 'Something went wrong',
+ message: IS_PROD ? 'Internal error' : err.message,
+ path: req.path
+ });
+ }
+ res.json({ error: err.message || 'internal_error' });
+});
+
+if (require.main === module) {
+ app.listen(PORT, () => {
+ console.log(`[lai] listening on :${PORT} (${process.env.NODE_ENV || 'development'})`);
+ });
+}
+
+module.exports = app;
diff --git a/tests/smoke.test.js b/tests/smoke.test.js
new file mode 100644
index 0000000..1bb8d5e
--- /dev/null
+++ b/tests/smoke.test.js
@@ -0,0 +1,115 @@
+// smoke.test.js — boots the app on an ephemeral port and hits the v0 contract endpoints.
+//
+// Requires the database to be created/seeded:
+// createdb lifestyle_asset_intel
+// PGDATABASE=lifestyle_asset_intel psql -f db/schema.sql
+// PGDATABASE=lifestyle_asset_intel psql -f db/seed.sql
+
+'use strict';
+
+const { test, after } = require('node:test');
+const assert = require('node:assert/strict');
+
+process.env.NODE_ENV = process.env.NODE_ENV || 'test';
+process.env.PGDATABASE = process.env.PGDATABASE || 'lifestyle_asset_intel';
+
+const app = require('../server');
+const { pool } = require('../lib/db');
+
+let server;
+let baseUrl;
+
+function ready() {
+ return new Promise((resolve) => {
+ server = app.listen(0, '127.0.0.1', () => {
+ const { port } = server.address();
+ baseUrl = `http://127.0.0.1:${port}`;
+ resolve();
+ });
+ });
+}
+
+async function get(path) {
+ const r = await fetch(baseUrl + path);
+ const text = await r.text();
+ let json = null;
+ try { json = JSON.parse(text); } catch (e) { /* not json */ }
+ return { status: r.status, json, text };
+}
+
+test('boots and returns 200 on /api/health with db_ok true', async () => {
+ await ready();
+ const r = await get('/api/health');
+ assert.equal(r.status, 200);
+ assert.equal(r.json.ok, true);
+ assert.equal(r.json.db_ok, true, 'db connection check');
+ assert.equal(r.json.version, '0.0.1');
+});
+
+test('GET /api/sources returns the seeded source registry', async () => {
+ const r = await get('/api/sources');
+ assert.equal(r.status, 200);
+ assert.ok(Array.isArray(r.json.sources));
+ assert.ok(r.json.sources.length >= 4, 'at least four sources seeded');
+ const slugs = r.json.sources.map((s) => s.slug);
+ assert.ok(slugs.includes('sothebys'));
+ assert.ok(slugs.includes('fashionphile'));
+});
+
+test('GET /api/valuation/birkin-30-togo-gold-ghw-us returns blueprint contract shape', async () => {
+ const r = await get('/api/valuation/birkin-30-togo-gold-ghw-us');
+ assert.equal(r.status, 200);
+ assert.ok(r.json.asset, 'asset block present');
+ assert.equal(r.json.asset.brand, 'Hermès');
+ assert.equal(r.json.asset.family, 'Birkin');
+ assert.equal(r.json.asset.size, '30');
+ assert.equal(r.json.asset.material, 'Togo');
+ assert.ok(r.json.latest_snapshot, 'latest_snapshot present');
+ assert.ok(r.json.latest_snapshot.q10 > 0);
+ assert.ok(r.json.latest_snapshot.q50 > r.json.latest_snapshot.q10);
+ assert.ok(r.json.latest_snapshot.q90 >= r.json.latest_snapshot.q50);
+ assert.ok(Array.isArray(r.json.comps));
+ assert.ok(r.json.comps.length >= 4, 'at least four blueprint comps seeded');
+ assert.ok(r.json.confidence_breakdown);
+ assert.ok('source_quality_weight' in r.json.confidence_breakdown);
+ assert.ok('authenticity_risk_penalty' in r.json.confidence_breakdown);
+});
+
+test('GET /api/valuation/<unknown-slug> returns 404 with shape', async () => {
+ const r = await get('/api/valuation/does-not-exist');
+ assert.equal(r.status, 404);
+ assert.equal(r.json.error, 'asset_not_found');
+});
+
+test('GET /api/index/birkin-30-togo-neutral returns time series', async () => {
+ const r = await get('/api/index/birkin-30-togo-neutral');
+ assert.equal(r.status, 200);
+ assert.equal(r.json.index.slug, 'birkin-30-togo-neutral');
+ assert.ok(Array.isArray(r.json.points));
+ assert.ok(r.json.points.length >= 4);
+});
+
+test('GET / renders the console with sort and density controls', async () => {
+ const r = await get('/');
+ assert.equal(r.status, 200);
+ assert.ok(/<select id="sort"/.test(r.text), 'sort <select> present');
+ assert.ok(/id="density"/.test(r.text), 'density <input> present');
+ assert.ok(/id="search"/.test(r.text), 'grid-search <input> present');
+ assert.ok(/id="themeToggle"/.test(r.text), 'dark/light toggle present');
+ assert.ok(/application\/ld\+json/.test(r.text), 'schema.org JSON-LD present');
+});
+
+test('GET /asset/birkin-30-togo-gold-ghw-us renders the detail page', async () => {
+ const r = await get('/asset/birkin-30-togo-gold-ghw-us');
+ assert.equal(r.status, 200);
+ assert.ok(/Hermès/.test(r.text));
+ assert.ok(/Birkin/.test(r.text));
+ assert.ok(/Confidence breakdown/.test(r.text));
+ assert.ok(/Comparable transactions/.test(r.text));
+ assert.ok(/"@type": "Product"/.test(r.text), 'Product schema.org block present');
+});
+
+after(async () => {
+ if (server) await new Promise((res) => server.close(res));
+ await pool.end();
+});
diff --git a/views/asset.ejs b/views/asset.ejs
new file mode 100644
index 0000000..1b2b242
--- /dev/null
+++ b/views/asset.ejs
@@ -0,0 +1,95 @@
+<%- include('partials/head') %>
+<main class="container">
+ <p class="crumbs"><a href="/">← Console</a></p>
+ <h1><%= data.asset.brand %> <%= data.asset.family %> <%= data.asset.size || '' %></h1>
+ <p class="muted">
+ <%= data.asset.material || '—' %> · <%= data.asset.color || '—' %> · <%= data.asset.hardware || '—' %>
+ · <%= data.asset.construction || '—' %> · <%= data.asset.region %>
+ </p>
+
+ <% if (data.latest_snapshot) { %>
+ <section class="snapshot">
+ <h2>Current valuation snapshot · <%= fmtDate(data.latest_snapshot.as_of) %></h2>
+ <div class="value-band big">
+ <div><span class="lbl">Q10</span><span class="val">$<%= money(data.latest_snapshot.q10) %></span></div>
+ <div class="mid"><span class="lbl">Q50</span><span class="val">$<%= money(data.latest_snapshot.q50) %></span></div>
+ <div><span class="lbl">Q90</span><span class="val">$<%= money(data.latest_snapshot.q90) %></span></div>
+ </div>
+ <ul class="kpis">
+ <li><span class="lbl">Liquidity</span><span class="val"><%= pct(data.latest_snapshot.liquidity_score) %></span></li>
+ <li><span class="lbl">Days-to-sell</span><span class="val"><%= data.latest_snapshot.expected_dts ?? '—' %></span></li>
+ <li><span class="lbl">Confidence</span><span class="val"><%= pct(data.latest_snapshot.confidence) %></span></li>
+ <li><span class="lbl">Auth risk</span><span class="val"><%= pct(data.latest_snapshot.auth_risk) %></span></li>
+ <li><span class="lbl">Comps</span><span class="val"><%= data.latest_snapshot.comp_count %></span></li>
+ <li><span class="lbl">Method</span><span class="val mono"><%= data.latest_snapshot.methodology_version %></span></li>
+ </ul>
+ </section>
+
+ <% if (data.confidence_breakdown && Object.keys(data.confidence_breakdown).length) { %>
+ <section class="breakdown">
+ <h2>Confidence breakdown</h2>
+ <table>
+ <thead><tr><th>Component</th><th>Weight</th></tr></thead>
+ <tbody>
+ <% Object.entries(data.confidence_breakdown).forEach(([k,v]) => { %>
+ <tr><td><%= k %></td><td class="num"><%= numFmt(v) %></td></tr>
+ <% }) %>
+ </tbody>
+ </table>
+ </section>
+ <% } %>
+ <% } else { %>
+ <p class="muted">No snapshot yet for this asset.</p>
+ <% } %>
+
+ <section class="comps">
+ <h2>Comparable transactions (<%= data.comps.length %>)</h2>
+ <% if (data.comps.length) { %>
+ <table>
+ <thead><tr><th>Date</th><th>Source</th><th>Tier</th><th>Cond.</th><th>Gross</th><th>Net</th></tr></thead>
+ <tbody>
+ <% data.comps.forEach(c => { %>
+ <tr>
+ <td><%= fmtDate(c.transacted_at) %></td>
+ <td><%= c.source %></td>
+ <td>T<%= c.source_tier %></td>
+ <td><%= grade(c.condition_grade) %></td>
+ <td class="num">$<%= money(c.gross) %></td>
+ <td class="num">$<%= money(c.net) %></td>
+ </tr>
+ <% }) %>
+ </tbody>
+ </table>
+ <% } else { %>
+ <p class="muted">No comps yet.</p>
+ <% } %>
+ </section>
+
+ <script type="application/ld+json">
+ {
+ "@context": "https://schema.org",
+ "@type": "Product",
+ "name": "<%= data.asset.brand %> <%= data.asset.family %> <%= data.asset.size || '' %>",
+ "brand": { "@type": "Brand", "name": "<%= data.asset.brand %>" },
+ "category": "<%= data.asset.family %>",
+ "color": "<%= data.asset.color || '' %>",
+ "material": "<%= data.asset.material || '' %>",
+ "offers": {
+ "@type": "AggregateOffer",
+ "priceCurrency": "USD",
+ "lowPrice": "<%= data.latest_snapshot ? data.latest_snapshot.q10 : '' %>",
+ "highPrice": "<%= data.latest_snapshot ? data.latest_snapshot.q90 : '' %>",
+ "offerCount": "<%= data.latest_snapshot ? data.latest_snapshot.comp_count : 0 %>"
+ }
+ }
+ </script>
+</main>
+
+<%
+function money(v){ if(v==null||v==='') return '—'; var n=parseFloat(v); return isFinite(n)?n.toLocaleString('en-US',{maximumFractionDigits:0}):'—'; }
+function pct(v){ if(v==null||v==='') return '—'; var n=parseFloat(v); return isFinite(n)?Math.round(n*100)+'%':'—'; }
+function numFmt(v){ if(v==null) return '—'; var n=parseFloat(v); return isFinite(n)?n.toFixed(2):String(v); }
+function fmtDate(v){ if(!v) return '—'; try { return new Date(v).toISOString().slice(0,10); } catch(e){ return String(v); } }
+function grade(g){ const labels=['As Is','Fair','Good','Very Good','Excellent','Pristine','Giftable']; return (g==null) ? '—' : (labels[g] || g); }
+%>
+<%- include('partials/foot') %>
diff --git a/views/error.ejs b/views/error.ejs
new file mode 100644
index 0000000..85321e2
--- /dev/null
+++ b/views/error.ejs
@@ -0,0 +1,7 @@
+<%- include('partials/head') %>
+<main class="container">
+ <h1><%= title %></h1>
+ <p><%= message %></p>
+ <p><a href="/">Back to console</a></p>
+</main>
+<%- include('partials/foot') %>
diff --git a/views/index.ejs b/views/index.ejs
new file mode 100644
index 0000000..0ad1e31
--- /dev/null
+++ b/views/index.ejs
@@ -0,0 +1,70 @@
+<%- include('partials/head') %>
+<main class="container">
+ <h1>Market-Ops Console</h1>
+ <p class="muted">v0 stub — internal only. Sample data is the blueprint's Birkin 30 Togo dataset; everything below is shaped per the future enterprise valuation API contract.</p>
+
+ <div class="controls" role="region" aria-label="Grid controls">
+ <label>Sort
+ <select id="sort">
+ <option value="newest">Newest snapshot</option>
+ <option value="brand">Brand A→Z</option>
+ <option value="family">Family A→Z</option>
+ <option value="q50_asc">Q50 ascending</option>
+ <option value="q50_desc">Q50 descending</option>
+ <option value="liquidity_desc">Liquidity ↓</option>
+ <option value="confidence_desc">Confidence ↓</option>
+ <option value="recency_desc">Recently revalued</option>
+ </select>
+ </label>
+ <label>Density
+ <input type="range" id="density" min="3" max="8" step="1" value="5" aria-label="Grid density">
+ <output id="densityOut">5</output>
+ </label>
+ <label class="grow">Search
+ <input type="search" id="search" placeholder="brand, family, material, color…" aria-label="Filter assets">
+ </label>
+ </div>
+
+ <section class="grid" id="grid">
+ <% assets.forEach(a => { %>
+ <a class="card"
+ href="/asset/<%= a.slug %>"
+ data-brand="<%= a.brand_name %>"
+ data-family="<%= a.family_name %>"
+ data-material="<%= a.material || '' %>"
+ data-color="<%= a.color || '' %>"
+ data-q50="<%= a.q50 || 0 %>"
+ data-liquidity="<%= a.liquidity_score || 0 %>"
+ data-confidence="<%= a.confidence || 0 %>"
+ data-asof="<%= a.as_of || '' %>">
+ <div class="card-head">
+ <span class="brand-tag"><%= a.brand_name %></span>
+ <span class="family-tag"><%= a.family_name %> <%= a.size || '' %></span>
+ </div>
+ <div class="card-body">
+ <div class="material"><%= a.material || '—' %><% if (a.color) { %> · <%= a.color %><% } %><% if (a.hardware) { %> · <%= a.hardware %><% } %></div>
+ <div class="value-band">
+ <span class="q10">$<%= fmt(a.q10) %></span>
+ <span class="q50">$<%= fmt(a.q50) %></span>
+ <span class="q90">$<%= fmt(a.q90) %></span>
+ </div>
+ <div class="meta">
+ <span title="Liquidity score">L <%= pct(a.liquidity_score) %></span>
+ <span title="Confidence">C <%= pct(a.confidence) %></span>
+ <span title="Comp count">n <%= a.comp_count || 0 %></span>
+ </div>
+ </div>
+ </a>
+ <% }) %>
+ <% if (!assets.length) { %>
+ <div class="empty">No canonical assets seeded yet — run <code>npm run seed</code>.</div>
+ <% } %>
+ </section>
+</main>
+
+<%
+function fmt(v){ if(v==null||v==='') return '—'; var n=parseFloat(v); return isFinite(n)?n.toLocaleString('en-US',{maximumFractionDigits:0}):'—'; }
+function pct(v){ if(v==null||v==='') return '—'; var n=parseFloat(v); return isFinite(n)?Math.round(n*100)+'%':'—'; }
+%>
+
+<%- include('partials/foot') %>
diff --git a/views/index_detail.ejs b/views/index_detail.ejs
new file mode 100644
index 0000000..060b29a
--- /dev/null
+++ b/views/index_detail.ejs
@@ -0,0 +1,32 @@
+<%- include('partials/head') %>
+<main class="container">
+ <p class="crumbs"><a href="/">← Console</a></p>
+ <h1><%= out.index.name %></h1>
+ <p class="muted mono">slug: <%= out.index.slug %> · methodology: <%= out.index.methodology_version %></p>
+
+ <section>
+ <h2>Definition</h2>
+ <pre class="json"><%= JSON.stringify(out.index.definition, null, 2) %></pre>
+ </section>
+
+ <section>
+ <h2>Time series (<%= out.points.length %> points)</h2>
+ <table>
+ <thead><tr><th>As of</th><th>Value</th><th>Δ%</th></tr></thead>
+ <tbody>
+ <% out.points.forEach(p => { %>
+ <tr>
+ <td><%= fmtDate(p.as_of) %></td>
+ <td class="num">$<%= money(p.value) %></td>
+ <td class="num"><%= p.change_pct == null ? '—' : (p.change_pct * 100).toFixed(2) + '%' %></td>
+ </tr>
+ <% }) %>
+ </tbody>
+ </table>
+ </section>
+</main>
+<%
+function money(v){ if(v==null||v==='') return '—'; var n=parseFloat(v); return isFinite(n)?n.toLocaleString('en-US',{maximumFractionDigits:0}):'—'; }
+function fmtDate(v){ if(!v) return '—'; try { return new Date(v).toISOString().slice(0,10); } catch(e){ return String(v); } }
+%>
+<%- include('partials/foot') %>
diff --git a/views/partials/foot.ejs b/views/partials/foot.ejs
new file mode 100644
index 0000000..676d687
--- /dev/null
+++ b/views/partials/foot.ejs
@@ -0,0 +1,13 @@
+<footer class="sitefoot">
+ <div>© <%= new Date().getFullYear() %> Lifestyle Asset Intelligence</div>
+ <div class="contact">
+ <% if (contactEmail) { %>
+ Contact: <a href="mailto:<%= contactEmail %>"><%= contactEmail %></a>
+ <% } else { %>
+ <span class="todo">[TODO v0.1] register info@<domain> at Purelymail once brand domain is chosen</span>
+ <% } %>
+ </div>
+</footer>
+<script src="/js/app.js"></script>
+</body>
+</html>
diff --git a/views/partials/head.ejs b/views/partials/head.ejs
new file mode 100644
index 0000000..51acdfd
--- /dev/null
+++ b/views/partials/head.ejs
@@ -0,0 +1,41 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title><%= title %></title>
+<!-- anti-flash dark/light: apply theme before paint -->
+<script>(function(){try{var t=localStorage.getItem('lai-theme');if(t==='dark')document.documentElement.dataset.theme='dark';}catch(e){}})();</script>
+<link rel="stylesheet" href="/css/app.css">
+<script type="application/ld+json">
+{ "@context":"https://schema.org","@type":"WebSite","name":"Lifestyle Asset Intelligence",
+ "url":"<%= publicUrl %>","description":"Valuation, liquidity, and ownership-cost intelligence for luxury secondary-market assets." }
+</script>
+</head>
+<body>
+<header class="topbar">
+ <button class="hamburger" aria-label="Menu" aria-expanded="false" id="navToggle">
+ <span></span><span></span><span></span>
+ </button>
+ <a class="brand" href="/">LAI <span class="brand-sub">Lifestyle Asset Intel</span></a>
+ <nav class="topnav" id="topnav">
+ <a href="/" class="<%= path === '/' ? 'active' : '' %>">Console</a>
+ <a href="/indices/birkin-30-togo-neutral">Indices</a>
+ <a href="/api/sources" target="_blank">Sources</a>
+ <a href="/api/portfolio?owner=demo@example.com" target="_blank">Portfolio</a>
+ <a href="/api/health" target="_blank">API</a>
+ </nav>
+ <button class="theme-toggle" id="themeToggle" aria-label="Toggle dark mode" title="Toggle dark mode">
+ <svg class="icon-sun" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
+ <circle cx="12" cy="12" r="4" fill="currentColor"/>
+ <g stroke="currentColor" stroke-width="2" stroke-linecap="round">
+ <path d="M12 2v3"/><path d="M12 19v3"/><path d="M2 12h3"/><path d="M19 12h3"/>
+ <path d="M4.2 4.2l2.1 2.1"/><path d="M17.7 17.7l2.1 2.1"/>
+ <path d="M19.8 4.2l-2.1 2.1"/><path d="M6.3 17.7l-2.1 2.1"/>
+ </g>
+ </svg>
+ <svg class="icon-moon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
+ <path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" fill="currentColor"/>
+ </svg>
+ </button>
+</header>
← 5afcf7c canonical schema + Birkin sample seed + pg pool wrapper
·
back to Lifestyle Asset Intel
·
catalog breadth: 14 more assets + 3 indices (Max It batch) 0877b89 →