# Score ring

A computed score out of a maximum ("84 out of 100", "Workspace health"), as a ring with the score in its centre. It is a figure, not a progressbar: a score isn't work done, so it has no `progressbar` role and no `aria-valuenow`, and it is never drawn with `ProgressRing`, which would read as "84 of 100 done".

A score says it is one, when and from what: `computedAt` and `basis` are required, and the caption reads "Computed 2 hours ago from a weighted mean of five subscores", with a "How it's computed" link to the subscores when `detailsHref` is given. A `delta` names the score it is compared with ("Up 6 points since last month"). `bands` name the range the score falls in with a word beside a dot. With a null score it says "Not computed" and why (`missing`), and the ring is a dashed track, never an empty ring that reads as zero.

A `projected` score (what the app expects if something happens) is an estimate, so it is drawn apart from the score: a thin dashed arc on its own inner track, never the score's solid arc, and the words "Projected 90 if the 2 chosen fixes land" under the caption, with its basis. It is shown only beside a computed score; with "Not computed" there is nothing to project from, and it is left out.

The arc runs clockwise from 12 o'clock in every language, as a dial does. In forced colours it is drawn in `Highlight` on a `CanvasText` track. Put a `SubscoreCard` with `layout="tiles"` beside it, computed from the same subscores.

- Registry name: `score-ring` (`@candor/score-ring`)
- Tier: Pro. Installing it needs a Pro, Premium or Team licence key: the person runs `npx candor-ui key <key>` themselves; never ask for the key in chat or write it to a file.
- Docs: https://candor.themethodic.com/docs/score-ring

## Install

```bash
npx candor-ui add score-ring
```

Run `npx candor-ui@latest init` once first (https://candor.themethodic.com/get-started.md). Without the CLI: `npx shadcn@latest add @candor/score-ring`.
It brings in: link, locale, motion, relative-time, tokens, utils.
npm packages: lucide-react@^1.67.0.

## Example

```tsx
"use client"

import { ScoreRing } from "@/components/candor/score-ring"
import { SubscoreCard, type SubscoreTile } from "@/components/candor/subscore-card"

// One set of subscores feeds both: the ring's score is their mean, and the tiles show where it comes from.
const subscores: SubscoreTile[] = [
  { label: "Access and permissions", score: 74, max: 100, tone: { word: "Needs work", tone: "warning" }, findings: [{ severity: "serious", count: 1 }] },
  { label: "Maintenance debt", score: 84, max: 100, tone: { word: "Fair", tone: "neutral" }, findings: [{ severity: "warning", count: 1 }] },
  { label: "Automation upkeep", score: 84, max: 100, tone: { word: "Fair", tone: "neutral" }, findings: [{ severity: "warning", count: 1 }] },
  { label: "Unused configuration", score: 90, max: 100, tone: { word: "Good", tone: "success" }, findings: [{ severity: "warning", count: 1 }] },
  { label: "Documentation", score: 94, max: 100, tone: { word: "Good", tone: "success" }, findings: [{ severity: "info", count: 2 }] },
]
const score = Math.round(subscores.reduce((n, s) => n + (s.score ?? 0), 0) / subscores.length)
const twoHoursAgo = new Date(Date.now() - 2 * 3_600_000).toISOString()

export default function Example() {
  return (
    <div className="grid items-start gap-4 md:grid-cols-[16rem_1fr]">
      <div className="rounded-xl border bg-card p-4">
        <ScoreRing
          score={score}
          max={100}
          label="Workspace health"
          computedAt={twoHoursAgo}
          basis="the mean of five subscores"
          detailsHref="#subscores"
          delta={{ value: 6, vs: "last month", good: "up" }}
          // An estimate, drawn apart from the score: a dashed inner arc and "Projected 90 if …".
          projected={{ score: 90, basis: "if the 2 chosen fixes land" }}
          bands={[
            { from: 0, word: "Needs attention", tone: "destructive" },
            { from: 60, word: "Fair", tone: "warning" },
            { from: 80, word: "Healthy", tone: "success" },
          ]}
        />
      </div>
      <div id="subscores">
        <SubscoreCard layout="tiles" title="Subscores" subscores={subscores} />
      </div>
    </div>
  )
}
```

## <ScoreRing>

Props:

- `score: number | null`: The score; `null` when it isn't computed (say why in `missing`).
- `max: number`: The highest score possible: a full ring.
- `label: string`: What is scored: "Workspace health". It names the figure.
- `computedAt: string | null`: When the score was computed (ISO date); `null` when that isn't recorded, and the caption says so.
- `basis: string`: What the score is computed from, in words that finish "Computed … from": "a weighted mean of five subscores".
- `detailsHref?: string | undefined`: Where the subscores or the report are, linked as "How it's computed".
- `delta?: ScoreDelta | undefined`: The change against a named earlier score.
- `bands?: ScoreBand[] | undefined`: Named ranges of the score; the one the score falls in is drawn as its word.
- `missing?: string | undefined`: Why the score isn't computed, shown under "Not computed" when `score` is null.
- `projected?: ScoreProjection | undefined`: A projected score with its basis: drawn as a dashed inner arc and "Projected {score} {basis}", only beside a computed score.
- `action?: React.ReactNode | undefined`: A control under the ring, such as a button that opens the report.
- `className?: string | undefined`

## Strings (`scoreRing`)

The fixed words, English by default. Override any of them under `scoreRing` in `<LocaleProvider strings={{ scoreRing: { … } }}>`; the table is exported as `scoreRingStrings`.

- `outOf`: out of {max}
- `notComputed`: Not computed
- `computed`: Computed {when} from {basis}
- `computedUnknown`: Computed from {basis}; when isn’t recorded
- `howComputed`: How it’s computed
- `up`: { one: "Up {count} point since {vs}", other: "Up {count} points since {vs}" }
- `down`: { one: "Down {count} point since {vs}", other: "Down {count} points since {vs}" }
- `same`: No change since {vs}
- `projected`: Projected {score} {basis}

## Types

### ScoreDelta

A change in the score against a named earlier one, as `StatCard`'s delta: the period is always said.

```ts
{
  /** The change in points: positive is up. */
  value: number;
  /** What it is compared with, in words that finish "since …": "last month", "the audit on 5 November". */
  vs: string;
  /** Which direction is good news; it sets the colour, never the words. */
  good: "up" | "down";
}
```

### ScoreBand

A range of scores named by a word: a score at or above `from` (and below the next band's `from`) falls in it.

```ts
{
  /** The lowest score in the band. */
  from: number;
  /** The band's word, printed beside its dot ("Healthy", "Needs work"). */
  word: string;
  /** The dot's colour. */
  tone: "success" | "warning" | "destructive" | "neutral";
}
```

### ScoreProjection

A score the app expects if something happens: an estimate, drawn apart from the score.

```ts
{
  /** The projected score. */
  score: number;
  /** What it rests on, in words that finish "Projected 90 …": "if the 2 chosen fixes land". */
  basis: string;
}
```

## Motion

- **value** (value, fade): When a new score arrives while the ring is on screen (a new `computedAt` or a new score), the arc and the number crossfade to the new value. A ring that mounts with its score draws it still.

Set with `--cd-motion-score-ring-<moment>-*` in your brand file; the families are in the design reference.

## Rules for using Candor UI

- Components take plain typed data. Never pass model output as HTML or render it as a link; messages are typed blocks.
- A control that can't be used says why: wrap it in `<Locked reason="…">`, don't just disable it.
- Don't show a value you don't have: leave it out (it renders as —) rather than guess. An estimate is labelled as one.
- Nothing is pre-selected in a decision a person makes, and the agent is named from your app's data, never from model output.
- Your app's words go in `LocaleProvider`: its fixed strings in `strings`, its nouns in `nouns`; entity kinds and agent roles in `EntityKindsProvider` and `AgentRolesProvider`.
