Direction, not sentiment Up is not always good. Churn rising, latency rising, cost rising—a token called --positive would have to lie about all three. These are named for direction and the metric decides what it means. Tokens this needs: --card, --card-foreground, --muted-foreground, --border, --direction-up, --direction-down, --direction-flat The status, chart, scale, state and direction names are an extension, not a rename. shadcn has --destructive and five --chart-* and nothing else in this territory. ──────────────────────────────────────────────────────────────────────── // direction.ts ──────────────────────────────────────────────────────────────────────── /** * Direction is arithmetic. Whether it is good news is a property of the metric. * * Revenue up is good, churn up is bad, and both are "+12%". A token named for * sentiment forces the caller to lie about one of them, so the tokens are named * --direction-up / --direction-down and the polarity below decides which one a * given metric should wear. */ export type Polarity = "higher-is-better" | "lower-is-better" | "neutral"; /** * Which hue "up" wears is a market convention, not a constant. Several Asian * markets read red for up, so a platform serving both audiences keeps this as * a user preference and passes it through. */ export type Mapping = "western" | "inverted"; export function directionColor(delta: number, polarity: Polarity, mapping: Mapping = "western"): string { if (delta === 0 || polarity === "neutral") return "hsl(var(--direction-flat))"; const good = polarity === "higher-is-better" ? delta > 0 : delta < 0; const up = mapping === "inverted" ? !good : good; return up ? "hsl(var(--direction-up))" : "hsl(var(--direction-down))"; } /** * Colour is never the only carrier. * * The glyph goes with the sign, always, so the reading survives greyscale and * the roughly one man in twelve who cannot separate the two hues. In markets * that invert the convention this mapping is a user preference, not a constant. */ export const directionGlyph = (delta: number) => (delta === 0 ? "→" : delta > 0 ? "▲" : "▼"); ──────────────────────────────────────────────────────────────────────── // MoveTable.tsx ──────────────────────────────────────────────────────────────────────── import { directionColor, directionGlyph, type Mapping, type Polarity } from "./direction"; export type Move = { ticker: string; move: number }; type Props = { rows: Move[]; /** For a price, higher is better from the holder's side. A cost ladder * would pass lower-is-better and the same rows would swap colour. */ polarity?: Polarity; /** The market convention, threaded through from user settings. */ mapping?: Mapping; }; /** Signed always, with a real minus. The sign is the cheapest carrier there is. */ const signed = (n: number) => `${n > 0 ? "+" : n < 0 ? "−" : ""}${Math.abs(n).toFixed(2)}`; /** * Four carriers per row and hue is the fourth. The glyph and the sign go * with the arithmetic; the bar sits left or right of a zero line, which is * structural and needs no colour at all; the tokens differ in lightness, so * the hue survives greyscale when it is all that is left. */ export function MoveTable({ rows, polarity = "higher-is-better", mapping = "western" }: Props) { const max = Math.max(...rows.map((r) => Math.abs(r.move)), 0.01); return (