toolkit

Report builder · Playbook

Report builder report spec

A single JSON object. Everything is optional except title and a non-empty sections.

The spec is deliberately small: every field it accepts is a field all three surfaces understand. A field only the browser could honour would break the “same rows everywhere” guarantee that makes the PDF and the email possible, so there isn’t one.

{
  "title": "South Bay — Page Health",
  "subject": "https://southbay.example.com",
  "verdict": "REVIEW",
  "slug": "kartana",
  "preheader": "2 pages need a call",
  "meta": { "period": "July 2026", "pages": "120" },
  "sections": [
    {
      "heading": "Pages",
      "lede": "Every indexable page, worst first.",
      "table": {
        "label": "pages",
        "page": 25,
        "total": 340,
        "source": "pulse_mart.page_daily",
        "zebra": true,
        "columns": [
          { "label": "URL",      "sort": "text",    "width": "45%", "mono": true },
          { "label": "Verdict",  "sort": "verdict", "width": "15%" },
          { "label": "Size",     "sort": "num",     "width": "20%" },
          { "label": "Checked",  "sort": "date",    "width": "20%" }
        ],
        "rows": [
          ["/about", "PASS", { "text": "1.2 MB", "sortValue": 1258291 }, "2026-07-02"],
          ["/pricing", "FAIL", { "text": "840 KB", "sortValue": 860160 }, "2026-07-11"]
        ]
      }
    },
    { "heading": "Notes", "lede": "Plain prose is a section too." },
    { "heading": "Chart", "html": "<svg …></svg>" }
  ]
}

Top level

Field Meaning
title Required. The report’s name, in the <h1>, the <title> and the email subject line.
subject What the report is about — usually a URL. Rendered under the title.
verdict PASS / REVIEW / FAIL / INFO / BLOCKED / SKIPPED. Rendered as a badge, never re-graded. See docs/RESULT-CONTRACT.md for what each one means — “did not run” is not “passed”.
slug A short codename chip beside the title.
preheader The line an email client shows beside the subject in the inbox list. Defaults to <verdict> · <title>.
meta Free-form key/value pairs rendered as one muted line.
sections Required, non-empty. A report with nothing in it is a bug, not an empty state.

Section

One of table, html or lede must be present.

Field Meaning
heading The <h2>.
lede A sentence before the table. A section that opens with a bare table is a section nobody reads.
table See below.
html Escape hatch, inserted verbatim — a chart, a callout, anything kartana has no opinion about. Not escaped, so never build it from untrusted input.

Table

Field Meaning
columns Required. See below.
rows Required. An array of arrays (positional, matching columns) or of objects keyed by key/label. A row whose length disagrees with columns is a hard error — it renders as a plausible table with every value shifted one column, which is a wrong report that looks right.
label What a row IS (“pages”, “findings”). Used in the filter placeholder and the row count: “12 of 340 pages”. Default rows.
page Rows per page. Omit for no pagination; the browser paginates automatically past 50 rows. Pagination never affects the PDF or the CSV export.
total The real total when rows is a sample. Renders as “Showing 25 of 340” in every surface. Set it whenever you LIMIT.
source Where the data came from — dataset, table, query. One line under the table.
zebra Alternating row backgrounds. Off by default; worth it past ~15 rows.
name Filename stem for the CSV export. Defaults to the document title.

Column

Field Meaning
label Required. The header text, and the CSV column heading.
sort text | num | date | verdict. Omit to make the column unsortable. verdict ranks by severity, worst first — alphabetical would bury a FAIL between BLOCKED and PASS.
width A CSS width for the <colgroup>. Set it on every column of a table wider than two: Chrome’s auto-sizing differs between screen and print, so a table that looks balanced reflows in the PDF.
align right for numbers. Implied by sort: "num".
mono Monospace the cells — paths, IDs, hashes.
key Property name when rows holds objects rather than arrays. Defaults to label.

Evidence (per-claim panels, issue #686)

A evidence block turns a kartana report into a claim-attached deliverable: one page-level EVIDENCE object plus collapsed <details> panels directly under the claims they support.

{
  "evidence": {
    "high-intent-queries": {
      "columns": ["Query","Clicks","Impressions"],
      "rows": [["how much is a facelift", 120, 4300]],
      "workbook": "appendix.xlsx",
      "sheet": "Queries",
      "source": "GSC 2026-03-01..2026-05-31"
    }
  },
  "sections": [
    { "heading": "Finding", "lede": "Demand is …", "evidence": "high-intent-queries" }
  ]
}
  • evidence — map of key → { columns, rows, workbook?, sheet?, source? }. Every data-ev reference must resolve to a key, and no entry may have empty rows.
  • A section may carry evidence: "key" (or evidence: ["k1","k2"]) — kartana renders a details.ev[data-ev=key] after that section’s table/lede, wired to report-kit/evidence.js (renderPanels(EVIDENCE)), with a CSV export button and a workbook/sheet pointer.
  • Panels are details.ev (see report-kit/evidence.css) — hidden in print, with any on-page promise of the panels wrapped .screenonly.

Cells

A cell is a scalar, or an object when the displayed text is not what should sort or export:

{ "text": "1.2 MB", "sortValue": 1258291 }

sortValue is what the column actually ranks and exports by. Without it, "1.2 MB" sorts below "210 KB" both as text and as a parsed number — the classic formatted-cell bug. Anything with a unit, an abbreviation, or a relative date (“3 days ago”) needs one.

{ "text": "PASS", "verdict": true } forces badge rendering in a column that isn’t sort: "verdict".