Proof and provenance
A case study is only as good as the numbers in it, so every number carries where it came from, and the kit decides what each one may claim. This page covers that model and the functions that apply it: what to run before you render, the line a number wears, which number a card leads with, and which live analytics changes are worth showing.
Everything here is pure (no React, no network, no server guard) and imports
from @sonordev/agency-site-kit/portfolio.
Where a number came from
Every KPI tile (PortfolioKPI) and results card (PortfolioResultItem) has a
source:
| Source | Meaning | What it must carry |
|---|---|---|
measured | Counted from real data: analytics, search, a Lighthouse run | Nothing more |
reported | The client told you | reportedBy: who, where they work, and the day they said it |
estimated | A projection or an estimate | Nothing more, but it can never lead a card |
reportedBy is a MetricAttribution:
{
name: 'Dana Reyes', // required
role: 'Director of Operations', // optional
organization: 'Northwind Engineering', // required
date: '2026-09-25', // required, YYYY-MM-DD
}A number with no source at all is older data. It displays without a label
and can't lead a card. The model is defined once, in
@sonordev/site-kit/portfolio/contract, and Sonor applies the same rules
when it writes a case study.
Before you render: curatePortfolioProof
import { curatePortfolioProof } from '@sonordev/agency-site-kit/portfolio';
const curated = curatePortfolioProof(item);It applies the display policy to a PortfolioItemFull and returns a new item
(the one you fetched is never changed):
- Hero KPIs collapse Lighthouse tiles. A Lighthouse tile is one whose
label mentions Lighthouse. When the hero carries two or more, they become
one: four or more, all at 100, turn into a single
Lighthouse, all 4 categoriestile; otherwise the Performance tile (or the first Lighthouse tile, if none is Performance) stands in for the set. Business numbers keep their order and come first. - Results cards stop repeating scores. When the case study has a
Performance section, a results card that only restates a Lighthouse score
is dropped (its title mentions a score, and it mentions Lighthouse or
carries an
x/100figure). If every card is one of those, the first card stays, so the section is never empty. - Every other section passes through untouched.
The two halves are exported on their own too, if you're building from something other than a whole item:
| Function | Returns |
|---|---|
curateHeroKpis(kpis) | The KPI list with Lighthouse tiles collapsed. Fewer than two Lighthouse tiles come back unchanged. |
curateResultItems(items, hasPerformanceSection) | The results cards with Lighthouse restatements dropped, when hasPerformanceSection is true. |
The line a number wears: metricNote
metricNote(metric, 'short' | 'full'): string | nullRender it under every number you didn't measure. It's what keeps a client's figure from reading as something you counted.
| The number | 'short' | 'full' |
|---|---|---|
Reported, with reportedBy | per Northwind Engineering | Reported by Dana Reyes, Director of Operations, Northwind Engineering, September 25, 2026 |
| Reported, nobody named | Client-reported | Client-reported |
| Estimated | Estimated | Estimated |
| Measured, or no source | null | null |
Use 'short' on a card or a hero tile and 'full' under a results card.
import { formatMetricValue, metricNote } from '@sonordev/agency-site-kit/portfolio';
{results.items.map((r) => {
const note = metricNote(r, 'full');
return (
<li key={r.title}>
{r.metric ? <strong>{formatMetricValue(r.metric)}</strong> : null}
<h3>{r.title}</h3>
{note ? <p className="note">{note}</p> : null}
</li>
);
})}metricCredit(metric, form) is the credit on its own: the same strings as the
first row above, and null for everything else.
The number a card leads with: headlineKpi
headlineKpi(kpis: PortfolioKPI[] | null | undefined): HeadlineKpi | nullIt picks the first KPI that may headline, in the item's own order, after the
Lighthouse tiles collapse: a measured number, or a reported one with someone
named behind it. Never an estimate and never an unlabelled number. It returns
null when there isn't one, so show the card without a figure.
interface HeadlineKpi {
value: string; // formatted, e.g. "$1,000,000"
label: string;
source: 'measured' | 'reported';
credit: string | null; // "per Northwind Engineering" on a reported number
kpi: PortfolioKPI; // the tile it came from
}import { headlineKpi } from '@sonordev/agency-site-kit/portfolio';
const kpi = headlineKpi(item.kpis);
{kpi ? (
<p>
<strong>{kpi.value}</strong> {kpi.label}
{kpi.credit ? <span> ({kpi.credit})</span> : null}
</p>
) : null}It works on list items (PortfolioItem.kpis), so the index needs no extra
fetch.
Printing a figure: formatMetricValue
formatMetricValue({ value, prefix?, suffix? }): stringPrefix, the number with thousands separators (up to two decimal places), then the suffix:
| Input | Output |
|---|---|
{ value: 1000000, prefix: '$' } | $1,000,000 |
{ value: 1, prefix: '$', suffix: 'M' } | $1M |
{ value: 12500 } | 12,500 |
{ value: 98, suffix: '/100' } | 98/100 |
A string value prints as stored. It takes a KPI, a results card's metric,
or anything else with that shape.
Live analytics changes: gateMetricsDeltas
PortfolioItemFull.metricsDelta compares a project's live numbers with its
baseline. Not every change is worth publishing, so run them through the gate
first:
import { gateMetricsDeltas } from '@sonordev/agency-site-kit/portfolio';
const wins = gateMetricsDeltas(item.metricsDelta);A change passes when all of these hold:
- It improved.
directionis'up'for an improvement even on lower-is-better metrics like bounce rate, so a drop in bounce rate reads'up'. - It moved by 10% or more (
deltaPercent). - If it's a bounce rate, the current value is 70 or lower. A bounce rate that went from 100 to 99.7 improved, and it still isn't something to show off.
It returns an empty array for null, undefined or an empty list.
The contract, re-exported
These come from @sonordev/site-kit/portfolio/contract and are re-exported,
so one import covers everything:
| Export | What it does |
|---|---|
METRIC_SOURCES | ['measured', 'reported', 'estimated'] |
MetricSource, MetricAttribution | The types above (from /portfolio and the root entry) |
isHeadlineMetric(metric) | True for a measured number, or a reported one with a valid reportedBy |
metricSourceLabel(source) | Measured, Client-reported, Estimated, or null for a missing or unknown source |
metricSourceProblem(metric) | What's wrong with a number's provenance as a sentence, or null. Handy for flagging bad data in a preview. |
formatAttribution(by) | The full credit sentence |
shortAttribution(by) | per <organization> |
If your design badges every number, measured ones included, read
metricSourceLabel for the badge and keep metricNote for the credit.
Showing the evidence
Sonor can publish the evidence behind a case study's numbers: what was
counted, over which window, against which baseline, where it came from, and
what it doesn't prove. Fetch it on the server with getProofRecords:
import { formatIsoDate } from '@sonordev/agency-site-kit/portfolio';
import { getProofRecords } from '@sonordev/agency-site-kit/portfolio/server';
const records = await getProofRecords(item.slug, { site: 'youragency.com' }).catch(() => []);
{records.length ? (
<section>
<h2>How we measured this</h2>
<ul>
{records.map((r) => (
<li key={r.id}>
<strong>
{r.result_value} {r.unit}
</strong>{' '}
{r.definition}, {formatIsoDate(r.window_start)} to {formatIsoDate(r.window_end)}.{' '}
<a href={r.source_url}>{r.source_label}</a>. {r.limitations}
</li>
))}
</ul>
</section>
) : null}site is your own site's host: records are published per site, so the same
case study can carry different evidence on two sites. An empty list is a
healthy answer. Like every fetcher, it throws when Sonor can't be reached; the
.catch above renders the page without its evidence instead. See
Fetching for the details and Types for every
ProofRecord field.