← 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
M public/css/app.cssM tests/smoke.test.jsM views/partials/sparkline.ejs
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 →