Contract
- A product can contain
filter,espresso, or both cards. - Product title and handle are intentionally absent. Shopify already supplies product identity through the product that owns this metafield value.
- Missing optional content is omitted. Unresolved calculated values are
null. versionis the schema version. Consumers should ignore unknown future keys.eventscontains only the final edited values. Detection thresholds, CSV remarks and provenance remain inside Roast Studio.
Root object
| Attribute | Type | Meaning |
|---|---|---|
| version | integer | Currently 1. |
| cards | object | Contains optional filter: RoastCard and espresso: RoastCard. |
RoastCard object
| Attribute | Type | Meaning |
|---|---|---|
| title | string, optional | Short display title used by the roast card. |
| copy | object, optional | Optional cupProfile, roastIntent and sensoryTarget strings. |
| colour | Colour, optional | Agtron readings. Omit the whole component when no readings or notes exist. |
| events | EventSet | Final values after manual corrections, including end — the roast's finishing time and temperature. |
| phases | PhaseSet | Calculated Drying, Yellowing and Development durations. |
| curves | Curves | Five-second chart samples plus event rows. |
Colour
| scale | "agtron" | Measurement scale. |
| ground | number | null | Ground coffee Agtron, supporting up to two decimals. |
| bean | number | null | Whole bean Agtron, supporting up to two decimals. |
| notes | string, optional | Free-text colour context. |
Events
An EventSet always has turningPoint, yellowingStart, firstCrack and end. Any unresolved event is null.
| Event attribute | Type | Meaning |
|---|---|---|
| seconds | number | Seconds elapsed from the start of the roast. |
| time | string | Display value in mm:ss. |
| temperatures | object | internalCelsius, beanSurfaceCelsius, hotAirCelsius and drumSurfaceCelsius; each number or null. |
Phases
phases contains drying, yellowing and development. Yellowing and Development are null when First Crack is unavailable.
| Phase attribute | Type | Meaning |
|---|---|---|
| durationSeconds | number | Duration in seconds. |
| durationLabel | string | Duration in mm:ss. |
| percentOfRoast | number | Duration divided by End time, rounded to one decimal. |
Phase boundary timestamps aren't repeated here — they're the same values already in events (e.g. events.yellowingStart.seconds is the start of Yellowing). Use the relevant events entries if you need the boundary times.
Curves
curves.samplingSeconds is currently 5. curves.rows is an ordered array.
| Row attribute | Type | Meaning |
|---|---|---|
| seconds / time | number / string | Elapsed time in seconds and mm:ss. |
| internalCelsius | number | null | Internal bean temperature. |
| beanSurfaceCelsius | number | null | Bean surface temperature. |
| hotAirCelsius | number | null | Hot-air temperature. |
| drumSurfaceCelsius | number | null | Drum-surface temperature. |
Building the SVG graph
The graph is built entirely from curves, events and phases — no other fields are needed. Everything below maps a JSON field directly to something drawn on screen.
| On screen | From JSON | Notes |
|---|---|---|
| X axis (time) | curves.rows[].seconds | Domain is 0 to events.end.seconds. Don't trust the last row's seconds alone — use events.end.seconds as the authoritative roast length. |
| Temperature line(s) | curves.rows[].internalCelsius / beanSurfaceCelsius (and optionally hotAirCelsius, drumSurfaceCelsius) | Each is independently nullable. Build a separate path per series and break the path wherever a value is null rather than interpolating across the gap. |
| Event markers (Turning Point, Yellowing, First Crack, End) | events.turningPoint, events.yellowingStart, events.firstCrack, events.end | Position horizontally with seconds and vertically with the first available event temperature, preferring Internal. Skip unresolved events or events without a temperature. Use time for the label. |
| Drying band | phases.drying | Spans 0 → events.yellowingStart.seconds. Width comes from phases.drying.durationSeconds; label from durationLabel / percentOfRoast. |
| Yellowing band | phases.yellowing | Spans events.yellowingStart.seconds → events.firstCrack.seconds. null when First Crack is unavailable — omit the band entirely. |
| Development band | phases.development | Spans events.firstCrack.seconds → events.end.seconds. null under the same condition as Yellowing. |
startSeconds/endSeconds — derive each band's horizontal position from the bordering events entries (table above), and only use phases for the duration/percentage label drawn on or near the band.Suggested scales
- X (time): linear, domain
[0, events.end.seconds], range[chartX, chartX + chartWidth]. - Y (temperature): linear, domain padded slightly beyond the min/max of whichever series you're plotting (e.g. round down/up to the nearest 10°C) so the curve doesn't touch the chart edges, range
[chartY + chartHeight, chartY](inverted, since SVG y grows downward).
Worked example
Vanilla HTML/CSS/SVG — no chart library required. Drop the roast card object into card and call renderRoastGraph.
<div id="roastGraph"></div>
<style>
.roast-graph { width: 100%; height: auto; font-family: "DM Mono", monospace; }
.roast-graph .axis { stroke: rgba(0,0,0,0.15); stroke-width: 1; }
.roast-graph .axis-label { fill: rgba(0,0,0,0.5); font-size: 11px; }
.roast-graph .band-drying { fill: rgba(14,167,201,0.08); }
.roast-graph .band-yellowing { fill: rgba(255,159,10,0.10); }
.roast-graph .band-development { fill: rgba(255,45,85,0.10); }
.roast-graph .line-internal { fill: none; stroke: #160b04; stroke-width: 2; }
.roast-graph .line-surface { fill: none; stroke: #0ea7c9; stroke-width: 2; stroke-dasharray: 4 3; }
.roast-graph .event-dot { fill: #fff; stroke: #160b04; stroke-width: 2; }
.roast-graph .event-label { fill: #160b04; font-size: 10px; text-transform: uppercase; }
</style>
<script>
function renderRoastGraph(card, mountEl, { width = 900, height = 360, padding = 36 } = {}) {
const end = card.events.end;
if (!end || !card.curves?.rows?.length) { mountEl.innerHTML = ""; return; }
const chartX = padding, chartY = padding;
const chartWidth = width - padding * 2;
const chartHeight = height - padding * 2;
// --- scales -------------------------------------------------
const xMax = end.seconds;
if (xMax <= 0) { mountEl.innerHTML = ""; return; }
const seriesKeys = ["internalCelsius", "beanSurfaceCelsius", "hotAirCelsius", "drumSurfaceCelsius"];
const temps = card.curves.rows.flatMap((row) => seriesKeys.map((key) => row[key])).filter(Number.isFinite);
if (!temps.length) { mountEl.innerHTML = ""; return; }
const yMin = Math.floor(Math.min(...temps, 0) / 10) * 10;
const yMax = Math.ceil(Math.max(...temps, 100) / 10) * 10;
const xFor = (seconds) => chartX + (seconds / xMax) * chartWidth;
const yFor = (celsius) => chartY + chartHeight - ((celsius - yMin) / (yMax - yMin)) * chartHeight;
// --- temperature line, broken across nulls -------------------
const linePath = (key) => {
let d = "";
let drawing = false;
for (const row of card.curves.rows) {
const value = row[key];
if (value == null) { drawing = false; continue; }
const point = `${xFor(row.seconds).toFixed(1)} ${yFor(value).toFixed(1)}`;
d += drawing ? ` L ${point}` : ` M ${point}`;
drawing = true;
}
return d.trim();
};
// --- phase bands, positioned from events, labelled from phases ---
const bands = [];
if (card.events.yellowingStart) {
bands.push({ cls: "band-drying", from: 0, to: card.events.yellowingStart.seconds, phase: card.phases.drying });
}
if (card.phases.yellowing && card.events.yellowingStart && card.events.firstCrack) {
bands.push({ cls: "band-yellowing", from: card.events.yellowingStart.seconds, to: card.events.firstCrack.seconds, phase: card.phases.yellowing });
}
if (card.phases.development && card.events.firstCrack) {
bands.push({ cls: "band-development", from: card.events.firstCrack.seconds, to: end.seconds, phase: card.phases.development });
}
const bandRects = bands.map((band) => {
const x = xFor(band.from), bandWidth = xFor(band.to) - x;
return `<rect class="${band.cls}" x="${x.toFixed(1)}" y="${chartY}" width="${bandWidth.toFixed(1)}" height="${chartHeight}"></rect>`;
}).join("");
// --- event markers --------------------------------------------
const markers = ["turningPoint", "yellowingStart", "firstCrack", "end"]
.map((key) => card.events[key])
.filter(Boolean)
.map((event) => {
const temperature = [
event.temperatures?.internalCelsius,
event.temperatures?.beanSurfaceCelsius,
event.temperatures?.hotAirCelsius,
event.temperatures?.drumSurfaceCelsius,
].find(Number.isFinite);
if (temperature == null) return "";
const x = xFor(event.seconds), y = yFor(temperature);
return `
<circle class="event-dot" cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="4"></circle>
<text class="event-label" x="${x.toFixed(1)}" y="${(y - 10).toFixed(1)}" text-anchor="middle">${event.time}</text>
`;
}).filter(Boolean).join("");
mountEl.innerHTML = `
<svg class="roast-graph" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMidYMid meet">
${bandRects}
<line class="axis" x1="${chartX}" y1="${chartY + chartHeight}" x2="${chartX + chartWidth}" y2="${chartY + chartHeight}"></line>
<path class="line-internal" d="${linePath("internalCelsius")}"></path>
<path class="line-surface" d="${linePath("beanSurfaceCelsius")}"></path>
${markers}
</svg>
`;
}
</script>
Things to handle defensively
- Missing First Crack.
events.firstCrackandphases.development/phases.yellowingare allnulltogether — render Drying only, and hide any "Development" callout in the surrounding card UI. - Null sensor columns.
hotAirCelsiusanddrumSurfaceCelsiusaren't always recorded; only draw a series if at least one row has a non-null value for it. - Very short roasts. Guard against
xMax === 0before dividing. - Responsive sizing. Keep the SVG's
viewBoxfixed and let CSS scale the element (width: 100%; height: auto;) rather than recalculating coordinates per breakpoint.
Storefront rendering rules
- Select
cards.filterorcards.espressofrom the current brew context. Never assume both exist. - Read end-of-roast time and temperature from
events.end— there's no separate target/summary field. - Hide the colour block when
colouris absent. - Hide Development when
events.firstCrackorphases.developmentisnull. - Draw only curve series that contain numeric readings; sensor values can be
null. - Use the supplied labels for editorial display and numeric seconds for graph positioning and calculations.
Sample library download
Download all samples is a development fixture bundle, not a value to paste into Shopify. Product identity lives only in this wrapper so each sample is recognisable; metafieldValue is the exact paste-ready value.
| Attribute | Type | Meaning |
|---|---|---|
| fixtureVersion | integer | Version of the fixture envelope. |
| sampleCount | integer | Number of identifiable product fixtures. |
| profileCount | integer | Total Filter and Espresso cards across the products. |
| samples | FixtureSample[] | Each item contains product for identification and metafieldValue for the contract being implemented. |
{
"product": { "title": "Sample coffee", "handle": "sample-coffee" },
"metafieldValue": { "version": 1, "cards": { "filter": {} } }
}
Shopify Admin setup
This workflow uses a merchant-owned custom product metafield because Carlo will paste values manually in Shopify Admin.
1. Create the definition once
- In Shopify Admin, open Settings → Custom data → Products.
- Choose Add definition.
- Name it Roast Cards.
- Set namespace and key to
custom.roast_cards. - Select the JSON type, keep the definition pinned, and enable Storefront access if the storefront reads it through an API.
2. Paste a value into a product
- Open the product in Shopify Admin.
- Find Roast Cards in the Metafields section.
- Click the JSON field and paste the complete Roast Studio output beginning with
{. - Click Save.
3. Read it in the theme
{% assign roast_cards = product.metafields.custom.roast_cards.value %}
{% if roast_cards.cards.filter %}
{{ roast_cards.cards.filter.title }}
{% endif %}
4. Read it through Storefront GraphQL
product(handle: "PRODUCT_HANDLE") {
roastCards: metafield(namespace: "custom", key: "roast_cards") {
jsonValue
}
}
Shopify references: creating a custom metafield definition and adding a JSON value.
Abbreviated example
Curve rows and repeated event details are shortened here. Download all samples from Roast Studio for full production-shaped fixtures.
{
"version": 1,
"cards": {
"filter": {
"title": "TNT Koji",
"copy": {
"cupProfile": "Tropical fruit, citrus peel and fermented sweetness.",
"roastIntent": "Preserve the Koji expression while keeping the roast light.",
"sensoryTarget": "A bright, expressive filter profile."
},
"colour": {
"scale": "agtron",
"ground": 91.25,
"bean": 71.5
},
"events": {
"turningPoint": {
"seconds": 57,
"time": "00:57",
"temperatures": {
"internalCelsius": 68,
"beanSurfaceCelsius": 74,
"hotAirCelsius": 340,
"drumSurfaceCelsius": 210
}
},
"yellowingStart": { "seconds": 208, "time": "03:28", "temperatures": { "internalCelsius": 118, "beanSurfaceCelsius": 160, "hotAirCelsius": 455, "drumSurfaceCelsius": 205 } },
"firstCrack": { "seconds": 449, "time": "07:29", "temperatures": { "internalCelsius": 171.6, "beanSurfaceCelsius": 202, "hotAirCelsius": 472, "drumSurfaceCelsius": 215 } },
"end": { "seconds": 471, "time": "07:51", "temperatures": { "internalCelsius": 174, "beanSurfaceCelsius": 216, "hotAirCelsius": 432, "drumSurfaceCelsius": 220 } }
},
"phases": {
"drying": { "durationSeconds": 208, "durationLabel": "03:28", "percentOfRoast": 44.2 },
"yellowing": { "durationSeconds": 241, "durationLabel": "04:01", "percentOfRoast": 51.2 },
"development": { "durationSeconds": 22, "durationLabel": "00:22", "percentOfRoast": 4.7 }
},
"curves": {
"samplingSeconds": 5,
"rows": [
{ "seconds": 0, "time": "00:00", "internalCelsius": 105, "beanSurfaceCelsius": 28 },
{ "seconds": 471, "time": "07:51", "internalCelsius": 174, "beanSurfaceCelsius": 216 }
]
}
}
}
}