Glass profile tools

Roast Cards JSON reference

Frontend handoff for Victoria and Konstantinos: the exact JSON value generated by Roast Studio, pasted into Shopify Admin, and consumed by storefront and email product surfaces.

Back to Roast Studio

Contract

Paste the generated root object directly into the product's Roast Cards JSON metafield. Do not wrap it in a metafield object and do not stringify it when pasting through Shopify Admin.
  • 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.
  • version is the schema version. Consumers should ignore unknown future keys.
  • events contains only the final edited values. Detection thresholds, CSV remarks and provenance remain inside Roast Studio.

Root object

AttributeTypeMeaning
versionintegerCurrently 1.
cardsobjectContains optional filter: RoastCard and espresso: RoastCard.

RoastCard object

AttributeTypeMeaning
titlestring, optionalShort display title used by the roast card.
copyobject, optionalOptional cupProfile, roastIntent and sensoryTarget strings.
colourColour, optionalAgtron readings. Omit the whole component when no readings or notes exist.
eventsEventSetFinal values after manual corrections, including end — the roast's finishing time and temperature.
phasesPhaseSetCalculated Drying, Yellowing and Development durations.
curvesCurvesFive-second chart samples plus event rows.

Colour

scale"agtron"Measurement scale.
groundnumber | nullGround coffee Agtron, supporting up to two decimals.
beannumber | nullWhole bean Agtron, supporting up to two decimals.
notesstring, optionalFree-text colour context.

Events

An EventSet always has turningPoint, yellowingStart, firstCrack and end. Any unresolved event is null.

Event attributeTypeMeaning
secondsnumberSeconds elapsed from the start of the roast.
timestringDisplay value in mm:ss.
temperaturesobjectinternalCelsius, 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 attributeTypeMeaning
durationSecondsnumberDuration in seconds.
durationLabelstringDuration in mm:ss.
percentOfRoastnumberDuration 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 attributeTypeMeaning
seconds / timenumber / stringElapsed time in seconds and mm:ss.
internalCelsiusnumber | nullInternal bean temperature.
beanSurfaceCelsiusnumber | nullBean surface temperature.
hotAirCelsiusnumber | nullHot-air temperature.
drumSurfaceCelsiusnumber | nullDrum-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 screenFrom JSONNotes
X axis (time)curves.rows[].secondsDomain 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.endPosition 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 bandphases.dryingSpans 0events.yellowingStart.seconds. Width comes from phases.drying.durationSeconds; label from durationLabel / percentOfRoast.
Yellowing bandphases.yellowingSpans events.yellowingStart.secondsevents.firstCrack.seconds. null when First Crack is unavailable — omit the band entirely.
Development bandphases.developmentSpans events.firstCrack.secondsevents.end.seconds. null under the same condition as Yellowing.
Phase objects intentionally don't repeat 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.firstCrack and phases.development/phases.yellowing are all null together — render Drying only, and hide any "Development" callout in the surrounding card UI.
  • Null sensor columns. hotAirCelsius and drumSurfaceCelsius aren'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 === 0 before dividing.
  • Responsive sizing. Keep the SVG's viewBox fixed and let CSS scale the element (width: 100%; height: auto;) rather than recalculating coordinates per breakpoint.

Storefront rendering rules

  • Select cards.filter or cards.espresso from 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 colour is absent.
  • Hide Development when events.firstCrack or phases.development is null.
  • 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.

AttributeTypeMeaning
fixtureVersionintegerVersion of the fixture envelope.
sampleCountintegerNumber of identifiable product fixtures.
profileCountintegerTotal Filter and Espresso cards across the products.
samplesFixtureSample[]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

  1. In Shopify Admin, open Settings → Custom data → Products.
  2. Choose Add definition.
  3. Name it Roast Cards.
  4. Set namespace and key to custom.roast_cards.
  5. 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

  1. Open the product in Shopify Admin.
  2. Find Roast Cards in the Metafields section.
  3. Click the JSON field and paste the complete Roast Studio output beginning with {.
  4. Click Save.
Paste only the generated value. Do not add quotes, a metafield wrapper, product identity, namespace or key. Shopify already supplies all of those around the field value.

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 }
        ]
      }
    }
  }
}