{
	"$schema": "https://json-schema.org/draft/2020-12/schema",
	"$id": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest.schema.json",
	"title": "Conduction App Manifest",
	"description": "Schema for the JSON-driven page and navigation manifest consumed by @conduction/nextcloud-vue. Each Conduction Nextcloud app declares its routes, menu, page types, widget configuration, and required app dependencies in a single src/manifest.json validated against this schema. Manifests can also be mounted in-memory at runtime (no static file, no backend route) via the `useAppManifest({ manifest })` overload — see the in-memory-app-manifest-loader capability for the virtual-app-host call shape.",
	"version": "1.8.0",
	"type": "object",
	"required": ["version", "menu", "pages"],
	"additionalProperties": false,
	"properties": {
		"$schema": {
			"type": "string",
			"format": "uri",
			"description": "Optional URL of the schema this manifest validates against. Enables editor auto-validation."
		},
		"version": {
			"type": "string",
			"pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$",
			"description": "Semver of the manifest content. Bump when the manifest changes meaningfully. Used for cache busting and app-builder migration tracking. Distinct from the schema's own version."
		},
		"dependencies": {
			"type": "array",
			"default": [],
			"items": {
				"oneOf": [
					{ "type": "string" },
					{
						"type": "object",
						"additionalProperties": false,
						"required": ["id"],
						"properties": {
							"id": { "type": "string", "description": "Nextcloud app id." },
							"required": { "type": "boolean", "default": true, "description": "true (default) = HARD: absence blocks the app shell behind CnDependencyMissing. false = SOFT: an optional integration whose absence surfaces a dismissible in-shell notice and never blocks." },
							"name": { "type": "string", "description": "Human-readable display label; falls back to id." }
						}
					}
				]
			},
			"description": "Nextcloud app dependencies. Each entry is either a string (a HARD dependency — the app cannot run without it, absence blocks the shell) or an object { id, required?, name? } where required:false marks a SOFT (optional) dependency (required defaults to true). CnAppRoot checks each via useAppStatus(id); unresolved HARD deps block behind CnDependencyMissing, unresolved SOFT deps show a dismissible NcNoteCard banner."
		},
		"nav": {
			"type": "object",
			"additionalProperties": false,
			"description": "Navigation-level configuration consumed by CnAppNav.",
			"properties": {
				"includePersonalSettings": {
					"type": "boolean",
					"default": true,
					"description": "Auto-prepend a 'Personal settings' entry at the top of the settings foldout (opens the host's NcAppSettingsDialog via cnOpenUserSettings). Set false for apps with no per-user settings."
				},
				"settingsLabel": {
					"type": "string",
					"description": "Override label for the settings foldout's gear button. Defaults to 'Settings'."
				},
				"primaryAction": {
					"$ref": "#/$defs/primaryAction",
					"description": "App-wide default primary action rendered as an NcAppNavigationNew button above the main menu list. A page-scoped `pages[].primaryAction` for the current route wins over this default. The CnAppNav #primary-action slot still takes precedence over both."
				},
				"featureRequestRepo": {
					"type": "string",
					"description": "`<owner>/<repo>` slug on the forge that the in-product feature-request deep-link targets (provided to descendants as `cnFeatureRequestRepo`). Falls back to `Conduction/<appId>` when omitted."
				},
				"forge": {
					"type": "object",
					"additionalProperties": false,
					"description": "Forge that the in-product feature-request deep-link targets. Switching the whole fleet's forge (onto a self-hosted Forgejo/Gitea, or back to Codeberg) is just this one field. Defaults to GitHub, the only host the fleet publishes to.",
					"properties": {
						"type": {
							"type": "string",
							"enum": ["codeberg", "forgejo", "gitea", "github"],
							"default": "github",
							"description": "Forge type. Selects how the 'new issue' form is pre-filled: `github` uses per-field Issue-Form query params; `codeberg`/`forgejo`/`gitea` assemble a Markdown body (only title + body are supported there)."
						},
						"baseUrl": {
							"type": "string",
							"description": "Override the forge host. Required for self-hosted `forgejo`/`gitea`; optional for `codeberg`/`github` (their canonical public hosts are used when omitted)."
						}
					}
				}
			}
		},
		"runtime": {
			"type": "object",
			"description": "Runtime context data injected into the manifest by the backend (e.g. OpenRegister) at serve time. Carries per-user fields that `visibleIf` context-path predicates resolve against. The canonical sub-object is `user`, which exposes user-specific flags and role information. Example: `{ \"user\": { \"primaryRole\": \"compliance-officer\", \"isOverdueOnMandatoryTraining\": false } }`. Consumer apps MUST NOT assume any field is present; the FE evaluator returns `false` (hidden) when a referenced path resolves to `undefined`.",
			"additionalProperties": true,
			"properties": {
				"user": {
					"type": "object",
					"description": "Per-user runtime context. Populated by the backend for authenticated requests. All fields are optional; the FE predicate evaluator treats missing fields as `undefined`.",
					"additionalProperties": true
				}
			}
		},
		"menu": {
			"type": "array",
			"items": { "$ref": "#/$defs/menuItem" },
			"description": "Top-level navigation entries rendered by CnAppNav."
		},
		"pages": {
			"type": "array",
			"items": { "$ref": "#/$defs/page" },
			"description": "Page definitions dispatched by CnPageRenderer. Each page's id is also its vue-router route name. Ids MUST be unique across the array; uniqueness is enforced by useAppManifest at validation time."
		},
		"credentials": {
			"type": "array",
			"description": "External-provider credentials this app can use via the OpenRegister credential broker (github, gitlab, …). Each entry declares which provider, why, and at what scope; the app never receives the secret — the broker performs the outbound call on the user's behalf. See the credential-broker capability.",
			"items": {
				"type": "object",
				"required": ["provider"],
				"additionalProperties": false,
				"properties": {
					"provider": { "type": "string", "description": "Catalogue provider identifier (e.g. \"github\", \"gitlab\") — a key in OpenRegister's credential-providers catalogue." },
					"reason": { "type": "string", "description": "Human-readable reason shown to the user in credential settings (why the app wants this provider)." },
					"scopes": { "type": "array", "items": { "type": "string" }, "description": "Advisory scopes the app needs (e.g. [\"repo\"])." }
				}
			}
		}
	},
	"$defs": {
		"primaryAction": {
			"type": "object",
			"required": ["label"],
			"additionalProperties": false,
			"description": "A primary action declared on either a `pages[]` entry (active-page scoped) or `nav.primaryAction` (app-wide default). Rendered above the menu list as an NcAppNavigationNew button. Click emits @primary-action-click on CnAppNav with the resolved block as payload.",
			"properties": {
				"id": {
					"type": "string",
					"description": "Stable identifier for this action. Recommended when both a page-scoped and a nav-root action are declared in the same manifest so the host dispatcher can tell them apart."
				},
				"label": {
					"type": "string",
					"description": "i18n translation key / text for the button, resolved by the consuming app's translate function."
				},
				"icon": {
					"type": "string",
					"description": "MDI icon name (e.g. 'Plus') resolved against CnIcon's ICON_MAP. Defaults to 'Plus' when omitted."
				},
				"route": {
					"type": "string",
					"description": "Named vue-router route to navigate to on click."
				},
				"href": {
					"type": "string",
					"description": "External URL opened in a new tab on click. Takes precedence over route."
				},
				"payload": {
					"description": "Free-form payload passed back to the host inside the @primary-action-click payload. Use for context the host dispatcher needs (e.g. a preset schema id for the create dialog)."
				}
			}
		},
		"menuItem": {
			"type": "object",
			"required": ["id", "label"],
			"additionalProperties": false,
			"description": "A top-level navigation entry. May contain one level of nested children.",
			"properties": {
				"id": {
					"type": "string",
					"description": "Unique identifier for this menu entry."
				},
				"label": {
					"type": "string",
					"description": "i18n translation key resolved by the consuming app's t() function at render time."
				},
				"icon": {
					"type": "string",
					"description": "CSS class for the icon (e.g. 'icon-checkmark')."
				},
				"route": {
					"type": "string",
					"description": "Vue-router route name (matches a pages[].id) that this entry navigates to."
				},
				"order": {
					"type": "integer",
					"description": "Display order in the menu. Items without an order render last."
				},
				"permission": {
					"type": "string",
					"description": "Permission string the user must hold for this entry to render. CnAppNav filters items whose permission is not present in its permissions prop."
				},
				"section": {
					"type": "string",
					"enum": ["main", "footer", "settings"],
					"default": "main",
					"description": "Where the entry renders inside CnAppNav. \"main\" (default) places it in the top list. \"footer\" pins the entry to the bottom of the navigation as a flat item above the settings foldout — used for always-visible non-settings links (Documentation, Features & Roadmap, About). \"settings\" places it inside the NcAppNavigationSettings gear-icon foldout — used for app-level configuration pages."
				},
				"type": {
					"type": "string",
					"enum": ["item", "caption"],
					"default": "item",
					"description": "Render kind. \"item\" (default) renders a clickable NcAppNavigationItem. \"caption\" renders an NcAppNavigationCaption section divider — only `label`, `id`, `order`, and `section` are honoured; `route`, `href`, `action`, `icon`, `count`, `children`, and `pinned` are ignored."
				},
				"count": {
					"oneOf": [
						{ "type": "integer", "minimum": 0 },
						{ "type": "string", "enum": ["auto"] }
					],
					"description": "Counter badge rendered in the entry's #counter slot via NcCounterBubble. A positive integer renders as-is. The sentinel string \"auto\" resolves the count from the `cnMenuCounts` inject (populated by CnAppRoot from useObjectStore totals) for the entry's resolved index-type page (`{ register, schema }` in its config). A resolved count of 0 / null / undefined renders no badge."
				},
				"pinned": {
					"type": "boolean",
					"default": false,
					"description": "Forwarded to the rendered NcAppNavigationItem's `pinned` prop. NC bottom-pins pinned items inside the parent list region. Note: `section: \"footer\"` entries are pinned automatically — this field is for the rare case of explicitly pinning a `\"main\"` entry inside the top list."
				},
				"open": {
					"type": "boolean",
					"default": false,
					"description": "Initial expansion state for a parent entry with children. When `true` and `children[]` is non-empty, the parent NcAppNavigationItem renders with `:open=\"true\"` so children are visible on mount. Users can still collapse/expand interactively; the manifest value is only the initial state."
				},
				"href": {
					"type": "string",
					"description": "Destination URL. When set, the entry renders as a real anchor pointing at this URL (visible on hover, native link cursor) instead of dispatching a vue-router navigation. External URLs (containing a `scheme://`) open in a new tab; internal app paths (e.g. `/index.php/apps/foo/`) navigate in the same tab. `route` is ignored when `href` is present. Useful for documentation / help links and cross-app links."
				},
				"action": {
					"type": "string",
					"enum": ["user-settings"],
					"description": "Built-in action to invoke when the entry is clicked, instead of routing or opening a URL. Closed enum: \"user-settings\" opens the host app's NcAppSettingsDialog modal (provided by CnAppRoot via the `cnOpenUserSettings` inject). When `action` is set, both `route` and `href` are ignored. Added in schema 1.5.0."
				},
				"visibleIf": {
					"$ref": "#/$defs/visibleIfCondition",
					"description": "Optional display condition evaluated at render time. When set, the entry only renders when ALL declared conditions are satisfied. Two forms are accepted: (1) `appInstalled` form — checks whether a given Nextcloud app is installed and enabled; (2) context-path-predicate map — each key is a dot-separated path into `manifest.runtime` (e.g. `\"user.primaryRole\"`) and the value is a predicate expression (`{ \"in\": [\"role-a\", \"role-b\"] }`, `true`/`false`, or an operator object). Items without `visibleIf` are always visible (backwards-compatible). Examples: `{ \"appInstalled\": \"launchpad\" }` — hides when launchpad is absent; `{ \"user.primaryRole\": { \"in\": [\"compliance-officer\", \"hr-coordinator\"] } }` — hides unless the user holds one of those roles."
				},
				"children": {
					"type": "array",
					"items": { "$ref": "#/$defs/menuItemLeaf" },
					"description": "Nested entries. Only one level of nesting is supported (children cannot themselves have children)."
				}
			}
		},
		"menuItemLeaf": {
			"type": "object",
			"required": ["id", "label"],
			"additionalProperties": false,
			"description": "A nested menu entry. Has no further children.",
			"properties": {
				"id": { "type": "string" },
				"label": { "type": "string" },
				"icon": { "type": "string" },
				"route": { "type": "string" },
				"order": { "type": "integer" },
				"permission": { "type": "string" },
				"section": {
					"type": "string",
					"enum": ["main", "footer", "settings"],
					"default": "main"
				},
				"type": {
					"type": "string",
					"enum": ["item", "caption"],
					"default": "item"
				},
				"count": {
					"oneOf": [
						{ "type": "integer", "minimum": 0 },
						{ "type": "string", "enum": ["auto"] }
					]
				},
				"pinned": { "type": "boolean", "default": false },
				"href": { "type": "string" },
				"action": {
					"type": "string",
					"enum": ["user-settings"]
				},
				"visibleIf": {
					"$ref": "#/$defs/visibleIfCondition",
					"description": "Optional display condition for a nested menu entry. Same semantics as the top-level `menuItem.visibleIf` — see that field's description for the full predicate reference."
				}
			}
		},
		"page": {
			"type": "object",
			"required": ["id", "route", "type", "title"],
			"additionalProperties": false,
			"description": "A page definition. CnPageRenderer dispatches by `type` and matches by $route.name === page.id.",
			"properties": {
				"id": {
					"type": "string",
					"description": "Vue-router route name. MUST be unique across pages[]. CnPageRenderer matches the current route by id only (not by path)."
				},
				"route": {
					"type": "string",
					"description": "Path pattern (e.g. '/decisions', '/decisions/:id'). Used by the consuming app when generating its vue-router config from the manifest."
				},
				"type": {
					"type": "string",
					"description": "Page type. Must match a key in the renderer's `pageTypes` registry (library defaults: \"index\", \"detail\", \"dashboard\", \"logs\", \"settings\", \"chat\", \"files\", \"form\", \"map\", \"wiki\") OR be \"custom\" — in which case `component` resolves against the customComponents registry. Library extensions add their built-in types to `defaultPageTypes`; consumer apps pass a merged map via the `pageTypes` prop on CnAppRoot / CnPageRenderer. The \"form\" type was added by `manifest-form-page-type` and renders a manifest-declared field set with submit/save handlers — see the type='form' note in `pages[].config` for the dispatch contract. The \"map\" type was added by `manifest-map-widget` and renders a Leaflet map with declarative tile/WMS/WFS/GeoJSON layers + markers — see the type='map' note in `pages[].config` for the dispatch contract. The \"wiki\" type was added by `manifest-wiki-page-type` and renders a manifest-declared markdown article (CnWikiPage) plus an optional sidebar tree; it MUST declare config.register + config.schema (the markdown body is read from the named OpenRegister register/schema), and accepts optional string config keys contentField, titleField, idParam, treeField, sidebarTitleField, sidebarRegister, sidebarSchema, emptyText, emptyDescription, emptyBodyText, emptyBodyDescription."
				},
				"title": {
					"type": "string",
					"description": "i18n translation key for the page title."
				},
				"permission": {
					"type": "string",
					"description": "Optional permission identifier required to access this page. Consumer-side enforcement only — CnPageRenderer / CnAppRoot do NOT currently gate the page on it; consumers that want enforcement filter the manifest themselves before passing it to CnAppRoot. Added (schema-only) so the multiple apps that declare page-level permissions (procest's WmsLayers* / LhsMatrices*) validate clean."
				},
				"config": {
					"type": "object",
					"description": "Type-specific configuration. For type='index': { register, schema, columns, actions, sidebar?, cardComponent? }. The optional `cardComponent` is a string referencing a key in the consuming app's `customComponents` registry (the same registry that powers `type:'custom'` pages). When set AND the page is in card-grid view mode AND the parent has not provided a `#card` scoped slot, CnIndexPage mounts the registry-resolved component for each row instead of the schema-driven CnObjectCard, passing `{ item, object, schema, register, selected }` props and forwarding `click` + `select` events. An unknown name logs a console warning and falls back to CnObjectCard so a misconfigured manifest never blanks the grid. The optional `createOverride` is a string referencing a key in the consuming app's `customComponents` registry that MUST resolve to an async create handler; CnPageRenderer resolves the name to the function and forwards it to CnIndexPage's `createOverride` prop, so a **create** from the generic Add button runs the app's contact-aware (or otherwise side-effecting) persistence path instead of saving straight to OpenRegister. An unknown name is dropped with a console warning. The optional `sidebar` is an object `{ enabled: boolean, show?: boolean, columnGroups?: array, facets?: object, showMetadata?: boolean, search?: object }` that, when `enabled`, makes CnIndexPage auto-mount its embedded CnIndexSidebar. `show` (default true) suppresses the embedded sidebar without removing config when set false. For type='detail': { register, schema, sidebar?, sidebarProps? }. The `sidebar` field accepts EITHER a Boolean (legacy: true/false toggles the external CnObjectSidebar) OR an Object mirroring the index shape plus detail-specific fields `{ show?: boolean, enabled?: boolean, register?, schema?, hiddenTabs?, title?, subtitle?, tabs? }`. `config.sidebar.show: false` suppresses the embedded sidebar on either index or detail pages. The optional `sidebarProps.tabs` is an open-enum array of tab definitions `{ id, label, icon?, widgets?, component?, order? }` that overrides CnObjectSidebar's hard-coded built-in tab set; each tab declares either a list of widgets (`type: 'data' | 'metadata' | <registry-name>`) or a registry component name. For type='dashboard': { widgets, layout, content? }. The optional `content[]` array is an alternative to the `widgets` + `layout` pair — each item is a `widgetRefItem` with `type: \"widget-ref\"` and a `ref` URI (`openregister://widget/<schemaSlug>/<widgetSlug>`) that CnDashboardPage resolves from OR's widget registry at runtime. For type='logs': { register?, schema?, source?, columns?, filter?, pagination?, sortKey?, sortOrder?, sortKeys?, fixedLayout?, rowRoute?, rowDetail?, rowKey? } — one of register+schema OR source MUST be set. `filter` is a fixed filter map merged above the `?key=value` deep-link filters, supporting the shared token grammar (`@route.<param>` / `:<param>` / `@me` / `@today±Nd` / `@workspace.<key>` / `@config.<key>`); `pagination` is `{ limit }`; `sortKeys` is an ordered `[{ key, order }]` priority list outranking `sortKey`/`sortOrder`, and a `?_order=` param on the URL outranks both; `fixedLayout` makes the columns' declared widths authoritative (`table-layout: fixed`) so a long unbreakable value wraps in its cell instead of dictating the column widths; `rowRoute` is a page id to open on a row click (`{ name, params: { id } }`), and `rowDetail` opens a read-only dialog instead when no `rowRoute` is set. Everything except `columns` and `source` is store-mode only. For type='settings': { sections?: array<Section>, tabs?: array<Tab>, saveEndpoint? } where EXACTLY ONE of `sections` or `tabs` MUST be set (XOR — see manifest-settings-orchestration spec). Each Section declares EXACTLY ONE of `fields[]` (back-compat flat-field body), `component: <registry-name>` + optional `props` (mounts a customComponents-resolved component as the section body), OR `widgets: array<{ type, props?, componentName? }>` (mounts one or more widgets in sequence; built-in widget types `version-info` → CnVersionInfoCard, `register-mapping` → CnRegisterMapping, and `component` (with `componentName: <registry-name>`) → customComponents-resolved component). Each Tab is `{ id, label, icon?, sections: array<Section> }` and CnSettingsPage renders a tab strip switching between them. Widget events bubble through CnSettingsPage's `@widget-event` so consumers wire one page-level handler (see manifest-settings-rich-sections spec). For type='chat': { conversationSource?, postUrl?, schema? } — one of conversationSource OR postUrl MUST be set. For type='files': { folder, allowedTypes? }. For type='form': { fields: array<formField>, submitHandler? OR submitEndpoint?, submitMethod?, mode?, submitLabel?, successMessage?, initialValue? }. Exactly one of `submitHandler` (registry name resolved against customComponents) OR `submitEndpoint` (URL string; `:paramName` segments resolve against `$route.params`) MUST be set. `submitMethod` (default POST) MUST be one of POST | PUT | PATCH when set. `mode` (default public) MUST be one of edit | create | public when set. The `fields[]` array `$ref`s the same `formField` $def `pages[].config.sections[].fields[]` consumes for type='settings'. See manifest-form-page-type spec. For type='map': { center: [lat, lng], zoom?, layers?: array<{ type: 'tile'|'wms'|'wfs'|'geojson', url?, data?, options?, attribution? }>, markers?: { features?: array, dataSource?: { url } | { register, schema }, latField?, lngField?, popupField?, clustering?, iconColor?, iconUrl? }, height?, clustering?, autoFit? } — `center` MUST be a length-2 array of finite numbers; layer `type` values are validated against the closed enum; `markers.dataSource` MUST declare exactly one of `url` OR `register + schema`. See manifest-map-widget spec. For type='custom': any shape the custom component expects. As of schema version 1.2.0, the recurring sub-shapes (`columns[]`, `actions[]`, `widgets[]`, `layout[]`, `sections[].fields[]`, `sidebar.columnGroups[]`, `sidebar.tabs[]`, `sidebarProps.tabs[]`) `$ref` the seven `$defs` (`column`, `action`, `widgetDef`, `layoutItem`, `formField`, `sidebarSection`, `sidebarTab`). The OUTER `config` block keeps `additionalProperties: true` so per-type scalars (`register`, `schema`, `source`, `folder`, `saveEndpoint`, …) and consumer-app extension keys remain free-form.",
					"additionalProperties": true,
					"properties": {
						"columns": {
							"type": "array",
							"description": "Columns rendered by CnDataTable / CnIndexPage (for type='index' and type='logs'). Each item is EITHER a string (legacy shorthand: just the property key) OR a typed `column` object. The string shorthand stays supported for back-compat with v1.0/v1.1 manifests.",
							"items": {
								"oneOf": [
									{ "type": "string" },
									{ "$ref": "#/$defs/column" }
								]
							}
						},
						"actions": {
							"type": "array",
							"description": "Row / bulk actions consumed by CnRowActions / CnActionsBar (for type='index'). Each item references the `action` $def.",
							"items": { "$ref": "#/$defs/action" }
						},
						"defaultSort": {
							"type": "array",
							"description": "Default multi-key client-side sort for a type='index' page, applied to the already-loaded rows whenever no explicit column sort is active. Rows are compared by the first entry, ties broken by the next, etc. (type-aware: numbers numerically, dates by timestamp, else localeCompare). A user clicking a sortable header takes over and suppresses this default. Maps to CnIndexPage `defaultSort`. Useful for a fixed presentation order such as group-by-type-then-name.",
							"items": {
								"type": "object",
								"required": ["field"],
								"additionalProperties": false,
								"properties": {
									"field": { "type": "string", "description": "Row field (supports dot notation; falls back to the `@self` metadata block)." },
									"order": { "type": "string", "enum": ["asc", "desc"], "default": "asc", "description": "Sort direction for this key. Defaults to ascending." }
								}
							}
						},
						"headerActions": {
							"type": "array",
							"description": "Page-level header actions rendered inside CnActionsBar's overflow dropdown (between the built-in Refresh action and the `#action-items` slot). Same shape as the row-level `actions[]`, but handlers receive NO row context — they are page-level. Reserved ids (`refresh`, `import`, `export`, `copy`, `delete`) are dropped at render time to avoid shadowing built-ins. See manifest-icons-and-page-actions for the dispatch semantics.",
							"items": { "$ref": "#/$defs/action" }
						},
						"quickFilters": {
							"type": "array",
							"description": "Clickable filter tabs rendered above the table on a type='index' page. Each tab carries a `filter` map merged into the useListView fetch — spread AFTER `config.filter` (so the active tab overrides a colliding fixed entry) and BEFORE the user's facet `activeFilters` (so user facets still narrow within the active tab). Tab `filter` values follow the same syntax as `config.filter`: literals pass through; string values of the form `\"@route.<name>\"` or `\":<name>\"` resolve from `$route.params`. The first tab with `default:true` (else index 0) is active on mount; clicking a different tab refetches at page 1. Omitting the array is a no-op (no tab strip rendered).",
							"items": {
								"type": "object",
								"required": ["label", "filter"],
								"additionalProperties": false,
								"properties": {
									"label": { "type": "string", "description": "i18n key or plain text rendered on the tab." },
									"filter": { "type": "object", "additionalProperties": true, "description": "Filter map merged into the fetch when this tab is active." },
									"default": { "type": "boolean", "default": false, "description": "Pre-selected on mount." },
									"icon": { "type": "string", "description": "Optional MDI icon name displayed alongside the label." }
								}
							}
						},
						"quickFilterMode": {
							"type": "string",
							"enum": ["chips", "dropdown"],
							"default": "chips",
							"description": "How `quickFilters` render on a type='index' page: `chips` (pill button strip, default) or `dropdown` (a single NcSelect; the empty-filter \"All\" tab is dropped — an empty selection means all)."
						},
						"quickFilterMultiple": {
							"type": "boolean",
							"default": false,
							"description": "Allow several `quickFilters` active at once. Selected tabs' filters are OR-ed together into the fetch (same field → array value → `field[]=` IN query). Default false (single-select)."
						},
						"readOnly": {
							"type": "boolean",
							"default": false,
							"description": "Shorthand on a type='index' page: when true, CnPageRenderer merges read-only defaults UNDER the explicit props before mounting CnIndexPage — `selectable:false`, `showAdd:false`, `showFormDialog:false`, `showEditAction:false`, `showCopyAction:false`, `showDeleteAction:false`, `showMassImport:false`, `showMassCopy:false`, `showMassDelete:false`. An explicit `config.showAdd:true` (etc.) still wins. Omitting (or false) is the default behaviour."
						},
						"widgets": {
							"type": "array",
							"description": "Dashboard widgets consumed by CnDashboardPage (for type='dashboard'). Each item references the `widgetDef` $def. NOT consumed for type='settings'; settings widgets use a different (thinner `{ type, props? }`) shape — see `sections[].widgets[]`.",
							"items": { "$ref": "#/$defs/widgetDef" }
						},
						"content": {
							"type": "array",
							"description": "Declarative content items for a page (currently: type='dashboard'). Each item references the `widgetRefItem` $def. A `widget-ref` item points at an OR-declared widget via its `ref` URI (`openregister://widget/<schemaSlug>/<widgetSlug>`); CnDashboardPage resolves and renders each widget at runtime. Graceful loading state and error fallback are applied for unknown widgets. Composes with `widgets` + `layout` — set `content` instead of `widgets`/`layout` when OR owns the widget definitions.",
							"items": { "$ref": "#/$defs/widgetRefItem" }
						},
						"layout": {
							"type": "array",
							"description": "Dashboard layout entries consumed by CnDashboardGrid / CnDashboardPage (for type='dashboard'). Each item references the `layoutItem` $def.",
							"items": { "$ref": "#/$defs/layoutItem" }
						},
						"pageFilters": {
							"type": "array",
							"description": "Page-level filter controls (for type='dashboard') rendered in the dashboard header by CnDashboardPage. Each selection is written into the reactive page-level workspace context, so any widget can read it via a `@page.<key>` / `@workspace.<key>` token — e.g. a period selector every endpoint-bound `stat` KPI's URL interpolates. Maps to the CnDashboardPage `pageFilters` prop.",
							"items": {
								"type": "object",
								"required": ["key", "options"],
								"additionalProperties": false,
								"properties": {
									"key": { "type": "string", "description": "Workspace-context key written on change (the `@page.<key>` / `@workspace.<key>` token name)." },
									"label": { "type": "string", "description": "Optional control label (i18n key or plain text)." },
									"type": { "type": "string", "enum": ["select"], "default": "select", "description": "Control type. Closed enum — \"select\" only for now." },
									"default": { "type": ["string", "number"], "description": "Initial value seeded into the context on mount. Defaults to the first option's value when omitted." },
									"options": {
										"type": "array",
										"description": "Selectable options.",
										"items": {
											"type": "object",
											"required": ["value", "label"],
											"additionalProperties": false,
											"properties": {
												"value": { "type": ["string", "number"], "description": "Value written into the context when picked." },
												"label": { "type": "string", "description": "Display text (i18n key or plain text)." }
											}
										}
									}
								}
							}
						},
						"fields": {
							"type": "array",
							"description": "Form fields consumed by CnFormPage (for type='form'). Each item references the `formField` $def — the same shape `pages[].config.sections[].fields[]` uses for type='settings'.",
							"items": { "$ref": "#/$defs/formField" }
						},
						"sections": {
							"type": "array",
							"description": "Settings sections consumed by CnSettingsPage (for type='settings'). Each section declares EXACTLY ONE of `fields[]` / `component` / `widgets[]` (FE-validated mutual exclusion). The outer section object keeps `additionalProperties: true`; only `fields[]` is typed via $ref formField. Settings widgets use a thinner shape `{ type, props?, componentName? }` (NOT the same as dashboard widgetDef) and are NOT typed by this schema. Mutually exclusive with `tabs[]` at the page level — see manifest-settings-orchestration.",
							"items": {
								"type": "object",
								"additionalProperties": true,
								"properties": {
									"fields": {
										"type": "array",
										"description": "Flat field-body for this section. Each item references the `formField` $def.",
										"items": { "$ref": "#/$defs/formField" }
									}
								}
							}
						},
						"tabs": {
							"type": "array",
							"description": "Settings tabs consumed by CnSettingsPage (for type='settings'). When set, CnSettingsPage renders a tab strip and the active tab's `sections[]` flow into the same renderer used by the flat shape. Mutually exclusive with `sections[]` at the page level (XOR). Each tab is `{ id: string, label: string, icon?: string, sections: array<Section> }`. See manifest-settings-orchestration spec.",
							"items": {
								"type": "object",
								"additionalProperties": true,
								"required": ["id", "label", "sections"],
								"properties": {
									"id": {
										"type": "string",
										"description": "Tab identifier — addressable by `initialTab` and emitted in `@tab-change`. MUST be non-empty and unique within the page."
									},
									"label": {
										"type": "string",
										"description": "Tab button label (i18n translation key — passed through `translate()` if a translate prop is wired on CnSettingsPage)."
									},
									"icon": {
										"type": "string",
										"description": "Optional MDI component name prepended to the tab button (e.g. \"Cog\")."
									},
									"sections": {
										"type": "array",
										"description": "Sections rendered when this tab is active. Same shape and rules as the flat `sections[]` case.",
										"items": {
											"type": "object",
											"additionalProperties": true,
											"properties": {
												"fields": {
													"type": "array",
													"description": "Flat field-body for this section. Each item references the `formField` $def.",
													"items": { "$ref": "#/$defs/formField" }
												}
											}
										}
									}
								}
							}
						},
						"sidebar": {
							"description": "Index/detail sidebar configuration. For type='index': an object with `columnGroups[]` ($ref sidebarSection) plus open scalars (enabled, show, facets, search, showMetadata). For type='detail': either a Boolean (legacy on/off) OR an object whose `tabs[]` references the `sidebarTab` $def — the rest of the object (`register`, `schema`, `title`, `subtitle`, `hiddenTabs`, `show`, `enabled`) stays free-form via `additionalProperties: true`.",
							"oneOf": [
								{ "type": "boolean" },
								{
									"type": "object",
									"additionalProperties": true,
									"properties": {
										"columnGroups": {
											"type": "array",
											"description": "Index sidebar collapsible groups of column-visibility toggles. Each item references the `sidebarSection` $def.",
											"items": { "$ref": "#/$defs/sidebarSection" }
										},
										"tabs": {
											"type": "array",
											"description": "Detail sidebar tabs (Object form, preferred path). Each item references the `sidebarTab` $def.",
											"items": { "$ref": "#/$defs/sidebarTab" }
										}
									}
								}
							]
						},
						"sidebarProps": {
							"type": "object",
							"additionalProperties": true,
							"description": "Detail sidebar props (legacy alternate path; the Object form of `sidebar` is preferred). The `tabs[]` array references the `sidebarTab` $def.",
							"properties": {
								"tabs": {
									"type": "array",
									"description": "Detail sidebar tabs (legacy path). Each item references the `sidebarTab` $def.",
									"items": { "$ref": "#/$defs/sidebarTab" }
								}
							}
						},
						"subscribe": {
							"type": "boolean",
							"default": true,
							"description": "Collaborative-editing default for type='detail'. When true (the default) CnDetailPage calls `objectStore.subscribe(objectType, objectId)` on mount via `useObjectSubscription` and unsubscribes on unmount. Set to false for read-only or archive views where live updates are undesired. Mirrors the `subscribe` Boolean prop on CnDetailPage / CnObjectSidebar — declared here so consumer manifests can opt out of live updates declaratively. See the `collaborative-editing-defaults` capability for the wiring contract."
						},
						"lock": {
							"type": "boolean",
							"default": true,
							"description": "Collaborative-editing default for type='detail'. When true (the default) the page wires `useObjectLock` so the locked-by-other banner renders when a remote lock is active and `lockState` is exposed to slot consumers. Explicit lock acquire/release on the edit toggle is the v2 follow-up of the collaborative-editing-defaults capability — at v1 this flag only controls whether the read-side `lockState` is wired. Set to false for views that should never display a lock banner. Mirrors the `lock` Boolean prop on CnDetailPage / CnObjectSidebar."
						}
					}
				},
				"sidebar": {
					"type": "object",
					"description": "Per-page sidebar configuration applied across every page type (including type='custom'). Sibling of `config` so it works even on pages whose `config` is opaque. Currently exposes one field — `show: boolean` — that gates rendering of the host App's `#sidebar` slot via the `cnPageSidebarVisible` provide/inject channel CnPageRenderer publishes. When `show` is false, CnPageRenderer applies the CSS hook class `cn-page-renderer--no-sidebar` AND CnAppRoot's `<slot name=\"sidebar\" />` stops rendering. When this field is unset or `show` is true (the default), behaviour matches today — the sidebar slot renders. Future fields (e.g. `position`, `width`) MAY land here in follow-up changes; `additionalProperties: true` keeps that path open without another schema bump.",
					"additionalProperties": true,
					"properties": {
						"show": {
							"type": "boolean",
							"description": "Whether the host App's #sidebar slot renders for this page. Defaults to true. Set to false to declaratively hide the sidebar on this specific page (works on every page type including type='custom')."
						}
					}
				},
				"component": {
					"type": "string",
					"description": "For type='custom': name resolved against the app-provided customComponents registry that is passed to CnAppRoot at boot."
				},
				"headerComponent": {
					"type": "string",
					"description": "Optional registry name for a component injected into the page's #header slot. Enables partial bailout without going full type='custom'."
				},
				"actionsComponent": {
					"type": "string",
					"description": "Optional registry name for a component injected into the page's #actions slot. Enables partial bailout without going full type='custom'. Equivalent to `slots.actions`; takes precedence when both are set."
				},
				"sidebarComponent": {
					"type": "string",
					"minLength": 1,
					"description": "Optional registry name resolved against the consuming app's customComponents registry. When set, CnPageRenderer publishes the resolved component on the `cnPageSidebarComponent` reactive provide channel; CnAppRoot mounts it as the DEFAULT content of its `#sidebar` slot for the lifetime of this page mount. The consumer's `#sidebar` slot override (when supplied) wins over the resolved component via Vue's standard slot mechanic — apps that already wire a sibling `<CnObjectSidebar>` keep working unchanged. Composes with `sidebar.show`: when `show: false`, the slot does not render at all and the resolved component is suppressed (visibility wins; CnPageRenderer logs a console.warn about the dead config). Use this field for the per-page full-sidebar swap pattern (Vue Router named-view equivalent — e.g. opencatalogi's Search route swapping in a SearchSideBar). For per-tab content on the built-in CnObjectSidebar use `pages[].config.sidebar.tabs[]` instead. Unknown registry names log a console.warn and fall through to the consumer's slot content."
				},
				"slots": {
					"type": "object",
					"description": "Generic slot-override map. Each key is the name of a scoped slot exposed by the dispatched page component (e.g. 'create-dialog', 'form-fields', 'row-actions', 'empty'); each value is a registry component name resolved against the customComponents registry passed to CnAppRoot. CnPageRenderer creates a scoped-slot template for each entry and forwards all slot-scope props to the resolved component. Use this to preserve every slot override the underlying Cn*Page supports without writing a wrapper component.",
					"additionalProperties": { "type": "string" }
				},
				"primaryAction": {
					"$ref": "#/$defs/primaryAction",
					"description": "Active-page-scoped primary action rendered as an NcAppNavigationNew button above the menu list when the current route resolves to this page. Page-scoped declarations win over `nav.primaryAction`; emits @primary-action-click on CnAppNav with the resolved block as payload."
				}
			}
		},
		"column": {
			"type": "object",
			"required": ["key", "label"],
			"additionalProperties": false,
			"description": "A table column definition consumed by CnDataTable / CnIndexPage. Mirrors the manifest-driven subset of `columnsFromSchema()`'s output and CnDataTable's manual-mode column shape. As of schema 1.2.0 referenced from `pages[].config.columns[]` for `type:'index'` / `type:'logs'` (alongside the legacy string shorthand admitted via `oneOf`).",
			"properties": {
				"key": {
					"type": "string",
					"description": "Object property to render in this column (e.g. \"title\", \"@self.created\")."
				},
				"label": {
					"type": "string",
					"description": "Column header text. i18n key resolved by the consuming app's t() function at render time."
				},
				"sortable": {
					"type": "boolean",
					"default": true,
					"description": "Whether the column header is clickable to sort. Defaults to true."
				},
				"width": {
					"type": "string",
					"description": "Optional CSS width (e.g. \"200px\", \"15%\"). When omitted CnDataTable falls back to its built-in default-width heuristic by type+format."
				},
				"align": {
					"type": "string",
					"enum": ["left", "center", "right"],
					"description": "Cell text alignment. Closed enum."
				},
				"class": {
					"type": "string",
					"description": "CSS class(es) applied to BOTH the column's header cell and its data cells."
				},
				"cellClass": {
					"type": "string",
					"description": "CSS class(es) applied to the column's data cells only. Built-in utilities: `cn-cell--strong`, `cn-cell--muted`, `cn-cell--end` (right-aligned, no wrap) and `cn-cell--truncate` (single line with a trailing ellipsis, for a long unbreakable value like a URL). `cn-cell--truncate` needs a width to truncate against, so it works best where the table's declared column widths are authoritative — that is `config.fixedLayout: true`, which only type='logs' pages forward to CnDataTable; a type='index' page accepts the key and ignores it, and truncates against the browser's own auto layout."
				},
				"formatter": {
					"type": "string",
					"description": "Optional cell-formatter id (e.g. \"date\", \"currency\", \"count\"). Resolves against the consuming app's formatter registry; left as a free-form string because formatter sets vary per app."
				},
				"formatterOptions": {
					"type": "object",
					"description": "Optional options object passed as the formatter's 4th argument. Consumed by the built-ins that need configuring — `currency` (`{ currency, decimals }`), `conditionalPhrase` (`{ negative, zero, positive }`) and `count` (`{ singular, plural, zero }`, with `{n}` substituted). Ignored when `formatter` is unset.",
					"additionalProperties": true
				},
				"widget": {
					"type": "string",
					"description": "Optional cell-widget id (e.g. \"badge\", or a consumer-registered name). When it resolves in the app's cell-widget registry (`CnAppRoot`'s `cellWidgets` prop → `cnCellWidgets`) the cell renders that component with `{ value, row, property, formatted, ...widgetProps }`; the built-in id \"badge\" renders `CnStatusBadge`. Takes precedence over `formatter` / the type-aware rendering. Free-form because widget sets vary per app."
				},
				"widgetProps": {
					"type": "object",
					"description": "Optional extra props spread onto the resolved cell-widget component (e.g. `{ \"variant\": \"warning\" }` for the built-in `badge` widget). Ignored when `widget` is unset or unresolved.",
					"additionalProperties": true
				},
				"format": {
					"type": "object",
					"additionalProperties": false,
					"description": "Optional declarative cell-format — a no-code alternative to a registry `formatter`. Resolved AFTER `formatter` / `widget` (those win) but BEFORE type-aware rendering, so a manifest column can opt into a currency / duration / percent number or a colour swatch without registering a function. Handled by CnCellRenderer.",
					"properties": {
						"style": {
							"type": "string",
							"enum": ["currency", "number", "percent", "duration", "swatch"],
							"description": "Format kind. `currency` → Intl currency (e.g. \"€ 1.234,56\"); `number` / `percent` → localized number (percent appends \"%\"); `duration` → a seconds value rendered compact (\"1u 23m\"); `swatch` → a colour dot read from the `colorField` sibling field beside the cell text."
						},
						"currency": {
							"type": "string",
							"description": "ISO-4217 currency code for `style:\"currency\"`. Defaults to \"EUR\"."
						},
						"decimals": {
							"type": "integer",
							"minimum": 0,
							"description": "Fraction digits for the numeric styles. Defaults to 2 for currency, 0 otherwise."
						},
						"unit": {
							"type": "string",
							"enum": ["milliseconds", "seconds", "minutes", "hours"],
							"description": "Input unit for `style:\"duration\"`. Defaults to \"seconds\". Sub-second \"milliseconds\" values render as \"245ms\" rather than rounding down to \"0s\"."
						},
						"prefix": {
							"type": "string",
							"description": "String prepended to the formatted numeric/duration value."
						},
						"suffix": {
							"type": "string",
							"description": "String appended to the formatted numeric/duration value."
						},
						"colorField": {
							"type": "string",
							"description": "For `style:\"swatch\"` — the sibling row field holding the colour string (any CSS colour). Defaults to \"color\". The dot is omitted when that field is empty."
						}
					}
				},
				"aggregate": {
					"type": "object",
					"required": ["schema", "op"],
					"additionalProperties": false,
					"description": "Render this cell as a count of related objects rather than a property of the row. The cell value is the number of `schema` objects matching `where` — string values in `where` of the form \"@self.<path>\" are interpolated per-row from the parent row (e.g. `{ \"intakeForm\": \"@self.id\" }` filters the related collection on `intakeForm == row.id`). `register` defaults to the page's `config.register` (CnIndexPage fills it in before passing columns to CnDataTable). `op` is \"count\" for now; \"sum\"/\"min\"/\"max\"/\"avg\" (each needing a `field`) are a planned follow-up. CnDataTable issues one `_limit=0` count request per visible row for each aggregate column (batched); failures degrade that one cell, not the page.",
					"properties": {
						"register": {
							"type": "string",
							"description": "OpenRegister register slug of the related collection. Defaults to the page's `config.register`."
						},
						"schema": {
							"type": "string",
							"description": "OpenRegister schema slug of the related collection to count."
						},
						"op": {
							"type": "string",
							"enum": ["count"],
							"description": "Aggregation operation. Closed enum — \"count\" only for now."
						},
						"where": {
							"type": "object",
							"additionalProperties": true,
							"description": "Filter applied to the related collection. String values \"@self.<path>\" are replaced per-row with `getCellValue(row, path)`; everything else is a literal."
						}
					}
				},
				"hidden": {
					"type": "boolean",
					"default": false,
					"description": "When true the column is hidden by default; users can re-enable it from the columns sidebar tab."
				}
			}
		},
		"action": {
			"type": "object",
			"required": ["id", "label"],
			"additionalProperties": false,
			"description": "A row or bulk action definition consumed by CnRowActions / CnActionsBar. The runtime API on CnRowActions accepts function-typed `handler`, `disabled`, `visible` predicates; those cannot be expressed in JSON. The manifest representation substitutes an `id` (the action key the consumer's component dispatches on) plus a `permission` gate. As of schema 1.2.0 referenced from `pages[].config.actions[]` for `type:'index'`.",
			"properties": {
				"id": {
					"type": "string",
					"description": "Stable action identifier. Dispatched as the action key when the user clicks the entry; the consumer wires the side-effect."
				},
				"label": {
					"type": "string",
					"description": "Action display text. i18n key resolved by the consuming app's t() function at render time."
				},
				"icon": {
					"type": "string",
					"description": "Optional icon name. May be an MDI component name (e.g. \"Pencil\") OR a registry id resolved via the consuming app's icon registry. Free-form because the disambiguation is consumer-specific."
				},
				"permission": {
					"type": "string",
					"description": "Optional permission key. When set the action only renders for users that hold this permission; the consuming app's permissions list is the source of truth."
				},
				"primary": {
					"type": "boolean",
					"default": false,
					"description": "When true the action renders as a primary button outside the actions menu. Defaults to false (the action lives inside the overflow menu)."
				},
				"confirm": {
					"type": "boolean",
					"default": false,
					"description": "When true the consumer SHOULD show a confirmation dialog before invoking the action. Useful for destructive actions; defaults to false."
				},
				"handler": {
					"type": "string",
					"description": "Optional dispatch target for the action. Either (a) one of the reserved keywords \"navigate\" / \"emit\" / \"none\", or (b) a registry name resolving to a function in the customComponents map passed to CnAppRoot. When the registry name resolves to a function, CnIndexPage / CnDetailPage call it with `{ actionId, item }` on row-action click. The reserved keywords short-circuit the registry lookup: \"navigate\" calls `$router.push({ name: action.route, params: { id: row[rowKey] } })`; \"emit\" emits `@action` only (semantic-explicit no-op); \"none\" disables the click entirely. When unset (the default), the action only emits `@action` and the page-level listener decides the side-effect — preserves v1.2 behaviour. Added in schema 1.3.0.",
					"pattern": "^(navigate|emit|none|[A-Za-z][A-Za-z0-9_]*)$"
				},
				"route": {
					"type": "string",
					"description": "Vue-router route name dispatched when `handler === \"navigate\"`. Required for the navigate keyword; ignored for other handler values. CnIndexPage uses it as `$router.push({ name: action.route, params: { id: row[rowKey], ...action.params } })`. Added in schema 1.3.0."
				},
				"params": {
					"type": "object",
					"additionalProperties": { "type": ["string", "number", "boolean"] },
					"description": "Optional LITERAL route params merged into the navigate push (`handler === \"navigate\"`). Row actions default `{ id: row[rowKey] }`; these literals override it (e.g. `{ \"id\": \"new\" }` so a \"New X\" header/row action lands on the detail route in create mode). Header-level actions carry no row, so the literal params are the entire param map. Ignored for non-navigate handlers."
				}
			}
		},
		"widgetDef": {
			"type": "object",
			"required": ["id", "title", "type"],
			"additionalProperties": false,
			"description": "A dashboard widget definition consumed by CnDashboardPage. The manifest-friendly subset of the widget shape CnDashboardPage / useDashboardView accept; runtime-only fields like `buttons` (NcAction-shaped objects) and `reloadInterval` (computed by the consumer) are intentionally omitted. As of schema 1.2.0 referenced from `pages[].config.widgets[]` for `type:'dashboard'`. Note: settings widgets (`pages[].config.sections[].widgets[]`) use a thinner `{ type, props? }` shape and are NOT referenced from this $def.",
			"properties": {
				"id": {
					"type": "string",
					"description": "Stable widget identifier. Used by `layoutItem.widgetId` to position the widget in the grid, and by the consumer's `#widget-{id}` slot to render custom content."
				},
				"title": {
					"type": "string",
					"description": "Widget title. i18n key resolved by the consuming app's t() function at render time."
				},
				"type": {
					"type": "string",
					"description": "Widget type. Conventional values: \"custom\" (consumer renders via the `#widget-{id}` slot), \"tile\" (renders as a CnTileWidget), or any Nextcloud Dashboard API widget id (e.g. \"calendar\", \"talk\"). Left as a free-form string to keep NC widget interop open."
				},
				"iconUrl": {
					"type": "string",
					"description": "Optional URL of an icon image (SVG or raster). Mutually informative with `iconClass`; consumers prefer iconUrl when both are set."
				},
				"iconClass": {
					"type": "string",
					"description": "Optional CSS class for the icon (e.g. \"icon-graph\")."
				},
				"itemApiVersions": {
					"type": "array",
					"items": { "type": "integer" },
					"description": "NC Dashboard API versions this widget supports. Presence of this field signals to CnDashboardPage that the widget renders via CnWidgetRenderer rather than the custom slot."
				},
				"props": {
					"type": "object",
					"description": "Free-form props object passed through to the rendered widget component. The shape varies per widget type, so it stays open — only the few keys whose VALUE is a closed enum are typed below, to catch a misspelt value that would otherwise fall back to the default in silence. For type='chart' the forwarded keys are: chartKind, series, categories, labels, options, colors, toolbar, legend, height, width, horizontal, legendPosition, valueFormat, valueAxisBaseline, colorMap, emptyLabel, unavailableLabel, views, endpointSource. Note that a misspelt KEY cannot be caught here (the bag is open by design) and is dropped at render time.",
					"additionalProperties": true,
					"properties": {
						"valueAxisBaseline": {
							"type": "string",
							"enum": ["auto", "zero", "fit"],
							"description": "type='chart' only. Value-axis baseline: `auto` (the default — zero for bar/area, a bounded window for line/scatter), `zero` (force a zero baseline), or `fit` (let ApexCharts frame the data range, for a series that lives far from zero such as an SLA hovering 95–99%)."
						}
					}
				},
				"dataSource": {
					"type": "object",
					"description": "Optional data binding for widgets that fetch via OpenRegister's GraphQL endpoint. Two forms: (a) shorthand `{ register, schema, filter?, aggregate: 'count' }` — the lib builds `{ <schemaSlug>(filter: …) { totalCount } }` and resolves to `{ count }`. (b) raw `{ graphql: { query, variables?, selectors } }` — the lib issues `query` + `variables` and runs each value of `selectors` (a dot-path with optional `[]` array hops, e.g. `data.foo[].count`) against the response, building a result map keyed by the selector keys. Consumed by `type: 'stats-block'` (uses `count`) and `type: 'chart'` (uses `series`/`categories`/`labels`).",
					"additionalProperties": true,
					"properties": {
						"register": {
							"type": "string",
							"description": "Shorthand: register slug. Decorative — included for symmetry with index/detail pages; the GraphQL field name is the schema slug."
						},
						"schema": {
							"type": "string",
							"description": "Shorthand: schema slug. Used as the connection field name in the generated GraphQL query."
						},
						"filter": {
							"type": "object",
							"description": "Shorthand: filter map inlined into the generated `filter:` arg.",
							"additionalProperties": true
						},
						"aggregate": {
							"type": "string",
							"enum": ["count"],
							"description": "Shorthand aggregation. Currently only `count` is supported; richer aggregates land with OpenRegister #1455."
						},
						"graphql": {
							"type": "object",
							"description": "Raw GraphQL form. Use when the shorthand is too narrow.",
							"additionalProperties": false,
							"required": ["query", "selectors"],
							"properties": {
								"query": {
									"type": "string",
									"description": "GraphQL query document."
								},
								"variables": {
									"type": "object",
									"description": "Variables map passed alongside `query`.",
									"additionalProperties": true
								},
								"selectors": {
									"type": "object",
									"description": "Map of result-prop name → dot-path selector. `[]` segments flat-map across arrays, e.g. `data.foo[].count`.",
									"additionalProperties": { "type": "string" }
								}
							}
						}
					}
				}
			}
		},
		"layoutItem": {
			"type": "object",
			"required": ["id", "widgetId", "gridX", "gridY", "gridWidth", "gridHeight"],
			"additionalProperties": false,
			"description": "A grid layout entry consumed by CnDashboardPage / CnDashboardGrid. Pairs a widget (by id) with its position and size in the grid. Free-form runtime fields like `styleConfig` are intentionally omitted from this $def — they're consumer-piped pass-throughs that don't benefit from a contract. As of schema 1.2.0 referenced from `pages[].config.layout[]` for `type:'dashboard'`.",
			"properties": {
				"id": {
					"type": "string",
					"description": "Unique identifier for this layout entry. Distinct from `widgetId` so the same widget definition can appear multiple times in a layout (rare but supported by CnDashboardGrid)."
				},
				"widgetId": {
					"type": "string",
					"description": "References a `widgetDef.id` from the same dashboard's widget array."
				},
				"gridX": {
					"type": "integer",
					"minimum": 0,
					"description": "Column position (0-indexed) of the widget's top-left corner."
				},
				"gridY": {
					"type": "integer",
					"minimum": 0,
					"description": "Row position (0-indexed) of the widget's top-left corner."
				},
				"gridWidth": {
					"type": "integer",
					"minimum": 1,
					"description": "Width of the widget in grid columns. CnDashboardPage defaults the grid to 12 columns."
				},
				"gridHeight": {
					"type": "integer",
					"minimum": 1,
					"description": "Height of the widget in grid rows. CnDashboardPage's default cell height is 80px."
				},
				"showTitle": {
					"type": "boolean",
					"default": true,
					"description": "When false the widget renders without its title bar (and without the surrounding card chrome). Defaults to true."
				},
				"dateChip": {
					"type": "boolean",
					"default": false,
					"description": "Opt-in (custom widgets only): when true AND the dashboard page's `dateRange.enabled` is true, CnDashboardPage renders the shared date-range chip in this widget's title bar — the same chip chart widgets with a `dataSource.bucket` get automatically. The chip reads and writes the SHARED dashboard range (`cnDashboardDateRange`); combine with `dateRange.showHeaderPicker: false` to surface the range exclusively in widget headers. Defaults to false."
				}
			}
		},
		"formField": {
			"type": "object",
			"required": ["key", "label", "type"],
			"additionalProperties": false,
			"description": "A schema-driven form field consumed by manifest-driven settings / form pages. The manifest-relevant subset of `fieldsFromSchema()`'s output; the runtime `validation` block (min/max/pattern) and `items` (array element schema) are deferred to a follow-up tightening change because they belong on a richer `validation` $def of their own. As of schema 1.2.0 referenced from `pages[].config.sections[].fields[]` for `type:'settings'`.",
			"properties": {
				"key": {
					"type": "string",
					"description": "IAppConfig key (or schema property name) the field reads/writes."
				},
				"label": {
					"type": "string",
					"description": "Field label. i18n key resolved by the consuming app's t() function at render time."
				},
				"type": {
					"type": "string",
					"enum": ["boolean", "number", "string", "enum", "password", "json"],
					"description": "Field value type. Closed enum: \"boolean\" (checkbox), \"number\" (NcTextField type=number), \"string\" (NcTextField), \"enum\" (NcSelect), \"password\" (NcPasswordField), \"json\" (CnJsonViewer)."
				},
				"required": {
					"type": "boolean",
					"default": false,
					"description": "When true the field MUST have a value before save. Defaults to false."
				},
				"default": {
					"description": "Default value for the field when no IAppConfig value is set. Type matches `type`; not constrained at the schema level (JSON Schema \"any\")."
				},
				"enum": {
					"type": "array",
					"description": "When `type === \"enum\"`, the list of allowed values. Each entry can be a string OR an `{ value, label }` object; not constrained at the schema level here."
				},
				"widget": {
					"type": "string",
					"description": "Optional widget hint that overrides the default widget for the type (e.g. \"textarea\" instead of \"string\"'s default NcTextField). Resolves against the consuming app's widget registry."
				},
				"help": {
					"type": "string",
					"description": "Optional help / tooltip text shown next to the field. i18n key resolved at render time."
				}
			}
		},
		"sidebarSection": {
			"type": "object",
			"required": ["id", "label"],
			"additionalProperties": false,
			"description": "An index-sidebar config group consumed by CnIndexSidebar (manifest-side equivalent of its `columnGroups` prop). Used to declare collapsible groups of fields a user can toggle column visibility on. As of schema 1.2.0 referenced from `pages[].config.sidebar.columnGroups[]` for `type:'index'`.",
			"properties": {
				"id": {
					"type": "string",
					"description": "Unique identifier for this sidebar group. Used as the React-style key when rendering."
				},
				"label": {
					"type": "string",
					"description": "Group heading. i18n key resolved by the consuming app's t() function at render time."
				},
				"icon": {
					"type": "string",
					"description": "Optional MDI component name (e.g. \"Information\") for the group's leading icon."
				},
				"fields": {
					"type": "array",
					"description": "Fields contained in this group. Each item declares a column key/label pair (the column visibility toggle).",
					"items": {
						"type": "object",
						"required": ["key", "label"],
						"additionalProperties": false,
						"properties": {
							"key": {
								"type": "string",
								"description": "Column key the toggle controls (matches `column.key`)."
							},
							"label": {
								"type": "string",
								"description": "Display text for the toggle. i18n key resolved at render time."
							}
						}
					}
				}
			}
		},
		"widgetRefItem": {
			"type": "object",
			"required": ["type", "ref"],
			"additionalProperties": false,
			"description": "A declarative widget-reference content item. Used in `pages[].config.content[]` for type='dashboard' pages. CnDashboardPage resolves the widget at runtime by calling GET /index.php/apps/openregister/api/schemas/<schemaSlug>/widgets/<widgetSlug> and renders the returned component. A graceful loading skeleton and error fallback are rendered when the ref cannot be resolved.",
			"properties": {
				"type": {
					"const": "widget-ref",
					"description": "Discriminator — MUST be the literal string \"widget-ref\"."
				},
				"ref": {
					"type": "string",
					"pattern": "^openregister://widget/[a-z0-9-]+/[a-zA-Z][a-zA-Z0-9-]+$",
					"description": "URI of the OR-declared widget. Format: openregister://widget/<schemaSlug>/<widgetSlug>. The schemaSlug uses lowercase letters, digits, and hyphens. The widgetSlug MUST start with a letter (upper or lower) and may contain letters, digits, and hyphens (camelCase is supported, e.g. `coverageGrid`)."
				}
			}
		},
		"sidebarTab": {
			"type": "object",
			"required": ["id", "label"],
			"additionalProperties": false,
			"description": "A detail-sidebar tab consumed by CnObjectSidebar after the parallel manifest-abstract-sidebar change opened the previously-closed tab registry. A tab declares its content via either a `widgets` list (Conduction widget renderers) OR a `component` registry name — the mutual exclusion is enforced at runtime by `validateManifest`'s sidebar-tab rules; this $def keeps both fields optional so consumers can mix per-tab. As of schema 1.2.0 referenced from both `pages[].config.sidebar.tabs[]` (Object form, preferred) and `pages[].config.sidebarProps.tabs[]` (legacy path) for `type:'detail'`.",
			"properties": {
				"id": {
					"type": "string",
					"description": "Unique tab identifier. Used as the active-tab key and the slot name (`#tab-{id}`) for content overrides."
				},
				"label": {
					"type": "string",
					"description": "Tab label. i18n key resolved by the consuming app's t() function at render time."
				},
				"icon": {
					"type": "string",
					"description": "Optional MDI component name (e.g. \"Information\") for the tab's icon."
				},
				"widgets": {
					"type": "array",
					"description": "Optional list of widget references. Each item is `{ type: 'data' | 'metadata' | <registry> }`. Mutually exclusive with `component` at runtime. Sidebar widgets are NOT the same shape as dashboard widgetDef entries; a richer `sidebarWidget` $def can land in a follow-up.",
					"items": {
						"type": "object",
						"additionalProperties": true
					}
				},
				"component": {
					"type": "string",
					"description": "Optional registry component name. When set the tab body renders the resolved component. Mutually exclusive with `widgets` at runtime."
				}
			}
		},
		"visibleIfCondition": {
			"type": "object",
			"description": "A `visibleIf` condition block. Each key is either a reserved specialised condition (`appInstalled`) OR a dot-separated path into `manifest.runtime` (e.g. `\"user.primaryRole\"`). The value for a context-path key is a predicate expression — either a scalar (strict equality shorthand) or an operator object (`{ eq }`, `{ in }`, `{ notIn }`, `{ gt }`, `{ gte }`, `{ lt }`, `{ lte }`, `{ truthy }`). All conditions across all keys are combined with implicit AND — every condition must pass for the item to render. As of schema 1.4.0 this $def is referenced by both `menuItem.visibleIf` and `menuItemLeaf.visibleIf`.",
			"additionalProperties": true,
			"properties": {
				"appInstalled": {
					"type": "string",
					"minLength": 1,
					"description": "Nextcloud app id that MUST be installed and enabled for this entry to render. CnAppNav checks via `OC.appswebroots` (primary) or the capabilities API (fallback). Result is cached per page load. Use for cross-app links that are meaningless when the target app is absent (e.g. a 'View in launchpad' link should only show when launchpad is enabled)."
				}
			}
		}
	}
}
