{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://docs.printless.app/schemas/printless-doc-v1.schema.json",
  "title": ".printless Block Document v1",
  "description": "Canonical schema for the .printless v1 block document format (PRINTLESS_BLOCK_DOCUMENT_SPEC.md). The envelope {printlessDoc:\"1\", blocks:[...]} is what renderers receive; POST /ent/v1/print/document accepts the blocks array and wraps it. Blocks allow unknown extra properties (renderers ignore them) so the vocabulary can grow without breaking issued documents; unknown block *types* are rejected. v1.1 adds image, table, list, callout, quote, code, chart, signature, attachment and columns so a document can say anything a PDF can; every block carries fallbackText, so a renderer that predates them degrades to text rather than dropping content. v1.2 (2026-09-08) adds meter, the document-level lang/dir/locale/uses fields and the ext extension namespace, bounds columns nesting to one level and caps plain text at 20,000 characters. The vocabulary stays presentational: what a row or a signature MEANS belongs to the print type's metadata or to ext, not to the block.",
  "type": "object",
  "required": [
    "printlessDoc",
    "blocks"
  ],
  "properties": {
    "printlessDoc": {
      "const": "1"
    },
    "type": {
      "description": "What kind of document this is — receipt, token, ticket, parking, invoice, booking, work_order, pass, invitation, print, or any vendor-defined name. Renderers dispatch on this to pick a type-specific layout, exactly as they dispatch on Print.printType today; a type they do not recognise renders generically from blocks, which is what makes vendor-defined formats work with no client release. Optional for backward compatibility (documents issued before this field default to \"print\"); the server stamps it on every document going forward.",
      "type": "string",
      "minLength": 1,
      "maxLength": 64,
      "pattern": "^[a-z][a-z0-9_]*$"
    },
    "blocks": {
      "$ref": "#/$defs/blocks"
    },
    "lang": {
      "type": "string",
      "pattern": "^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$",
      "maxLength": 35,
      "description": "BCP 47 language tag of the document's text (\"en\", \"ar\", \"ja\"). Renderers pick fonts and hyphenation from it. Absent = the app's language."
    },
    "dir": {
      "enum": [
        "ltr",
        "rtl"
      ],
      "description": "Writing direction. Absent = derived from lang (ar, he, fa, ur → rtl) — set it explicitly for a bilingual document."
    },
    "locale": {
      "type": "string",
      "pattern": "^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$",
      "maxLength": 35,
      "description": "Locale for number, currency and date formatting (\"en-IN\", \"ar-SA\", \"ja-JP\") when it differs from lang."
    },
    "uses": {
      "type": "array",
      "maxItems": 30,
      "uniqueItems": true,
      "items": {
        "type": "string",
        "maxLength": 40
      },
      "description": "Block types in this document that postdate v1.1 (\"meter\"). Optional hint so a renderer that lacks one can offer an update instead of silently showing fallbackText."
    }
  },
  "$defs": {
    "blocks": {
      "type": "array",
      "minItems": 1,
      "maxItems": 200,
      "items": {
        "$ref": "#/$defs/block"
      }
    },
    "block": {
      "type": "object",
      "required": [
        "type"
      ],
      "properties": {
        "type": {
          "enum": [
            "header",
            "paragraph",
            "label_value",
            "metric_grid",
            "divider",
            "spacer",
            "line_items",
            "summary",
            "stat_row",
            "qr",
            "barcode",
            "brand_header",
            "page_break",
            "image",
            "table",
            "list",
            "callout",
            "quote",
            "code",
            "chart",
            "signature",
            "attachment",
            "columns",
            "meter"
          ]
        },
        "fallbackText": {
          "description": "Plain-text rendering of this block, shown by any renderer that does not recognise the block type. Optional on input — the server derives one for every block at issue time, so stored documents always carry it. A renderer MUST NOT silently drop a block it cannot render: it shows fallbackText, or a visible 'unsupported content' placeholder when absent. Never omit a block from the display.",
          "type": "string",
          "maxLength": 2000
        },
        "key": {
          "description": "Stable semantic identifier for this block within its document type, e.g. \"queuePosition\", \"vehicleNumber\", \"total\". This is what lets a type-specific screen render a bespoke layout while the document stays a single list of blocks: the token screen looks up key \"queuePosition\" instead of relying on block order, and a generic renderer ignores keys and draws the blocks in sequence. Keys are unique within a document. Without this, a bespoke layout would need the structured payload stored alongside the blocks, and the two would drift.",
          "type": "string",
          "minLength": 1,
          "maxLength": 64,
          "pattern": "^[a-zA-Z][a-zA-Z0-9_]*$"
        },
        "ext": {
          "type": "object",
          "description": "Reserved extension namespace. Anything the vocabulary does not name — a tax code, a PLU, a seat, an integrator's own ids — goes here, never as a new top-level property, so it can never collide with a future core property. Renderers ignore it; it survives round-trips."
        }
      },
      "allOf": [
        {
          "if": {
            "properties": {
              "type": {
                "const": "header"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/headerBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "paragraph"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/paragraphBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "label_value"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/labelValueBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "metric_grid"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/metricGridBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "divider"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/dividerBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "spacer"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/spacerBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "line_items"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/lineItemsBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "summary"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/summaryBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "stat_row"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/statRowBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "qr"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/qrBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "barcode"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/barcodeBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "brand_header"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/brandHeaderBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "image"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/imageBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "table"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/tableBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "list"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/listBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "callout"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/calloutBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "quote"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/quoteBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "code"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/codeBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "chart"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/chartBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "signature"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/signatureBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "attachment"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/attachmentBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "columns"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/columnsBlock"
          }
        },
        {
          "if": {
            "properties": {
              "type": {
                "const": "meter"
              }
            }
          },
          "then": {
            "$ref": "#/$defs/meterBlock"
          }
        }
      ]
    },
    "plainOrRichText": {
      "description": "Either a plain string or a rich-text span array (Notion-compatible subset).",
      "oneOf": [
        {
          "type": "string",
          "maxLength": 20000
        },
        {
          "$ref": "#/$defs/richText"
        }
      ]
    },
    "richText": {
      "type": "array",
      "minItems": 1,
      "maxItems": 100,
      "items": {
        "$ref": "#/$defs/richTextSpan"
      }
    },
    "richTextSpan": {
      "type": "object",
      "required": [
        "text"
      ],
      "properties": {
        "type": {
          "const": "text"
        },
        "text": {
          "type": "object",
          "required": [
            "content"
          ],
          "properties": {
            "content": {
              "type": "string",
              "maxLength": 4000
            },
            "link": {
              "oneOf": [
                {
                  "type": "null"
                },
                {
                  "type": "object",
                  "required": [
                    "url"
                  ],
                  "properties": {
                    "url": {
                      "type": "string",
                      "format": "uri",
                      "maxLength": 2000
                    }
                  }
                }
              ]
            }
          }
        },
        "annotations": {
          "type": "object",
          "properties": {
            "bold": {
              "type": "boolean"
            },
            "italic": {
              "type": "boolean"
            },
            "strikethrough": {
              "type": "boolean"
            },
            "underline": {
              "type": "boolean"
            },
            "code": {
              "type": "boolean"
            }
          }
        }
      }
    },
    "scalarText": {
      "description": "String value; numbers are accepted and rendered with default formatting.",
      "type": [
        "string",
        "number"
      ]
    },
    "headerBlock": {
      "type": "object",
      "required": [
        "text"
      ],
      "properties": {
        "text": {
          "type": "string",
          "maxLength": 500
        },
        "size": {
          "enum": [
            "xl",
            "lg",
            "md"
          ]
        },
        "align": {
          "description": "Alignment relative to the WRITING DIRECTION, not the physical page: \"left\" means the start of the line and \"right\" means the end, so a document rendered right-to-left (Arabic) mirrors them. This keeps an authored layout — label at the start, amount at the end — correct in every language. Renderers that draw in a fixed direction (the server-side PDF, which has no viewer locale) resolve start/end left-to-right.",
          "enum": [
            "left",
            "center",
            "right"
          ]
        }
      }
    },
    "paragraphBlock": {
      "type": "object",
      "required": [
        "text"
      ],
      "properties": {
        "text": {
          "$ref": "#/$defs/plainOrRichText"
        }
      }
    },
    "labelValueBlock": {
      "type": "object",
      "required": [
        "label",
        "value"
      ],
      "properties": {
        "label": {
          "type": "string",
          "maxLength": 200
        },
        "value": {
          "oneOf": [
            {
              "$ref": "#/$defs/scalarText"
            },
            {
              "$ref": "#/$defs/richText"
            }
          ]
        },
        "bold": {
          "type": "boolean"
        },
        "currency": {
          "description": "ISO 4217 code, present only when the numeric value is meant for client-side money computation (e.g. parking hourlyRate × elapsed). Blocks are self-contained — the currency lives on the block that needs it, never on the envelope.",
          "type": "string",
          "minLength": 3,
          "maxLength": 8
        }
      }
    },
    "metricGridBlock": {
      "type": "object",
      "required": [
        "items"
      ],
      "properties": {
        "columns": {
          "type": "integer",
          "minimum": 1,
          "maximum": 4
        },
        "items": {
          "type": "array",
          "minItems": 1,
          "maxItems": 12,
          "items": {
            "$ref": "#/$defs/labelValuePair"
          }
        }
      }
    },
    "labelValuePair": {
      "type": "object",
      "required": [
        "label",
        "value"
      ],
      "properties": {
        "label": {
          "type": "string",
          "maxLength": 200
        },
        "value": {
          "$ref": "#/$defs/scalarText"
        }
      }
    },
    "dividerBlock": {
      "type": "object",
      "properties": {
        "style": {
          "enum": [
            "solid",
            "dashed",
            "dotted"
          ]
        }
      }
    },
    "spacerBlock": {
      "type": "object",
      "properties": {
        "size": {
          "enum": [
            "sm",
            "md",
            "lg"
          ]
        }
      }
    },
    "lineItemsBlock": {
      "type": "object",
      "required": [
        "items"
      ],
      "properties": {
        "items": {
          "type": "array",
          "minItems": 1,
          "maxItems": 200,
          "items": {
            "type": "object",
            "required": [
              "label"
            ],
            "properties": {
              "label": {
                "type": "string",
                "maxLength": 500
              },
              "amount": {
                "type": [
                  "number",
                  "string"
                ]
              },
              "currency": {
                "type": "string",
                "maxLength": 8
              },
              "ext": {
                "type": "object",
                "description": "Reserved extension namespace. Anything the vocabulary does not name — a tax code, a PLU, a seat, an integrator's own ids — goes here, never as a new top-level property, so it can never collide with a future core property. Renderers ignore it; it survives round-trips."
              }
            }
          }
        }
      }
    },
    "summaryBlock": {
      "type": "object",
      "required": [
        "rows"
      ],
      "properties": {
        "rows": {
          "type": "array",
          "minItems": 1,
          "maxItems": 50,
          "items": {
            "type": "object",
            "required": [
              "label",
              "value"
            ],
            "properties": {
              "label": {
                "type": "string",
                "maxLength": 200
              },
              "value": {
                "$ref": "#/$defs/scalarText"
              },
              "secondary": {
                "type": "boolean"
              },
              "bold": {
                "type": "boolean"
              },
              "large": {
                "type": "boolean"
              },
              "ext": {
                "type": "object",
                "description": "Reserved extension namespace. Anything the vocabulary does not name — a tax code, a PLU, a seat, an integrator's own ids — goes here, never as a new top-level property, so it can never collide with a future core property. Renderers ignore it; it survives round-trips."
              }
            }
          }
        }
      }
    },
    "statRowBlock": {
      "type": "object",
      "required": [
        "items"
      ],
      "properties": {
        "items": {
          "type": "array",
          "minItems": 1,
          "maxItems": 6,
          "items": {
            "$ref": "#/$defs/labelValuePair"
          }
        }
      }
    },
    "qrBlock": {
      "type": "object",
      "required": [
        "value"
      ],
      "properties": {
        "value": {
          "type": "string",
          "minLength": 1,
          "maxLength": 2000
        },
        "caption": {
          "type": "string",
          "maxLength": 200
        }
      }
    },
    "barcodeBlock": {
      "type": "object",
      "required": [
        "value"
      ],
      "properties": {
        "format": {
          "enum": [
            "code128",
            "qr",
            "pdf417"
          ]
        },
        "value": {
          "type": "string",
          "minLength": 1,
          "maxLength": 500
        }
      }
    },
    "brandHeaderBlock": {
      "type": "object",
      "properties": {
        "logo": {
          "type": "string",
          "format": "uri",
          "maxLength": 2000
        },
        "name": {
          "type": "string",
          "maxLength": 200
        },
        "tagline": {
          "type": "string",
          "maxLength": 300
        }
      }
    },
    "imageBlock": {
      "description": "Vector artwork carried INLINE as SVG source. There is no URL and no binary: a print is a record, and a record whose picture lives in a bucket stops being self-contained the day the bucket moves. SVG is text, so it rides the same encrypted content path as everything else and scales to any screen or paper size. Sanitised at issue time.",
      "type": "object",
      "required": [
        "svg"
      ],
      "properties": {
        "svg": {
          "description": "A single <svg> root element. Static presentation only: no script, no event handlers, no external references, no embedded raster.",
          "type": "string",
          "minLength": 10,
          "maxLength": 120000
        },
        "alt": {
          "description": "What the picture says, for screen readers and for the fallback text.",
          "type": "string",
          "maxLength": 300
        },
        "caption": {
          "$ref": "#/$defs/plainOrRichText"
        },
        "align": {
          "enum": [
            "start",
            "center",
            "end"
          ]
        },
        "width": {
          "description": "Fraction of the content column - never pixels. The renderer owns layout.",
          "enum": [
            "full",
            "half",
            "third"
          ]
        },
        "aspect": {
          "description": "width divided by height. Optional hint so a renderer can reserve space without parsing viewBox; the viewBox stays authoritative.",
          "type": "number",
          "exclusiveMinimum": 0,
          "maximum": 20
        }
      }
    },
    "tableCell": {
      "description": "Scalar text or a rich-text span array.",
      "oneOf": [
        {
          "$ref": "#/$defs/scalarText"
        },
        {
          "$ref": "#/$defs/richText"
        }
      ]
    },
    "tableBlock": {
      "type": "object",
      "required": [
        "rows"
      ],
      "properties": {
        "columns": {
          "description": "Header + per-column alignment. Omit `label` for a headerless table.",
          "type": "array",
          "minItems": 1,
          "maxItems": 12,
          "items": {
            "type": "object",
            "properties": {
              "label": {
                "type": "string",
                "maxLength": 120
              },
              "align": {
                "enum": [
                  "start",
                  "center",
                  "end"
                ]
              },
              "width": {
                "description": "Relative weight, not pixels.",
                "type": "number",
                "minimum": 0.1,
                "maximum": 10
              }
            }
          }
        },
        "rows": {
          "type": "array",
          "minItems": 1,
          "maxItems": 200,
          "items": {
            "type": "array",
            "minItems": 1,
            "maxItems": 12,
            "items": {
              "$ref": "#/$defs/tableCell"
            }
          }
        },
        "zebra": {
          "description": "Alternate row shading.",
          "type": "boolean"
        }
      }
    },
    "listBlock": {
      "type": "object",
      "required": [
        "items"
      ],
      "properties": {
        "style": {
          "enum": [
            "bulleted",
            "numbered",
            "checklist"
          ]
        },
        "start": {
          "description": "First number for a numbered list.",
          "type": "integer",
          "minimum": 1
        },
        "items": {
          "type": "array",
          "minItems": 1,
          "maxItems": 200,
          "items": {
            "oneOf": [
              {
                "$ref": "#/$defs/scalarText"
              },
              {
                "type": "object",
                "required": [
                  "text"
                ],
                "properties": {
                  "text": {
                    "$ref": "#/$defs/plainOrRichText"
                  },
                  "level": {
                    "description": "Indent depth. Padding, not nested blocks.",
                    "type": "integer",
                    "minimum": 0,
                    "maximum": 2
                  },
                  "checked": {
                    "type": "boolean"
                  }
                }
              }
            ]
          }
        }
      }
    },
    "calloutBlock": {
      "type": "object",
      "required": [
        "text"
      ],
      "properties": {
        "text": {
          "$ref": "#/$defs/plainOrRichText"
        },
        "title": {
          "type": "string",
          "maxLength": 200
        },
        "icon": {
          "description": "A single emoji.",
          "type": "string",
          "maxLength": 8
        },
        "tone": {
          "description": "Semantic, never a colour — each app maps it to its own palette so callouts stay correct in dark mode and in print.",
          "enum": [
            "info",
            "success",
            "warning",
            "danger",
            "neutral"
          ]
        }
      }
    },
    "quoteBlock": {
      "type": "object",
      "required": [
        "text"
      ],
      "properties": {
        "text": {
          "$ref": "#/$defs/plainOrRichText"
        },
        "attribution": {
          "type": "string",
          "maxLength": 200
        }
      }
    },
    "codeBlock": {
      "type": "object",
      "required": [
        "text"
      ],
      "properties": {
        "text": {
          "type": "string",
          "maxLength": 20000
        },
        "language": {
          "description": "A hint a renderer may ignore; highlighting is not required.",
          "type": "string",
          "maxLength": 40
        },
        "caption": {
          "$ref": "#/$defs/plainOrRichText"
        }
      }
    },
    "chartBlock": {
      "type": "object",
      "required": [
        "kind",
        "series"
      ],
      "properties": {
        "kind": {
          "enum": [
            "bar",
            "line",
            "pie",
            "donut"
          ]
        },
        "title": {
          "type": "string",
          "maxLength": 200
        },
        "labels": {
          "type": "array",
          "maxItems": 50,
          "items": {
            "type": "string",
            "maxLength": 60
          }
        },
        "series": {
          "type": "array",
          "minItems": 1,
          "maxItems": 6,
          "items": {
            "type": "object",
            "required": [
              "data"
            ],
            "properties": {
              "name": {
                "type": "string",
                "maxLength": 80
              },
              "data": {
                "type": "array",
                "minItems": 1,
                "maxItems": 50,
                "items": {
                  "type": [
                    "number",
                    "null"
                  ]
                }
              }
            }
          }
        },
        "reference": {
          "description": "Dashed limit line per label — the thing clinical and financial charts need.",
          "type": "array",
          "maxItems": 50,
          "items": {
            "type": [
              "number",
              "null"
            ]
          }
        },
        "unit": {
          "type": "string",
          "maxLength": 20
        },
        "showValues": {
          "type": "boolean"
        }
      }
    },
    "signatureBlock": {
      "type": "object",
      "properties": {
        "name": {
          "type": "string",
          "maxLength": 200
        },
        "role": {
          "type": "string",
          "maxLength": 200
        },
        "signatureSvg": {
          "description": "The signature itself as inline SVG (a path traced from the signing pad). A ruled line is drawn when absent.",
          "type": "string",
          "minLength": 10,
          "maxLength": 60000
        },
        "signedAt": {
          "type": "string",
          "format": "date-time"
        },
        "statement": {
          "type": "string",
          "maxLength": 300
        }
      }
    },
    "attachmentBlock": {
      "type": "object",
      "required": [
        "url"
      ],
      "properties": {
        "url": {
          "type": "string",
          "maxLength": 2000
        },
        "label": {
          "type": "string",
          "maxLength": 200
        },
        "mime": {
          "type": "string",
          "maxLength": 100
        },
        "sizeBytes": {
          "type": "integer",
          "minimum": 0
        }
      }
    },
    "columnsBlock": {
      "description": "The only nesting block, one level deep — enforced by nestedBlock. A narrow screen may stack the columns and ignore `ratio` — that is a correct rendering, not a fallback.",
      "type": "object",
      "required": [
        "columns"
      ],
      "properties": {
        "ratio": {
          "type": "array",
          "minItems": 2,
          "maxItems": 3,
          "items": {
            "type": "number",
            "minimum": 0.1,
            "maximum": 10
          }
        },
        "columns": {
          "type": "array",
          "minItems": 2,
          "maxItems": 3,
          "items": {
            "type": "object",
            "required": [
              "blocks"
            ],
            "properties": {
              "blocks": {
                "type": "array",
                "minItems": 1,
                "maxItems": 30,
                "items": {
                  "$ref": "#/$defs/nestedBlock"
                }
              }
            }
          }
        }
      }
    },
    "nestedBlock": {
      "description": "A block inside a column: anything a top-level block may be, except another `columns`. The prose has always said one level deep, but v1.1 expressed the children as a plain $ref back to `block`, which made arbitrary depth schema-legal and let a document smuggle thousands of blocks past the 200-block cap.",
      "allOf": [
        {
          "$ref": "#/$defs/block"
        },
        {
          "properties": {
            "type": {
              "not": {
                "const": "columns"
              }
            }
          }
        }
      ]
    },
    "meterBlock": {
      "description": "A value against its range — the HTML <meter>: data used of a quota, a fuel or charge level, stamps on a punch card, sessions left on a package. The one v1.2 block: metric_grid and stat_row can state a value but not what it is out of. Required: current, max. Optional: min (default 0), unit, tone, minLabel/maxLabel for meters where the number is not the point (\"E\" → \"F\").",
      "type": "object",
      "required": [
        "current",
        "max"
      ],
      "properties": {
        "label": {
          "type": "string",
          "maxLength": 200
        },
        "current": {
          "type": "number"
        },
        "min": {
          "description": "Defaults to 0.",
          "type": "number"
        },
        "max": {
          "type": "number"
        },
        "unit": {
          "description": "\"GB\", \"kWh\", \"%\", \"stamps\". Omit for a bare count.",
          "type": "string",
          "maxLength": 20
        },
        "minLabel": {
          "description": "Endpoint label where the number is not the point — \"E\" on a fuel gauge.",
          "type": "string",
          "maxLength": 40
        },
        "maxLabel": {
          "type": "string",
          "maxLength": 40
        },
        "tone": {
          "description": "Semantic, never a colour — same vocabulary as callout.",
          "enum": [
            "info",
            "success",
            "warning",
            "danger",
            "neutral"
          ]
        }
      }
    }
  }
}
