@conduction/nextcloud-vue 2.63.0 → 2.65.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 (111) hide show
  1. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +253 -10
  2. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
  3. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js +139 -46
  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/CnBreadcrumbs/CnBreadcrumbs.vue2.js +13 -0
  17. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue2.js.map +1 -1
  18. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js +2 -1
  19. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js.map +1 -1
  20. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js +33 -2
  21. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue2.js.map +1 -1
  22. package/dist/esm/components/CnCellRenderer/CnCellRenderer.vue3.js.map +1 -1
  23. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +55 -2
  24. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  25. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +153 -133
  26. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  27. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +37 -1
  28. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  29. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +12 -4
  30. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  31. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +125 -14
  32. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  33. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +87 -40
  34. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  35. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js +101 -7
  36. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js.map +1 -1
  37. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js +22 -5
  38. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js.map +1 -1
  39. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +30 -0
  40. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  41. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +2 -2
  42. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  43. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +5 -1
  44. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
  45. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js +4 -2
  46. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
  47. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js +63 -2
  48. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js.map +1 -1
  49. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js +96 -37
  50. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js.map +1 -1
  51. package/dist/esm/components/CnStagesWidget/stagesModel.js +5 -2
  52. package/dist/esm/components/CnStagesWidget/stagesModel.js.map +1 -1
  53. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +74 -5
  54. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
  55. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js +15 -8
  56. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
  57. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js +14 -2
  58. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue2.js.map +1 -1
  59. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js +2 -1
  60. package/dist/esm/components/CnTabsWidget/CnTabsWidget.vue3.js.map +1 -1
  61. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js +73 -1
  62. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue2.js.map +1 -1
  63. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js +14 -2
  64. package/dist/esm/components/CnWidgetWrapper/CnWidgetWrapper.vue3.js.map +1 -1
  65. package/dist/esm/l10n/en.json.js +2 -1
  66. package/dist/esm/l10n/en.json.js.map +1 -1
  67. package/dist/esm/l10n/nl.json.js +2 -1
  68. package/dist/esm/l10n/nl.json.js.map +1 -1
  69. package/dist/esm/nextcloud-vue.css +495 -81
  70. package/dist/esm/store/useObjectStore.js +118 -33
  71. package/dist/esm/store/useObjectStore.js.map +1 -1
  72. package/dist/esm/utils/actionsDispatcher.js +14 -0
  73. package/dist/esm/utils/actionsDispatcher.js.map +1 -1
  74. package/dist/esm/utils/schemaRefSlug.js +19 -3
  75. package/dist/esm/utils/schemaRefSlug.js.map +1 -1
  76. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  77. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  78. package/dist/nextcloud-vue.cjs.js +7388 -6021
  79. package/dist/nextcloud-vue.cjs.js.map +2 -2
  80. package/dist/nextcloud-vue.css +495 -81
  81. package/l10n/en.json +2 -0
  82. package/l10n/nl.json +2 -0
  83. package/package.json +1 -1
  84. package/src/components/CnAppNav/CnAppNav.vue +441 -14
  85. package/src/components/CnAppRoot/CnAppRoot.md +4 -0
  86. package/src/components/CnAppRoot/CnAppRoot.vue +94 -1
  87. package/src/components/CnAuditTrailCard/CnAuditTrailCard.vue +61 -16
  88. package/src/components/CnAuditTrailWidget/CnAuditTrailWidget.vue +22 -1
  89. package/src/components/CnBreadcrumbs/CnBreadcrumbs.vue +14 -0
  90. package/src/components/CnCellRenderer/CnCellRenderer.vue +33 -2
  91. package/src/components/CnDashboardPage/CnDashboardPage.vue +72 -4
  92. package/src/components/CnDataTable/CnDataTable.vue +45 -1
  93. package/src/components/CnDetailPage/CnDetailPage.md +8 -0
  94. package/src/components/CnDetailPage/CnDetailPage.vue +169 -2
  95. package/src/components/CnHeaderWidget/CnHeaderWidget.vue +147 -9
  96. package/src/components/CnIndexPage/CnIndexPage.vue +32 -2
  97. package/src/components/CnObjectListWidget/CnObjectListWidget.vue +7 -1
  98. package/src/components/CnStagesWidget/CnStagesWidget.vue +189 -2
  99. package/src/components/CnStagesWidget/stagesModel.js +5 -2
  100. package/src/components/CnStatWidget/CnStatWidget.vue +89 -7
  101. package/src/components/CnTabsWidget/CnTabsWidget.vue +15 -2
  102. package/src/components/CnWidgetWrapper/CnWidgetWrapper.md +4 -0
  103. package/src/components/CnWidgetWrapper/CnWidgetWrapper.vue +107 -1
  104. package/src/css/detail-page.css +32 -4
  105. package/src/css/kpi-card.css +71 -1
  106. package/src/css/table.css +18 -6
  107. package/src/schemas/app-manifest-v2.schema.json +134 -3
  108. package/src/store/useObjectStore.js +118 -33
  109. package/src/utils/actionsDispatcher.js +14 -0
  110. package/src/utils/schemaRefSlug.js +19 -3
  111. package/src/utils/validateManifestV2.compiled.js +2 -2
@@ -35,7 +35,45 @@
35
35
  {{ tr('No stages to show') }}
36
36
  </p>
37
37
  <template v-else>
38
+ <!-- The bars variant (`variant: "bars"`): equal columns, a thin bar
39
+ per step coloured by state, the label under it and an optional
40
+ date line. No description: eight Woo steps with their
41
+ descriptions crowded into five lines and clipped the first
42
+ label. The current step says so for a screen reader. -->
43
+ <ol
44
+ v-if="isBars"
45
+ class="cn-stages-widget__bars"
46
+ :aria-label="ariaLabel"
47
+ data-testid="cn-stages-widget-bars">
48
+ <li
49
+ v-for="stage in barStages"
50
+ :key="stage.id"
51
+ class="cn-stages-widget__bar-step"
52
+ :class="`cn-stages-widget__bar-step--${stage.state}`"
53
+ :aria-current="stage.state === 'current' ? 'step' : null"
54
+ :data-testid="`cn-stages-widget-bar-${stage.id}`">
55
+ <span class="cn-stages-widget__bar" aria-hidden="true" />
56
+ <component
57
+ :is="stage.clickable ? 'button' : 'span'"
58
+ :type="stage.clickable ? 'button' : null"
59
+ class="cn-stages-widget__bar-label"
60
+ :title="stage.hint || stage.label"
61
+ :aria-disabled="stage.disabled && stage.clickable ? 'true' : null"
62
+ :data-testid="`cn-stages-widget-stage-${stage.id}`"
63
+ @click="onBarClick(stage)">
64
+ {{ stage.label }}<span v-if="stage.state === 'current'" class="cn-stages-widget__sr-only"> ({{ tr('current step') }})</span>
65
+ </component>
66
+ <span v-if="stage.date" class="cn-stages-widget__bar-date">{{ stage.date }}</span>
67
+ <span
68
+ v-if="stage.reason"
69
+ :class="stage.reasonVisible ? 'cn-stages-widget__reason' : 'cn-stages-widget__sr-only'"
70
+ :data-testid="`cn-stages-widget-reason-${stage.id}`">
71
+ {{ stage.reason }}
72
+ </span>
73
+ </li>
74
+ </ol>
38
75
  <CnTimelineStages
76
+ v-else
39
77
  :stages="timelineStages"
40
78
  :currentStage="currentStageId"
41
79
  :orientation="orientation"
@@ -310,13 +348,15 @@ export default {
310
348
  * `currentField`, `stagesEndpoint` or `stagesSource`, `transition`
311
349
  * (`{ kind: 'lifecycle' }` by default, or `{ kind: 'field' }`, or
312
350
  * absent for a read-only strip), `unreachableReason`, `orientation`,
313
- * `size`, `ariaLabel` and `label`.
351
+ * `size`, `ariaLabel`, `label` and `variant` (`"bars"` draws a thin
352
+ * coloured bar per step with the label under it and no description;
353
+ * the source's `dateField` adds a date line).
314
354
  *
315
355
  * `ariaLabel` names the strip for a screen reader. It falls back to
316
356
  * `label`, the placement's own title, so a titled card does not have to
317
357
  * repeat itself, and then to "Stages".
318
358
  *
319
- * @type {{currentField?: string, stagesEndpoint?: object, stagesSource?: object, transition?: object, unreachableReason?: string, orientation?: ('horizontal'|'vertical'), size?: ('medium'|'small'), ariaLabel?: string, label?: string}}
359
+ * @type {{currentField?: string, stagesEndpoint?: object, stagesSource?: object, transition?: object, unreachableReason?: string, orientation?: ('horizontal'|'vertical'), size?: ('medium'|'small'), ariaLabel?: string, label?: string, variant?: ('dots'|'bars')}}
320
360
  */
321
361
  content: {
322
362
  type: Object,
@@ -723,6 +763,45 @@ export default {
723
763
  return label ? this.effectiveTranslate(label) : this.tr('Stages')
724
764
  },
725
765
 
766
+ /**
767
+ * Whether the bars variant is on (`content.variant: "bars"`). Off by
768
+ * default: the dot strip renders as it always has.
769
+ *
770
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-stages-widget-draws-bars
771
+ * @return {boolean}
772
+ */
773
+ isBars() {
774
+ return this.content.variant === 'bars'
775
+ },
776
+
777
+ /**
778
+ * The stages for the bars variant: each with its `state` (`done`
779
+ * before the current stage, `current`, `todo` after it, every stage
780
+ * `todo` when none is current), its `date`, and whether it is a
781
+ * button (the strip is interactive and the stage is not the current
782
+ * one).
783
+ *
784
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-stages-widget-draws-bars
785
+ * @return {Array<object>}
786
+ */
787
+ barStages() {
788
+ const current = this.stages.findIndex((s) => s.id === this.currentStageId)
789
+ return this.timelineStages.map((stage, index) => {
790
+ let state = 'todo'
791
+ if (current >= 0 && index < current) {
792
+ state = 'done'
793
+ } else if (index === current) {
794
+ state = 'current'
795
+ }
796
+ return {
797
+ ...stage,
798
+ date: this.stages[index]?.date || '',
799
+ state,
800
+ clickable: this.interactive && state !== 'current',
801
+ }
802
+ })
803
+ },
804
+
726
805
  /** @return {'horizontal'|'vertical'} The layout direction. */
727
806
  orientation() {
728
807
  return this.content.orientation === 'vertical' ? 'vertical' : 'horizontal'
@@ -969,6 +1048,26 @@ export default {
969
1048
  * @param {{stage: {id: string}}} payload The CnTimelineStages event.
970
1049
  * @return {void}
971
1050
  */
1051
+ /**
1052
+ * A click on a bar-variant step: a disabled step reports why (as
1053
+ * CnTimelineStages does through `stage-blocked`), any other goes
1054
+ * through the shared move path.
1055
+ *
1056
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-stages-widget-draws-bars
1057
+ * @param {object} stage The bar step.
1058
+ * @return {void}
1059
+ */
1060
+ onBarClick(stage) {
1061
+ if (!stage.clickable) {
1062
+ return
1063
+ }
1064
+ if (stage.disabled) {
1065
+ this.onStageBlocked({ stage })
1066
+ return
1067
+ }
1068
+ this.onStageClick({ stage })
1069
+ },
1070
+
972
1071
  onStageClick({ stage }) {
973
1072
  if (!this.canMove || !stage) {
974
1073
  return
@@ -1340,6 +1439,94 @@ export default {
1340
1439
  color: var(--color-text-maxcontrast);
1341
1440
  }
1342
1441
 
1442
+ /* The bars variant: equal columns, a thin bar per step. Theme hooks:
1443
+ --cn-stages-bar-height, --cn-stages-bar-radius, --cn-stages-bar-done,
1444
+ --cn-stages-bar-current, --cn-stages-bar-todo. */
1445
+ .cn-stages-widget__bars {
1446
+ display: grid;
1447
+ grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
1448
+ grid-auto-flow: column;
1449
+ gap: 8px;
1450
+ margin: 0;
1451
+ padding: 0;
1452
+ list-style: none;
1453
+ }
1454
+
1455
+ .cn-stages-widget__bar-step {
1456
+ display: flex;
1457
+ flex-direction: column;
1458
+ gap: 8px;
1459
+ min-width: 0;
1460
+ }
1461
+
1462
+ .cn-stages-widget__bar {
1463
+ display: block;
1464
+ height: var(--cn-stages-bar-height, 6px);
1465
+ border-radius: var(--cn-stages-bar-radius, 3px);
1466
+ background: var(--cn-stages-bar-todo, var(--color-border));
1467
+ }
1468
+
1469
+ .cn-stages-widget__bar-step--done .cn-stages-widget__bar {
1470
+ background: var(--cn-stages-bar-done, var(--color-primary-element));
1471
+ }
1472
+
1473
+ .cn-stages-widget__bar-step--current .cn-stages-widget__bar {
1474
+ background: var(--cn-stages-bar-current, var(--color-error));
1475
+ }
1476
+
1477
+ .cn-stages-widget__bar-label {
1478
+ display: block;
1479
+ margin: 0;
1480
+ padding: 0;
1481
+ border: 0;
1482
+ background: none;
1483
+ color: var(--color-text-maxcontrast);
1484
+ font: inherit;
1485
+ font-size: 14px;
1486
+ text-align: start;
1487
+ overflow: hidden;
1488
+ text-overflow: ellipsis;
1489
+ white-space: nowrap;
1490
+ }
1491
+
1492
+ /* A clickable label is a <button>, and Nextcloud's core stylesheet gives
1493
+ every `button:not(.button-vue)` a min-height of the clickable area (34 to
1494
+ 44px) with the text centred in it. That pushed each reachable stage's label
1495
+ about 12px below the current one, which is a <span>. Zeroed here so all
1496
+ labels sit 8px under their bar, as the board draws them. The visually
1497
+ hidden "(current step)" is absolutely positioned and takes no space. */
1498
+ button.cn-stages-widget__bar-label {
1499
+ min-height: 0;
1500
+ height: auto;
1501
+ line-height: inherit;
1502
+ cursor: pointer;
1503
+ }
1504
+
1505
+ button.cn-stages-widget__bar-label[aria-disabled="true"] {
1506
+ cursor: not-allowed;
1507
+ }
1508
+
1509
+ button.cn-stages-widget__bar-label:focus-visible {
1510
+ outline: 2px solid var(--color-primary-element);
1511
+ outline-offset: 2px;
1512
+ border-radius: var(--border-radius-small, 4px);
1513
+ }
1514
+
1515
+ .cn-stages-widget__bar-step--done .cn-stages-widget__bar-label {
1516
+ color: var(--color-main-text);
1517
+ font-weight: 600;
1518
+ }
1519
+
1520
+ .cn-stages-widget__bar-step--current .cn-stages-widget__bar-label {
1521
+ color: var(--color-main-text);
1522
+ font-weight: 700;
1523
+ }
1524
+
1525
+ .cn-stages-widget__bar-date {
1526
+ font-size: 12px;
1527
+ color: var(--color-text-maxcontrast);
1528
+ }
1529
+
1343
1530
  /* Text for screen readers only. */
1344
1531
  .cn-stages-widget__sr-only {
1345
1532
  position: absolute;
@@ -85,8 +85,9 @@ export function isTrue(value) {
85
85
  * the rows keep the order they arrived in.
86
86
  *
87
87
  * @param {Array<object>} rows The stage rows.
88
- * @param {{idField?: string, labelField?: string, descriptionField?: string, orderField?: string, finalField?: string}} [cfg] The field mapping.
89
- * @return {Array<{id: string, label: string, subtitle: string, final: boolean}>} The stages.
88
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-stages-widget-draws-bars
89
+ * @param {{idField?: string, labelField?: string, descriptionField?: string, dateField?: string, orderField?: string, finalField?: string}} [cfg] The field mapping.
90
+ * @return {Array<{id: string, label: string, subtitle: string, date: string, final: boolean}>} The stages.
90
91
  */
91
92
  export function normalizeStages(rows, cfg = {}) {
92
93
  const list = Array.isArray(rows) ? rows.filter((r) => r && typeof r === 'object') : []
@@ -108,6 +109,8 @@ export function normalizeStages(rows, cfg = {}) {
108
109
  id: rowId(row, cfg.idField),
109
110
  label,
110
111
  subtitle: cfg.descriptionField ? labelText(getByPath(row, cfg.descriptionField)) : '',
112
+ // An optional date line (`dateField`), drawn by the bars variant.
113
+ date: cfg.dateField ? labelText(getByPath(row, cfg.dateField)) : '',
111
114
  final: cfg.finalField ? isTrue(getByPath(row, cfg.finalField)) : false,
112
115
  }
113
116
  })
@@ -25,7 +25,7 @@
25
25
  ]"
26
26
  v-bind="linkAttrs">
27
27
  <div
28
- v-if="resolvedIcon"
28
+ v-if="resolvedIcon && !isStacked"
29
29
  class="cn-kpi-card__icon cn-stat-widget__icon"
30
30
  :style="iconCircleStyle">
31
31
  <CnWidgetIcon :name="resolvedIcon" :size="24" />
@@ -94,10 +94,23 @@
94
94
  {{ formattedTrend }}
95
95
  </span>
96
96
  </template>
97
- <span v-if="!displayLoading && !displayError && content.caption" class="cn-kpi-card__label cn-stat-widget__caption">
97
+ <span
98
+ v-if="!isStacked && !displayLoading && !displayError && resolvedCaption"
99
+ class="cn-kpi-card__label cn-stat-widget__caption"
100
+ :class="captionVariantClass"
101
+ data-testid="cn-stat-widget-caption">
98
102
  {{ resolvedCaption }}
99
103
  </span>
100
104
  </div>
105
+ <!-- Stacked (`content.layout: "stacked"`): the caption is a line of
106
+ its own under the number, as the board's "3 new this week". -->
107
+ <span
108
+ v-if="isStacked && !displayLoading && !displayError && resolvedCaption"
109
+ class="cn-kpi-card__label cn-stat-widget__caption cn-kpi-card__caption-line"
110
+ :class="captionVariantClass"
111
+ data-testid="cn-stat-widget-caption">
112
+ {{ resolvedCaption }}
113
+ </span>
101
114
  </div>
102
115
  </component>
103
116
  </template>
@@ -461,7 +474,7 @@ export default {
461
474
  * counted between LOCAL CALENDAR DAYS, not as elapsed milliseconds, so
462
475
  * the time of day on a deadline never moves the answer.
463
476
  *
464
- * @type {{label?: string, icon?: string, iconColor?: string, valueColor?: string, caption?: string, route?: (object|string), clickRoute?: (object|string), link?: string, format?: {style?: string, currency?: string, decimals?: number, prefix?: string, suffix?: string}, source?: {kind?: string, register?: string, schema?: string, metric?: string, field?: string, filter?: object, url?: string, path?: string, params?: object}, endpointSource?: {url: string, method?: string, params?: object, responsePath?: string}, valueField?: string, limitField?: string, limit?: number, dateRange?: {presets?: Array<{id: string, label?: string, from?: string, to?: string}>, default?: string}, previousField?: string, deltaField?: string, goodDirection?: ('up'|'down'), variant?: ('default'|'primary'|'success'|'warning'|'error'|'danger'), variantWhen?: Array<{op: string, value: unknown, variant: string, icon?: string}>, objectField?: (string|{field: string, resolve?: {register: string, schema: string, labelField?: string, variantField?: string, variantMap?: {[key: string]: string}}}), display?: ('text'|'badge'|'countdown'), countdown?: {unit?: 'days', warnAt?: number, dangerAt?: number, futureLabel?: string, todayLabel?: string, pastLabel?: string, emptyText?: string}, emptyText?: string, overrides?: Array<{when: {field: string, op?: string, value?: unknown}, label?: string, variant?: string, icon?: string}>}}
477
+ * @type {{label?: string, icon?: string, iconColor?: string, valueColor?: string, caption?: string, route?: (object|string), clickRoute?: (object|string), link?: string, format?: {style?: string, currency?: string, decimals?: number, prefix?: string, suffix?: string}, source?: {kind?: string, register?: string, schema?: string, metric?: string, field?: string, filter?: object, url?: string, path?: string, params?: object}, endpointSource?: {url: string, method?: string, params?: object, responsePath?: string}, valueField?: string, limitField?: string, limit?: number, dateRange?: {presets?: Array<{id: string, label?: string, from?: string, to?: string}>, default?: string}, previousField?: string, deltaField?: string, goodDirection?: ('up'|'down'), variant?: ('default'|'primary'|'success'|'warning'|'error'|'danger'), variantWhen?: Array<{op: string, value: unknown, variant: string, icon?: string}>, objectField?: (string|{field: string, resolve?: {register: string, schema: string, labelField?: string, variantField?: string, variantMap?: {[key: string]: string}}}), display?: ('text'|'badge'|'countdown'), countdown?: {unit?: 'days', warnAt?: number, dangerAt?: number, futureLabel?: string, todayLabel?: string, pastLabel?: string, emptyText?: string}, emptyText?: string, captionVariant?: ('default'|'primary'|'success'|'warning'|'error'|'danger'), captionVariantWhen?: Array<{op: string, value: unknown, variant: string}>, overrides?: Array<{when: {field: string, op?: string, value?: unknown}, label?: string, variant?: string, icon?: string, caption?: string, captionVariant?: string}>}}
465
478
  */
466
479
  content: {
467
480
  type: Object,
@@ -605,7 +618,10 @@ export default {
605
618
  * @return {string}
606
619
  */
607
620
  resolvedCaption() {
608
- const caption = this.content.caption
621
+ const override = this.activeOverride
622
+ const caption = (override && typeof override.caption === 'string' && override.caption !== '')
623
+ ? override.caption
624
+ : this.content.caption
609
625
  if (!caption) {
610
626
  return ''
611
627
  }
@@ -615,11 +631,58 @@ export default {
615
631
  }
616
632
  const payload = this.endpointMode ? this.epData : null
617
633
  return translated.replace(/\{([A-Za-z0-9_.]+)\}/g, (whole, path) => {
634
+ // `{value}` is the tile's own number, so a caption can read
635
+ // "{value} due today" on a register-counted tile that has no
636
+ // payload to read fields from.
637
+ if (path === 'value' && (payload === null || getByPath(payload, path) === undefined)) {
638
+ return this.formattedValue === undefined || this.formattedValue === null ? '' : String(this.formattedValue)
639
+ }
618
640
  const v = getByPath(payload, path)
619
641
  return (v === undefined || v === null) ? '' : String(v)
620
642
  }).replace(/\s{2,}/g, ' ').trim()
621
643
  },
622
644
 
645
+ /**
646
+ * The first `content.captionVariantWhen` rule the tile's value
647
+ * matches (`[{ op, value, variant }]`, the `variantWhen` shape), or
648
+ * null. Colours the caption on the value without recolouring the
649
+ * tile: "1 due today" turns red while the number stays ink.
650
+ *
651
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-stat-tile-colours-its-caption-by-rule
652
+ * @return {object|null}
653
+ */
654
+ activeCaptionRule() {
655
+ const rules = this.content.captionVariantWhen
656
+ if (!Array.isArray(rules) || rules.length === 0) {
657
+ return null
658
+ }
659
+ const current = this.displayValue
660
+ if (current === null || current === undefined) {
661
+ return null
662
+ }
663
+ return rules.find((r) => r && this.matchesRule(current, r)) || null
664
+ },
665
+
666
+ /**
667
+ * The caption's colour class: the matching override's
668
+ * `captionVariant`, else `content.captionVariant`, as
669
+ * `cn-kpi-card__label--<variant>`. '' for `default` or an unknown
670
+ * variant, so an uncoloured caption is what it always was.
671
+ *
672
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-stat-tile-colours-its-caption-by-rule
673
+ * @return {string}
674
+ */
675
+ captionVariantClass() {
676
+ const override = this.activeOverride
677
+ const variant = (override && override.captionVariant)
678
+ || (this.activeCaptionRule && this.activeCaptionRule.variant)
679
+ || this.content.captionVariant
680
+ if (!variant || variant === 'default' || !Object.hasOwn(VARIANT_COLORS, variant)) {
681
+ return ''
682
+ }
683
+ return `cn-kpi-card__label--${variant === 'danger' ? 'error' : variant}`
684
+ },
685
+
623
686
  /**
624
687
  * The unwrapped detail-page object context for token resolution, or null
625
688
  * on surfaces (dashboards) that don't provide one.
@@ -1188,12 +1251,31 @@ export default {
1188
1251
  /**
1189
1252
  * Card orientation. Horizontal (icon beside the number) is the
1190
1253
  * canonical KPI card; `content.layout: 'vertical'` stacks the icon
1191
- * above a centred number for a tile taller than it is wide.
1254
+ * above a centred number for a tile taller than it is wide;
1255
+ * `content.layout: 'stacked'` is the board look (see `isStacked`).
1192
1256
  *
1193
- * @return {'horizontal'|'vertical'}
1257
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-2/specs/zuiddrecht-pixel-gaps-2/spec.md#requirement-a-stat-tile-can-take-the-stacked-board-look
1258
+ * @return {'horizontal'|'vertical'|'stacked'}
1194
1259
  */
1195
1260
  cardLayout() {
1196
- return (this.content || {}).layout === 'vertical' ? 'vertical' : 'horizontal'
1261
+ const layout = (this.content || {}).layout
1262
+ if (layout === 'vertical' || layout === 'stacked') {
1263
+ return layout
1264
+ }
1265
+ return 'horizontal'
1266
+ },
1267
+
1268
+ /**
1269
+ * Whether the tile takes the stacked board look (`content.layout:
1270
+ * "stacked"`): a plain muted label, the number at 34px in the text
1271
+ * colour, the caption on a line of its own, and no icon circle. Off by
1272
+ * default.
1273
+ *
1274
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-2/specs/zuiddrecht-pixel-gaps-2/spec.md#requirement-a-stat-tile-can-take-the-stacked-board-look
1275
+ * @return {boolean}
1276
+ */
1277
+ isStacked() {
1278
+ return this.cardLayout === 'stacked'
1197
1279
  },
1198
1280
 
1199
1281
  /**
@@ -8,6 +8,7 @@
8
8
  <CnTabs
9
9
  :aria-label="stripLabel"
10
10
  :moreLabel="moreLabel"
11
+ :variant="stripVariant"
11
12
  class="cn-tabs-widget__tabs"
12
13
  @update:activeIndex="onTabChange">
13
14
  <!-- One Actions menu for the whole widget, bound to whichever child
@@ -246,9 +247,10 @@ export default {
246
247
  *
247
248
  * `maxVisibleTabs` caps the strip: later tabs go under "More".
248
249
  * `hideEmpty: true` moves a tab whose count is 0 there too.
249
- * `moreLabel` names that menu.
250
+ * `moreLabel` names that menu. `variant: "segmented"` draws the strip
251
+ * as a pill switch (CnTabs' segmented variant); `line` is the default.
250
252
  *
251
- * @type {{ tabs?: Array<{widgetId: string, label?: string, icon?: string, count?: number, countField?: string, overflow?: boolean}>, ariaLabel?: string, maxVisibleTabs?: number, hideEmpty?: boolean, moreLabel?: string }}
253
+ * @type {{ tabs?: Array<{widgetId: string, label?: string, icon?: string, count?: number, countField?: string, overflow?: boolean}>, ariaLabel?: string, maxVisibleTabs?: number, hideEmpty?: boolean, moreLabel?: string, variant?: ('line'|'segmented') }}
252
254
  */
253
255
  content: {
254
256
  type: Object,
@@ -464,6 +466,17 @@ export default {
464
466
  return this.activeTab?.label || ''
465
467
  },
466
468
 
469
+ /**
470
+ * The strip's look: `segmented` when the content asks for it, else
471
+ * CnTabs' default `line`. An unknown value falls back to `line`.
472
+ *
473
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-tabs-widget-renders-a-segmented-strip
474
+ * @return {('line'|'segmented')} The CnTabs variant.
475
+ */
476
+ stripVariant() {
477
+ return this.content?.variant === 'segmented' ? 'segmented' : 'line'
478
+ },
479
+
467
480
  /**
468
481
  * Accessible name for the tab strip.
469
482
  *
@@ -163,3 +163,7 @@ Request a feature / Report a bug / Documentation render on every widget; the sha
163
163
  ## High contrast
164
164
 
165
165
  `high-contrast` declares that this widget wants a high contrast treatment. The flag reaches the theme; the component picks no colour. See [the reference page](../../../docs/components/cn-widget-wrapper.md#high-contrast).
166
+
167
+ ### A text link in the header (`headerLink`)
168
+
169
+ `headerLink` (`{ label, route?, params?, query?, href? }`) draws one text link in the header before the overflow menu, such as "All deadlines".
@@ -69,6 +69,17 @@
69
69
  It used to gate both, so a caller who switched the menu off
70
70
  also lost the Save button for an inline edit, silently. -->
71
71
  <div v-if="actionsAreVisible()" class="cn-widget-wrapper__actions">
72
+ <!-- One text link in the header (`headerLink`): "All deadlines",
73
+ "To the board". A route is a router link, an href a plain
74
+ anchor. Sits before the slot and the overflow menu. -->
75
+ <component
76
+ :is="headerLinkTag"
77
+ v-if="headerLinkTag"
78
+ class="cn-widget-wrapper__header-link"
79
+ data-testid="cn-widget-wrapper-header-link"
80
+ v-bind="headerLinkAttrs">
81
+ {{ headerLinkLabel }}
82
+ </component>
72
83
  <!-- @slot actions Custom action buttons rendered before the
73
84
  built-in overflow menu. -->
74
85
  <slot name="actions" />
@@ -351,6 +362,22 @@ export default {
351
362
  default: () => [],
352
363
  },
353
364
 
365
+ /**
366
+ * One text link in the header's actions group, before the overflow
367
+ * menu: `{ label, route?, params?, query?, href? }`. A `route` is a
368
+ * named router link (needs a router), an `href` a plain anchor. The
369
+ * label goes through the host translate function. Null (the
370
+ * default) draws nothing. Manifest: `headerLink` on the widget
371
+ * definition or its layout entry.
372
+ *
373
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-widget-header-carries-a-text-link
374
+ * @type {{label: string, route?: string, params?: object, query?: object, href?: string}|null}
375
+ */
376
+ headerLink: {
377
+ type: Object,
378
+ default: null,
379
+ },
380
+
354
381
  /**
355
382
  * Whether the header's overflow action menu (Refresh / Documentation /
356
383
  * Request-a-feature + any `#action-items`) renders. Shown by default;
@@ -634,6 +661,62 @@ export default {
634
661
  return this.translate ?? this.cnTranslate
635
662
  },
636
663
 
664
+ /**
665
+ * The header link's tag: `router-link` for a route (when a router is
666
+ * mounted), `a` for an href, '' when there is no usable link.
667
+ *
668
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-widget-header-carries-a-text-link
669
+ * @return {string}
670
+ */
671
+ headerLinkTag() {
672
+ const link = this.headerLink
673
+ if (!link || typeof link.label !== 'string' || link.label === '') {
674
+ return ''
675
+ }
676
+ if (link.route && this.$router) {
677
+ return 'router-link'
678
+ }
679
+ if (typeof link.href === 'string' && link.href !== '') {
680
+ return 'a'
681
+ }
682
+ return ''
683
+ },
684
+
685
+ /**
686
+ * The header link's attributes for its tag.
687
+ *
688
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-widget-header-carries-a-text-link
689
+ * @return {object}
690
+ */
691
+ headerLinkAttrs() {
692
+ const link = this.headerLink || {}
693
+ if (this.headerLinkTag === 'router-link') {
694
+ const to = { name: link.route }
695
+ if (link.params && typeof link.params === 'object') {
696
+ to.params = link.params
697
+ }
698
+ if (link.query && typeof link.query === 'object') {
699
+ to.query = link.query
700
+ }
701
+ return { to }
702
+ }
703
+ if (this.headerLinkTag === 'a') {
704
+ return { href: link.href }
705
+ }
706
+ return {}
707
+ },
708
+
709
+ /**
710
+ * The header link's text, through the host translate function.
711
+ *
712
+ * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-widget-header-carries-a-text-link
713
+ * @return {string}
714
+ */
715
+ headerLinkLabel() {
716
+ const label = this.headerLink && this.headerLink.label
717
+ return label ? this.effectiveTranslate(label) : ''
718
+ },
719
+
637
720
  /**
638
721
  * The RENDERED widget title — `displayTitle` run through the host
639
722
  * translate function. Kept separate from `displayTitle` on purpose:
@@ -877,7 +960,7 @@ export default {
877
960
  * @return {boolean} True when the group would hold a visible control.
878
961
  */
879
962
  actionsAreVisible() {
880
- return this.showActions || this.actionsSlotRenders()
963
+ return this.showActions || this.actionsSlotRenders() || Boolean(this.headerLinkTag)
881
964
  },
882
965
 
883
966
  /**
@@ -1127,6 +1210,29 @@ export default {
1127
1210
  flex-shrink: 0;
1128
1211
  }
1129
1212
 
1213
+ /* The header's text link (`headerLink`). Theme hooks: --cn-widget-header-link-size
1214
+ and --cn-widget-header-link-weight. */
1215
+ .cn-widget-wrapper__header-link {
1216
+ align-self: center;
1217
+ padding: 0 calc(1 * var(--default-grid-baseline));
1218
+ color: var(--color-primary-element);
1219
+ font-size: var(--cn-widget-header-link-size, 14px);
1220
+ font-weight: var(--cn-widget-header-link-weight, 600);
1221
+ text-decoration: none;
1222
+ white-space: nowrap;
1223
+ }
1224
+
1225
+ .cn-widget-wrapper__header-link:hover,
1226
+ .cn-widget-wrapper__header-link:focus-visible {
1227
+ text-decoration: underline;
1228
+ }
1229
+
1230
+ .cn-widget-wrapper__header-link:focus-visible {
1231
+ outline: 2px solid var(--color-primary-element);
1232
+ outline-offset: 2px;
1233
+ border-radius: var(--border-radius-small, 4px);
1234
+ }
1235
+
1130
1236
  /* Every widget's header icon is coloured — `--cn-widget-icon-color` is set on
1131
1237
  the header from titleIconStyle, and defaults to the theme colour if a
1132
1238
  wrapper is ever rendered without it. Material-design-icon components paint
@@ -8,6 +8,12 @@
8
8
  position: relative;
9
9
  }
10
10
 
11
+ /* The breadcrumb line above the header (`config.breadcrumb`). */
12
+ .cn-detail-page__breadcrumbs {
13
+ margin-bottom: calc(2 * var(--default-grid-baseline));
14
+ color: var(--color-text-maxcontrast);
15
+ }
16
+
11
17
  /* Header */
12
18
  .cn-detail-page__header {
13
19
  display: flex;
@@ -33,6 +39,28 @@
33
39
  min-width: 0;
34
40
  }
35
41
 
42
+ /* A widget in the header (`config.headerWidget`) takes a row of its own under
43
+ the title and the actions. */
44
+ .cn-detail-page__header--with-widget {
45
+ flex-wrap: wrap;
46
+ row-gap: var(--cn-detail-header-row-gap, 22px);
47
+ }
48
+
49
+ .cn-detail-page__header-widget {
50
+ flex: 1 0 100%;
51
+ min-width: 0;
52
+ }
53
+
54
+ /* The header as a card (`config.headerCard`): the board's case header, a
55
+ bordered box on the surface colour. The 56px inset that clears the
56
+ navigation toggle is not needed inside a card that starts below it. */
57
+ .cn-detail-page__header--card {
58
+ padding: var(--cn-detail-header-card-padding, 22px 24px);
59
+ border: 1px solid var(--color-border);
60
+ border-radius: var(--cn-detail-header-card-radius, 12px);
61
+ background: var(--color-main-background);
62
+ }
63
+
36
64
  .cn-detail-page__header-left {
37
65
  display: flex;
38
66
  align-items: center;
@@ -175,7 +203,7 @@
175
203
 
176
204
  .cn-detail-page__stats-table thead th {
177
205
  padding: calc(1.5 * var(--default-grid-baseline)) calc(2 * var(--default-grid-baseline));
178
- text-align: left;
206
+ text-align: start;
179
207
  font-weight: 600;
180
208
  font-size: 0.9em;
181
209
  color: var(--color-text-maxcontrast);
@@ -200,7 +228,7 @@
200
228
  }
201
229
 
202
230
  .cn-detail-page__stats-cell--indented {
203
- padding-left: calc(5 * var(--default-grid-baseline)) !important;
231
+ padding-inline-start: calc(5 * var(--default-grid-baseline)) !important;
204
232
  }
205
233
 
206
234
  .cn-detail-page__stats-cell--center {
@@ -208,7 +236,7 @@
208
236
  }
209
237
 
210
238
  .cn-detail-page__stats-cell--right {
211
- text-align: right;
239
+ text-align: end;
212
240
  }
213
241
 
214
242
  /* Body & content */
@@ -342,7 +370,7 @@
342
370
 
343
371
  .cn-detail-page__sidebar-toggle {
344
372
  position: fixed;
345
- right: calc(5 * var(--default-grid-baseline));
373
+ inset-inline-end: calc(5 * var(--default-grid-baseline));
346
374
  top: 80px;
347
375
  z-index: 10;
348
376
  }