{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://neverlogoff.com/schemas/nlo-page-v0.json",
  "title": "NeverLogOff page v0 — a person's /@name page as an ordered list of blocks",
  "description": "Preview (2026-10-04). The ONE page format: the browser editor (/me/page), the CLI (nlo-page-push) and a person's own AI (agents/tools-pages.json) all send and receive exactly this. The server (lib/nlo-page-model-v0.cjs) is the authority: unknown keys, unknown block types and out-of-range values are refused, never ignored. Doc: docs/PAGE-BUILDER-v0.md.",
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "v": { "const": 1 },
    "use_site": { "type": "boolean", "default": false, "description": "true = /@name shows the person's uploaded site instead of these blocks" },
    "title": { "type": "string", "maxLength": 80 },
    "blocks": { "type": "array", "maxItems": 40, "items": { "$ref": "#/$defs/block" } },
    "scenes": { "type": "array", "minItems": 1, "maxItems": 12, "items": { "$ref": "#/$defs/scene" }, "description": "feat/scenes-v0 (agents/scenes.md): named arrangements of THESE blocks per screen. Absent = one scene \"home\" with the page as it is. Computer and phone layouts here are a draft until published; stream layouts and the live scene change at once through /api/scenes (agents/tools-scenes.json), and those win over the values here." },
    "active_scene": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,23}$", "description": "the scene shown by default; must name one of scenes" }
  },
  "$defs": {
    "scene": { "type": "object", "additionalProperties": false, "required": ["id"], "properties": { "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,23}$", "not": { "const": "active" } }, "name": { "type": "string", "maxLength": 40 }, "layouts": { "type": "object", "additionalProperties": false, "properties": { "desktop": { "$ref": "#/$defs/flow_layout" }, "phone_portrait": { "$ref": "#/$defs/flow_layout" }, "phone_landscape": { "$ref": "#/$defs/flow_layout" }, "stream_16x9": { "$ref": "#/$defs/free_layout" }, "stream_9x16": { "$ref": "#/$defs/free_layout" } } } } },
    "flow_layout": { "type": "object", "additionalProperties": false, "description": "column flow: ids in order come first (the rest follow in page order); ids in hide are not shown on this screen; ids of deleted blocks are dropped", "properties": { "columns": { "enum": [1, 2, 3], "default": 1 }, "order": { "type": "array", "maxItems": 80, "uniqueItems": true, "items": { "type": "string" } }, "hide": { "type": "array", "maxItems": 80, "uniqueItems": true, "items": { "type": "string" } } } },
    "free_layout": { "type": "object", "additionalProperties": false, "description": "free placement: x, y, w, h in percent of the screen; x + w <= 100, y + h <= 100, w and h >= 2; z 0-99 (higher in front); one layer per block or source", "properties": { "layers": { "type": "array", "maxItems": 24, "items": { "type": "object", "additionalProperties": false, "required": ["id", "x", "y", "w", "h"], "oneOf": [{ "required": ["block"] }, { "required": ["source"] }], "properties": { "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,19}$" }, "block": { "type": "string" }, "source": { "enum": ["camera", "game", "chat"] }, "x": { "type": "number", "minimum": 0, "maximum": 98 }, "y": { "type": "number", "minimum": 0, "maximum": 98 }, "w": { "type": "number", "minimum": 2, "maximum": 100 }, "h": { "type": "number", "minimum": 2, "maximum": 100 }, "z": { "type": "integer", "minimum": 0, "maximum": 99, "default": 0 } } } } } },
    "audience": { "enum": ["public", "followers", "approved", "only_me"], "description": "who may see the block. approved = people the owner approved for all their posts" },
    "block": {
      "type": "object",
      "additionalProperties": false,
      "required": ["id", "type"],
      "properties": {
        "id": { "type": "string", "pattern": "^[a-z0-9]{2,12}$", "description": "stable per page; the anchor is #b-<id>. Never reuse an id for a different block." },
        "type": { "enum": ["wall", "about", "links", "post", "stream", "clips", "coin", "custom", "ask_ai"] },
        "visibility": { "$ref": "#/$defs/audience", "default": "public" },
        "hidden": { "type": "boolean", "default": false, "description": "kept but shown to nobody" },
        "settings": { "type": "object" }
      },
      "allOf": [
        { "if": { "properties": { "type": { "const": "wall" } } }, "then": { "properties": { "settings": { "$ref": "#/$defs/wall" } } } },
        { "if": { "properties": { "type": { "const": "about" } } }, "then": { "properties": { "settings": { "$ref": "#/$defs/about" } } } },
        { "if": { "properties": { "type": { "const": "links" } } }, "then": { "properties": { "settings": { "$ref": "#/$defs/links" } } } },
        { "if": { "properties": { "type": { "const": "post" } } }, "then": { "properties": { "settings": { "$ref": "#/$defs/post" } } } },
        { "if": { "properties": { "type": { "const": "stream" } } }, "then": { "properties": { "settings": { "$ref": "#/$defs/stream" } } } },
        { "if": { "properties": { "type": { "const": "clips" } } }, "then": { "properties": { "settings": { "$ref": "#/$defs/clips" } } } },
        { "if": { "properties": { "type": { "const": "coin" } } }, "then": { "properties": { "settings": { "$ref": "#/$defs/coin" } } } },
        { "if": { "properties": { "type": { "const": "custom" } } }, "then": { "properties": { "settings": { "$ref": "#/$defs/custom" } } } },
        { "if": { "properties": { "type": { "const": "ask_ai" } } }, "then": { "properties": { "settings": { "$ref": "#/$defs/ask_ai" } } } }
      ]
    },
    "wall": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "source": { "enum": ["mine", "following", "search", "mix"], "default": "mine", "description": "following = people the PAGE OWNER follows" },
        "search": { "type": "object", "additionalProperties": false, "properties": { "q": { "type": "string", "maxLength": 200 }, "tags": { "type": "array", "maxItems": 8, "items": { "type": "string", "pattern": "^(gn:[0-9]{1,12}|nlo:[a-z]+:[a-z0-9-]{1,60}|topic:[a-z0-9_-]{1,60})$" } } } },
        "mix": { "type": "array", "minItems": 1, "items": { "enum": ["mine", "following", "search"] } },
        "view": { "enum": ["grid", "read", "watch", "timeline", "map"], "default": "grid" },
        "allow_view_change": { "type": "boolean", "default": true },
        "filters": {
          "type": "object",
          "additionalProperties": false,
          "properties": {
            "formats": { "type": "array", "items": { "enum": ["video", "photo", "text", "link"] } },
            "from": { "type": "string", "pattern": "^(\\d{4}-\\d{2}-\\d{2})?$" },
            "to": { "type": "string", "pattern": "^(\\d{4}-\\d{2}-\\d{2})?$" },
            "lang": { "type": "string", "pattern": "^([a-z]{2,3})?$" }
          }
        },
        "sort": { "const": "newest", "description": "the only order. No engagement ranking." },
        "density": { "enum": ["compact", "comfortable"], "default": "comfortable" },
        "max_items": { "type": "integer", "minimum": 1, "maximum": 60, "default": 24 }
      }
    },
    "about": { "type": "object", "additionalProperties": false, "properties": { "heading": { "type": "string", "maxLength": 80 }, "text": { "type": "string", "maxLength": 2000 }, "avatar_post": { "type": "string", "pattern": "^(p_[a-f0-9]{20})?$" } } },
    "links": { "type": "object", "additionalProperties": false, "properties": { "items": { "type": "array", "maxItems": 24, "items": { "type": "object", "additionalProperties": false, "required": ["label", "url"], "properties": { "label": { "type": "string", "minLength": 1, "maxLength": 80 }, "url": { "type": "string", "format": "uri", "maxLength": 500, "pattern": "^https?://" } } } } } },
    "post": { "type": "object", "additionalProperties": false, "required": ["post_id"], "properties": { "post_id": { "type": "string", "pattern": "^p_[a-f0-9]{20}$" } } },
    "stream": { "type": "object", "additionalProperties": false, "properties": { "source": { "enum": ["url", "nlo"], "default": "url" }, "url": { "type": "string", "maxLength": 500, "description": "twitch.tv/<channel>, youtube.com/watch?v=…, youtu.be/…, youtube.com/live/…" } } },
    "clips": { "type": "object", "additionalProperties": false, "properties": { "max_items": { "type": "integer", "minimum": 1, "maximum": 24, "default": 12 } } },
    "coin": { "type": "object", "additionalProperties": false, "required": ["mint"], "properties": { "mint": { "type": "string", "pattern": "^[1-9A-HJ-NP-Za-km-z]{32,44}$" } }, "description": "placeholder: a link to the coin page, shown only where coin pages are allowed. No money moves here." },
    "ask_ai": { "type": "object", "additionalProperties": false, "properties": { "heading": { "type": "string", "maxLength": 80 }, "intro": { "type": "string", "maxLength": 300 } }, "description": "feat/reach-v0: visitors ask the owner's own AI (on the owner's computer), which answers from the owner's PUBLIC content only, always labelled as AI. docs/REACH-v0.md §4" },
    "custom": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "html": { "type": "string", "maxLength": 65536, "description": "runs ONLY inside the sandboxed /_u/ frame (opaque origin): no cookies, no account access" },
        "use_site": { "type": "boolean", "default": false, "description": "frame the person's uploaded site instead of html" },
        "height": { "type": "integer", "minimum": 120, "maximum": 2400, "default": 480 },
        "file": { "type": "string", "pattern": "^_blocks/[a-f0-9]{24}\\.html$", "description": "set by the server; send html instead" }
      }
    }
  },
  "examples": [
    {
      "v": 1,
      "use_site": false,
      "title": "Alice cooks",
      "blocks": [
        { "id": "about1", "type": "about", "visibility": "public", "settings": { "heading": "Hi, I'm Alice", "text": "I cook and film it.\n\nMostly ramen, mostly in Manchester." } },
        { "id": "links1", "type": "links", "visibility": "public", "settings": { "items": [ { "label": "My recipes", "url": "https://example.com/recipes" } ] } },
        { "id": "wall1", "type": "wall", "visibility": "public", "settings": { "source": "mine", "view": "grid", "allow_view_change": true, "filters": { "formats": ["photo", "video"] }, "density": "comfortable", "max_items": 24 } },
        { "id": "food", "type": "wall", "visibility": "followers", "settings": { "source": "search", "search": { "q": "food england", "tags": [] }, "view": "read", "allow_view_change": false, "density": "compact", "max_items": 12 } },
        { "id": "live", "type": "stream", "visibility": "public", "settings": { "source": "url", "url": "https://www.twitch.tv/alice" } },
        { "id": "box", "type": "custom", "visibility": "only_me", "hidden": false, "settings": { "html": "<!doctype html><h1>Hello</h1>", "height": 240 } }
      ]
    }
  ]
}
