Charts and tablesScore breakdown
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 callScore 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.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add score-breakdownReact
npx designme add score-breakdown --reactAstro
npx designme add score-breakdown --astroThe 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.
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.
- A half-circle gauge split by weight, with a small gap between segments, each filled to earned / weight in the chart palette.
- The score counts up to the total of the earned points, with a band word (Excellent from 90, Good from 75, Fair from 60, else Needs work) and the change against
previousas a green or red tag. - Rows with the factor's colour, name, earned / weight, a bar and the agent's note; the weakest factor (the lowest share of its weight) has a Weakest tag.
- Pointing at a row or its segment, or moving the focus into a row, lights both and dims the other segments.
- A row opens to say how many points it loses; with
improvableandonImproveit has Improve. While the proposal is on its way the row says what the agent is doing, then shows the proposal, what it gains and the new points, with Apply and Not now. - Apply calls
onApply; update the factor and the gauge, the score and the rows move to the new values. The open rows can be controlled withopenandonOpenChange.
- The gauge has role="meter" with the score and an aria-valuetext with the band and the change, like "86 of 100, good, up 9 since last week".
- Each row is a button with aria-expanded and a label with its points, the Weakest flag and the note; its closed detail is inert and hidden from screen readers.
- The weakest factor and the change are said in words, not only by colour.
- Number count-ups finish at once when the user prefers reduced motion.
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" />Props
Everything ScoreBreakdown takes.
| Prop | Type | What it does |
|---|---|---|
factorsrequired | ScoreFactor[] | |
title | string | |
previous | number | Last period's score, for the change beside the band word |
since | string | What the change is measured against ("since last week") |
open | string[] | The open rows, by key |
defaultOpen | string[] | |
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) => void | Called by Apply on a proposal; update the factor's `earned` and `note` |
improvingLabel | string | What the row says while the agent works on a proposal |
className | string |
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);
});
});
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.
More in Charts and tables
Lightweight charts and tables for dashboards and reports, drawn in plain SVG and HTML.