{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "$id": "https://graph-paper.io/docs/schema/notebook-content.schema.json",
  "title": "Graph Paper notebook content",
  "description": "The `content` payload of a `type: \"notebook\"` ExportedDocument (see https://graph-paper.io/docs/notebook-file-format.md). This is a DESCRIPTIVE producer contract — the Graph Paper loader is total and lenient and remains authoritative. A producer that conforms here round-trips cleanly. Paths such as `src/…`, `workers/…` and `requirements/…` in the descriptions below refer to files in the Tycho repository.",
  "type": "object",
  "required": ["version", "sections"],
  "properties": {
    "version": { "const": 1 },
    "minorVersion": {
      "type": "integer",
      "minimum": 0,
      "description": "Minor format version (§16 ladder). Default 0; preserved; never gates load (only a higher MAJOR `version` loads read-only). Ladder: minor 0 = pre-2026-07 base; minor 1 = figure items, Phase-3 prose roles + list/language, styleId, cell-plugin fields, columnBreakBefore, Section.scope, kind:\"cell\"; minor 2 = the `deck` flag (NOTEBOOK_DECKS §3); minor 3 = `TableBlock.caption` (scientific-environments E3) — a new sibling field on the `table` kind; minor 4 = the `theorem`/`proof` prose roles + their `Paragraph.theorem`/`Paragraph.proof` meta (scientific-environments E4). Accepted older-reader strip per §16 (U-R4): a pre-minor-3 reader drops a table `caption`; a pre-minor-4 reader drops the `theorem`/`proof` paragraph meta (the open-string ROLE survives, but coerces to a body paragraph without its meta). Verified by inspection of the pre-E4 paragraph decode + Y.Doc read arms that never read a `theorem`/`proof` key: `src/graph-paper/notebook/decode.ts` `decodeParagraph` (built id/role/styleId/list/language/source/metadata only), `src/graph-paper/notebook/notebook-ydoc-translation.ts` `readParagraph` (read id/role/styleId/list/language/source/metadata only), and the worker mirror `workers/api/src/lib/notebook-ydoc.ts` `readParagraph`. Accepted pre-launch (single deployed reader). Producers stamp the current minor (seed + client/worker serialize paths); the low-level Yjs translation stays faithful (absent ⇒ omitted)."
    },
    "deck": {
      "type": "boolean",
      "description": "Deck flag (NOTEBOOK_DECKS §3). Document-level, sibling of page. Omitted = false; only literal true is kept (the decoder drops a non-true value). Deck behavior requires a finite page box. minor 2."
    },
    "page": { "$ref": "#/definitions/page" },
    "preset": { "$ref": "#/definitions/preset" },
    "sections": {
      "type": "array",
      "minItems": 1,
      "items": { "$ref": "#/definitions/section" }
    },
    "metadata": { "$ref": "#/definitions/metadata" },
    "assets": {
      "type": "object",
      "propertyNames": { "pattern": "^[0-9a-f]{64}$" },
      "additionalProperties": { "$ref": "#/definitions/asset" },
      "description": "Document asset table (https://graph-paper.io/docs/notebook-file-format.md §16), keyed by the content sha256 of the image bytes — the map key IS the asset's sha (enforced by propertyNames), referenced by a figure item's `asset` field (§9.7)."
    }
  },
  "definitions": {
    "metadata": {
      "type": "object",
      "description": "Namespaced extension metadata (§5.2). Reserve `graph-paper` for first-party; reverse-DNS for third parties. Size-capped (16 KB/bag, 256 KB/doc)."
    },
    "margins": {
      "type": "object",
      "properties": {
        "top": { "type": "number", "minimum": 0 },
        "right": { "type": "number", "minimum": 0 },
        "bottom": { "type": "number", "minimum": 0 },
        "left": { "type": "number", "minimum": 0 }
      }
    },
    "page": {
      "type": "object",
      "required": ["format"],
      "properties": {
        "format": {
          "enum": ["infinite", "a4", "letter", "legal", "16:9", "4:3", "custom"]
        },
        "orientation": { "enum": ["portrait", "landscape"] },
        "width": { "type": "number", "minimum": 32 },
        "height": { "type": "number", "minimum": 32 },
        "margins": { "$ref": "#/definitions/margins" }
      },
      "if": { "properties": { "format": { "const": "custom" } } },
      "then": { "required": ["width", "height"] }
    },
    "preset": {
      "type": "object",
      "required": ["base"],
      "properties": {
        "base": { "enum": ["basel", "vellum", "academic", "slides"] },
        "fonts": { "type": "object" },
        "palette": {
          "oneOf": [
            { "type": "string" },
            { "type": "array", "items": { "type": "string" } }
          ]
        },
        "mathScale": { "type": "number" },
        "styles": {
          "type": "object",
          "description": "Data-reserved custom-style overrides (SS-F9), keyed by style id. No v1 authoring UI writes these; the decoder + both Yjs mirrors + the worker carry them opaquely (verbatim JSON, size-sanity only: 16 KB per bag, UTF-8 of the JSON serialization — an over-cap or non-serializable bag is dropped) so a future Styles-panel edit round-trips losslessly."
        },
        "colors": {
          "type": "object",
          "description": "Data-reserved per-token color overrides (SS-F9). Carried opaquely, like `styles` (same 16 KB per-bag cap)."
        },
        "label": { "type": "string" }
      }
    },
    "section": {
      "type": "object",
      "required": ["id", "layout", "items"],
      "properties": {
        "id": { "type": "string", "minLength": 1 },
        "name": { "type": "string" },
        "breakBefore": { "type": "boolean" },
        "orientation": { "enum": ["portrait", "landscape"] },
        "scope": { "enum": ["own", "inherit"] },
        "metadata": { "$ref": "#/definitions/metadata" },
        "layout": { "$ref": "#/definitions/layout" },
        "items": { "type": "array", "items": { "$ref": "#/definitions/item" } }
      }
    },
    "layout": {
      "type": "object",
      "required": ["columns", "columnGutter"],
      "properties": {
        "columns": { "type": "integer", "minimum": 1 },
        "columnGutter": { "type": "number", "minimum": 0 },
        "rowGap": { "type": "number", "minimum": 0 },
        "columnFill": { "enum": ["sequential", "balance"] }
      }
    },
    "itemEnvelope": {
      "type": "object",
      "required": ["id", "kind"],
      "properties": {
        "id": { "type": "string", "minLength": 1 },
        "kind": { "type": "string" },
        "breakBefore": { "type": "boolean" },
        "columnBreakBefore": { "type": "boolean" },
        "columnSpan": {
          "oneOf": [{ "type": "integer", "minimum": 1 }, { "const": "full" }]
        },
        "metadata": { "$ref": "#/definitions/metadata" }
      }
    },
    "item": {
      "allOf": [
        { "$ref": "#/definitions/itemEnvelope" },
        {
          "oneOf": [
            {
              "properties": {
                "kind": { "const": "text" },
                "paragraphs": {
                  "type": "array",
                  "minItems": 1,
                  "items": { "$ref": "#/definitions/paragraph" }
                }
              },
              "required": ["paragraphs"]
            },
            {
              "properties": {
                "kind": { "const": "equation" },
                "latex": { "type": "string" },
                "numbered": { "type": "boolean" },
                "align": { "enum": ["left", "center", "right"] },
                "tag": {
                  "type": "string",
                  "minLength": 1,
                  "description": "Custom equation tag (amsmath \\tag): a LaTeX fragment (text mode with $...$ inline math). Present ⇒ the gutter shows (tag) instead of a running number and the block consumes no number (takes precedence over `numbered`). Omitted when unset; empty string is never stored."
                }
              },
              "required": ["latex"]
            },
            {
              "properties": {
                "kind": { "const": "compute" },
                "inputType": { "type": "string" },
                "inputEncoding": {
                  "type": "string",
                  "minLength": 1,
                  "description": "Persisted, worker-visible payload encoding (§9.4). OPEN string (CP rec 13): the known values are \"plain-text\" | \"structured\" | \"json\" (absent ⇒ \"plain-text\"); an unknown/forward-version encoding is preserved verbatim on the wire and coerced at use, never rewritten on save."
                },
                "evalOptions": { "type": "object" },
                "input": { "type": "string" },
                "output": { "$ref": "#/definitions/computeOutput" }
              },
              "required": ["input", "output"]
            },
            {
              "properties": {
                "kind": { "const": "table" },
                "name": { "type": "string" },
                "caption": {
                  "type": "string",
                  "description": "Inline-Markdown table caption (scientific-environments E3, minor 3), collaboratively edited as a Y.Text — the mirror of a figure's `caption`. Distinct from `name` (a compute-scope symbol): the caption is the \"Table N: …\" surface. Omitted when absent; an empty \"\" is dropped on decode."
                },
                "table": { "$ref": "#/definitions/table" }
              },
              "required": ["table"]
            },
            {
              "properties": {
                "kind": { "const": "graphics" },
                "name": { "type": "string" },
                "graphics": {
                  "type": "object",
                  "description": "The vector-graphics payload — the editor primitive's GraphicsDocument model (GRAPHICS_EDITOR §8.1), stored as a structured object (NOT a JSON string). Only `object` is constrained here: the decoder is authoritative, running the §4.1a normalize→validate pipeline; a payload that fails it is preserved verbatim in the block's error state (§8.2). Size-capped at 256 KB (ITEM_PAYLOAD_MAX_BYTES — UTF-8 bytes of the JSON serialization; §8.3 / decision 32), enforced client-side on the write path (editor warns at 192 KB, refuses a commit that would cross 256 KB); an over-cap payload skips the pipeline and routes to the §8.2 error state, preserved verbatim (never dropped). The 1 MiB whole-document cap is the storage backstop."
                }
              },
              "required": ["graphics"]
            },
            {
              "properties": {
                "kind": { "const": "figure" },
                "asset": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
                "alt": { "type": "string" },
                "caption": { "type": "string" },
                "width": {
                  "type": "number",
                  "exclusiveMinimum": 0,
                  "maximum": 1,
                  "description": "Fraction of the content column, (0, 1]. 1 is the default full-width value and is normalized away on decode: only a value strictly inside (0, 1) is stored; 1 (or an explicit value >= 1) decodes to absent (full width)."
                },
                "align": { "enum": ["left", "center", "right"] }
              },
              "required": ["asset"]
            },
            {
              "properties": {
                "kind": { "const": "unsupported" },
                "originalKind": { "type": "string" },
                "raw": {}
              },
              "required": ["raw"]
            }
          ]
        }
      ]
    },
    "paragraph": {
      "type": "object",
      "required": ["id", "role", "source"],
      "properties": {
        "id": { "type": "string", "minLength": 1 },
        "role": {
          "type": "string",
          "description": "An OPEN string (§5.6). Known render roles: body, heading-1..6, caption, hr, blockquote, list-item, code-block; an unknown role renders as a body paragraph but is preserved."
        },
        "styleId": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" },
        "source": { "type": "string" },
        "list": {
          "type": "object",
          "description": "Phase 3C list-item metadata — meaningful only when role === \"list-item\"; absent on every other role.",
          "required": ["marker", "indent"],
          "properties": {
            "marker": { "type": "string", "minLength": 1 },
            "ordinal": { "type": "number" },
            "indent": { "type": "integer", "minimum": 0 }
          }
        },
        "language": {
          "type": "string",
          "description": "Phase 3D code-block language (the fence's info string, e.g. \"python\"; \"\" = no language) — meaningful only when role === \"code-block\"; absent on every other role."
        },
        "theorem": {
          "type": "object",
          "description": "Scientific-environments E4 (minor 4) theorem-environment metadata — meaningful only when role === \"theorem\"; absent on every other role. A `theorem` paragraph LACKING valid meta render-coerces to a body paragraph (never crashes, never fabricated). The identity fields `label`/`counterGroup`/`envGroup` are required non-empty strings (a malformed value is dropped on decode); `variant` is an OPEN string (v1: \"plain\"|\"definition\"; a forward value coerces to \"plain\" at render).",
          "required": [
            "variant",
            "label",
            "counterGroup",
            "numbered",
            "envGroup"
          ],
          "properties": {
            "variant": { "type": "string", "minLength": 1 },
            "label": { "type": "string", "minLength": 1 },
            "name": { "type": "string" },
            "counterGroup": { "type": "string", "minLength": 1 },
            "numbered": { "type": "boolean" },
            "envGroup": { "type": "string", "minLength": 1 }
          }
        },
        "proof": {
          "type": "object",
          "description": "Scientific-environments E4 (minor 4) proof-run metadata (the explicit run id only — no label/variant/number) — meaningful only when role === \"proof\"; absent on every other role. A `proof` paragraph LACKING valid meta render-coerces to a body paragraph.",
          "required": ["envGroup"],
          "properties": {
            "envGroup": { "type": "string", "minLength": 1 }
          }
        },
        "metadata": { "$ref": "#/definitions/metadata" }
      }
    },
    "computeOutput": {
      "type": "object",
      "required": ["placement"],
      "properties": {
        "as": { "type": "string" },
        "placement": {
          "enum": [
            "below",
            "above",
            "left",
            "right",
            "input-only",
            "output-only"
          ]
        },
        "optionsByRenderer": { "type": "object" },
        "metadata": { "$ref": "#/definitions/metadata" }
      }
    },
    "table": {
      "type": "object",
      "description": "A table item's payload. Size-capped at 256 KB (ITEM_PAYLOAD_MAX_BYTES — UTF-8 bytes of the JSON serialization; GRAPHICS_EDITOR §8.3 / decision 32), enforced client-side on the write path (editor warns at 192 KB, refuses a commit that would cross 256 KB). An over-cap payload is preserved verbatim as the revivable `unsupported` placeholder on decode — never dropped; the 1 MiB whole-document cap is the storage backstop.",
      "required": ["columns", "cells"],
      "properties": {
        "columns": {
          "type": "array",
          "items": {
            "type": "object",
            "properties": {
              "header": { "type": "string" },
              "type": { "enum": ["string", "number"] }
            }
          }
        },
        "cells": {
          "type": "array",
          "items": { "type": "array", "items": { "type": "string" } }
        },
        "contentMode": { "enum": ["text", "latex", "markdown"] }
      }
    },
    "asset": {
      "type": "object",
      "required": ["mime"],
      "not": {
        "anyOf": [
          { "required": ["data"] },
          { "required": ["href"] },
          { "required": ["sha256"] }
        ]
      },
      "description": "Stored content is sha-only, keyed by the content sha256 itself (decode.ts drops any data/href/sha256 transport fields — sha256 would be redundant with the map key, so it's dropped too, not just data/href). Self-containment for export travels in a separate zip envelope (requirements/todo/NOTEBOOK_EXPORT.md), not inline base64. data/href/sha256 are explicitly rejected so producers get a validation error instead of silent byte loss.",
      "properties": {
        "mime": { "type": "string", "minLength": 1 },
        "name": { "type": "string" },
        "metadata": { "$ref": "#/definitions/metadata" }
      }
    }
  }
}
