@conduction/nextcloud-vue 1.0.0-beta.20 → 1.0.0-beta.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@conduction/nextcloud-vue",
3
- "version": "1.0.0-beta.20",
3
+ "version": "1.0.0-beta.21",
4
4
  "description": "Shared Vue component library for Conduction Nextcloud apps — complements @nextcloud/vue with higher-level components, OpenRegister integration, and NL Design System support",
5
5
  "license": "EUPL-1.2",
6
6
  "author": "Conduction B.V. <info@conduction.nl>",
@@ -1,8 +1,12 @@
1
1
  <!--
2
2
  CnSettingsPage — Admin / config surface.
3
3
 
4
- Renders manifest-driven config sections. Each section in
5
- `config.sections[]` is a CnSettingsCard wrapping a CnSettingsSection.
4
+ Renders manifest-driven config sections. The page MAY declare
5
+ EITHER a flat `sections[]` array (back-compat) OR a `tabs[]` array
6
+ (each tab owns its own `sections[]`) — XOR, never both. See
7
+ manifest-settings-orchestration spec.
8
+
9
+ Each section is a CnSettingsCard wrapping a CnSettingsSection.
6
10
  A section MUST declare exactly one of three body kinds:
7
11
 
8
12
  1. `fields[]` — flat form fields (back-compat, default)
@@ -12,6 +16,8 @@
12
16
  Built-in widget types (resolved BEFORE the customComponents registry):
13
17
  - `version-info` → CnVersionInfoCard
14
18
  - `register-mapping` → CnRegisterMapping
19
+ - `component` → discriminator; `widget.componentName`
20
+ resolves against customComponents
15
21
 
16
22
  Saves via `axios.put(saveEndpoint, formData)` with the consumer's
17
23
  settings controller URL. Widget events bubble up through
@@ -44,10 +50,33 @@
44
50
  <slot name="actions" />
45
51
  </div>
46
52
 
47
- <!-- Sections -->
53
+ <!-- Tab strip — only rendered when `tabs[]` is set
54
+ (manifest-settings-orchestration REQ-MSO-5). Built with
55
+ native <button role="tab"> + ARIA wiring; uses Nextcloud
56
+ CSS variables only (no hex literals). -->
57
+ <div
58
+ v-if="hasTabs"
59
+ class="cn-settings-page__tabs"
60
+ role="tablist">
61
+ <button
62
+ v-for="(tab, tabIndex) in tabs"
63
+ :key="`tab-${tab.id}`"
64
+ role="tab"
65
+ type="button"
66
+ class="cn-settings-page__tab"
67
+ :class="{ 'cn-settings-page__tab--active': activeTabId === tab.id }"
68
+ :aria-selected="activeTabId === tab.id ? 'true' : 'false'"
69
+ :aria-controls="`cn-settings-tab-panel-${tab.id}`"
70
+ @click="onTabClick(tab, tabIndex)">
71
+ {{ resolveLabel(tab.label) }}
72
+ </button>
73
+ </div>
74
+
75
+ <!-- Sections — rendered for the active tab when `tabs[]` is
76
+ set, otherwise the flat `sections[]` (back-compat). -->
48
77
  <CnSettingsCard
49
- v-for="(section, sectionIndex) in sections"
50
- :key="`section-${sectionIndex}`"
78
+ v-for="(section, sectionIndex) in activeSections"
79
+ :key="`section-${activeTabId || 'flat'}-${sectionIndex}`"
51
80
  :title="resolveLabel(section.title)"
52
81
  :icon="section.icon || ''"
53
82
  :collapsible="section.collapsible || false">
@@ -198,6 +227,15 @@ import CnVersionInfoCard from '../CnVersionInfoCard/CnVersionInfoCard.vue'
198
227
  import CnRegisterMapping from '../CnRegisterMapping/CnRegisterMapping.vue'
199
228
  import CnSettingsWidgetMount from './CnSettingsWidgetMount.js'
200
229
 
230
+ /**
231
+ * Sentinel value used in the built-in widget registry to mark the
232
+ * `'component'` discriminator (manifest-settings-orchestration
233
+ * REQ-MSO-6). The discriminator does NOT resolve to a fixed
234
+ * component — instead, the resolver detects this sentinel and looks
235
+ * up `widget.componentName` in the customComponents registry.
236
+ */
237
+ const COMPONENT_DISCRIMINATOR = Symbol('cn-settings-component-widget')
238
+
201
239
  /**
202
240
  * Built-in widget registry. Used by `CnSettingsPage` to resolve
203
241
  * `widgets[].type` to a component BEFORE consulting the
@@ -206,13 +244,19 @@ import CnSettingsWidgetMount from './CnSettingsWidgetMount.js'
206
244
  * The order matters — built-ins win on collision so consumers can't
207
245
  * accidentally shadow `version-info` with their own component. If a
208
246
  * consumer needs to truly replace one of these, they can render their
209
- * own component via `section.component` instead of `widgets[]`.
247
+ * own component via `section.component` or
248
+ * `{ type: "component", componentName: <name> }` instead of `widgets[]`.
210
249
  *
211
- * Spec: REQ-MSRS-2 (manifest-settings-rich-sections).
250
+ * Spec:
251
+ * - REQ-MSRS-2 (manifest-settings-rich-sections) — fixed-component
252
+ * built-ins (`version-info`, `register-mapping`).
253
+ * - REQ-MSO-6 (manifest-settings-orchestration) — `'component'`
254
+ * discriminator (sentinel value, resolved via componentName).
212
255
  */
213
256
  const BUILTIN_SETTINGS_WIDGETS = Object.freeze({
214
257
  'version-info': CnVersionInfoCard,
215
258
  'register-mapping': CnRegisterMapping,
259
+ component: COMPONENT_DISCRIMINATOR,
216
260
  })
217
261
 
218
262
  /**
@@ -320,19 +364,50 @@ export default {
320
364
  default: '',
321
365
  },
322
366
  /**
323
- * Section definitions. Each section MUST declare EXACTLY ONE of:
367
+ * Section definitions (flat shape back-compat). Each section
368
+ * MUST declare EXACTLY ONE of:
324
369
  * - `fields: Array<Field>` (back-compat flat-field body)
325
370
  * - `component: <registry-name>` + optional `props`
326
- * - `widgets: Array<{ type, props? }>`
371
+ * - `widgets: Array<{ type, props?, componentName? }>`
327
372
  *
328
373
  * Common keys: `{ title, description?, icon?, collapsible?, docUrl? }`.
329
374
  *
375
+ * Mutually exclusive with `tabs[]` (XOR — see
376
+ * manifest-settings-orchestration REQ-MSO-1).
377
+ *
330
378
  * @type {Array<object>}
331
379
  */
332
380
  sections: {
333
381
  type: Array,
334
382
  default: () => [],
335
383
  },
384
+ /**
385
+ * Tab definitions (orchestration shape — manifest-settings-
386
+ * orchestration REQ-MSO-2). When set, CnSettingsPage renders
387
+ * a tab strip above the section area; the active tab's
388
+ * `sections[]` flow into the same renderer used by the flat
389
+ * shape. Mutually exclusive with `sections[]`.
390
+ *
391
+ * Each tab MUST be `{ id: string, label: string,
392
+ * icon?: string, sections: array<Section> }`.
393
+ *
394
+ * @type {Array<object>}
395
+ */
396
+ tabs: {
397
+ type: Array,
398
+ default: () => [],
399
+ },
400
+ /**
401
+ * Optional ID of the tab to activate on mount. When empty AND
402
+ * `tabs[]` is non-empty, the first tab is active by default.
403
+ * Unknown IDs fall back to the first tab.
404
+ *
405
+ * @type {string}
406
+ */
407
+ initialTab: {
408
+ type: String,
409
+ default: '',
410
+ },
336
411
  /**
337
412
  * Initial values keyed by `field.key`. Defaults to an empty
338
413
  * object; in practice the consumer passes the current
@@ -394,14 +469,30 @@ export default {
394
469
  },
395
470
  },
396
471
 
397
- emits: ['save', 'error', 'input', 'widget-event'],
472
+ emits: ['save', 'error', 'input', 'widget-event', 'tab-change'],
398
473
 
399
474
  data() {
475
+ // Resolve the initial active-tab id synchronously so the very
476
+ // first render has the correct tab active (otherwise tests
477
+ // that mount + assert without an `await tick` see the empty
478
+ // default). Mirrors the logic in `resolveInitialTabId` (the
479
+ // watcher path); keep them aligned.
480
+ let activeTabId = ''
481
+ const tabs = Array.isArray(this.tabs) ? this.tabs : []
482
+ if (tabs.length > 0) {
483
+ if (typeof this.initialTab === 'string' && this.initialTab.length > 0
484
+ && tabs.some(t => t && t.id === this.initialTab)) {
485
+ activeTabId = this.initialTab
486
+ } else if (tabs[0] && typeof tabs[0].id === 'string') {
487
+ activeTabId = tabs[0].id
488
+ }
489
+ }
400
490
  return {
401
491
  formData: this.cloneInitial(),
402
492
  originalData: this.cloneInitial(),
403
493
  saving: false,
404
494
  lastError: null,
495
+ activeTabId,
405
496
  }
406
497
  },
407
498
 
@@ -420,6 +511,35 @@ export default {
420
511
  effectiveCustomComponents() {
421
512
  return this.customComponents ?? this.cnCustomComponents ?? {}
422
513
  },
514
+ /**
515
+ * Whether the page is in tabs orchestration mode. True when
516
+ * `tabs[]` is non-empty — drives the tab-strip render gate
517
+ * (manifest-settings-orchestration REQ-MSO-5).
518
+ *
519
+ * @return {boolean}
520
+ */
521
+ hasTabs() {
522
+ return Array.isArray(this.tabs) && this.tabs.length > 0
523
+ },
524
+ /**
525
+ * The sections to render right now. In flat mode, this is the
526
+ * `sections` prop directly. In tabs mode, this is the
527
+ * `sections[]` array of the currently active tab. Centralising
528
+ * this in one computed keeps the template's `v-for` simple
529
+ * and decouples it from the body kind dispatcher (which
530
+ * applies per-section, not per-mode).
531
+ *
532
+ * @return {Array<object>}
533
+ */
534
+ activeSections() {
535
+ if (!this.hasTabs) return this.sections || []
536
+ const active = this.tabs.find(t => t && t.id === this.activeTabId)
537
+ if (active && Array.isArray(active.sections)) return active.sections
538
+ // Defensive fallback — should not happen because
539
+ // `resolveInitialTabId` always lands on a known tab.
540
+ const first = this.tabs[0]
541
+ return first && Array.isArray(first.sections) ? first.sections : []
542
+ },
423
543
  },
424
544
 
425
545
  watch: {
@@ -430,6 +550,22 @@ export default {
430
550
  this.originalData = this.cloneInitial()
431
551
  },
432
552
  },
553
+ // When `tabs[]` changes (e.g. consumer swaps manifests at
554
+ // runtime), re-resolve the active tab so the page doesn't get
555
+ // stuck on a removed id.
556
+ tabs: {
557
+ handler() {
558
+ this.activeTabId = this.resolveInitialTabId()
559
+ },
560
+ },
561
+ // When `initialTab` changes (consumer-controlled tab
562
+ // activation), follow it.
563
+ initialTab(next) {
564
+ if (typeof next === 'string' && next.length > 0) {
565
+ const exists = this.tabs.some(t => t && t.id === next)
566
+ if (exists) this.activeTabId = next
567
+ }
568
+ },
433
569
  },
434
570
 
435
571
  methods: {
@@ -475,11 +611,20 @@ export default {
475
611
  },
476
612
  cloneInitial() {
477
613
  const merged = { ...(this.initialValues || {}) }
478
- // Pre-populate any field with a `default` if no value is set yet.
479
- // Only flat-field sections contribute defaults; component and
480
- // widgets sections own their own state.
481
- for (const section of this.sections || []) {
482
- if (!Array.isArray(section.fields)) continue
614
+ // Collect every section across both modes (flat
615
+ // `sections[]` AND `tabs[].sections[]`) so default values
616
+ // are applied regardless of orchestration shape.
617
+ // Only flat-field sections contribute defaults; component
618
+ // and widgets sections own their own state.
619
+ const allSections = []
620
+ for (const section of this.sections || []) allSections.push(section)
621
+ for (const tab of this.tabs || []) {
622
+ if (tab && Array.isArray(tab.sections)) {
623
+ for (const section of tab.sections) allSections.push(section)
624
+ }
625
+ }
626
+ for (const section of allSections) {
627
+ if (!section || !Array.isArray(section.fields)) continue
483
628
  for (const field of section.fields) {
484
629
  if (field.default !== undefined && merged[field.key] === undefined) {
485
630
  merged[field.key] = field.default
@@ -545,23 +690,55 @@ export default {
545
690
  },
546
691
 
547
692
  /**
548
- * Resolve a `widgets[].type` string to a Vue component. Lookup
549
- * order:
693
+ * Resolve a single `widgets[]` entry to a concrete Vue
694
+ * component. Lookup order:
550
695
  *
551
696
  * 1. Built-in widget map (`version-info`, `register-mapping`).
552
- * 2. `effectiveCustomComponents` registry.
697
+ * 2. `'component'` discriminator (REQ-MSO-6) — resolves
698
+ * `widget.componentName` against `effectiveCustomComponents`.
699
+ * 3. Legacy fallback — looks up `widget.type` against
700
+ * `effectiveCustomComponents`. Kept for back-compat with
701
+ * manifest-settings-rich-sections consumers; flagged as
702
+ * deprecated in JSDoc — manifest authors should migrate to
703
+ * the explicit `{ type: "component", componentName }` shape.
553
704
  *
554
- * Returns `null` (and warns) when neither resolves. Built-ins
555
- * win on collision so consumers can't accidentally shadow them
556
- * (REQ-MSRS-2).
705
+ * Returns `null` (and warns) when nothing resolves. Built-ins
706
+ * win on collision so consumers can't accidentally shadow them.
557
707
  *
558
- * @param {string} type The widget type string.
708
+ * @param {object} widget A `widgets[]` entry, e.g. `{ type, props?, componentName? }`.
559
709
  * @return {object|null} Vue component or null.
560
710
  */
561
- resolveWidgetComponent(type) {
711
+ resolveWidgetComponent(widget) {
712
+ const type = widget && typeof widget.type === 'string' ? widget.type : ''
713
+ if (!type) return null
562
714
  if (Object.prototype.hasOwnProperty.call(BUILTIN_SETTINGS_WIDGETS, type)) {
563
- return BUILTIN_SETTINGS_WIDGETS[type]
715
+ const builtin = BUILTIN_SETTINGS_WIDGETS[type]
716
+ if (builtin === COMPONENT_DISCRIMINATOR) {
717
+ // REQ-MSO-6: discriminator — look up `componentName`.
718
+ const name = widget.componentName
719
+ if (typeof name !== 'string' || name.length === 0) {
720
+ // eslint-disable-next-line no-console
721
+ console.warn(
722
+ '[CnSettingsPage] Widget {type:"component"} requires a non-empty `componentName`. Widget will be skipped.',
723
+ )
724
+ return null
725
+ }
726
+ const resolved = this.effectiveCustomComponents[name]
727
+ if (!resolved) {
728
+ // eslint-disable-next-line no-console
729
+ console.warn(
730
+ `[CnSettingsPage] Widget component "${name}" not found in customComponents registry. Widget will be skipped.`,
731
+ )
732
+ return null
733
+ }
734
+ return resolved
735
+ }
736
+ return builtin
564
737
  }
738
+ // Legacy fallback (manifest-settings-rich-sections REQ-MSRS-2).
739
+ // Deprecated — manifest authors should migrate to
740
+ // `{ type: "component", componentName: <X> }`. Kept here so
741
+ // existing consumers continue working unchanged.
565
742
  const resolved = this.effectiveCustomComponents[type]
566
743
  if (!resolved) {
567
744
  // eslint-disable-next-line no-console
@@ -580,6 +757,11 @@ export default {
580
757
  * has already logged a warn. The filter happens here so the
581
758
  * template can use a clean `v-for` without nested `v-if`.
582
759
  *
760
+ * The `widgetType` carried on the bubbled `@widget-event`
761
+ * payload is the widget's `componentName` (when the
762
+ * discriminator is `'component'`) or `widget.type` otherwise —
763
+ * giving consumers a stable identifier for the dispatch.
764
+ *
583
765
  * @param {object} section A section entry with `widgets[]`.
584
766
  * @param {number} sectionIndex Index in `sections[]`.
585
767
  * @return {Array<{key: string, component: object, props: object, widgetType: string, widgetIndex: number}>}
@@ -589,19 +771,57 @@ export default {
589
771
  const widgets = Array.isArray(section.widgets) ? section.widgets : []
590
772
  for (let widgetIndex = 0; widgetIndex < widgets.length; widgetIndex++) {
591
773
  const widget = widgets[widgetIndex] || {}
592
- const component = this.resolveWidgetComponent(widget.type)
774
+ const component = this.resolveWidgetComponent(widget)
593
775
  if (!component) continue
776
+ const widgetType = widget.type === 'component' && typeof widget.componentName === 'string'
777
+ ? widget.componentName
778
+ : widget.type
594
779
  entries.push({
595
780
  key: `widget-${sectionIndex}-${widgetIndex}`,
596
781
  component,
597
782
  props: widget.props || {},
598
- widgetType: widget.type,
783
+ widgetType,
599
784
  widgetIndex,
600
785
  })
601
786
  }
602
787
  return entries
603
788
  },
604
789
 
790
+ /**
791
+ * Resolve the active-tab id on mount / when `tabs[]` changes.
792
+ * Lookup order: explicit `initialTab` prop → first tab in
793
+ * `tabs[]` → empty string. Unknown `initialTab` values fall
794
+ * back to the first tab so the page never gets stuck.
795
+ * (manifest-settings-orchestration REQ-MSO-5.)
796
+ *
797
+ * @return {string} The resolved tab id (empty in flat mode).
798
+ */
799
+ resolveInitialTabId() {
800
+ if (!this.hasTabs) return ''
801
+ if (typeof this.initialTab === 'string' && this.initialTab.length > 0) {
802
+ const exists = this.tabs.some(t => t && t.id === this.initialTab)
803
+ if (exists) return this.initialTab
804
+ }
805
+ const first = this.tabs[0]
806
+ return first && typeof first.id === 'string' ? first.id : ''
807
+ },
808
+
809
+ /**
810
+ * Handle a tab button click. Switches the active tab and
811
+ * emits `@tab-change` so consumers can react (e.g. persist the
812
+ * active tab in their preference store, update the URL hash).
813
+ * (manifest-settings-orchestration REQ-MSO-5.)
814
+ *
815
+ * @param {object} tab The clicked tab definition.
816
+ * @param {number} tabIndex The tab's index in `tabs[]`.
817
+ */
818
+ onTabClick(tab, tabIndex) {
819
+ if (!tab || typeof tab.id !== 'string') return
820
+ if (this.activeTabId === tab.id) return
821
+ this.activeTabId = tab.id
822
+ this.$emit('tab-change', { tabId: tab.id, tabIndex })
823
+ },
824
+
605
825
  /**
606
826
  * Re-emit a widget's `widget-event` (caught by the local
607
827
  * CnSettingsWidgetMount helper) as a top-level `widget-event`
@@ -653,6 +873,65 @@ export default {
653
873
  gap: 8px;
654
874
  }
655
875
 
876
+ /*
877
+ * Tab strip for the orchestration shape (manifest-settings-
878
+ * orchestration REQ-MSO-5). Uses Nextcloud CSS variables only — no
879
+ * hex literals, no rgba overrides on the elements themselves.
880
+ */
881
+ .cn-settings-page__tabs {
882
+ display: flex;
883
+ flex-wrap: wrap;
884
+ gap: 4px;
885
+ border-bottom: 1px solid var(--color-border);
886
+ padding-bottom: 0;
887
+ margin-bottom: 8px;
888
+ }
889
+
890
+ .cn-settings-page__tab {
891
+ background: transparent;
892
+ border: 0;
893
+ border-bottom: 3px solid transparent;
894
+ padding: 8px 16px;
895
+ margin: 0;
896
+ cursor: pointer;
897
+ color: var(--color-text-maxcontrast);
898
+ font-weight: normal;
899
+ border-radius: var(--border-radius-large) var(--border-radius-large) 0 0;
900
+ transition: background-color 0.1s ease-in-out, color 0.1s ease-in-out, border-color 0.1s ease-in-out;
901
+ }
902
+
903
+ .cn-settings-page__tab:hover,
904
+ .cn-settings-page__tab:focus-visible {
905
+ background-color: var(--color-background-hover);
906
+ color: var(--color-main-text);
907
+ outline: none;
908
+ }
909
+
910
+ .cn-settings-page__tab--active {
911
+ color: var(--color-primary-element);
912
+ border-bottom-color: var(--color-primary-element);
913
+ font-weight: bold;
914
+ }
915
+
916
+ @media (max-width: 768px) {
917
+ .cn-settings-page__tabs {
918
+ flex-direction: column;
919
+ gap: 0;
920
+ border-bottom: 0;
921
+ }
922
+
923
+ .cn-settings-page__tab {
924
+ border-bottom: 1px solid var(--color-border);
925
+ border-radius: 0;
926
+ text-align: left;
927
+ }
928
+
929
+ .cn-settings-page__tab--active {
930
+ border-bottom-width: 1px;
931
+ background-color: var(--color-background-hover);
932
+ }
933
+ }
934
+
656
935
  .cn-settings-page__fields {
657
936
  display: flex;
658
937
  flex-direction: column;
@@ -127,7 +127,7 @@
127
127
  },
128
128
  "config": {
129
129
  "type": "object",
130
- "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 `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 }. For type='logs': { register?, schema?, source?, columns? } — one of register+schema OR source MUST be set. For type='settings': { sections: array<Section>, saveEndpoint? } where 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? }>` (mounts one or more widgets in sequence; built-in widget types `version-info` → CnVersionInfoCard and `register-mapping` → CnRegisterMapping resolve first, falling back to the customComponents registry). 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='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.",
130
+ "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 `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 }. For type='logs': { register?, schema?, source?, columns? } — one of register+schema OR source MUST be set. 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='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.",
131
131
  "additionalProperties": true,
132
132
  "properties": {
133
133
  "columns": {
@@ -162,7 +162,7 @@
162
162
  },
163
163
  "sections": {
164
164
  "type": "array",
165
- "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? }` (NOT the same as dashboard widgetDef) and are NOT typed by this schema.",
165
+ "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.",
166
166
  "items": {
167
167
  "type": "object",
168
168
  "additionalProperties": true,
@@ -175,6 +175,44 @@
175
175
  }
176
176
  }
177
177
  },
178
+ "tabs": {
179
+ "type": "array",
180
+ "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.",
181
+ "items": {
182
+ "type": "object",
183
+ "additionalProperties": true,
184
+ "required": ["id", "label", "sections"],
185
+ "properties": {
186
+ "id": {
187
+ "type": "string",
188
+ "description": "Tab identifier — addressable by `initialTab` and emitted in `@tab-change`. MUST be non-empty and unique within the page."
189
+ },
190
+ "label": {
191
+ "type": "string",
192
+ "description": "Tab button label (i18n translation key — passed through `translate()` if a translate prop is wired on CnSettingsPage)."
193
+ },
194
+ "icon": {
195
+ "type": "string",
196
+ "description": "Optional MDI component name prepended to the tab button (e.g. \"Cog\")."
197
+ },
198
+ "sections": {
199
+ "type": "array",
200
+ "description": "Sections rendered when this tab is active. Same shape and rules as the flat `sections[]` case.",
201
+ "items": {
202
+ "type": "object",
203
+ "additionalProperties": true,
204
+ "properties": {
205
+ "fields": {
206
+ "type": "array",
207
+ "description": "Flat field-body for this section. Each item references the `formField` $def.",
208
+ "items": { "$ref": "#/$defs/formField" }
209
+ }
210
+ }
211
+ }
212
+ }
213
+ }
214
+ }
215
+ },
178
216
  "sidebar": {
179
217
  "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`.",
180
218
  "oneOf": [