@conduction/nextcloud-vue 2.63.0 → 2.64.0

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.
Files changed (100) hide show
  1. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +154 -7
  2. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
  3. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js +119 -42
  4. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
  5. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +87 -0
  6. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
  7. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js +26 -24
  8. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
  9. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue2.js +61 -16
  10. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue2.js.map +1 -1
  11. package/dist/esm/components/CnAuditTrailCard/CnAuditTrailCard.vue3.js.map +1 -1
  12. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue2.js +20 -0
  13. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue2.js.map +1 -1
  14. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue3.js +3 -2
  15. package/dist/esm/components/CnAuditTrailWidget/CnAuditTrailWidget.vue3.js.map +1 -1
  16. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js +33 -2
  17. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js.map +1 -1
  18. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js.map +1 -1
  19. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +29 -0
  20. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  21. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +150 -133
  22. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  23. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +37 -1
  24. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  25. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +12 -4
  26. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  27. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +66 -14
  28. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  29. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +23 -14
  30. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  31. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js +81 -4
  32. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js.map +1 -1
  33. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js +22 -5
  34. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js.map +1 -1
  35. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +30 -0
  36. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  37. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +2 -2
  38. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  39. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +5 -1
  40. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
  41. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js +4 -2
  42. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
  43. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js +63 -2
  44. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js.map +1 -1
  45. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js +96 -37
  46. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js.map +1 -1
  47. package/dist/esm/components/CnStagesWidget/stagesModel.js +5 -2
  48. package/dist/esm/components/CnStagesWidget/stagesModel.js.map +1 -1
  49. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +52 -2
  50. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
  51. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js +6 -6
  52. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
  53. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +14 -2
  54. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  55. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js +2 -1
  56. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  57. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js +73 -1
  58. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js.map +1 -1
  59. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js +14 -2
  60. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js.map +1 -1
  61. package/dist/esm/l10n/en.json.js +2 -1
  62. package/dist/esm/l10n/en.json.js.map +1 -1
  63. package/dist/esm/l10n/nl.json.js +2 -1
  64. package/dist/esm/l10n/nl.json.js.map +1 -1
  65. package/dist/esm/nextcloud-vue.css +331 -81
  66. package/dist/esm/utils/schemaRefSlug.js +19 -3
  67. package/dist/esm/utils/schemaRefSlug.js.map +1 -1
  68. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  69. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  70. package/dist/nextcloud-vue.cjs.js +6933 -5958
  71. package/dist/nextcloud-vue.cjs.js.map +2 -2
  72. package/dist/nextcloud-vue.css +331 -81
  73. package/l10n/en.json +2 -0
  74. package/l10n/nl.json +2 -0
  75. package/package.json +1 -1
  76. package/src/components/CnAppNav/CnAppNav.vue +297 -11
  77. package/src/components/CnAppRoot/CnAppRoot.md +4 -0
  78. package/src/components/CnAppRoot/CnAppRoot.vue +94 -1
  79. package/src/components/CnAuditTrailCard/CnAuditTrailCard.vue +61 -16
  80. package/src/components/CnAuditTrailWidget/CnAuditTrailWidget.vue +22 -1
  81. package/src/components/CnCellRenderer/CnCellRenderer.vue +33 -2
  82. package/src/components/CnDashboardPage/CnDashboardPage.vue +43 -2
  83. package/src/components/CnDataTable/CnDataTable.vue +45 -1
  84. package/src/components/CnDetailPage/CnDetailPage.md +4 -0
  85. package/src/components/CnDetailPage/CnDetailPage.vue +60 -1
  86. package/src/components/CnHeaderWidget/CnHeaderWidget.vue +116 -6
  87. package/src/components/CnIndexPage/CnIndexPage.vue +32 -2
  88. package/src/components/CnObjectListWidget/CnObjectListWidget.vue +7 -1
  89. package/src/components/CnStagesWidget/CnStagesWidget.vue +180 -2
  90. package/src/components/CnStagesWidget/stagesModel.js +5 -2
  91. package/src/components/CnStatWidget/CnStatWidget.vue +57 -3
  92. package/src/components/CnTabsWidget/CnTabsWidget.vue +15 -2
  93. package/src/components/CnWidgetWrapper/CnWidgetWrapper.md +4 -0
  94. package/src/components/CnWidgetWrapper/CnWidgetWrapper.vue +107 -1
  95. package/src/css/detail-page.css +10 -4
  96. package/src/css/kpi-card.css +24 -1
  97. package/src/css/table.css +18 -6
  98. package/src/schemas/app-manifest-v2.schema.json +113 -3
  99. package/src/utils/schemaRefSlug.js +19 -3
  100. package/src/utils/validateManifestV2.compiled.js +2 -2
@@ -69,12 +69,27 @@ export default {
69
69
  components: { CnDetailCard, NcLoadingIcon },
70
70
 
71
71
  props: {
72
- /** OpenRegister register id (slug or uuid). */
73
- register: { type: String, required: true },
74
- /** OpenRegister schema id (slug or uuid). */
75
- schema: { type: String, required: true },
76
- /** Parent object id. */
77
- objectId: { type: String, required: true },
72
+ /** OpenRegister register id (slug or uuid). Optional for `scope: "app"`. */
73
+ register: { type: String, default: '' },
74
+ /** OpenRegister schema id (slug or uuid). Optional for `scope: "app"`. */
75
+ schema: { type: String, default: '' },
76
+ /** Parent object id. Required for `scope: "object"` (the default); unused for `scope: "app"`. */
77
+ objectId: { type: String, default: '' },
78
+ /**
79
+ * What the card lists. `object` (the default) is one object's trail
80
+ * and needs `register`, `schema` and `objectId`. `app` is the
81
+ * app-wide feed: the entries of every object the caller may read
82
+ * (OpenRegister's `/audit-trails/readable`), narrowed to `register`
83
+ * and `schema` when those are set.
84
+ *
85
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-the-audit-trail-widget-reads-the-app-wide-feed
86
+ */
87
+ scope: {
88
+ type: String,
89
+ default: 'object',
90
+ validator: (value) => ['object', 'app'].includes(value),
91
+ },
92
+
78
93
  /** Rendering surface — passed for AD-19 surface fallback consumers. */
79
94
  surface: {
80
95
  type: String,
@@ -116,13 +131,38 @@ export default {
116
131
  displayedEntries() {
117
132
  return this.entries.slice(0, this.maxDisplay)
118
133
  },
134
+
135
+ /**
136
+ * The path the card reads (prefixed at fetch time), or '' when it has nothing to read: the
137
+ * object's own trail, or the app-wide readable feed narrowed to the
138
+ * register and schema when set.
139
+ *
140
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-the-audit-trail-widget-reads-the-app-wide-feed
141
+ * @return {string}
142
+ */
143
+ fetchUrl() {
144
+ const params = new URLSearchParams({ limit: String(this.maxDisplay) })
145
+ if (this.scope === 'app') {
146
+ if (this.register) {
147
+ params.set('register', this.register)
148
+ }
149
+ if (this.schema) {
150
+ params.set('schema', this.schema)
151
+ }
152
+ return `${this.apiBase}/audit-trails/readable?${params.toString()}`
153
+ }
154
+ if (!this.register || !this.schema || !this.objectId) {
155
+ return ''
156
+ }
157
+ return `${this.apiBase}/objects/${this.register}/${this.schema}/${this.objectId}/audit-trail?${params.toString()}`
158
+ },
119
159
  },
120
160
 
121
161
  watch: {
122
- objectId: {
162
+ fetchUrl: {
123
163
  immediate: true,
124
- handler(id) {
125
- if (id) {
164
+ handler(url) {
165
+ if (url) {
126
166
  this.fetchEntries()
127
167
  }
128
168
  },
@@ -130,20 +170,25 @@ export default {
130
170
  },
131
171
 
132
172
  methods: {
173
+ /**
174
+ * Read the entries from `fetchUrl`. The readable feed answers
175
+ * `{ rows, nextCursor }`, the object trail `{ results }`.
176
+ *
177
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-the-audit-trail-widget-reads-the-app-wide-feed
178
+ * @return {Promise<void>}
179
+ */
133
180
  async fetchEntries() {
134
- if (!this.register || !this.schema || !this.objectId) {
181
+ const url = this.fetchUrl
182
+ if (!url) {
135
183
  return
136
184
  }
137
185
  this.loading = true
138
186
  try {
139
- const params = new URLSearchParams({ limit: String(this.maxDisplay) })
140
- const response = await fetch(
141
- prefixUrl(`${this.apiBase}/objects/${this.register}/${this.schema}/${this.objectId}/audit-trail?${params.toString()}`),
142
- { headers: buildHeaders() },
143
- )
187
+ const response = await fetch(prefixUrl(url), { headers: buildHeaders() })
144
188
  if (response.ok) {
145
189
  const data = await response.json()
146
- this.entries = data.results || data || []
190
+ const list = data.results || data.rows || data
191
+ this.entries = Array.isArray(list) ? list : []
147
192
  } else {
148
193
  this.entries = []
149
194
  }
@@ -5,10 +5,11 @@
5
5
 
6
6
  <template>
7
7
  <CnAuditTrailCard
8
- v-if="resolvedRegister && resolvedSchema && resolvedObjectId"
8
+ v-if="resolvedScope === 'app' || (resolvedRegister && resolvedSchema && resolvedObjectId)"
9
9
  :register="resolvedRegister"
10
10
  :schema="resolvedSchema"
11
11
  :objectId="resolvedObjectId"
12
+ :scope="resolvedScope"
12
13
  :title="resolvedTitle"
13
14
  :maxDisplay="resolvedMaxDisplay" />
14
15
  </template>
@@ -70,6 +71,15 @@ export default {
70
71
  title: { type: String, default: '' },
71
72
  /** Maximum audit rows to render (0 falls back to the card default). */
72
73
  maxDisplay: { type: Number, default: 0 },
74
+ /**
75
+ * `app` lists the app-wide feed (every object the caller may read,
76
+ * narrowed to `register` / `schema` when set) and needs no object;
77
+ * `object` (the default) is one object's trail. Also read from
78
+ * `content.scope`.
79
+ *
80
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-the-audit-trail-widget-reads-the-app-wide-feed
81
+ */
82
+ scope: { type: String, default: '' },
73
83
  /** Stored widget content blob (CnDashboardPage registry branch): `{ title, maxDisplay, register?, schema?, objectId? }`. */
74
84
  content: { type: Object, default: () => ({}) },
75
85
  },
@@ -115,6 +125,17 @@ export default {
115
125
  return typeof s === 'string' ? s : (s && (s.slug || s.name || s.id)) || ''
116
126
  },
117
127
 
128
+ /**
129
+ * The feed scope: explicit prop → content → `object`.
130
+ *
131
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-the-audit-trail-widget-reads-the-app-wide-feed
132
+ * @return {('object'|'app')}
133
+ */
134
+ resolvedScope() {
135
+ const s = this.scope || this.content.scope
136
+ return s === 'app' ? 'app' : 'object'
137
+ },
138
+
118
139
  /**
119
140
  * The card title: explicit prop → content → '' (card default).
120
141
  *
@@ -177,6 +177,9 @@ import { safeHref } from '../../utils/safeHref.js'
177
177
  import { formatValue } from '../../utils/schema.js'
178
178
  import { CnStatusBadge } from '../CnStatusBadge/index.js'
179
179
 
180
+ /** A uuid in any version, the shape OpenRegister ids take. */
181
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
182
+
180
183
  /**
181
184
  * Module-level set of column keys already warned about for a
182
185
  * `widget:"link"` declaration with no resolvable target — guarantees
@@ -483,13 +486,20 @@ export default {
483
486
  *
484
487
  * @return {string}
485
488
  * @spec openspec/changes/workplace-dashboard-primitives/specs/workplace-dashboard-primitives/spec.md#requirement-avatar-and-date-cells
489
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-small-opt-ins-from-the-decidiq-and-learniq-lanes
486
490
  */
487
491
  dateLabel() {
488
492
  const date = parseDateValue(this.value)
489
493
  if (date === null) {
490
494
  return ''
491
495
  }
492
- const options = { day: 'numeric', month: 'short', year: 'numeric' }
496
+ // `widgetProps.showTime: true` adds the time of day ("5 Oct 2026,
497
+ // 08:30"); `widgetProps.timeOnly: true` shows just the time
498
+ // ("08:30"), for a timetable row. Default: the date alone.
499
+ const props = this.widgetProps || {}
500
+ const options = props.timeOnly === true
501
+ ? { hour: '2-digit', minute: '2-digit' }
502
+ : { day: 'numeric', month: 'short', year: 'numeric', ...(props.showTime === true ? { hour: '2-digit', minute: '2-digit' } : {}) }
493
503
  let locale
494
504
  try {
495
505
  locale = getCanonicalLocale()
@@ -738,6 +748,27 @@ export default {
738
748
  return undefined
739
749
  },
740
750
 
751
+ /**
752
+ * Whether the cell draws a uuid, and so takes the monospace uuid
753
+ * style. The property's `format: "uuid"` alone used to decide this,
754
+ * so a reference that a formatter or a widget had resolved to its
755
+ * name was still drawn as code. Now the value has to be a uuid AND
756
+ * nothing resolves it: a resolving widget or formatter, or a value
757
+ * that is not a uuid, renders in the normal text style.
758
+ *
759
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-resolved-reference-is-not-drawn-as-a-uuid
760
+ * @return {boolean}
761
+ */
762
+ showsUuid() {
763
+ if (this.property?.format !== 'uuid') {
764
+ return false
765
+ }
766
+ if (this.widgetComponent || this.widget || this.hasFormatter) {
767
+ return false
768
+ }
769
+ return typeof this.value === 'string' && UUID_PATTERN.test(this.value)
770
+ },
771
+
741
772
  cellClass() {
742
773
  const classes = []
743
774
  if (this.propertyType === 'boolean') {
@@ -749,7 +780,7 @@ export default {
749
780
  if (this.property?.format === 'date-time' || this.property?.format === 'date') {
750
781
  classes.push('cn-cell-renderer--date')
751
782
  }
752
- if (this.property?.format === 'uuid') {
783
+ if (this.showsUuid) {
753
784
  classes.push('cn-cell-renderer--uuid')
754
785
  }
755
786
  if (this.propertyType === 'integer' || this.propertyType === 'number') {
@@ -11,8 +11,14 @@
11
11
  -->
12
12
  <template>
13
13
  <div class="cn-dashboard-page" data-testid="cn-dashboard-page">
14
- <!-- Header -->
15
- <div class="cn-dashboard-page__header" data-testid="cn-dashboard-page-header">
14
+ <!-- Header. `showHeader: false` drops the whole row (title, description,
15
+ header actions, edit toggle) for a page that opens with its own
16
+ heading, such as a greeting; the title then stays as a visually
17
+ hidden heading so the main landmark keeps its name. -->
18
+ <h2 v-if="!showHeader && title" class="hidden-visually" data-testid="cn-dashboard-page-hidden-title">
19
+ {{ resolvedTitle }}
20
+ </h2>
21
+ <div v-if="showHeader" class="cn-dashboard-page__header" data-testid="cn-dashboard-page-header">
16
22
  <div class="cn-dashboard-page__header-left">
17
23
  <div v-if="title || $slots['title-meta']" class="cn-dashboard-page__title-row">
18
24
  <h2 v-if="title" class="cn-dashboard-page__title">
@@ -320,6 +326,7 @@
320
326
  :borderless="widgetBorderless(item)"
321
327
  :flush="item.flush !== false"
322
328
  :buttons="getWidgetButtons(item)"
329
+ :headerLink="getWidgetHeaderLink(item)"
323
330
  :styleConfig="item.styleConfig || {}"
324
331
  :titleIconPosition="getWidgetTitleIconPosition(item)"
325
332
  :titleIconColor="getWidgetTitleIconColor(item)"
@@ -400,6 +407,7 @@
400
407
  :borderless="widgetBorderless(item)"
401
408
  :flush="item.flush !== false"
402
409
  :buttons="getWidgetButtons(item)"
410
+ :headerLink="getWidgetHeaderLink(item)"
403
411
  :styleConfig="item.styleConfig || {}"
404
412
  :titleIconPosition="getWidgetTitleIconPosition(item)"
405
413
  :titleIconColor="getWidgetTitleIconColor(item)"
@@ -478,6 +486,7 @@
478
486
  :flush="item.flush !== false"
479
487
  :class="{ 'cn-dashboard-page__card-fit': isCardWidget(item) }"
480
488
  :buttons="getWidgetButtons(item)"
489
+ :headerLink="getWidgetHeaderLink(item)"
481
490
  :styleConfig="item.styleConfig || {}"
482
491
  :documentationUrl="getWidgetDocumentationUrl(item)"
483
492
  :docsAnchor="getWidgetDocsAnchor(item)"
@@ -502,6 +511,7 @@
502
511
  :borderless="widgetBorderless(item)"
503
512
  :flush="item.flush !== false"
504
513
  :buttons="getWidgetButtons(item)"
514
+ :headerLink="getWidgetHeaderLink(item)"
505
515
  :styleConfig="item.styleConfig || {}"
506
516
  :titleIconPosition="getWidgetTitleIconPosition(item)"
507
517
  :titleIconColor="getWidgetTitleIconColor(item)"
@@ -537,6 +547,7 @@
537
547
  :iconClass="getWidgetIconClass(item)"
538
548
  :showTitle="widgetShowTitle(item)"
539
549
  :buttons="getWidgetButtons(item)"
550
+ :headerLink="getWidgetHeaderLink(item)"
540
551
  :styleConfig="item.styleConfig || {}"
541
552
  :showRefresh="getWidgetShowRefresh(item)"
542
553
  :documentationUrl="getWidgetDocumentationUrl(item)"
@@ -566,6 +577,7 @@
566
577
  :titleIconVariant="getWidgetTitleIconVariant(item)"
567
578
  :class="{ 'cn-dashboard-page__card-fit': isCardWidget(item) }"
568
579
  :buttons="getWidgetButtons(item)"
580
+ :headerLink="getWidgetHeaderLink(item)"
569
581
  :styleConfig="item.styleConfig || {}"
570
582
  :documentationUrl="getWidgetDocumentationUrl(item)"
571
583
  :docsAnchor="getWidgetDocsAnchor(item)"
@@ -921,6 +933,19 @@ export default {
921
933
  default: '',
922
934
  },
923
935
 
936
+ /**
937
+ * Whether the header row renders: title, description, header actions
938
+ * and the edit toggle. `false` (manifest `config.showHeader: false`)
939
+ * drops it for a page whose first widget is its heading; the title
940
+ * then renders visually hidden so the page keeps an accessible name.
941
+ *
942
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-dashboard-page-can-hide-its-header
943
+ */
944
+ showHeader: {
945
+ type: Boolean,
946
+ default: true,
947
+ },
948
+
924
949
  /**
925
950
  * Widget definitions array. Each widget defines metadata for rendering.
926
951
  *
@@ -3410,6 +3435,22 @@ export default {
3410
3435
  return def?.buttons || []
3411
3436
  },
3412
3437
 
3438
+ /**
3439
+ * The widget's header text link (`headerLink` on the layout entry,
3440
+ * else on the widget definition), forwarded to CnWidgetWrapper.
3441
+ *
3442
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-widget-header-carries-a-text-link
3443
+ * @param {object} item Layout placement entry.
3444
+ * @return {object|null}
3445
+ */
3446
+ getWidgetHeaderLink(item) {
3447
+ if (item?.headerLink && typeof item.headerLink === 'object') {
3448
+ return item.headerLink
3449
+ }
3450
+ const def = this.getWidgetDef(item.widgetId)
3451
+ return (def?.headerLink && typeof def.headerLink === 'object') ? def.headerLink : null
3452
+ },
3453
+
3413
3454
  /**
3414
3455
  * Whether a widget's overflow Actions menu shows the Refresh item.
3415
3456
  * An explicit per-widget flag wins — on the widget definition or
@@ -278,6 +278,14 @@
278
278
  :row="row"
279
279
  :rowKey="rowKey" />
280
280
  </slot>
281
+ <!-- A second, muted line under the value, from `col.secondary`:
282
+ a field key, a "{field} · {other}" template, or a function
283
+ of the row. Lets one column read "title" over "number ·
284
+ requester" without a custom cell. -->
285
+ <span
286
+ v-if="secondaryValue(row, col)"
287
+ class="cn-table-cell__secondary"
288
+ data-testid="cn-cell-secondary">{{ secondaryValue(row, col) }}</span>
281
289
  </td>
282
290
 
283
291
  <!-- Row actions -->
@@ -444,7 +452,13 @@ export default {
444
452
  * reader is looking. `columnsFromSchema` fills it from the JSON Schema property
445
453
  * description automatically, so schema-driven tables get it for free.
446
454
  *
447
- * @type {Array<{key: string, label: string, description: string, sortable: boolean, width: string, class: string, cellClass: string}|string>}
455
+ * `secondary` draws a second, muted line under the cell's value: a field
456
+ * key (`"number"`), a template with `{field}` placeholders
457
+ * (`"{number} · {requester}"`, dotted paths allowed), or a function of
458
+ * the row. Empty fields leave their placeholder blank; a line that
459
+ * resolves to nothing is not drawn.
460
+ *
461
+ * @type {Array<{key: string, label: string, description: string, sortable: boolean, width: string, class: string, cellClass: string, secondary: (string|((row: object) => string))}|string>}
448
462
  */
449
463
  columns: {
450
464
  type: Array,
@@ -1415,6 +1429,36 @@ export default {
1415
1429
  * @param {object} col The column definition.
1416
1430
  * @return {unknown} The value handed to the slot / CnCellRenderer.
1417
1431
  */
1432
+ /**
1433
+ * The secondary line of a cell, from `col.secondary`: a function is
1434
+ * called with the row; a string with `{field}` placeholders is a
1435
+ * template filled from the row (dotted paths allowed); any other
1436
+ * string is a field key. Returns '' when there is nothing to draw.
1437
+ *
1438
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-table-column-carries-a-secondary-line
1439
+ * @param {object} row The row.
1440
+ * @param {object} col The column definition.
1441
+ * @return {string} The secondary text, or ''.
1442
+ */
1443
+ secondaryValue(row, col) {
1444
+ const spec = col?.secondary
1445
+ if (typeof spec === 'function') {
1446
+ const out = spec(row)
1447
+ return out === null || out === undefined ? '' : String(out)
1448
+ }
1449
+ if (typeof spec !== 'string' || spec === '') {
1450
+ return ''
1451
+ }
1452
+ const asText = (v) => (v === null || v === undefined ? '' : String(v))
1453
+ if (spec.includes('{')) {
1454
+ const filled = spec.replace(/\{([^}]+)\}/g, (_, key) => asText(this.getCellValue(row, key.trim())))
1455
+ // A template whose every field is empty leaves only separators:
1456
+ // nothing worth a line.
1457
+ return /[\p{L}\p{N}]/u.test(filled) ? filled.trim() : ''
1458
+ }
1459
+ return asText(this.getCellValue(row, spec))
1460
+ },
1461
+
1418
1462
  cellValue(row, col) {
1419
1463
  if (col && col.aggregate) {
1420
1464
  const cached = this.aggregateValues[String(row[this.rowKey])]
@@ -430,3 +430,7 @@ Four levels of action and the case surfaces around them. All opt in. See [the re
430
430
  ## Not found
431
431
 
432
432
  When the schema-driven fetch answers 404 the page shows a not-found state whose back button is a router link to `notFoundRoute` (default `null`, which means the app root `/`). `notFoundRouteLabel` names that page in the button's "Back to …" label.
433
+
434
+ ### Type eyebrow and breadcrumb
435
+
436
+ `showTypeEyebrow` (default `true`) can drop the type label above the record name. `breadcrumb` (`{ label, route?, params?, href? }`) draws a breadcrumb line above the header: that crumb, then the record's name as the current crumb.
@@ -41,6 +41,13 @@
41
41
  {{ skipLinkLabel }}
42
42
  </a>
43
43
 
44
+ <!-- Breadcrumb line (manifest `config.breadcrumb`): the list the record
45
+ belongs to, then the record itself as the current crumb. -->
46
+ <CnBreadcrumbs
47
+ v-if="breadcrumbCrumbs.length > 0 && !objectNotFound"
48
+ class="cn-detail-page__breadcrumbs"
49
+ :crumbs="breadcrumbCrumbs"
50
+ data-testid="cn-detail-page-breadcrumbs" />
44
51
  <!-- Header -->
45
52
  <div v-if="!objectNotFound" class="cn-detail-page__header" data-testid="cn-detail-page-header">
46
53
  <!-- Header (left block) — overridable via #header slot. Default
@@ -98,7 +105,7 @@
98
105
  (ADR-062). Only shown once the object resolves to a
99
106
  display name that differs from the type label. -->
100
107
  <p
101
- v-if="typeEyebrow"
108
+ v-if="typeEyebrow && showTypeEyebrow"
102
109
  class="cn-detail-page__type-eyebrow"
103
110
  data-testid="cn-detail-page-type-eyebrow">
104
111
  {{ typeEyebrow }}
@@ -1025,6 +1032,7 @@ import {
1025
1032
  widgetTitleOf,
1026
1033
  } from '../../utils/widgetDispatch.js'
1027
1034
  import { CnActionButtons } from '../CnActionButtons/index.js'
1035
+ import { CnBreadcrumbs } from '../CnBreadcrumbs/index.js'
1028
1036
  import { CnIcon } from '../CnIcon/index.js'
1029
1037
  import { CnStatusBadge } from '../CnStatusBadge/index.js'
1030
1038
  import { getWidgetTypeEntry } from '../CnWidgetGrid/dashboardWidgetRegistry.js'
@@ -1172,6 +1180,7 @@ export default {
1172
1180
  CnDashboardGrid,
1173
1181
  CnLifecycleActions,
1174
1182
  CnActionButtons,
1183
+ CnBreadcrumbs,
1175
1184
  CnStatusBadge,
1176
1185
  CnSummaryAggregates,
1177
1186
  CnRelatedCollections,
@@ -2078,6 +2087,34 @@ export default {
2078
2087
  default: null,
2079
2088
  },
2080
2089
 
2090
+ /**
2091
+ * Whether the type eyebrow (the type label above the record name)
2092
+ * renders once the record resolves. `false` (manifest
2093
+ * `config.showTypeEyebrow: false`) drops it for a header that says
2094
+ * the type in a pill instead.
2095
+ *
2096
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-detail-page-drops-its-eyebrow-and-shows-a-breadcrumb
2097
+ */
2098
+ showTypeEyebrow: {
2099
+ type: Boolean,
2100
+ default: true,
2101
+ },
2102
+
2103
+ /**
2104
+ * A breadcrumb line above the header (manifest `config.breadcrumb`):
2105
+ * `{ label, route?, params?, href? }` names the list the record
2106
+ * belongs to; the record's display name follows as the current
2107
+ * crumb. `label` goes through the host translate function. Null (the
2108
+ * default) draws no trail.
2109
+ *
2110
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-detail-page-drops-its-eyebrow-and-shows-a-breadcrumb
2111
+ * @type {{label: string, route?: string, params?: object, href?: string}|null}
2112
+ */
2113
+ breadcrumb: {
2114
+ type: Object,
2115
+ default: null,
2116
+ },
2117
+
2081
2118
  /**
2082
2119
  * A column of cards beside the body (manifest `config.sideColumn`),
2083
2120
  * for the record's facts: deadline, requester, handler, links. Each
@@ -3201,6 +3238,28 @@ export default {
3201
3238
  return this.objectDisplayName || this.resolvedTitle
3202
3239
  },
3203
3240
 
3241
+ /**
3242
+ * The breadcrumb trail: the declared crumb (a router target from
3243
+ * `route` + `params`, or an `href`), then the record's display name
3244
+ * as the current crumb. Empty without a `breadcrumb` label.
3245
+ *
3246
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-detail-page-drops-its-eyebrow-and-shows-a-breadcrumb
3247
+ * @return {Array<{label: string, to?: object, href?: string}>}
3248
+ */
3249
+ breadcrumbCrumbs() {
3250
+ const crumb = this.breadcrumb
3251
+ if (!crumb || typeof crumb.label !== 'string' || crumb.label === '') {
3252
+ return []
3253
+ }
3254
+ const first = { label: this.effectiveTranslate(crumb.label) }
3255
+ if (crumb.route) {
3256
+ first.to = { name: crumb.route, ...(crumb.params ? { params: crumb.params } : {}) }
3257
+ } else if (crumb.href) {
3258
+ first.href = crumb.href
3259
+ }
3260
+ return [first, { label: this.displayTitle }]
3261
+ },
3262
+
3204
3263
  /**
3205
3264
  * Effective translate function: the injected `cnTranslate` (the host
3206
3265
  * app's bound `t()`), identity by default.