Skip to content

[DRAFT][PLAN] Add Scorecard (KPI card) chart component to packages/ui #141

Description

@goodbounties-nanoclaw-agent

[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.tsxdo 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-svg Path 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.tsText 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.tscreateComponent 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.

Existing @GoodDollar package imports

  • @goodwidget/uiStack, 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).
  • tamaguiuseTheme().
  • react-native-svgSvg, 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.

Required states, flows, and behaviors

variant prop ("bare" | "card", default "bare", added per Thales's sign-off):

  • "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.

Format modes (format prop, default "compact"):

  • "compact": <1,000 → as-is at decimals precision (default 1); ≥1,000X.XK;
    ≥1,000,000X.XM; ≥1,000,000,000X.XB; ≥1,000,000,000,000X.XT.
  • "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):

  1. label — secondary/caption-tone text.
  2. prefix + formatted value + suffix — primary-color heading text, bold, sized by
    the size variant.
  3. If trend is present: arrow glyph + signed percentage (+/- per direction, no sign
    for neutral) + optional trendLabel, colored per direction.

Trend direction → visual mapping:

  • "up": upward react-native-svg arrow, +X.X%, $success.
  • "down": downward arrow, -X.X%, $error.
  • "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

  1. 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.
  2. 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-svg Svg+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.
  3. Export Scorecard/ScorecardProps/ScorecardTrend/ScorecardVariant and
    formatMetricValue/its types from packages/ui/src/index.ts under a new
    // Analytics section near Web3.
  4. 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.
  5. 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.
  6. 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).
  7. Snapshot test for Scorecard rendering across the 5 mock-data configurations and
    both variant values.
  8. Manual QA: build/lint/typecheck scoped to changed files; verify in Storybook against
    all 3 design-reference images.
  9. 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)
  • Compact formatting applies correct K/M/B/T abbreviation thresholds
  • prefix/suffix render adjacent to the formatted value
  • Trend indicator shows correct arrow direction, sign, and color per direction
  • All 3 size variants render at the correct golden-ratio-derived sizes, with the
    12px floor correctly clamping the smallest label/trend text
  • variant="bare" renders no background/border/shadow; variant="card" renders all
    three via the existing Card primitive's theme tokens
  • All colors resolve via theme tokens — verified in both light and dark mode
  • Renders identically in React web and React Native (no web-only DOM APIs used)
  • All 5 [Feature]: Add Scorecard (KPI card) chart component #139 mock-data rows render correctly in the Storybook showcase story
  • 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    Status
    Prepare AI Task

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions