{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "money", "type": "registry:lib", "title": "Money rendering", "description": "Tabular figures, trailing currency code at 0.6x, true minus, debits never red, em-dash empty values.", "registryDependencies": [ "@venlyfinance/venly-tokens" ], "files": [ { "path": "registry/lib/money.tsx", "type": "registry:lib", "target": "~/components/venly/lib/money.tsx", "content": "import type { CSSProperties, ReactElement } from \"react\";\n\n/**\n * Money rendering rules this module owns (do not re-implement locally):\n * - Tabular figures, always. Wobbling decimals in a right-aligned column is\n * the fastest tell of a non-finance UI.\n * - Currency code trails the digits at ~0.6x, one tonal step down: `5.00 EUR`.\n * Code-first at equal size reads as a label.\n * - True minus sign − before the amount, never a hyphen.\n * - The LEVEL is tonally neutral regardless of sign: debits are not red.\n * Red is reserved for failure. Deltas may colour, but must also carry an\n * arrow so colour is never the sole carrier.\n * - Empty value renders an em-dash, never blank, never 0.00.\n * - Masking is fixed-length: it hides the magnitude as well as the digits,\n * and it must cover every figure on the surface (rows and deltas too),\n * never just the hero.\n */\n\nexport function formatAmount(amount: number, fractionDigits = 2): string {\n const formatted = new Intl.NumberFormat(\"en-US\", {\n minimumFractionDigits: fractionDigits,\n maximumFractionDigits: fractionDigits,\n }).format(Math.abs(amount));\n return amount < 0 ? `−${formatted}` : formatted;\n}\n\nconst EMPTY = \"—\"; // em-dash: an empty numeric cell is a statement, not a gap\n\nexport interface MoneyProps {\n /** null/undefined renders the em-dash empty state. */\n amount?: number | null;\n /** ISO-ish code rendered trailing at 0.6x, one tonal step down. */\n currency?: string;\n /**\n * Typographic register:\n * - \"row\": body-size, weight 600, for table cells (right-align via the cell)\n * - \"value\": summary figures (20px)\n * - \"hero\": the one emphasised figure on a card (28px; 2-2.5x its caption)\n */\n emphasis?: \"row\" | \"value\" | \"hero\";\n fractionDigits?: number;\n /**\n * Replaces the digits with a fixed-length mask that leaks no magnitude.\n * The currency code stays visible; the amount does not. Masking is a\n * surface-wide contract: if the hero is masked, every row and delta on\n * the same surface must receive the same flag.\n */\n masked?: boolean;\n style?: CSSProperties;\n className?: string;\n}\n\n/** Fixed-length mask: four dots regardless of the amount's magnitude. */\nexport const MASK = \"••••\";\n\nconst EMPHASIS: Record, CSSProperties> = {\n row: { fontSize: \"var(--font-size-body)\", fontWeight: 600 },\n value: { fontSize: \"var(--font-size-value)\", fontWeight: 600 },\n hero: { fontSize: \"var(--font-size-hero)\", fontWeight: 600, letterSpacing: \"-0.01em\" },\n};\n\nexport function Money({\n amount,\n currency,\n emphasis = \"row\",\n fractionDigits = 2,\n masked = false,\n style,\n className,\n}: MoneyProps): ReactElement {\n const base: CSSProperties = {\n fontVariantNumeric: \"tabular-nums\",\n color: \"var(--text-primary)\",\n whiteSpace: \"nowrap\",\n ...EMPHASIS[emphasis],\n ...style,\n };\n\n if (amount === null || amount === undefined) {\n return (\n \n {EMPTY}\n \n );\n }\n\n return (\n \n {masked ? MASK : formatAmount(amount, fractionDigits)}\n {currency ? (\n \n {currency}\n \n ) : null}\n \n );\n}\n" } ] }