[object Object]

← back to Lifestyle Asset Intel

yolo tick #20: sparkline Y-axis min/max labels + descriptive aria-label

39ba6145175f7c71d7fc1e2f50d9558bb2e62876 · 2026-05-13 14:45:37 -0700 · Steve Abrams

Adds a left-side label gutter to the SSR sparkline partial showing the
formatted min and max values ($1.2K / $14M style), plus a vertical
axis tick line. aria-label now describes the range and point count
rather than the static word "sparkline".

ROADMAP: 'Sparkline Y-axis labels' — done.

Author: Steve Abrams <steve@designerwallcoverings.com>

Files touched

Diff

commit 39ba6145175f7c71d7fc1e2f50d9558bb2e62876
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 13 14:45:37 2026 -0700

    yolo tick #20: sparkline Y-axis min/max labels + descriptive aria-label
    
    Adds a left-side label gutter to the SSR sparkline partial showing the
    formatted min and max values ($1.2K / $14M style), plus a vertical
    axis tick line. aria-label now describes the range and point count
    rather than the static word "sparkline".
    
    ROADMAP: 'Sparkline Y-axis labels' — done.
    
    Author: Steve Abrams <steve@designerwallcoverings.com>
---
 public/css/app.css           |  2 ++
 tests/smoke.test.js          |  4 ++++
 views/partials/sparkline.ejs | 37 ++++++++++++++++++++++++++++++++-----
 3 files changed, 38 insertions(+), 5 deletions(-)

diff --git a/public/css/app.css b/public/css/app.css
index d02a5ca..ccc91e9 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -141,6 +141,8 @@ pre.json { background: var(--card); border: 1px solid var(--line); border-radius
 
 /* sparkline + trend section (v0.2) */
 .spark { color: var(--accent); }
+.spark-axis { fill: var(--muted); font-size: 10px; font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); }
+.spark-axis-line { stroke: var(--line); stroke-width: 1; }
 .trend { margin: 1.4rem 0; }
 
 /* portfolio page (v0.2) */
diff --git a/tests/smoke.test.js b/tests/smoke.test.js
index ed3dedb..03f9549 100644
--- a/tests/smoke.test.js
+++ b/tests/smoke.test.js
@@ -306,6 +306,10 @@ test('GET /asset/birkin-30-togo-gold-ghw-us renders the detail page', async () =
   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');
+  // Sparkline Y-axis labels (yolo tick #20): aria-label exposes the range,
+  // and at least one $-formatted axis tick appears in the SVG.
+  assert.ok(/aria-label="price trend,/.test(r.text), 'sparkline aria-label with range');
+  assert.ok(/class="spark-axis"/.test(r.text), 'sparkline axis label text rendered');
 });
 
 after(async () => {
diff --git a/views/partials/sparkline.ejs b/views/partials/sparkline.ejs
index ebfcce4..8233317 100644
--- a/views/partials/sparkline.ejs
+++ b/views/partials/sparkline.ejs
@@ -1,16 +1,29 @@
 <%
 // sparkline.ejs — pure SSR inline-SVG line chart.
 //
-// locals: points (Array<{as_of, value}>), width?, height?, stroke?
+// locals: points (Array<{as_of, value}>), width?, height?, stroke?,
+//         showLabels? (default true), labelGutter? (default 52)
 //
 // Renders <span class="muted">— insufficient data —</span> if fewer than 2
 // numeric points are available. Otherwise an inline SVG line + per-point dots,
-// padded 4px on each edge so the strokes don't get clipped.
+// padded 4px on each edge so the strokes don't get clipped. When showLabels
+// is true (default), a left-side gutter renders min/max value labels so the
+// trend is readable without a tooltip — yolo tick #20.
 
 var _spark_points = (typeof points !== 'undefined' && Array.isArray(points)) ? points : [];
 var _spark_w  = (typeof width  !== 'undefined' && width)  ? Number(width)  : 320;
 var _spark_h  = (typeof height !== 'undefined' && height) ? Number(height) : 60;
 var _spark_st = (typeof stroke !== 'undefined' && stroke) ? String(stroke) : 'currentColor';
+var _spark_lab = (typeof showLabels !== 'undefined') ? !!showLabels : true;
+var _spark_gut = (typeof labelGutter !== 'undefined' && labelGutter != null)
+                 ? Number(labelGutter) : 52;
+
+function _spark_money(n) {
+  var v = Math.abs(n);
+  if (v >= 1e6) return '$' + (n / 1e6).toFixed(v >= 10e6 ? 0 : 1) + 'M';
+  if (v >= 1e3) return '$' + (n / 1e3).toFixed(v >= 10e3 ? 0 : 1) + 'K';
+  return '$' + Math.round(n).toLocaleString('en-US');
+}
 
 var _spark_clean = [];
 for (var _i = 0; _i < _spark_points.length; _i++) {
@@ -27,7 +40,9 @@ if (_spark_clean.length < 2) {
 %><span class="muted">— insufficient data —</span><%
 } else {
   var _pad = 4;
-  var _innerW = Math.max(1, _spark_w - _pad * 2);
+  var _gutter = _spark_lab ? _spark_gut : 0;
+  var _plotL  = _pad + _gutter;
+  var _innerW = Math.max(1, _spark_w - _plotL - _pad);
   var _innerH = Math.max(1, _spark_h - _pad * 2);
   var _minY =  Infinity, _maxY = -Infinity;
   for (var _j = 0; _j < _spark_clean.length; _j++) {
@@ -37,17 +52,29 @@ if (_spark_clean.length < 2) {
   var _span = (_maxY - _minY) || 1;
   var _step = _spark_clean.length > 1 ? _innerW / (_spark_clean.length - 1) : 0;
   var _coords = _spark_clean.map(function (pt, idx) {
-    var x = _pad + _step * idx;
+    var x = _plotL + _step * idx;
     var y = _pad + _innerH - ((pt.value - _minY) / _span) * _innerH;
     return { x: x, y: y };
   });
   var _path = _coords.map(function (c, idx) {
     return (idx === 0 ? 'M' : 'L') + c.x.toFixed(2) + ' ' + c.y.toFixed(2);
   }).join(' ');
+  var _maxLabel = _spark_money(_maxY);
+  var _minLabel = _spark_money(_minY);
+  var _aria = 'price trend, ' + _spark_clean.length + ' points, range '
+            + _minLabel + ' to ' + _maxLabel;
 %><svg class="spark" xmlns="http://www.w3.org/2000/svg"
        width="<%= _spark_w %>" height="<%= _spark_h %>"
        viewBox="0 0 <%= _spark_w %> <%= _spark_h %>"
-       role="img" aria-label="sparkline">
+       role="img" aria-label="<%= _aria %>">
+    <% if (_spark_lab) { %>
+      <text class="spark-axis" x="<%= (_plotL - 4).toFixed(2) %>" y="<%= (_pad + 4).toFixed(2) %>"
+            text-anchor="end" dominant-baseline="hanging"><%= _maxLabel %></text>
+      <text class="spark-axis" x="<%= (_plotL - 4).toFixed(2) %>" y="<%= (_pad + _innerH).toFixed(2) %>"
+            text-anchor="end" dominant-baseline="alphabetic"><%= _minLabel %></text>
+      <line class="spark-axis-line" x1="<%= _plotL.toFixed(2) %>" y1="<%= _pad.toFixed(2) %>"
+            x2="<%= _plotL.toFixed(2) %>" y2="<%= (_pad + _innerH).toFixed(2) %>"/>
+    <% } %>
     <path d="<%= _path %>" fill="none" stroke="<%= _spark_st %>" stroke-width="1.5"
           stroke-linecap="round" stroke-linejoin="round"/>
     <% _coords.forEach(function (c) { %><circle cx="<%= c.x.toFixed(2) %>" cy="<%= c.y.toFixed(2) %>" r="2.2" fill="<%= _spark_st %>"/><% }); %>

← 9cba52d yolo tick #19: condition_facets chips on asset detail comps  ·  back to Lifestyle Asset Intel  ·  yolo tick #21: /family HTML index page (families list with c 3a8e130 →