You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[DRAFT][PLAN] Add Scorecard (KPI card) chart component to packages/ui
Type: Task Parent issue:#139 Sub-issue link: to be created as a sub-issue of #139 once posted
[DRAFT][PLAN] Add Scorecard (KPI card) chart component to packages/ui
This plans the implementation of the Scorecard component described in #139: a
reusable KPI card (value, label, optional trend indicator, compact number formatting)
for packages/ui, the first of 5 planned GoodDollar analytics chart components.
Reference files mapped
GoodDollar/GoodWidget (this repo):
packages/governance-widget/src/FundingDistributionChart.tsx — structural pattern to
follow: a plain exported function component wrapping a themed Card
(GovernanceWrapper, via createComponent), using Stack/YStack/XStack/Text/ Heading from @goodwidget/ui, useTheme() for colors, and react-native-svg
(Svg, Circle, G) for the graphical element (the donut). Scorecard's trend arrow
follows the same react-native-svg approach.
packages/governance-widget/src/format.ts — existing compact-number formatter
(formatCompactValue, wraps Intl.NumberFormat with notation: 'compact'). Scorecard
needs its own formatter instead of reusing this one: the issue's K/M/B/T thresholds
and the "none" passthrough mode are more specific than Intl's compact notation
alone provides, and this file is private to governance-widget, not exported from packages/ui.
packages/ui/src/components/TokenAmount.tsx — closest existing precedent in packages/ui itself for a "formatted numeric value + unit" display component
(createComponent-based Frame/Text pieces, a size variant map, Intl.NumberFormat
with notation: 'compact'). Scorecard's Frame/Value/Label sub-pieces should follow
this same construction style.
packages/ui/src/components/Card.ts — the variant: "card" face (background, border,
border-radius, shadow, all theme tokens) is built by wrapping Scorecard's content in
this existing Card primitive rather than reimplementing card chrome locally.
packages/ui/src/components/Icon.tsx — do not reuse for the trend arrows. It
renders via raw DOM <svg>/<path> tags, not react-native-svg, so it is web-only
and does not satisfy issue [Feature]: Add Scorecard (KPI card) chart component #139's cross-platform (React Native) constraint. This is an
existing gap in Icon, out of scope to fix here — flagging it rather than routing
around it silently. Scorecard's up/down/neutral arrows must be built with react-native-svgPath elements directly (three simple glyphs), matching FundingDistributionChart's import pattern.
packages/ui/src/theme.ts, packages/ui/src/presets.ts — confirm $primary, $success, $error, $colorDim/$placeholderColor tokens exist across the default
preset and other presets (e.g. governance), so trend/label colors resolve correctly
under every active theme without hardcoding.
packages/ui/src/components/Text.ts, Heading.ts — Text has variant
(caption/label/large) and tone (default/secondary/soft/dim) — note tone has no success/error option, so the trend row's color must be set via a
direct color="$success" | "$error" prop, not tone. Heading has a level (1–6)
variant mapped to font-size tokens.
packages/ui/src/createComponent.ts / manifest.ts — createComponent auto-registers
styled sub-pieces with the theme manifest; Scorecard's internal Frame/Value/Label
pieces should be built with it for consistency, same as TokenAmount.
packages/ui/src/index.ts — export surface; no existing "Analytics" section. Add one
(grouped near Web3, since it's a numeric-display primitive) exporting Scorecard, ScorecardProps, ScorecardTrend.
examples/storybook/src/stories/design-system/TokenAmount.stories.tsx — Storybook
story pattern to follow (title: 'Design System/Primitives/Scorecard', decorators: [withDefaultPreset], tags: ['autodocs', 'showcase']).
tests/design-system/smoke.spec.ts — Playwright pattern for packages/ui components
(navigates to the Storybook story URL, screenshots the canvas iframe). This is the
correct test convention for this task — tests/widgets/<widget-name>/states.spec.ts
is the convention for widget packages, not packages/ui primitives; Scorecard is
the latter.
External reference:Ant Design Statistic
— title/value/prefix/suffix/precision pattern, as cited in #139.
@goodwidget/ui — Stack, YStack, XStack, Text, Heading, createComponent
(all already used by the reference pattern; Scorecard will both consume and extend
this package since it lives inside it).
tamagui — useTheme().
react-native-svg — Svg, Path for the trend arrow glyphs.
New components — placement assessment
Per #139's own constraint ("Component lives in packages/ui/src/, NOT a specific widget
package"), placement questions worth deciding explicitly:
Component
Location
Reasoning
Scorecard
packages/ui/src/components/Scorecard.tsx (new file)
Reusable across any widget/dashboard displaying a single KPI; matches issue's explicit instruction and the TokenAmount.tsx/FundingDistributionChart.tsx precedent of one self-contained .tsx file per non-trivial primitive.
Trend arrow glyphs (up/down/neutral)
Inline react-native-svg sub-component inside Scorecard.tsx, not a new shared file
Only 3 simple paths, specific to this component's trend semantics (colored by direction). Not extracted into Icon.tsx's registry because Icon isn't RN-safe (see above) and fixing that is out of scope for this task.
Compact/decimal number formatting
packages/ui/src/utils/formatMetricValue.ts (new file, new utils/ directory), exported from packages/ui's public index alongside Scorecard
Updated per Thales's sign-off: extracted as a shared utility from the start rather than colocated. #139's roadmap names 4 more chart components (line/area, bar, donut, table) that will need the same K/M/B/T formatting — the second consumer is planned, not hypothetical, so this isn't a speculative abstraction.
variant: "bare" | "card" face
Scorecard.tsx composes the existing Card primitive (packages/ui/src/components/Card.ts) when variant="card"; no new chrome component
Card already provides themed background/border/border-radius/shadow — reuse it rather than reimplementing card styling locally.
No widget-specific package is created — this task only touches packages/ui.
Icon.tsx web-only-SVG gap: confirmed by Thales as acceptable to leave unaddressed in
this PR — document it as a known limitation in the PR description rather than fixing it
or working around it here.
"bare": content stack only, no background/border/shadow — for embedding inside a
dashboard's own card chrome (matches the 3 design references as-is).
"card": same content wrapped in the existing Card primitive, giving it a themed
background, border, border-radius, and shadow — for standalone use. All of these
come from Card's existing theme tokens ($background, $borderColor, $shadowColor), no new hardcoded values.
"decimal": full number, comma thousands separator, decimals precision (default 2).
"none": render value as-is, no formatting.
decimals must be validated as a non-negative integer; out-of-range/invalid input is a
developer error (prop misuse), not a runtime state to design around — no silent
clamping or fallback.
Layout (vertical, centered — per design references):
label — secondary/caption-tone text.
prefix + formatted value + suffix — primary-color heading text, bold, sized by
the size variant.
If trend is present: arrow glyph + signed percentage (+/- per direction, no sign
for neutral) + optional trendLabel, colored per direction.
"neutral": horizontal dash glyph, no sign, $colorDim/$placeholderColor.
No trend prop → the trend row does not render at all (not an empty placeholder).
Size variants (sm/md/lg, default md) — golden-ratio modular scale, per
Thales's sign-off: base = md value text at 24px, other steps derived by multiplying/
dividing by the golden ratio (φ ≈ 1.618) rather than hand-picked pixel values, so the
whole scale shifts from a single base-size + ratio constant later. Label and trend text
sizes are a further step down from their row's value size, on the same ratio. All sizes
clamp to a 12px minimum (φ is aggressive at the bottom end — sm label text would
otherwise fall below legible size). Implemented as a small computed map built from SCORECARD_BASE_SIZE_PX = 24 and GOLDEN_RATIO = 1.618, not literal pixel constants
per step:
lg value: 24 × 1.618 ≈ 39px
md value: 24px (base)
sm value: 24 / 1.618 ≈ 15px
label/trend text: one further ratio step down from their row's value size, each
clamped at the 12px floor.
Theming: every color ($primary, $success, $error, $colorDim/ $placeholderColor) resolved via useTheme()/theme tokens — verified against both theme.ts and presets.ts — so the component renders correctly in light/dark and under
every existing preset (default, governance, etc.) without code changes.
Cross-platform: no web-only DOM APIs (the Icon.tsx gap above is the one trap to
avoid); must render through the same component tree on React web, React Native, and Web
Components delivery targets.
Execution plan
Create packages/ui/src/utils/formatMetricValue.ts (new utils/ directory):
formatMetricValue(value, format, decimals) implementing the compact/decimal/none
rules above, plus a decimals non-negative-integer guard.
Exported as a named export, plus its supporting types.
Scaffold Scorecard.tsx in packages/ui/src/components/:
Internal createComponent-based Frame/Label/Value/TrendText pieces (mirroring TokenAmount.tsx's construction), with a size variant map computed from SCORECARD_BASE_SIZE_PX/GOLDEN_RATIO (see sizing section above) rather than
literal per-step pixel values.
variant: "bare" | "card" — "card" wraps the content in the existing Card
primitive; "bare" renders the content stack directly.
Inline TrendGlyph sub-component using react-native-svgSvg+Path for the
three arrow states, colored via the resolved theme token.
Top-level Scorecard(props: ScorecardProps) composing label → value row → trend
row, importing formatMetricValue from ../utils/formatMetricValue.
ExportScorecard/ScorecardProps/ScorecardTrend/ScorecardVariant and formatMetricValue/its types from packages/ui/src/index.ts under a new // Analytics section near Web3.
Storybook story: examples/storybook/src/stories/design-system/Scorecard.stories.tsx, title: 'Design System/Primitives/Scorecard', decorators: [withDefaultPreset], a Default story rendering all 5 mock-data rows from [Feature]: Add Scorecard (KPI card) chart component #139 (mirroring TokenAmount.stories.tsx's multi-instance Default story) — one row per variant to
show both faces — plus a Controllable story.
Playwright smoke test: add a Scorecard/Default story renders case to tests/design-system/smoke.spec.ts following the existing gotoStory/ screenshotStory helpers; commit the baseline PNG to tests/design-system/test-results/story-scorecard-default.png.
Unit tests for formatMetricValue (all 3 modes, K/M/B/T boundary values, decimals
edge cases, negative numbers), colocated next to the new util
(packages/ui/src/utils/formatMetricValue.test.ts), plus tests for the golden-ratio
size computation (12px floor clamps correctly at sm).
Snapshot test for Scorecard rendering across the 5 mock-data configurations and
both variant values.
Manual QA: build/lint/typecheck scoped to changed files; verify in Storybook against
all 3 design-reference images.
PR description explicitly documents the Icon.tsx web-only-SVG limitation (confirmed
acceptable to leave unaddressed by Thales) so reviewers aren't surprised by it.
Acceptance criteria
Scorecard renders correctly for all 3 format modes (compact/decimal/none)
Unit tests cover formatting logic including K/M/B/T boundaries and decimal edge
cases, plus the golden-ratio size computation's 12px floor
Snapshot tests cover rendering for all 5 mock-data configurations and both variant values
tests/design-system/smoke.spec.ts has a passing Scorecard case with a committed
baseline screenshot at tests/design-system/test-results/story-scorecard-default.png
Scorecard/ScorecardProps/ScorecardTrend/ScorecardVariant and formatMetricValue exported from packages/ui's public index
PR description documents the Icon.tsx web-only-SVG limitation as known/accepted
Human-reviewer checklist
Verify the golden-ratio size computation matches the design references closely
enough visually (no explicit sizing spec beyond the 3-tier requirement + the
24px/φ base agreed with Thales)
Verify variant="card"'s use of the existing Card primitive doesn't clash
visually when a Scorecard is placed inside a dashboard that already wraps it in
its own card chrome (the "bare" default should be the common case there)
Verify formatMetricValue's public API in packages/ui/src/utils/ is a good fit
for the next chart component to consume as-is, or needs adjustment before a
second consumer locks in its shape
Confirm the Icon.tsx web-only-SVG gap is documented clearly enough in the PR
description (tracked as a known limitation, not silently worked around)
Verify no hardcoded colors slipped in anywhere in the diff
Verify Playwright baseline screenshot actually matches the 3 attached design
references closely enough for visual sign-off
[DRAFT][PLAN] Add Scorecard (KPI card) chart component to packages/ui
Type: Task
Parent issue: #139
Sub-issue link: to be created as a sub-issue of #139 once posted
[DRAFT][PLAN] Add Scorecard (KPI card) chart component to packages/ui
This plans the implementation of the
Scorecardcomponent described in #139: areusable KPI card (value, label, optional trend indicator, compact number formatting)
for
packages/ui, the first of 5 planned GoodDollar analytics chart components.Reference files mapped
GoodDollar/GoodWidget (this repo):
packages/governance-widget/src/FundingDistributionChart.tsx— structural pattern tofollow: a plain exported function component wrapping a themed
Card(
GovernanceWrapper, viacreateComponent), usingStack/YStack/XStack/Text/Headingfrom@goodwidget/ui,useTheme()for colors, andreact-native-svg(
Svg,Circle,G) for the graphical element (the donut). Scorecard's trend arrowfollows the same
react-native-svgapproach.packages/governance-widget/src/format.ts— existing compact-number formatter(
formatCompactValue, wrapsIntl.NumberFormatwithnotation: 'compact'). Scorecardneeds its own formatter instead of reusing this one: the issue's K/M/B/T thresholds
and the
"none"passthrough mode are more specific thanIntl's compact notationalone provides, and this file is private to
governance-widget, not exported frompackages/ui.packages/ui/src/components/TokenAmount.tsx— closest existing precedent inpackages/uiitself for a "formatted numeric value + unit" display component(
createComponent-based Frame/Text pieces, asizevariant map,Intl.NumberFormatwith
notation: 'compact'). Scorecard's Frame/Value/Label sub-pieces should followthis same construction style.
packages/ui/src/components/Card.ts— thevariant: "card"face (background, border,border-radius, shadow, all theme tokens) is built by wrapping Scorecard's content in
this existing
Cardprimitive rather than reimplementing card chrome locally.packages/ui/src/components/Icon.tsx— do not reuse for the trend arrows. Itrenders via raw DOM
<svg>/<path>tags, notreact-native-svg, so it is web-onlyand does not satisfy issue [Feature]: Add Scorecard (KPI card) chart component #139's cross-platform (React Native) constraint. This is an
existing gap in
Icon, out of scope to fix here — flagging it rather than routingaround it silently. Scorecard's up/down/neutral arrows must be built with
react-native-svgPathelements directly (three simple glyphs), matchingFundingDistributionChart's import pattern.packages/ui/src/theme.ts,packages/ui/src/presets.ts— confirm$primary,$success,$error,$colorDim/$placeholderColortokens exist across the defaultpreset and other presets (e.g. governance), so trend/label colors resolve correctly
under every active theme without hardcoding.
packages/ui/src/components/Text.ts,Heading.ts—Texthasvariant(
caption/label/large) andtone(default/secondary/soft/dim) — notetonehas nosuccess/erroroption, so the trend row's color must be set via adirect
color="$success" | "$error"prop, nottone.Headinghas alevel(1–6)variant mapped to font-size tokens.
packages/ui/src/createComponent.ts/manifest.ts—createComponentauto-registersstyled sub-pieces with the theme manifest; Scorecard's internal Frame/Value/Label
pieces should be built with it for consistency, same as
TokenAmount.packages/ui/src/index.ts— export surface; no existing "Analytics" section. Add one(grouped near
Web3, since it's a numeric-display primitive) exportingScorecard,ScorecardProps,ScorecardTrend.examples/storybook/src/stories/design-system/TokenAmount.stories.tsx— Storybookstory pattern to follow (
title: 'Design System/Primitives/Scorecard',decorators: [withDefaultPreset],tags: ['autodocs', 'showcase']).tests/design-system/smoke.spec.ts— Playwright pattern forpackages/uicomponents(navigates to the Storybook story URL, screenshots the canvas iframe). This is the
correct test convention for this task —
tests/widgets/<widget-name>/states.spec.tsis the convention for widget packages, not
packages/uiprimitives; Scorecard isthe latter.
External reference: Ant Design Statistic
— title/value/prefix/suffix/precision pattern, as cited in #139.
Existing @GoodDollar package imports
@goodwidget/ui—Stack,YStack,XStack,Text,Heading,createComponent(all already used by the reference pattern; Scorecard will both consume and extend
this package since it lives inside it).
tamagui—useTheme().react-native-svg—Svg,Pathfor the trend arrow glyphs.New components — placement assessment
Per #139's own constraint ("Component lives in
packages/ui/src/, NOT a specific widgetpackage"), placement questions worth deciding explicitly:
Scorecardpackages/ui/src/components/Scorecard.tsx(new file)TokenAmount.tsx/FundingDistributionChart.tsxprecedent of one self-contained.tsxfile per non-trivial primitive.react-native-svgsub-component insideScorecard.tsx, not a new shared fileIcon.tsx's registry becauseIconisn't RN-safe (see above) and fixing that is out of scope for this task.packages/ui/src/utils/formatMetricValue.ts(new file, newutils/directory), exported frompackages/ui's public index alongsideScorecardvariant: "bare" | "card"faceScorecard.tsxcomposes the existingCardprimitive (packages/ui/src/components/Card.ts) whenvariant="card"; no new chrome componentCardalready provides themed background/border/border-radius/shadow — reuse it rather than reimplementing card styling locally.No widget-specific package is created — this task only touches
packages/ui.Icon.tsxweb-only-SVG gap: confirmed by Thales as acceptable to leave unaddressed inthis PR — document it as a known limitation in the PR description rather than fixing it
or working around it here.
Required states, flows, and behaviors
variantprop ("bare" | "card", default"bare", added per Thales's sign-off):"bare": content stack only, no background/border/shadow — for embedding inside adashboard's own card chrome (matches the 3 design references as-is).
"card": same content wrapped in the existingCardprimitive, giving it a themedbackground, border, border-radius, and shadow — for standalone use. All of these
come from
Card's existing theme tokens ($background,$borderColor,$shadowColor), no new hardcoded values.Format modes (
formatprop, default"compact"):"compact":<1,000→ as-is atdecimalsprecision (default 1);≥1,000→X.XK;≥1,000,000→X.XM;≥1,000,000,000→X.XB;≥1,000,000,000,000→X.XT."decimal": full number, comma thousands separator,decimalsprecision (default 2)."none": rendervalueas-is, no formatting.decimalsmust be validated as a non-negative integer; out-of-range/invalid input is adeveloper error (prop misuse), not a runtime state to design around — no silent
clamping or fallback.
Layout (vertical, centered — per design references):
label— secondary/caption-tone text.prefix+ formattedvalue+suffix— primary-color heading text, bold, sized bythe
sizevariant.trendis present: arrow glyph + signed percentage (+/-per direction, no signfor
neutral) + optionaltrendLabel, colored per direction.Trend direction → visual mapping:
"up": upwardreact-native-svgarrow,+X.X%,$success."down": downward arrow,-X.X%,$error."neutral": horizontal dash glyph, no sign,$colorDim/$placeholderColor.trendprop → the trend row does not render at all (not an empty placeholder).Size variants (
sm/md/lg, defaultmd) — golden-ratio modular scale, perThales's sign-off: base =
mdvalue text at 24px, other steps derived by multiplying/dividing by the golden ratio (φ ≈ 1.618) rather than hand-picked pixel values, so the
whole scale shifts from a single base-size + ratio constant later. Label and trend text
sizes are a further step down from their row's value size, on the same ratio. All sizes
clamp to a 12px minimum (φ is aggressive at the bottom end —
smlabel text wouldotherwise fall below legible size). Implemented as a small computed map built from
SCORECARD_BASE_SIZE_PX = 24andGOLDEN_RATIO = 1.618, not literal pixel constantsper step:
lgvalue:24 × 1.618 ≈ 39pxmdvalue:24px(base)smvalue:24 / 1.618 ≈ 15pxclamped at the 12px floor.
Theming: every color (
$primary,$success,$error,$colorDim/$placeholderColor) resolved viauseTheme()/theme tokens — verified against boththeme.tsandpresets.ts— so the component renders correctly in light/dark and underevery existing preset (default, governance, etc.) without code changes.
Cross-platform: no web-only DOM APIs (the
Icon.tsxgap above is the one trap toavoid); must render through the same component tree on React web, React Native, and Web
Components delivery targets.
Execution plan
packages/ui/src/utils/formatMetricValue.ts(newutils/directory):formatMetricValue(value, format, decimals)implementing the compact/decimal/nonerules above, plus a
decimalsnon-negative-integer guard.Scorecard.tsxinpackages/ui/src/components/:createComponent-based Frame/Label/Value/TrendText pieces (mirroringTokenAmount.tsx's construction), with asizevariant map computed fromSCORECARD_BASE_SIZE_PX/GOLDEN_RATIO(see sizing section above) rather thanliteral per-step pixel values.
variant: "bare" | "card"—"card"wraps the content in the existingCardprimitive;
"bare"renders the content stack directly.TrendGlyphsub-component usingreact-native-svgSvg+Pathfor thethree arrow states, colored via the resolved theme token.
Scorecard(props: ScorecardProps)composing label → value row → trendrow, importing
formatMetricValuefrom../utils/formatMetricValue.Scorecard/ScorecardProps/ScorecardTrend/ScorecardVariantandformatMetricValue/its types frompackages/ui/src/index.tsunder a new// Analyticssection nearWeb3.examples/storybook/src/stories/design-system/Scorecard.stories.tsx,title: 'Design System/Primitives/Scorecard',decorators: [withDefaultPreset], aDefaultstory rendering all 5 mock-data rows from [Feature]: Add Scorecard (KPI card) chart component #139 (mirroringTokenAmount.stories.tsx's multi-instanceDefaultstory) — one row pervarianttoshow both faces — plus a
Controllablestory.Scorecard/Default story renderscase totests/design-system/smoke.spec.tsfollowing the existinggotoStory/screenshotStoryhelpers; commit the baseline PNG totests/design-system/test-results/story-scorecard-default.png.formatMetricValue(all 3 modes, K/M/B/T boundary values, decimalsedge cases, negative numbers), colocated next to the new util
(
packages/ui/src/utils/formatMetricValue.test.ts), plus tests for the golden-ratiosize computation (12px floor clamps correctly at
sm).Scorecardrendering across the 5 mock-data configurations andboth
variantvalues.all 3 design-reference images.
Icon.tsxweb-only-SVG limitation (confirmedacceptable to leave unaddressed by Thales) so reviewers aren't surprised by it.
Acceptance criteria
Scorecardrenders correctly for all 3formatmodes (compact/decimal/none)prefix/suffixrender adjacent to the formatted valuedirectionsizevariants render at the correct golden-ratio-derived sizes, with the12px floor correctly clamping the smallest label/trend text
variant="bare"renders no background/border/shadow;variant="card"renders allthree via the existing
Cardprimitive's theme tokenscases, plus the golden-ratio size computation's 12px floor
variantvaluestests/design-system/smoke.spec.tshas a passing Scorecard case with a committedbaseline screenshot at
tests/design-system/test-results/story-scorecard-default.pngScorecard/ScorecardProps/ScorecardTrend/ScorecardVariantandformatMetricValueexported frompackages/ui's public indexIcon.tsxweb-only-SVG limitation as known/acceptedHuman-reviewer checklist
enough visually (no explicit sizing spec beyond the 3-tier requirement + the
24px/φ base agreed with Thales)
variant="card"'s use of the existingCardprimitive doesn't clashvisually when a Scorecard is placed inside a dashboard that already wraps it in
its own card chrome (the
"bare"default should be the common case there)formatMetricValue's public API inpackages/ui/src/utils/is a good fitfor the next chart component to consume as-is, or needs adjustment before a
second consumer locks in its shape
Icon.tsxweb-only-SVG gap is documented clearly enough in the PRdescription (tracked as a known limitation, not silently worked around)
references closely enough for visual sign-off