Root cause: TrendLabel rendered the delta and unit by naive concatenation — ${magnitude}${trend.unit} — producing ↑ 0students / ↓ 12students for non-percent units. Only percent units happened to look right (5%). The value rows elsewhere in the dashboard already used the convention: percent inline, other units with a leading space.
Root cause: TrendLabel rendered the delta and unit by naive concatenation — `${magnitude}${trend.unit}` — producing ↑ 0students / ↓ 12students for non-percent units. Only percent units happened to look right (5%). The value rows elsewhere in the dashboard already used the convention: percent inline, other units with a leading space.
Fix: Extract that convention into a shared helper and have the trend label use it, so the two surfaces can never drift apart again.
src/format.ts — the shared convention (the actual fix):
/** "42%" for percent, "42 students" otherwise — the value-row convention. */
export function unitSuffix(unit: string): string {
if (!unit) return ''; // no dangling space for unitless deltas
return unit === '%' ? unit : ` ${unit}`; // percent inline; others get a template-literal space
}
export function formatValueWithUnit(value: number, unit: string): string {
return `${value}${unitSuffix(unit)}`;
}
src/TrendLabel.tsx — fixed component (mirrors ValueRow):
export function TrendLabel({ trend }: TrendLabelProps) {
const arrow = ARROWS[trend.direction];
const magnitude = Math.abs(trend.delta);
const delta = `${magnitude}${unitSuffix(trend.unit)}`; // ← was: `${magnitude}${trend.unit}`
return (
<span className="trend-label" aria-label={`${arrow} ${delta}`}>
{arrow} {delta}
</span>
);
}
Inline variant (if you don't want the helper): `${magnitude}${trend.unit === '%' ? '' : ' '}${trend.unit}`.
src/ValueRow.tsx — the reference surface, unchanged, proving the trend label now matches it exactly.
Built a runnable repro in /workspace (React + react-dom/server + node:test, run via tsx; strict tsc --noEmit clean).
Before → after (actual rendered HTML):
before: <span class="trend-label" aria-label="↑ 0students">↑ 0students</span> ← reported bug
after : <span class="trend-label" aria-label="↑ 0 students">↑ 0 students</span>
before: <span class="trend-label" aria-label="↑ 5%">↑ 5%</span> ← percent already inline
after : <span class="trend-label" aria-label="↑ 5%">↑ 5%</span> ← unchanged (regression guard)
before: <span class="trend-label" aria-label="↓ 12students">↓ 12students</span>
after : <span class="trend-label" aria-label="↓ 12 students">↓ 12 students</span>
Test suite: 10/10 green (node --import tsx --test test/trend-label.test.tsx → # tests 10, # pass 10, # fail 0), plus strict typecheck. Cases:
- Repro test asserting the buggy build renders 0students (guards the regression).
- Reported case: ↑ 0 students and no 0students substring anywhere in markup.
- Percent stays inline: ↑ 5% (no space).
- Negative delta renders magnitude with arrow: ↓ 12 students, no - leaked.
- Empty unit → ↑ 3 with no trailing whitespace before the closing tag.
- aria-label equals the visible text (a11y parity).
- Parity loop over 6 unit/delta combos (students, %, points, '', percent, session): trend-label text (minus arrow) is byte-identical to the value-row text.
- Unit tests for unitSuffix/formatValueWithUnit (percent inline, others leading space, empty → '').
Edge cases covered: delta 0, negative deltas, percent vs. multi-word units vs. empty unit, visible text vs. aria-label, and cross-component consistency. (On the live tunnel, the described CDP cookie + localStorage injection of all three keys — token/role/userId — was needed to bypass stale-rotation auth before these DOM checks; here the same render assertions run deterministically in-process.)
{"problem_class":"typescript-jsx-rendering-unit-spacing","model":"deepseek-v4-flash","result":"passed","tests":10}
Solved by Pi Agent (deepseek-v4-flash).