{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://github.com/ASIL-E-Hatake/hatake/spec/hatake-page.schema.json",
  "title": "hatake page definition",
  "description": "A hatake document: either a single `page` or an `app` (pages + navigation).",
  "type": "object",
  "additionalProperties": false,
  "oneOf": [
    {
      "required": [
        "page"
      ]
    },
    {
      "required": [
        "app"
      ]
    }
  ],
  "properties": {
    "dsl_version": {
      "type": "string",
      "description": "DSL version the document conforms to.",
      "default": "1.0",
      "examples": [
        "1.0"
      ]
    },
    "page": {
      "oneOf": [
        {
          "$ref": "#/$defs/crudPage"
        },
        {
          "$ref": "#/$defs/searchPage"
        },
        {
          "$ref": "#/$defs/masterPage"
        },
        {
          "$ref": "#/$defs/detailPage"
        },
        {
          "$ref": "#/$defs/formPage"
        },
        {
          "$ref": "#/$defs/wizardPage"
        },
        {
          "$ref": "#/$defs/dashboardPage"
        },
        {
          "$ref": "#/$defs/reportPage"
        }
      ]
    },
    "app": {
      "$ref": "#/$defs/app"
    }
  },
  "$defs": {
    "app": {
      "type": "object",
      "description": "An application: pages composed by a navigation menu.",
      "required": [
        "id",
        "title"
      ],
      "additionalProperties": false,
      "properties": {
        "id": {
          "type": "string"
        },
        "title": {
          "type": "string"
        },
        "home": {
          "type": "string",
          "description": "Initial route: a menu item id (or page id)."
        },
        "navigation": {
          "description": "How screens open. `single` (default) swaps the screen and a navigate action goes deeper (back returns). `tabs` opens screens side by side: a menu pick becomes a tab and stays open. The definition states the intent for this system; the application may override it (`HatakeApp(navigation:)`) so the same definition can be tabs on a desktop and single on a tablet.",
          "type": "string",
          "enum": [
            "single",
            "tabs"
          ],
          "default": "single"
        },
        "theme": {
          "$ref": "#/$defs/theme"
        },
        "menu": {
          "type": "array",
          "items": {
            "$ref": "#/$defs/menuItem"
          }
        },
        "pages": {
          "type": "array",
          "description": "Pages composing the app, referenced by id.",
          "items": {
            "oneOf": [
              {
                "$ref": "#/$defs/crudPage"
              },
              {
                "$ref": "#/$defs/searchPage"
              },
              {
                "$ref": "#/$defs/masterPage"
              },
              {
                "$ref": "#/$defs/detailPage"
              },
              {
                "$ref": "#/$defs/formPage"
              },
              {
                "$ref": "#/$defs/wizardPage"
              },
              {
                "$ref": "#/$defs/dashboardPage"
              },
              {
                "$ref": "#/$defs/reportPage"
              }
            ]
          }
        }
      }
    },
    "theme": {
      "type": "object",
      "description": "How the app looks: brand colour, brightness, density and shape. Renderer-neutral — a Material renderer maps this to a ThemeData, another renderer to its own equivalent. Nothing here changes behaviour.",
      "additionalProperties": false,
      "properties": {
        "primaryColor": {
          "type": "string",
          "description": "Brand colour as #RRGGBB (or #AARRGGBB). Used as the seed the rest of the palette is derived from.",
          "examples": [
            "#1B5E20"
          ]
        },
        "secondaryColor": {
          "type": "string",
          "description": "Accent colour as #RRGGBB. Derived from the primary colour when omitted."
        },
        "brightness": {
          "type": "string",
          "default": "light",
          "description": "`system` follows the device setting.",
          "enum": [
            "light",
            "dark",
            "system"
          ]
        },
        "density": {
          "type": "string",
          "default": "standard",
          "description": "Row height and padding. Business screens usually want `compact`.",
          "enum": [
            "comfortable",
            "standard",
            "compact"
          ]
        },
        "fontFamily": {
          "type": "string",
          "description": "Font family name; the renderer resolves it."
        },
        "radius": {
          "type": "number",
          "minimum": 0,
          "description": "Corner radius in logical pixels."
        },
        "config": {
          "$ref": "#/$defs/config"
        }
      }
    },
    "menuItem": {
      "type": "object",
      "description": "A navigation menu node: a leaf (opens `page`) or a group (has `items`).",
      "additionalProperties": false,
      "properties": {
        "id": {
          "type": "string",
          "description": "Route key for a leaf (defaults to `page`)."
        },
        "label": {
          "type": "string"
        },
        "group": {
          "type": "string",
          "description": "Group heading (marks this node as a group)."
        },
        "icon": {
          "type": "string"
        },
        "page": {
          "type": "string",
          "description": "Page id this leaf opens."
        },
        "items": {
          "type": "array",
          "items": {
            "$ref": "#/$defs/menuItem"
          }
        },
        "roles": {
          "$ref": "#/$defs/roles"
        }
      }
    },
    "crudPage": {
      "type": "object",
      "description": "A create/read/update/delete page over a single repository.",
      "required": [
        "type",
        "id",
        "title",
        "repository"
      ],
      "additionalProperties": false,
      "properties": {
        "type": {
          "description": "Page kind.",
          "enum": [
            "crud"
          ]
        },
        "id": {
          "type": "string",
          "description": "Stable page identifier."
        },
        "title": {
          "type": "string",
          "description": "Page title."
        },
        "repository": {
          "type": "string",
          "description": "Key resolving the user-provided Repository implementation."
        },
        "key": {
          "type": "string",
          "description": "Primary-key field name of a record.",
          "default": "id"
        },
        "search": {
          "$ref": "#/$defs/search"
        },
        "table": {
          "$ref": "#/$defs/table"
        },
        "form": {
          "$ref": "#/$defs/form"
        },
        "actions": {
          "type": "array",
          "description": "Page-level actions (buttons).",
          "items": {
            "$ref": "#/$defs/action"
          }
        }
      }
    },
    "searchPage": {
      "type": "object",
      "description": "A read-only search/list page (照会). Search + table, no form.",
      "required": [
        "type",
        "id",
        "title",
        "repository"
      ],
      "additionalProperties": false,
      "properties": {
        "type": {
          "description": "Page kind.",
          "enum": [
            "search"
          ]
        },
        "id": {
          "type": "string"
        },
        "title": {
          "type": "string"
        },
        "repository": {
          "type": "string"
        },
        "key": {
          "type": "string",
          "default": "id"
        },
        "search": {
          "$ref": "#/$defs/search"
        },
        "table": {
          "$ref": "#/$defs/table"
        },
        "actions": {
          "type": "array",
          "description": "Page-level actions; also referenced by table.rowActions.",
          "items": {
            "$ref": "#/$defs/action"
          }
        }
      }
    },
    "masterPage": {
      "type": "object",
      "description": "A master-maintenance page. Same shape as crud (search + table + form).",
      "required": [
        "type",
        "id",
        "title",
        "repository"
      ],
      "additionalProperties": false,
      "properties": {
        "type": {
          "description": "Page kind.",
          "enum": [
            "master"
          ]
        },
        "id": {
          "type": "string"
        },
        "title": {
          "type": "string"
        },
        "repository": {
          "type": "string"
        },
        "key": {
          "type": "string",
          "default": "id"
        },
        "search": {
          "$ref": "#/$defs/search"
        },
        "table": {
          "$ref": "#/$defs/table"
        },
        "form": {
          "$ref": "#/$defs/form"
        },
        "actions": {
          "type": "array",
          "items": {
            "$ref": "#/$defs/action"
          }
        }
      }
    },
    "detailPage": {
      "type": "object",
      "description": "A read-only single-record detail page. Displays the form's fields.",
      "required": [
        "type",
        "id",
        "title",
        "repository"
      ],
      "additionalProperties": false,
      "properties": {
        "type": {
          "description": "Page kind.",
          "enum": [
            "detail"
          ]
        },
        "id": {
          "type": "string"
        },
        "title": {
          "type": "string"
        },
        "repository": {
          "type": "string"
        },
        "key": {
          "type": "string",
          "default": "id"
        },
        "form": {
          "$ref": "#/$defs/form"
        },
        "actions": {
          "type": "array",
          "items": {
            "$ref": "#/$defs/action"
          }
        }
      }
    },
    "formPage": {
      "type": "object",
      "description": "A standalone create/edit form page (form only, no table).",
      "required": [
        "type",
        "id",
        "title",
        "repository"
      ],
      "additionalProperties": false,
      "properties": {
        "type": {
          "description": "Page kind.",
          "enum": [
            "form"
          ]
        },
        "id": {
          "type": "string"
        },
        "title": {
          "type": "string"
        },
        "repository": {
          "type": "string"
        },
        "key": {
          "type": "string",
          "default": "id"
        },
        "form": {
          "$ref": "#/$defs/form"
        },
        "actions": {
          "type": "array",
          "items": {
            "$ref": "#/$defs/action"
          }
        }
      }
    },
    "wizardPage": {
      "type": "object",
      "description": "A stepped-input page: the form is split into steps, each validated on its own before advancing. Persisted once, on the final step.",
      "required": [
        "type",
        "id",
        "title",
        "repository",
        "steps"
      ],
      "additionalProperties": false,
      "properties": {
        "type": {
          "description": "Page kind.",
          "enum": [
            "wizard"
          ]
        },
        "id": {
          "type": "string"
        },
        "title": {
          "type": "string"
        },
        "repository": {
          "type": "string"
        },
        "key": {
          "type": "string",
          "default": "id"
        },
        "steps": {
          "type": "array",
          "description": "Steps, walked in declaration order.",
          "minItems": 1,
          "items": {
            "$ref": "#/$defs/wizardStep"
          }
        },
        "actions": {
          "type": "array",
          "items": {
            "$ref": "#/$defs/action"
          }
        }
      }
    },
    "wizardStep": {
      "type": "object",
      "description": "One wizard step — a section with an id and a heading. Only this step's fields are validated when advancing.",
      "required": [
        "id",
        "title"
      ],
      "additionalProperties": false,
      "properties": {
        "id": {
          "type": "string",
          "description": "Step identifier."
        },
        "title": {
          "type": "string",
          "description": "Step heading."
        },
        "description": {
          "type": "string",
          "description": "Optional explanatory text."
        },
        "layout": {
          "$ref": "#/$defs/layout"
        },
        "fields": {
          "type": "array",
          "items": {
            "$ref": "#/$defs/field"
          }
        }
      }
    },
    "search": {
      "type": "object",
      "description": "The search area: filters plus their layout.",
      "additionalProperties": false,
      "properties": {
        "layout": {
          "$ref": "#/$defs/layout"
        },
        "filters": {
          "type": "array",
          "items": {
            "$ref": "#/$defs/filter"
          }
        }
      }
    },
    "filter": {
      "type": "object",
      "required": [
        "field",
        "label"
      ],
      "additionalProperties": false,
      "properties": {
        "field": {
          "type": "string",
          "description": "Backing data key."
        },
        "label": {
          "type": "string"
        },
        "type": {
          "type": "string",
          "description": "Input type (open string). Built-ins: text, textarea, number, select, multiSelect, checkbox, radio, date, dateTime, time.",
          "default": "text",
          "examples": [
            "text",
            "textarea",
            "number",
            "select",
            "multiSelect",
            "checkbox",
            "radio",
            "date",
            "dateTime",
            "time"
          ]
        },
        "operator": {
          "type": "string",
          "description": "Match operator (open string). Built-ins: equals, notEquals, contains, startsWith, endsWith, gt, gte, lt, lte, between, in.",
          "default": "contains",
          "examples": [
            "equals",
            "notEquals",
            "contains",
            "startsWith",
            "endsWith",
            "gt",
            "gte",
            "lt",
            "lte",
            "between",
            "in"
          ]
        },
        "options": {
          "type": "array",
          "description": "Options for select-style filters.",
          "items": {
            "$ref": "#/$defs/option"
          }
        },
        "optionsFrom": {
          "type": "string",
          "description": "Parent filter name in the same search area. The options shown are those whose `when` matches the parent's current value (plus any option without a `when`). Changing the parent clears a child value that is no longer offered."
        },
        "optionsSource": {
          "$ref": "#/$defs/optionsSource"
        },
        "config": {
          "$ref": "#/$defs/config"
        }
      }
    },
    "table": {
      "type": "object",
      "description": "The results table.",
      "additionalProperties": false,
      "properties": {
        "pagination": {
          "$ref": "#/$defs/pagination"
        },
        "rowActions": {
          "type": "array",
          "description": "Per-row action ids. Built-ins: edit, delete.",
          "items": {
            "type": "string",
            "examples": [
              "edit",
              "delete"
            ]
          }
        },
        "columns": {
          "type": "array",
          "items": {
            "$ref": "#/$defs/column"
          }
        }
      }
    },
    "column": {
      "type": "object",
      "required": [
        "field",
        "label"
      ],
      "additionalProperties": false,
      "properties": {
        "field": {
          "type": "string"
        },
        "label": {
          "type": "string"
        },
        "type": {
          "type": "string",
          "description": "Render type (open string). Built-ins: text, number, badge, boolean, date, dateTime.",
          "default": "text",
          "examples": [
            "text",
            "number",
            "badge",
            "boolean",
            "date",
            "dateTime"
          ]
        },
        "width": {
          "type": "number",
          "description": "Fixed width in logical pixels; omit for flexible."
        },
        "sortable": {
          "type": "boolean",
          "default": false
        },
        "format": {
          "type": "string",
          "description": "Display formatter name (open string). Built-ins: currency, percent, date, wareki, postal, mask.",
          "examples": [
            "currency",
            "percent",
            "date",
            "wareki",
            "postal",
            "mask"
          ]
        },
        "config": {
          "$ref": "#/$defs/config"
        },
        "roles": {
          "$ref": "#/$defs/roles"
        }
      }
    },
    "pagination": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "pageSize": {
          "type": "integer",
          "minimum": 1,
          "default": 50
        },
        "enabled": {
          "type": "boolean",
          "default": true
        }
      }
    },
    "form": {
      "type": "object",
      "description": "The create/edit form.",
      "additionalProperties": false,
      "properties": {
        "sections": {
          "type": "array",
          "items": {
            "$ref": "#/$defs/section"
          }
        }
      }
    },
    "section": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "title": {
          "type": "string"
        },
        "visibleWhen": {
          "$ref": "#/$defs/condition",
          "description": "Show this whole section only when the condition matches the current record. A hidden section's fields are not validated either."
        },
        "layout": {
          "$ref": "#/$defs/layout"
        },
        "fields": {
          "type": "array",
          "items": {
            "$ref": "#/$defs/field"
          }
        }
      }
    },
    "field": {
      "type": "object",
      "required": [
        "field",
        "label"
      ],
      "additionalProperties": false,
      "properties": {
        "field": {
          "type": "string"
        },
        "label": {
          "type": "string"
        },
        "type": {
          "type": "string",
          "description": "Field type (open string). Built-ins: text, textarea, number, select, multiSelect, checkbox, radio, date, dateTime, time, subTable. A subTable holds child rows — see columns / fields / source.",
          "default": "text",
          "examples": [
            "text",
            "textarea",
            "number",
            "select",
            "multiSelect",
            "checkbox",
            "radio",
            "date",
            "dateTime",
            "time",
            "subTable"
          ]
        },
        "required": {
          "type": "boolean",
          "default": false
        },
        "requiredWhen": {
          "$ref": "#/$defs/condition",
          "description": "Required only when the condition matches the current record. Unlike visibleWhen / enabledWhen this is also checked server-side, by the same validator."
        },
        "readOnly": {
          "type": "boolean",
          "default": false
        },
        "readOnlyWhen": {
          "$ref": "#/$defs/condition",
          "description": "Read-only while the condition matches: the value stays readable, only editing is blocked. Compare enabledWhen, which greys the input out."
        },
        "defaultValue": {
          "description": "Value applied when creating a new record.",
          "type": [
            "string",
            "number",
            "boolean",
            "null"
          ]
        },
        "validators": {
          "type": "array",
          "items": {
            "$ref": "#/$defs/validator"
          }
        },
        "options": {
          "type": "array",
          "items": {
            "$ref": "#/$defs/option"
          }
        },
        "optionsFrom": {
          "type": "string",
          "description": "Parent field name. The options shown are those whose `when` matches the parent's current value (plus any option without a `when`). Changing the parent clears a child value that is no longer offered."
        },
        "optionsSource": {
          "$ref": "#/$defs/optionsSource"
        },
        "format": {
          "type": "string",
          "description": "Display formatter name (open string). Built-ins: currency, percent, date, wareki, postal, mask.",
          "examples": [
            "currency",
            "percent",
            "date",
            "wareki",
            "postal",
            "mask"
          ]
        },
        "normalize": {
          "type": "array",
          "description": "Input converters applied before validation. Built-ins: toHankaku, toZenkaku, hiraToKata, kataToHira, trim, collapseSpaces, parseNumber.",
          "items": {
            "type": "string",
            "examples": [
              "toHankaku",
              "toZenkaku",
              "hiraToKata",
              "kataToHira",
              "trim",
              "collapseSpaces",
              "parseNumber"
            ]
          }
        },
        "config": {
          "$ref": "#/$defs/config"
        },
        "visibleWhen": {
          "$ref": "#/$defs/condition",
          "description": "Show this field only when the condition matches the current record."
        },
        "enabledWhen": {
          "$ref": "#/$defs/condition",
          "description": "Enable this field only when the condition matches the current record. A disabled field is greyed out; use readOnlyWhen when the value should stay plainly readable."
        },
        "computed": {
          "type": "object",
          "description": "Derive the field value from the record. Two modes: `fields` folds values of the same record (concat / sum / subtract / product), while `field` folds the rows of a subTable (count / sum / avg / min / max — the same aggregate vocabulary as dashboards and `compare` — plus `join`, which lists the rows as one string). Row-folding accepts `where` to keep only some rows. Derived once, in declaration order. Extensible via ComputedRegistry.",
          "required": [
            "op"
          ],
          "properties": {
            "op": {
              "type": "string",
              "description": "Same-record ops: concat, sum, subtract, product (with `fields`). Row-folding ops: count, sum, avg, min, max, join (with `field`). Open string — register your own on ComputedRegistry.",
              "examples": [
                "concat",
                "sum",
                "subtract",
                "product",
                "count",
                "avg",
                "min",
                "max",
                "join"
              ]
            },
            "fields": {
              "type": "array",
              "description": "Fields of the same record to fold (same-record mode).",
              "items": {
                "type": "string"
              }
            },
            "field": {
              "type": "string",
              "description": "A `subTable` field of this form whose rows are folded (row-folding mode). The rows must be saved with the parent record: a subTable with `source` is paged, so its rows are not here to fold.",
              "examples": [
                "lines"
              ]
            },
            "of": {
              "type": "string",
              "description": "Which value of each row to fold. Required except for `count`. Same meaning as `of` on the `compare` validator.",
              "examples": [
                "amount"
              ]
            },
            "where": {
              "$ref": "#/$defs/condition",
              "description": "Row-folding mode only: fold just the rows matching this condition, evaluated against one row (the same condition language as `visibleWhen`). `{ mode: ... }` is never true here — a row has no form mode."
            },
            "separator": {
              "type": "string",
              "description": "What goes between the joined values. Default: empty for `concat` (it packs values of one record, like a name), \", \" for `join` (it lists rows, which are unreadable packed together)."
            }
          }
        },
        "roles": {
          "$ref": "#/$defs/roles"
        },
        "columns": {
          "type": "array",
          "description": "Child-row grid columns, for type: subTable (master-detail). The field's value is then a list of records.",
          "items": {
            "$ref": "#/$defs/column"
          }
        },
        "fields": {
          "type": "array",
          "description": "Editor fields for one child row, for type: subTable. Omit to derive inputs from `columns`.",
          "items": {
            "$ref": "#/$defs/field"
          }
        },
        "source": {
          "$ref": "#/$defs/subTableSource"
        }
      }
    },
    "subTableSource": {
      "type": "object",
      "description": "Fetch a subTable's child rows from their own repository, paged and linked by a foreign key, instead of embedding them in the parent record. Rows are then saved per row (create/update/delete) and require the parent to already have a key.",
      "required": [
        "repository",
        "parentKey"
      ],
      "additionalProperties": false,
      "properties": {
        "repository": {
          "type": "string",
          "description": "Repository key for the child rows."
        },
        "parentKey": {
          "type": "string",
          "description": "Child field holding the parent key; passed as the search filter { parentKey: <parent key value> }."
        },
        "key": {
          "type": "string",
          "description": "Primary-key field of a child row, used to update/delete it.",
          "default": "id"
        },
        "pageSize": {
          "type": "integer",
          "minimum": 1,
          "description": "Rows per page.",
          "default": 20
        }
      }
    },
    "roles": {
      "type": "array",
      "description": "Roles allowed to see this item/action. Empty or absent = everyone. UI-level display gating only, not access enforcement.",
      "items": {
        "type": "string"
      }
    },
    "condition": {
      "type": "object",
      "description": "A conditional expression evaluated against a record. Either a leaf {field, operator, value}, a leaf {mode: create|edit}, or a combinator {all|any: [..]} / {not: {..}}.",
      "properties": {
        "mode": {
          "type": "string",
          "description": "True while the form is in this mode. Use it for \"only when creating\" / \"only when editing\" instead of inspecting the key field. False wherever the mode is unknown (a read-only detail page has none).",
          "enum": [
            "create",
            "edit"
          ]
        },
        "field": {
          "type": "string"
        },
        "operator": {
          "type": "string",
          "description": "Built-ins: equals, notEquals, gt, gte, lt, lte, contains, in, isEmpty, isNotEmpty.",
          "examples": [
            "equals",
            "notEquals",
            "gt",
            "gte",
            "lt",
            "lte",
            "contains",
            "in",
            "isEmpty",
            "isNotEmpty"
          ]
        },
        "value": {},
        "all": {
          "type": "array",
          "items": {
            "$ref": "#/$defs/condition"
          }
        },
        "any": {
          "type": "array",
          "items": {
            "$ref": "#/$defs/condition"
          }
        },
        "not": {
          "$ref": "#/$defs/condition"
        }
      }
    },
    "validator": {
      "type": "object",
      "description": "A validation rule. 'type' selects the validator; remaining keys (except 'message') are passed as parameters: maxLength / minLength / min / max take 'value', pattern takes 'pattern', and compare takes 'operator' (equals / notEquals / gt / gte / lt / lte) plus 'field' (the field to compare with), optionally 'aggregate' + 'of' to fold a subTable into a number, and 'where' to fold only some of its rows (the same row filter as `computed`).",
      "required": [
        "type"
      ],
      "additionalProperties": true,
      "properties": {
        "type": {
          "type": "string",
          "description": "Validator type (open string). Built-ins: required, maxLength, minLength, pattern, min, max, email, postalCode, compare. compare is the cross-field one: it reads another field of the same record (see the node description for its parameters).",
          "examples": [
            "required",
            "maxLength",
            "minLength",
            "pattern",
            "min",
            "max",
            "email",
            "postalCode",
            "compare"
          ]
        },
        "message": {
          "type": "string",
          "description": "Override message shown on failure."
        }
      }
    },
    "action": {
      "type": "object",
      "required": [
        "id",
        "type",
        "label"
      ],
      "additionalProperties": false,
      "properties": {
        "id": {
          "type": "string"
        },
        "type": {
          "type": "string",
          "description": "Action type (open string). Built-ins: create, edit, delete, navigate, plugin, export, print.",
          "examples": [
            "create",
            "edit",
            "delete",
            "navigate",
            "plugin",
            "export",
            "print"
          ]
        },
        "label": {
          "type": "string"
        },
        "scope": {
          "type": "string",
          "enum": ["page", "selection"],
          "default": "page",
          "description": "What the action runs on. 'page' (default) acts on the screen; 'selection' acts on the rows the user checked, and makes the table selectable."
        },
        "plugin": {
          "type": "string",
          "description": "Registered action plugin key (used when type is 'plugin')."
        },
        "page": {
          "type": "string",
          "description": "Target page id (used when type is 'navigate')."
        },
        "params": {
          "type": "object",
          "description": "Route params for a navigate action; $row.id / $record.id template against the current row/record."
        },
        "confirm": {
          "$ref": "#/$defs/confirm"
        },
        "onSuccess": {
          "$ref": "#/$defs/actionSuccess"
        },
        "onError": {
          "$ref": "#/$defs/actionError"
        },
        "prompt": {
          "$ref": "#/$defs/actionPrompt"
        },
        "maxRows": {
          "description": "For `scope: selection`: how many rows one press may act on. A plain integer caps everyone; the object form caps per role. While more than the limit are picked the button is disabled and says the limit — it never truncates the selection, since acting on part of what was picked is the failure nobody notices. Omit to leave it unbounded, in which case the real limit is how many rows are on screen (`table.pagination.pageSize`).",
          "oneOf": [
            {
              "type": "integer",
              "minimum": 1
            },
            {
              "$ref": "#/$defs/maxRows"
            }
          ],
          "examples": [
            20
          ]
        },
        "config": {
          "$ref": "#/$defs/config"
        },
        "batchSize": {
          "description": "For scope: selection: how many rows to hand the handler per call. A plain integer is the same for everyone; the object form sets it per role. Absent = one call with every checked row. With it the framework owns the loop, so it can show how far it got, estimate what is left, and stop between batches (a stopped run reports what it did not send, and leaves the unfinished rows checked).",
          "oneOf": [
            {
              "type": "integer",
              "minimum": 1
            },
            {
              "$ref": "#/$defs/batchSize"
            }
          ],
          "examples": [
            20
          ]
        },
        "open": {
          "description": "For `type: navigate`: where the target opens. `same` (default) goes deeper in the current screen — a list to its detail is the same job, so tabs should not pile up. `tab` opens it beside the current screen, which is a business intent (\"keep the list, open one row\"). Only meaningful when the app runs with `navigation: tabs`; validate says so otherwise.",
          "type": "string",
          "enum": [
            "same",
            "tab"
          ],
          "default": "same"
        },
        "enabledWhen": {
          "$ref": "#/$defs/condition",
          "description": "Enabled only while this condition matches. The record judged is the row for a row action, every checked row for scope: selection (all of them must match), and the current record for a page that has one. A page with no record to judge leaves the button enabled (validate says so)."
        },
        "roles": {
          "$ref": "#/$defs/roles"
        }
      }
    },
    "maxRows": {
      "type": "object",
      "description": "A per-role row limit. `default` applies to anyone the roles do not name. With several matching roles the **most permissive** one wins, the same way `roles` grants access when any role matches.",
      "required": [
        "default"
      ],
      "additionalProperties": false,
      "properties": {
        "default": {
          "$ref": "#/$defs/rowLimit"
        },
        "byRole": {
          "type": "object",
          "description": "Role name -> limit. A role that is not named falls back to `default`.",
          "additionalProperties": {
            "$ref": "#/$defs/rowLimit"
          }
        }
      }
    },
    "batchSize": {
      "type": "object",
      "description": "A per-role batch size. `default` applies to anyone the roles do not name. With several matching roles the **smallest** one wins — the opposite of `maxRows`, on purpose: a limit is about what someone is allowed to do (so roles widen it), while a batch is about how much is pushed through at once (so the safest number wins).",
      "required": [
        "default"
      ],
      "additionalProperties": false,
      "properties": {
        "default": {
          "type": "integer",
          "minimum": 1
        },
        "byRole": {
          "type": "object",
          "description": "Role name -> rows per call. A role that is not named falls back to `default`. There is no `all` here: not splitting means no progress and no stopping, which is what leaving `batchSize` out already says.",
          "additionalProperties": {
            "type": "integer",
            "minimum": 1
          }
        }
      }
    },
    "rowLimit": {
      "description": "A row limit: a positive count, or `all` for no limit.",
      "oneOf": [
        {
          "type": "integer",
          "minimum": 1
        },
        {
          "const": "all"
        }
      ]
    },
    "confirm": {
      "type": "object",
      "description": "Ask before running the action. A `delete` action asks even without this; declaring it replaces the wording. `title` and `message` may carry `{count}`, the number of rows picked — filled only on a `scope: selection` action, since nothing else knows a count before it runs. Nothing has happened yet, so `{failed}` / `{total}` / `{error}` do not fill here (`validate` says so).",
      "required": [
        "message"
      ],
      "additionalProperties": false,
      "properties": {
        "title": {
          "type": "string",
          "description": "Dialog heading. Omit for the renderer's default. May carry `{count}`."
        },
        "message": {
          "type": "string",
          "description": "The question itself. May carry `{count}` (the number of rows picked)."
        },
        "okLabel": {
          "type": "string",
          "description": "Label of the button that runs the action."
        },
        "cancelLabel": {
          "type": "string",
          "description": "Label of the button that does nothing."
        },
        "danger": {
          "type": "boolean",
          "default": false,
          "description": "Style the confirming button as destructive. A `delete` action is destructive by default."
        }
      }
    },
    "actionPrompt": {
      "type": "object",
      "description": "Asked before the action runs: a small form whose values reach the handler as `input`. It replaces the confirmation dialog rather than adding a second one — the same OK button confirms. `title` may carry `{count}` on a `scope: selection` action. Style the OK button as destructive with `confirm: { danger: true }` (the prompt has no `danger` of its own).",
      "required": [
        "fields"
      ],
      "additionalProperties": false,
      "properties": {
        "title": {
          "type": "string",
          "description": "Dialog heading. Defaults to the action's label."
        },
        "okLabel": {
          "type": "string",
          "description": "Confirming button. Falls back to `confirm.okLabel`, then the label."
        },
        "cancelLabel": {
          "type": "string",
          "description": "Cancelling button. Falls back to `confirm.cancelLabel`."
        },
        "fields": {
          "type": "array",
          "minItems": 1,
          "description": "What to ask. Ordinary fields: types, required, validators, computed and normalize behave as in a form.",
          "items": {
            "$ref": "#/$defs/field"
          }
        }
      }
    },
    "actionError": {
      "type": "object",
      "description": "What the user is told when the action failed. Placeholders: {error} the reason as reported, {failed} / {count} / {total} the row counts of a `scope: selection` action.",
      "required": [
        "message"
      ],
      "additionalProperties": false,
      "properties": {
        "message": {
          "type": "string",
          "description": "Shown instead of the raw failure (a snackbar / toast)."
        }
      }
    },
    "actionSuccess": {
      "type": "object",
      "description": "What happens once the action succeeded. Nothing here runs when it fails.",
      "additionalProperties": false,
      "properties": {
        "message": {
          "type": "string",
          "description": "Shown briefly to the user (a snackbar / toast)."
        },
        "page": {
          "type": "string",
          "description": "Page id to move to afterwards."
        },
        "params": {
          "type": "object",
          "description": "Route params for `page`; $row.id / $record.id template against the current row/record."
        }
      }
    },
    "option": {
      "type": "object",
      "required": [
        "label"
      ],
      "additionalProperties": false,
      "properties": {
        "value": {
          "description": "Stored value (kept as-is).",
          "type": [
            "string",
            "number",
            "boolean",
            "null"
          ]
        },
        "label": {
          "type": "string"
        },
        "when": {
          "description": "Show this option only while the parent field (`optionsFrom`) holds this value. Options without `when` always show.",
          "type": [
            "string",
            "number",
            "boolean"
          ]
        }
      }
    },
    "optionsSource": {
      "type": "object",
      "description": "Fetch a field's options from a repository instead of listing them. The framework knows no HTTP or SQL: it asks the repository the application registered, passing the parent value as a filter when `parentKey` is set.",
      "required": [
        "repository"
      ],
      "additionalProperties": false,
      "properties": {
        "repository": {
          "type": "string",
          "description": "Repository key holding the choices."
        },
        "value": {
          "type": "string",
          "description": "Field of a row to store.",
          "default": "code"
        },
        "label": {
          "type": "string",
          "description": "Field of a row to show.",
          "default": "name"
        },
        "parentKey": {
          "type": "string",
          "description": "Field of a row holding the parent value; passed as the filter { parentKey: <parent value> }. Omit to always fetch every row."
        },
        "limit": {
          "type": "integer",
          "minimum": 1,
          "default": 200,
          "description": "Rows to fetch (a select is not a list screen)."
        }
      }
    },
    "layout": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "columns": {
          "type": "integer",
          "minimum": 1,
          "default": 1,
          "description": "Items per row on wide layouts."
        }
      }
    },
    "config": {
      "type": "object",
      "description": "Plugin / renderer specific extra settings.",
      "additionalProperties": true
    },
    "dashboardPage": {
      "type": "object",
      "description": "A dashboard page: a grid of read-only cards, each one a small query plus how to display its result. It addresses no single record, so it has no `key`, and `repository` is only the default for cards that declare none.",
      "required": [
        "type",
        "id",
        "title",
        "items"
      ],
      "additionalProperties": false,
      "properties": {
        "type": {
          "description": "Page kind.",
          "enum": [
            "dashboard"
          ]
        },
        "id": {
          "type": "string"
        },
        "title": {
          "type": "string"
        },
        "repository": {
          "type": "string",
          "description": "Default repository key for cards that omit one."
        },
        "layout": {
          "$ref": "#/$defs/layout",
          "description": "Card grid width. Defaults to 2 columns."
        },
        "search": {
          "$ref": "#/$defs/search",
          "description": "Filters applied to every card's query."
        },
        "items": {
          "type": "array",
          "description": "Cards, in declaration order.",
          "minItems": 1,
          "items": {
            "$ref": "#/$defs/dashboardItem"
          }
        },
        "actions": {
          "type": "array",
          "items": {
            "$ref": "#/$defs/action"
          }
        }
      }
    },
    "dashboardItem": {
      "type": "object",
      "description": "One dashboard card: a query (repository + filters + limit + sort) and how to show its result (metric / table / chart).",
      "required": [
        "id",
        "title"
      ],
      "additionalProperties": false,
      "properties": {
        "id": {
          "type": "string",
          "description": "Card identifier."
        },
        "type": {
          "type": "string",
          "description": "Card kind. Open string; built-ins below.",
          "default": "metric",
          "examples": [
            "metric",
            "table",
            "chart"
          ]
        },
        "title": {
          "type": "string",
          "description": "Card heading."
        },
        "repository": {
          "type": "string",
          "description": "Repository key. Falls back to the page's."
        },
        "span": {
          "type": "integer",
          "minimum": 1,
          "default": 1,
          "description": "Grid columns this card occupies."
        },
        "filters": {
          "type": "object",
          "description": "Fixed filter values merged into the query."
        },
        "limit": {
          "type": "integer",
          "minimum": 1,
          "default": 100,
          "description": "Rows to fetch (the query's pageSize)."
        },
        "sort": {
          "type": "object",
          "additionalProperties": false,
          "description": "Sort passed to the repository.",
          "properties": {
            "field": {
              "type": "string"
            },
            "ascending": {
              "type": "boolean",
              "default": true
            }
          }
        },
        "value": {
          "$ref": "#/$defs/dashboardValue",
          "description": "Reduction for a `metric` card. Omitted = count."
        },
        "format": {
          "type": "string",
          "description": "Display formatter for a `metric` value (open string). Built-ins: currency, percent, date, wareki, postal, mask.",
          "examples": [
            "currency",
            "percent",
            "date",
            "wareki",
            "postal",
            "mask"
          ]
        },
        "config": {
          "type": "object",
          "description": "Extra settings (formatter options, `height`, ...)."
        },
        "columns": {
          "type": "array",
          "description": "Columns for a `table` card.",
          "items": {
            "$ref": "#/$defs/column"
          }
        },
        "chart": {
          "$ref": "#/$defs/chart",
          "description": "Plot for a `chart` card."
        },
        "action": {
          "type": "string",
          "description": "Id of a page action to run when the card is tapped."
        },
        "roles": {
          "type": "array",
          "description": "Roles allowed to see this card. Empty = everyone.",
          "items": {
            "type": "string"
          }
        }
      }
    },
    "dashboardValue": {
      "type": "object",
      "description": "How a `metric` card folds its rows into one number. The framework never issues an aggregate query: the repository returns rows and this reduces them.",
      "additionalProperties": false,
      "properties": {
        "aggregate": {
          "type": "string",
          "description": "Aggregate operation. Open string; built-ins below.",
          "default": "count",
          "examples": [
            "count",
            "sum",
            "avg",
            "min",
            "max"
          ]
        },
        "field": {
          "type": "string",
          "description": "Field to reduce. Not needed by `count`."
        }
      }
    },
    "chart": {
      "type": "object",
      "description": "How a `chart` card plots its rows. With `aggregate`, rows sharing a label fold into one point; without it every row is a point (which is what a pre-aggregated endpoint wants).",
      "required": [
        "labelField"
      ],
      "additionalProperties": false,
      "properties": {
        "kind": {
          "type": "string",
          "description": "Chart kind. Open string; built-ins below.",
          "default": "bar",
          "examples": [
            "bar",
            "line",
            "pie"
          ]
        },
        "labelField": {
          "type": "string",
          "description": "Field holding each point's label."
        },
        "valueField": {
          "type": "string",
          "description": "Field holding each point's value."
        },
        "aggregate": {
          "type": "string",
          "description": "Aggregate applied per label. Omitted = one point per row.",
          "examples": [
            "count",
            "sum",
            "avg",
            "min",
            "max"
          ]
        }
      }
    },
    "reportPage": {
      "type": "object",
      "description": "A report page (帳票): read-only rows laid out on sheets, grouped with subtotals — the printable counterpart of a list. Detail columns come from `table`; `report` adds only the printing structure. It addresses no single record, so it has no `key`.",
      "required": [
        "type",
        "id",
        "title",
        "repository"
      ],
      "additionalProperties": false,
      "properties": {
        "type": {
          "description": "Page kind.",
          "enum": [
            "report"
          ]
        },
        "id": {
          "type": "string"
        },
        "title": {
          "type": "string"
        },
        "repository": {
          "type": "string"
        },
        "search": {
          "$ref": "#/$defs/search",
          "description": "Output conditions, passed to the repository as filters."
        },
        "table": {
          "$ref": "#/$defs/table",
          "description": "Detail columns (number columns print right-aligned)."
        },
        "report": {
          "$ref": "#/$defs/report"
        },
        "actions": {
          "type": "array",
          "items": {
            "$ref": "#/$defs/action"
          }
        }
      }
    },
    "report": {
      "type": "object",
      "description": "The printing structure: paper, lines per sheet, control breaks and totals. Grouping is a control break over consecutive rows, so the repository must return them in the right order.",
      "additionalProperties": false,
      "properties": {
        "paper": {
          "$ref": "#/$defs/paper"
        },
        "rowsPerPage": {
          "type": "integer",
          "minimum": 1,
          "default": 40,
          "description": "Lines per sheet. Group headings and total lines count as lines."
        },
        "limit": {
          "type": "integer",
          "minimum": 1,
          "default": 1000,
          "description": "Rows read for one run (a report is printed, not paged)."
        },
        "groupBy": {
          "type": "array",
          "description": "Control breaks, outermost first.",
          "items": {
            "$ref": "#/$defs/reportGroup"
          }
        },
        "totals": {
          "type": "array",
          "description": "Figures on the subtotal / grand-total lines, in order.",
          "items": {
            "$ref": "#/$defs/reportTotal"
          }
        },
        "sort": {
          "type": "object",
          "additionalProperties": false,
          "description": "Print order, passed to the repository. Groups are control breaks, so the rows must arrive in this order.",
          "properties": {
            "field": {
              "type": "string"
            },
            "ascending": {
              "type": "boolean",
              "default": true
            }
          }
        }
      }
    },
    "paper": {
      "type": "object",
      "description": "The sheet a report is laid out on. The renderer previews at this shape.",
      "additionalProperties": false,
      "properties": {
        "size": {
          "type": "string",
          "description": "Paper size. Open string; built-ins below.",
          "default": "A4",
          "examples": [
            "A4",
            "A3",
            "B5",
            "letter"
          ]
        },
        "orientation": {
          "type": "string",
          "default": "portrait",
          "enum": [
            "portrait",
            "landscape"
          ]
        }
      }
    },
    "reportGroup": {
      "type": "object",
      "description": "A control break: rows whose `field` value changes start a new group.",
      "required": [
        "field",
        "label"
      ],
      "additionalProperties": false,
      "properties": {
        "field": {
          "type": "string"
        },
        "label": {
          "type": "string",
          "description": "Heading label shown next to the group's value."
        },
        "pageBreak": {
          "type": "boolean",
          "default": false,
          "description": "Start a new sheet whenever this group changes."
        }
      }
    },
    "reportTotal": {
      "type": "object",
      "description": "One figure on the subtotal / grand-total lines. Two totals may share a field (e.g. sum and count of the same column).",
      "required": [
        "field"
      ],
      "additionalProperties": false,
      "properties": {
        "field": {
          "type": "string"
        },
        "aggregate": {
          "type": "string",
          "description": "Aggregate operation (same vocabulary as a dashboard's).",
          "default": "sum",
          "examples": [
            "count",
            "sum",
            "avg",
            "min",
            "max"
          ]
        }
      }
    }
  }
}
