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 ofkey → { columns, rows, workbook?, sheet?, source? }. Everydata-evreference must resolve to a key, and no entry may have emptyrows.- A section may carry
evidence: "key"(orevidence: ["k1","k2"]) — kartana renders adetails.ev[data-ev=key]after that section’s table/lede, wired toreport-kit/evidence.js(renderPanels(EVIDENCE)), with a CSV export button and a workbook/sheet pointer. - Panels are
details.ev(seereport-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".