Skip to content
Star on GitHub
Charts and tablesScore breakdown
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises, from Astro sites to React apps and AI products.

Book a call

Score breakdown

A score out of 100 whose gauge is split by what goes into it, with the weakest part flagged and a fix to try. Free and MIT licensed, as HTML, a typed React component or an Astro component, in two styles with light and dark mode.

$npx designme add score-breakdown
0of 100
Support agent qualityScoring
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add score-breakdown

React

$npx designme add score-breakdown --react

Astro

$npx designme add score-breakdown --astro

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro islands. The Astro version lands in src/components/designme too, with the same markup, CSS and script as the HTML version. See Astro.

shadcn CLI, React

$npx shadcn@latest add https://designme.dev/r/shadcn/score-breakdown.json

shadcn CLI, Astro

$npx shadcn@latest add https://designme.dev/r/shadcn/astro/score-breakdown.json

In a project set up for shadcn/ui, its CLI adds the same files to your components folder, under designme/, with the shared core. To give the components your shadcn or Tailwind v4 theme, add the theme bridge.

02

About

What it is for and what it does.

A score out of 100 with what goes into it on show. The gauge is split into one segment per factor, sized by its weight (accuracy 40, speed 20, cost 20, safety 20), and each segment fills with the points that factor earned. Beside it sit the band word and the change since last week; under it, a row per factor with its points, a small bar and the agent's one-line note. The weakest factor is flagged in words, and its row offers Improve, which asks the agent for a proposal you can apply.

  • For an agent quality score, an eval score or a health score that is a weighted sum of a few factors.
  • When people should see why the score is what it is, not only the number.
  • When the agent can suggest a fix for the factor that costs the most.
03

Usage

Import it in React and drive it from your own state, or place it in an Astro page.

React

import { ScoreBreakdown } from '@/components/designme/score-breakdown/ScoreBreakdown';

React example

The demo above as a React component, 32 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React component: the weakest factor opens, the agent proposes a fix,
// the person applies it and the score rises.
import { useRef, useState } from 'react';
import { ScoreBreakdown, type ScoreFactor } from './ScoreBreakdown';
import { useStory } from '../lib/useScript';

const START: ScoreFactor[] = [
  { key: 'acc', label: 'Accuracy', weight: 40, earned: 34, note: '2 wrong refund amounts in 120 tickets' },
  { key: 'speed', label: 'Speed', weight: 20, earned: 11, note: 'Median reply 6.4s, target under 4s', improvable: true },
  { key: 'cost', label: 'Cost', weight: 20, earned: 16, note: '$0.11 a ticket, budget $0.12' },
  { key: 'safety', label: 'Safety', weight: 20, earned: 19, note: 'No policy breaks, 1 tone flag' }
];

export default function ScoreBreakdownDemo() {
  const ref = useRef<HTMLDivElement>(null);
  const [factors, setFactors] = useState(START);
  const [open, setOpen] = useState<string[]>([]);
  useStory(ref, async ({ wait, press }) => {
    await wait(2500); setOpen(['speed']);
    await wait(1500); press('.dm-sbx-r.dm-open .dm-pill');
    await wait(3500); press('.dm-sbx-r.dm-open .dm-primary');
  });
  return (
    <div ref={ref} style={{ width: '100%' }}>
      <ScoreBreakdown factors={factors} title="Support agent quality" previous={77} open={open} onOpenChange={setOpen} improvingLabel="Looking at 120 slow replies"
        onImprove={() => new Promise(res => setTimeout(() => res({ text: 'Cache the search results between steps and run the two account lookups at once.', gain: 'About 2.1s faster a reply', to: 17 }), 1300))}
        onApply={(f, p) => setFactors(fs => fs.map(x => x.key === f.key ? { ...x, earned: p.to, note: 'Median reply 4.3s, close to the target', improvable: false, detail: 'Applied: search results are cached and the lookups run together.' } : x))} />
    </div>
  );
}

Astro

In a page in src/pages. It plays the demo above; change its score-breakdown.js to show your own data. Attributes like class go on its root element.

---
import ScoreBreakdown from '../components/designme/score-breakdown/ScoreBreakdown.astro';
---
<ScoreBreakdown />
<ScoreBreakdown data-style="soft" class="my-class" />
04

Props

Everything ScoreBreakdown takes.

PropTypeWhat it does
factorsrequiredScoreFactor[]
titlestring
previousnumberLast period's score, for the change beside the band word
sincestringWhat the change is measured against ("since last week")
openstring[]The open rows, by key
defaultOpenstring[]
onOpenChange(open: string[]) => void
onImprove(factor: ScoreFactor) => ScoreProposal | Promise<ScoreProposal>Called by Improve. Return the agent's proposal (or a promise of it); while it waits the row says the agent is looking
onApply(factor: ScoreFactor, proposal: ScoreProposal) => voidCalled by Apply on a proposal; update the factor's `earned` and `note`
improvingLabelstringWhat the row says while the agent works on a proposal
classNamestring
05

Code

The full source, as the CLI installs it.

ScoreBreakdown.tsxReact · 132 lines
'use client';
import { useEffect, useState, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable, useTween } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { Fold, Swap } from '../lib/motion';
import '../designme.css';
import './score-breakdown.css';

export interface ScoreFactor {
  key: string;
  /** Like "Speed" */
  label: string;
  /** The points it can earn; the weights add up to 100 */
  weight: number;
  /** The points it earned */
  earned: number;
  /** The agent's one-line note, like "Median reply 6.4s, target under 4s" */
  note: string;
  /** Adds an Improve button to the open row */
  improvable?: boolean;
  /** A line shown first in the open row, like what was changed */
  detail?: string;
}

export interface ScoreProposal {
  /** What the agent would change */
  text: ReactNode;
  /** What it would gain, like "About 2.1s faster a reply" */
  gain?: string;
  /** The points the factor would earn after it */
  to: number;
}

export interface ScoreBreakdownProps {
  factors: ScoreFactor[];
  title?: string;
  /** Last period's score, for the change beside the band word */
  previous?: number;
  /** What the change is measured against ("since last week") */
  since?: string;
  /** The open rows, by key */
  open?: string[];
  defaultOpen?: string[];
  onOpenChange?: (open: string[]) => void;
  /** Called by Improve. Return the agent's proposal (or a promise of it); while it waits the row says the agent is looking */
  onImprove?: (factor: ScoreFactor) => ScoreProposal | Promise<ScoreProposal>;
  /** Called by Apply on a proposal; update the factor's `earned` and `note` */
  onApply?: (factor: ScoreFactor, proposal: ScoreProposal) => void;
  /** What the row says while the agent works on a proposal */
  improvingLabel?: string;
  className?: string;
}

const PAL = ['var(--dm-c1)', 'var(--dm-c2)', 'var(--dm-c3)', 'var(--dm-c4)', 'var(--dm-c5)', 'var(--dm-c6)'];
const band = (s: number) => s >= 90 ? 'Excellent' : s >= 75 ? 'Good' : s >= 60 ? 'Fair' : 'Needs work';
const CX = 100, CY = 100, RAD = 86, GAP = .04;
const at = (a: number) => [CX + Math.cos(a) * RAD, CY + Math.sin(a) * RAD];
const arc = (a0: number, a1: number) => { const [x0, y0] = at(a0), [x1, y1] = at(a1); return `M${x0.toFixed(2)} ${y0.toFixed(2)}A${RAD} ${RAD} 0 0 1 ${x1.toFixed(2)} ${y1.toFixed(2)}`; };

function Segment({ a0, a1, share, color, on, onPoint }: { a0: number; a1: number; share: number; color: string; on: boolean; onPoint: (on: boolean) => void }) {
  const p = useTween(share, 900, 0);
  return (
    <g className={cx(on && 'dm-on')} onPointerEnter={e => { if (e.pointerType !== 'touch') onPoint(true); }} onPointerLeave={() => onPoint(false)}>
      <path className="dm-tr" d={arc(a0, a1)} />
      {p > 0 && <path className="dm-fl" style={{ stroke: color }} d={arc(a0, a0 + (a1 - a0) * Math.min(1, p))} />}
    </g>
  );
}

/** A score out of 100 whose gauge is split by what goes into it, each part filled by the points it earned, with the weakest flagged and an agent's fix one click away. */
export function ScoreBreakdown({ factors, title = 'Quality score', previous, since = 'since last week', open, defaultOpen = [], onOpenChange, onImprove, onApply, improvingLabel = 'Looking into it', className }: ScoreBreakdownProps) {
  const [opened, setOpened] = useControllable(open, defaultOpen, onOpenChange);
  const [hot, setHot] = useState(-1);
  const [drawn, setDrawn] = useState(false);
  const [props, setProps] = useState<Record<string, ScoreProposal | 'busy'>>({});
  useEffect(() => { const t = setTimeout(() => setDrawn(true), 280); return () => clearTimeout(t); }, []);
  const total = factors.reduce((t, f) => t + f.earned, 0);
  const shown = useTween(drawn ? total : 0, 1000, 0);
  const weakest = factors.reduce((m, f, i) => f.earned / f.weight < factors[m].earned / factors[m].weight ? i : m, 0);
  let a = Math.PI;
  const seg = factors.map(f => { const span = f.weight / 100 * Math.PI, s = [a + GAP / 2, a + span - GAP / 2]; a += span; return s; });
  const d = previous === undefined ? null : total - previous;
  const improve = async (f: ScoreFactor) => {
    if (!onImprove) return;
    setProps(p => ({ ...p, [f.key]: 'busy' }));
    const pr = await onImprove(f); setProps(p => ({ ...p, [f.key]: pr }));
  };
  const drop = (k: string) => setProps(p => { const n = { ...p }; delete n[k]; return n; });
  return (
    <div data-dm="" className={cx('dm-sbx dm-panel', className)}>
      <div className="dm-sbx-top">
        <div className={cx('dm-sbx-g', hot >= 0 && 'dm-hov')} role="meter" aria-label={title} aria-valuemin={0} aria-valuemax={100} aria-valuenow={total}
          aria-valuetext={`${total} of 100, ${band(total).toLowerCase()}${d === null ? '' : `, ${d >= 0 ? 'up' : 'down'} ${Math.abs(d)} ${since}`}`}>
          <svg viewBox="0 0 200 108" aria-hidden="true">
            {factors.map((f, i) => <Segment key={f.key} a0={seg[i][0]} a1={seg[i][1]} share={drawn ? f.earned / f.weight : 0} color={PAL[i % PAL.length]} on={i === hot} onPoint={o => setHot(o ? i : -1)} />)}
          </svg>
          <div className="dm-sbx-v"><b className="dm-num">{Math.round(shown)}</b><span className="dm-muted">of 100</span></div>
        </div>
        <div className="dm-sbx-s"><b>{title}</b>
          <span className="dm-sbx-band"><Swap className="dm-muted">{drawn ? band(total) : 'Scoring'}</Swap>{d !== null && drawn && <span className={cx('dm-tag dm-num', d >= 0 ? 'dm-ok' : 'dm-bad')}>{d >= 0 ? '+' : '−'}{Math.abs(d)} {since}</span>}</span>
        </div>
      </div>
      <div className="dm-sbx-list" onPointerLeave={e => { if (!e.currentTarget.contains(document.activeElement)) setHot(-1); }} onBlur={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setHot(-1); }}>
        {factors.map((f, i) => {
          const isOpen = opened.includes(f.key), lost = f.weight - f.earned, pr = props[f.key];
          return (
            <div key={f.key} className={cx('dm-sbx-r', i === hot && 'dm-on', isOpen && 'dm-open')} onPointerEnter={e => { if (e.pointerType !== 'touch') setHot(i); }} onFocus={() => setHot(i)}>
              <button type="button" className="dm-sbx-rh" aria-expanded={isOpen} aria-label={`${f.label}: ${f.earned} of ${f.weight} points${i === weakest ? ', weakest' : ''}. ${f.note}`}
                onClick={() => setOpened(isOpen ? opened.filter(k => k !== f.key) : [...opened, f.key])}>
                <i style={{ background: PAL[i % PAL.length] }} /><span className="dm-n"><span>{f.label}</span>{i === weakest && <span className="dm-tag dm-warn">Weakest</span>}</span>
                <span className="dm-e dm-num">{f.earned}/{f.weight}</span><span className="dm-bar"><i style={{ background: PAL[i % PAL.length], width: drawn ? f.earned / f.weight * 100 + '%' : 0 }} /></span>
                <Icon name="chev-r" size="xs" /><span className="dm-sbx-nt">{f.note}</span>
              </button>
              <Fold open={isOpen}>
                <div className="dm-sbx-d">
                  {pr === 'busy' ? <div className="dm-sbx-pr dm-enter"><span className="dm-k"><Icon name="spark" size="xs" /><span className="dm-shim">{improvingLabel}</span></span></div>
                    : pr ? <div className="dm-sbx-pr"><span className="dm-k"><Icon name="spark" size="xs" />Proposal</span><span>{pr.text}</span>
                        <span className="dm-gain">{pr.gain ? pr.gain + ': ' : ''}{f.label} <b className="dm-num">{f.earned} → {pr.to}</b></span>
                        <span className="dm-acts"><button type="button" className="dm-pill dm-primary dm-sm" onClick={() => { drop(f.key); onApply?.(f, pr); }}>Apply</button><button type="button" className="dm-pill dm-ghost dm-sm" onClick={() => drop(f.key)}>Not now</button></span></div>
                    : <><span>{f.detail ? f.detail + ' ' : ''}{lost ? `Loses ${lost} of its ${f.weight} points` : 'Full marks'}{i === weakest && lost ? ', the most of any factor for its weight.' : '.'}</span>
                        {f.improvable && onImprove && <span className="dm-acts"><button type="button" className="dm-pill dm-soft dm-sm" onClick={() => improve(f)}><Icon name="wand" size="xs" />Improve</button></span>}</>}
                </div>
              </Fold>
            </div>
          );
        })}
      </div>
    </div>
  );
}
score-breakdown.cssReact · 49 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Score breakdown ---------- */
/* the gauge is split into one segment per factor, sized by its weight, each filled by the points it earned; the rows under it
   say what each earned and why, and the weakest one says so in words */
.dm-sbx{padding:var(--dm-s-4);display:flex;flex-direction:column;gap:var(--dm-s-3)}
.dm-sbx-top{display:flex;align-items:center;gap:var(--dm-s-4)}
.dm-sbx-g{position:relative;width:160px;height:88px;flex:none}
.dm-sbx-g svg{width:100%;height:100%;overflow:visible}
.dm-sbx-g path{fill:none;stroke-width:16;stroke-linecap:butt;transition:opacity .3s}
.dm-sbx-g .dm-tr{stroke:var(--dm-chip)}
.dm-sbx-g.dm-hov g:not(.dm-on) .dm-fl{opacity:.3}
.dm-sbx-g g{cursor:default}
.dm-sbx-v{position:absolute;left:0;right:0;bottom:0;display:flex;flex-direction:column;align-items:center;line-height:1.25}
.dm-sbx-v b{font-size:14px;font-weight:500}
.dm-sbx-v .dm-muted{font-size:11px}
.dm-sbx-s{display:flex;flex-direction:column;gap:2px;font-size:13.5px;min-width:0}
.dm-sbx-s b{font-weight:500}
.dm-sbx-band{display:flex;align-items:baseline;gap:var(--dm-s-2);flex-wrap:wrap;font-size:12.5px}
/* the change chip keeps its centred text when it crossfades (a swapped element is otherwise an inline block, which drops
   its text to the top), and shares a baseline with the band word beside it */
.dm-sbx-band .dm-tag{display:inline-flex}
.dm-sbx-list{display:flex;flex-direction:column;gap:2px;padding-top:var(--dm-s-3);border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-sbx-r{border-radius:calc(10px * var(--dm-rk));transition:background .2s}
.dm-sbx-r.dm-on,.dm-sbx-r.dm-open{background:var(--dm-row)}
@media (hover:hover) and (pointer:fine){.dm-sbx-r:hover{background:var(--dm-row)}}
.dm-sbx-rh{display:grid;grid-template-columns:8px minmax(0,1fr) auto 56px 12px;align-items:center;column-gap:10px;width:100%;padding:var(--dm-s-2);font-size:13px;text-align:left}
.dm-sbx-rh>i{width:8px;height:8px;border-radius:calc(2px * var(--dm-rk))}
.dm-sbx-rh .dm-n{display:flex;align-items:baseline;gap:6px;min-width:0}
.dm-sbx-rh .dm-n>span:first-child{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-sbx-rh .dm-tag{display:inline-flex;align-items:center;height:18px;padding-block:0;font-size:11px;line-height:1;flex:none;align-self:center}
.dm-sbx-rh:focus-visible{outline-offset:-2px;border-radius:inherit}
.dm-sbx-rh .dm-e{font-size:12px;color:var(--dm-fg2)}
.dm-sbx-rh .dm-bar{height:5px;border-radius:calc(3px * var(--dm-rk));background:var(--dm-line);overflow:hidden}
.dm-sbx-rh .dm-bar i{display:block;height:100%;width:0;border-radius:inherit;transition:width .8s var(--dm-ease)}
.dm-sbx-rh>svg{color:var(--dm-muted);transition:transform .35s var(--dm-ease)}
.dm-sbx-r.dm-open .dm-sbx-rh>svg{transform:rotate(90deg)}
.dm-sbx-nt{grid-column:2/-1;font-size:12px;color:var(--dm-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
@media (hover:hover) and (pointer:fine){.dm-sbx-r:is(:hover,.dm-on,.dm-open) .dm-sbx-nt{color:var(--dm-fg2)}}
.dm-sbx-d{padding:0 var(--dm-s-2) 10px calc(var(--dm-s-2) + 18px);display:flex;flex-direction:column;gap:var(--dm-s-2);font-size:12.5px;color:var(--dm-fg2);line-height:1.5}
.dm-sbx-d .dm-acts{display:flex;gap:6px;flex-wrap:wrap}
.dm-sbx-pr{display:flex;flex-direction:column;gap:6px;padding:10px var(--dm-s-3);border-radius:var(--dm-r-in);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-sbx-pr .dm-k{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dm-muted)}
.dm-sbx-pr .dm-k .dm-i{color:var(--dm-accent-fg,var(--dm-accent))}
.dm-sbx-pr .dm-gain{font-size:12px;color:var(--dm-fg2)}
.dm-sbx-pr .dm-gain b{font-weight:500;color:var(--dm-ok)}
/* on a phone the gauge sits over its title, and the rows drop their small bars */
.dm-sbx{container-type:inline-size}
@container (max-width:340px){.dm-sbx-top{flex-direction:column;gap:var(--dm-s-2)}.dm-sbx-s{align-items:center;text-align:center}.dm-sbx-band{justify-content:center}.dm-sbx-rh{grid-template-columns:8px minmax(0,1fr) auto 12px}.dm-sbx-rh .dm-bar{display:none}}
ScoreBreakdown.astroAstro · 30 lines
---
// Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync.
// Score breakdown: A score out of 100 whose gauge is split by what goes into it, with the weakest part flagged and a fix to try.
// The markup, CSS and script are designme.dev's own, run by the shared core in ../core, which mounts the component when it scrolls
// into view and plays its demo. To show your own data, change score-breakdown.js. The markup is kept exactly as it is (is:raw).
import type { HTMLAttributes } from 'astro/types';
import '../core/designme.css';
import './score-breakdown.css';

export interface Props extends HTMLAttributes<'div'> {
  /** "soft" draws it in the Soft style. Leave it out for Blueprint, or for the style set on <html> (data-style="soft") */
  'data-style'?: 'soft';
}
const { class: className, ...rest } = Astro.props;
---
<div class:list={['card', className]} data-c="score-breakdown" {...rest}>
  <div class="stage-in"><Fragment is:raw>
<div class="sbx panel">
  <div class="sbx-top">
    <div class="sbx-g" role="meter" aria-label="Quality score" aria-valuemin="0" aria-valuemax="100"><svg viewBox="0 0 200 108" aria-hidden="true"></svg><div class="sbx-v"><b class="num">0</b><span class="muted">of 100</span></div></div>
    <div class="sbx-s"><b>Support agent quality</b><span class="sbx-band"><span class="muted sbx-bw">Scoring</span><span class="tag ok num sbx-ch" hidden></span></span></div>
  </div>
  <div class="sbx-list"></div>
</div>
  </Fragment></div>
</div>
<script>
  import './score-breakdown.js';
</script>
score-breakdown.htmlHTML · 12 lines
<div class="card" data-c="score-breakdown">
  <div class="stage-in">
    <div class="sbx panel">
      <div class="sbx-top">
        <div class="sbx-g" role="meter" aria-label="Quality score" aria-valuemin="0" aria-valuemax="100"><svg viewBox="0 0 200 108" aria-hidden="true"></svg><div class="sbx-v"><b class="num">0</b><span class="muted">of 100</span></div></div>
        <div class="sbx-s"><b>Support agent quality</b><span class="sbx-band"><span class="muted sbx-bw">Scoring</span><span class="tag ok num sbx-ch" hidden></span></span></div>
      </div>
      <div class="sbx-list"></div>
    </div>
  </div>
</div>
score-breakdown.cssHTML · 48 lines
/* ---------- Score breakdown ---------- */
/* the gauge is split into one segment per factor, sized by its weight, each filled by the points it earned; the rows under it
   say what each earned and why, and the weakest one says so in words */
.sbx{padding:var(--dm-s-4);display:flex;flex-direction:column;gap:var(--dm-s-3)}
.sbx-top{display:flex;align-items:center;gap:var(--dm-s-4)}
.sbx-g{position:relative;width:160px;height:88px;flex:none}
.sbx-g svg{width:100%;height:100%;overflow:visible}
.sbx-g path{fill:none;stroke-width:16;stroke-linecap:butt;transition:opacity .3s}
.sbx-g .tr{stroke:var(--dm-chip)}
.sbx-g.hov g:not(.on) .fl{opacity:.3}
.sbx-g g{cursor:default}
.sbx-v{position:absolute;left:0;right:0;bottom:0;display:flex;flex-direction:column;align-items:center;line-height:1.25}
.sbx-v b{font-size:14px;font-weight:500}
.sbx-v .muted{font-size:11px}
.sbx-s{display:flex;flex-direction:column;gap:2px;font-size:13.5px;min-width:0}
.sbx-s b{font-weight:500}
.sbx-band{display:flex;align-items:baseline;gap:var(--dm-s-2);flex-wrap:wrap;font-size:12.5px}
/* the change chip keeps its centred text when it crossfades (a swapped element is otherwise an inline block, which drops
   its text to the top), and shares a baseline with the band word beside it */
.sbx-band .tag{display:inline-flex}
.sbx-list{display:flex;flex-direction:column;gap:2px;padding-top:var(--dm-s-3);border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.sbx-r{border-radius:calc(10px * var(--dm-rk));transition:background .2s}
.sbx-r.on,.sbx-r.open{background:var(--dm-row)}
@media (hover:hover) and (pointer:fine){.sbx-r:hover{background:var(--dm-row)}}
.sbx-rh{display:grid;grid-template-columns:8px minmax(0,1fr) auto 56px 12px;align-items:center;column-gap:10px;width:100%;padding:var(--dm-s-2);font-size:13px;text-align:left}
.sbx-rh>i{width:8px;height:8px;border-radius:calc(2px * var(--dm-rk))}
.sbx-rh .n{display:flex;align-items:baseline;gap:6px;min-width:0}
.sbx-rh .n>span:first-child{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.sbx-rh .tag{display:inline-flex;align-items:center;height:18px;padding-block:0;font-size:11px;line-height:1;flex:none;align-self:center}
.sbx-rh:focus-visible{outline-offset:-2px;border-radius:inherit}
.sbx-rh .e{font-size:12px;color:var(--dm-fg2)}
.sbx-rh .bar{height:5px;border-radius:calc(3px * var(--dm-rk));background:var(--dm-line);overflow:hidden}
.sbx-rh .bar i{display:block;height:100%;width:0;border-radius:inherit;transition:width .8s var(--dm-ease)}
.sbx-rh>svg{color:var(--dm-muted);transition:transform .35s var(--dm-ease)}
.sbx-r.open .sbx-rh>svg{transform:rotate(90deg)}
.sbx-nt{grid-column:2/-1;font-size:12px;color:var(--dm-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
@media (hover:hover) and (pointer:fine){.sbx-r:is(:hover,.on,.open) .sbx-nt{color:var(--dm-fg2)}}
.sbx-d{padding:0 var(--dm-s-2) 10px calc(var(--dm-s-2) + 18px);display:flex;flex-direction:column;gap:var(--dm-s-2);font-size:12.5px;color:var(--dm-fg2);line-height:1.5}
.sbx-d .acts{display:flex;gap:6px;flex-wrap:wrap}
.sbx-pr{display:flex;flex-direction:column;gap:6px;padding:10px var(--dm-s-3);border-radius:var(--dm-r-in);background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-line) 50%,transparent)}
.sbx-pr .k{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--dm-muted)}
.sbx-pr .k .i{color:var(--dm-accent-fg,var(--dm-accent))}
.sbx-pr .gain{font-size:12px;color:var(--dm-fg2)}
.sbx-pr .gain b{font-weight:500;color:var(--dm-ok)}
/* on a phone the gauge sits over its title, and the rows drop their small bars */
.sbx{container-type:inline-size}
@container (max-width:340px){.sbx-top{flex-direction:column;gap:var(--dm-s-2)}.sbx-s{align-items:center;text-align:center}.sbx-band{justify-content:center}.sbx-rh{grid-template-columns:8px minmax(0,1fr) auto 12px}.sbx-rh .bar{display:none}}
score-breakdown.jsHTML · 66 lines
DM.def('score-breakdown', (r, c) => {
  // the factors, their weight (the points each can earn), the points earned, the agent's one-line note, and what it would change
  const F = [
    { n:'Accuracy', w:40, e:34, c:'var(--dm-c1)', note:'2 wrong refund amounts in 120 tickets' },
    { n:'Speed', w:20, e:11, c:'var(--dm-c2)', note:'Median reply 6.4s, target under 4s', fix:{ t:'Cache the search results between steps and run the two account lookups at once.', to:17, gain:'About 2.1s faster a reply', after:'Median reply 4.3s, close to the target' } },
    { n:'Cost', w:20, e:16, c:'var(--dm-c3)', note:'$0.11 a ticket, budget $0.12' },
    { n:'Safety', w:20, e:19, c:'var(--dm-c4)', note:'No policy breaks, 1 tone flag' }
  ];
  const LAST = 77;
  const g = $('.sbx-g', r), svg = $('svg', g), vb = $('.sbx-v b', r), bw = $('.sbx-bw', r), ch = $('.sbx-ch', r), list = $('.sbx-list', r);
  const total = () => F.reduce((t, f) => t + f.e, 0), band = s => s >= 90 ? 'Excellent' : s >= 75 ? 'Good' : s >= 60 ? 'Fair' : 'Needs work';
  const weakest = () => F.reduce((m, f, i) => f.e / f.w < F[m].e / F[m].w ? i : m, 0);
  // the gauge: a half circle split by weight, with a small gap between the segments
  const CX = 100, CY = 100, RAD = 86, GAP = .04;
  const at = a => [CX + Math.cos(a) * RAD, CY + Math.sin(a) * RAD];
  const arc = (a0, a1) => { const [x0, y0] = at(a0), [x1, y1] = at(a1); return `M${x0.toFixed(2)} ${y0.toFixed(2)}A${RAD} ${RAD} 0 0 1 ${x1.toFixed(2)} ${y1.toFixed(2)}`; };
  let a = Math.PI; const SEG = F.map(f => { const span = f.w / 100 * Math.PI, s = [a + GAP / 2, a + span - GAP / 2]; a += span; return s; });
  svg.innerHTML = F.map((f, i) => `<g data-i="${i}"><path class="tr" d="${arc(...SEG[i])}"/><path class="fl" style="stroke:${f.c}" d=""/></g>`).join('');
  const G = $$('g', svg);
  const fill = (i, p) => { const [a0, a1] = SEG[i]; $('.fl', G[i]).setAttribute('d', p > 0 ? arc(a0, a0 + (a1 - a0) * Math.min(1, p)) : ''); };
  list.innerHTML = F.map((f, i) => `<div class="sbx-r" data-i="${i}"><button class="sbx-rh" aria-expanded="false"><i style="background:${f.c}"></i><span class="n"><span>${f.n}</span></span><span class="e num"></span><span class="bar"><i style="background:${f.c}"></i></span>${I('chev-r','xs')}<span class="sbx-nt">${f.note}</span></button><div class="fold shut"><div><div class="sbx-d"></div></div></div></div>`).join('');
  const R = $$('.sbx-r', list);
  let score = 0, shownFill = F.map(() => 0);
  function paint(ms = 700){
    const s = total(), w = weakest();
    tween(score, s, ms, x => { vb.textContent = Math.round(x); }); score = s;
    g.setAttribute('aria-valuenow', s); g.setAttribute('aria-valuetext', `${s} of 100, ${band(s).toLowerCase()}, ${s - LAST >= 0 ? 'up' : 'down'} ${Math.abs(s - LAST)} since last week`);
    swap(bw, band(s)); ch.hidden = false; ch.className = 'tag num sbx-ch ' + (s >= LAST ? 'ok' : 'bad'); swap(ch, `${s >= LAST ? '+' : '−'}${Math.abs(s - LAST)} since last week`);
    F.forEach((f, i) => { const row = R[i], from = shownFill[i], to = f.e / f.w; shownFill[i] = to; tween(from, to, ms + i * 120, p => fill(i, p));
      $('.e', row).textContent = `${f.e}/${f.w}`; $('.bar i', row).style.width = to * 100 + '%'; $('.sbx-nt', row).textContent = f.note;
      // the weakest factor is flagged in words
      const n = $('.n', row), tag = $('.tag', n); if (i === w && !tag) n.insertAdjacentHTML('beforeend', '<span class="tag warn">Weakest</span>'); if (i !== w && tag) tag.remove();
      $('.sbx-rh', row).setAttribute('aria-label', `${f.n}: ${f.e} of ${f.w} points${i === w ? ', weakest' : ''}. ${f.note}`); detail(i); });
  }
  // an open row: the note, and for the factor that can improve, Improve, which asks the agent for a proposal
  function detail(i){ const f = F[i], d = $('.sbx-d', R[i]); if (d.dataset.st === 'busy' || d.dataset.st === 'prop') return;
    const lost = f.w - f.e;
    d.innerHTML = `<span>${f.done ? f.done + ' ' : ''}${lost ? `Loses ${lost} of its ${f.w} points` : 'Full marks'}${i === weakest() && lost ? ', the most of any factor for its weight.' : '.'}</span>${f.fix ? `<span class="acts"><button class="pill soft sm sbx-imp">${I('wand','xs')}Improve</button></span>` : ''}`;
    const b = $('.sbx-imp', d); if (b) b.onclick = () => improve(i); }
  const improve = i => c.run(async () => { const f = F[i], d = $('.sbx-d', R[i]); d.dataset.st = 'busy';
    d.innerHTML = `<div class="sbx-pr"><span class="k">${I('spark','xs')}<span class="shim">Looking at 120 slow replies</span></span></div>`; enter(d.firstElementChild);
    await c.wait(1300);
    d.dataset.st = 'prop';
    d.innerHTML = `<div class="sbx-pr"><span class="k">${I('spark','xs')}Proposal</span><span>${f.fix.t}</span><span class="gain">${f.fix.gain}: ${f.n} <b class="num">${f.e} → ${f.fix.to}</b></span><span class="acts"><button class="pill primary sm sbx-ok">Apply</button><button class="pill ghost sm sbx-no">Not now</button></span></div>`;
    $('.sbx-ok', d).onclick = () => { f.e = f.fix.to; f.note = f.fix.after; f.done = 'Applied: search results are cached and the lookups run together.'; delete f.fix; d.dataset.st = ''; paint(900); };
    $('.sbx-no', d).onclick = () => { d.dataset.st = ''; detail(i); $('.sbx-imp', d) && $('.sbx-imp', d).focus({ preventScroll:true }); };
  });
  const open = (i, o) => { const row = R[i]; o = o ?? !row.classList.contains('open'); row.classList.toggle('open', o); $('.fold', row).classList.toggle('shut', !o); $('.sbx-rh', row).setAttribute('aria-expanded', o); };
  // a row and its segment light together, from either side
  const hl = i => { g.classList.toggle('hov', i >= 0); G.forEach((x, j) => x.classList.toggle('on', j === i)); R.forEach((x, j) => x.classList.toggle('on', j === i)); };
  R.forEach((row, i) => { $('.sbx-rh', row).onclick = () => open(i); c.on(row, 'pointerenter', e => { if (e.pointerType !== 'touch') hl(i); }); c.on(row, 'focusin', () => hl(i)); });
  G.forEach((x, i) => { c.on(x, 'pointerenter', e => { if (e.pointerType !== 'touch') hl(i); }); c.on(x, 'pointerleave', () => hl(-1)); });
  c.on(list, 'pointerleave', () => { if (!list.contains(document.activeElement)) hl(-1); });
  c.on(list, 'focusout', e => { if (!list.contains(e.relatedTarget)) hl(-1); });
  vb.textContent = '0';
  c.later(280, () => paint(1000));
  // the preview's story: the weakest factor opens, the agent proposes a fix, the person applies it and the score rises
  if (r === c.st) c.story(async s => {
    await s.beat(5);
    await s.tap($('.sbx-rh', R[1])); await s.beat(3);
    await s.tap($('.sbx-imp', R[1])); s.away();
    await s.until(() => $('.sbx-ok', R[1])); await s.beat(4);
    await s.tap($('.sbx-ok', R[1])); s.away(); await s.beat(6);
  });
});
06

Questions

What people ask about it.

Do the weights have to add up to 100?
Yes. Each weight is the number of points a factor can earn and the score is their sum, so the weights should total 100.
How is the weakest factor chosen?
It is the factor with the lowest share of its own weight, so 11 of 20 counts as weaker than 34 of 40.
How does this differ from Health score?
Health score averages equal checks. Score breakdown weighs its factors and shows each one's weight as its share of the gauge.
07

More in Charts and tables

Lightweight charts and tables for dashboards and reports, drawn in plain SVG and HTML.

Line chartTwo series over time with a hover readout and toggles.
Bar chartWeekly agent runs, split by outcome, with a range switch.
Donut chartWhere the agent's time went, by kind of task.
Radial goalsEach agent's share of its own target as a ring, with the overall score in the middle.
Funnel chartSignup to paid, with the biggest drop called out by the agent.
Top pagesMost visited pages with share of traffic and a quick sort.
Health scoreA design system score made of a few checks you can open.
Eval radarThis run against the baseline on six eval scores, with the weak ones flagged.
All 13 in Charts and tables →