@conduction/nextcloud-vue 2.56.0-beta.7 → 2.56.0-beta.9

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 (87) hide show
  1. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +169 -1
  2. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
  3. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
  4. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue2.js +88 -4
  5. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue2.js.map +1 -1
  6. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue3.js +38 -25
  7. package/dist/esm/components/CnBannerWidget/CnBannerWidget.vue3.js.map +1 -1
  8. package/dist/esm/components/CnBrandStripe/CnBrandStripe.vue2.js +21 -1
  9. package/dist/esm/components/CnBrandStripe/CnBrandStripe.vue2.js.map +1 -1
  10. package/dist/esm/components/CnBrandStripe/CnBrandStripe.vue3.js +4 -1
  11. package/dist/esm/components/CnBrandStripe/CnBrandStripe.vue3.js.map +1 -1
  12. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +40 -8
  13. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  14. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  15. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +40 -15
  16. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  17. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js +2 -2
  18. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  19. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  20. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +6 -5
  21. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  22. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js +3 -1
  23. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js.map +1 -1
  24. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js.map +1 -1
  25. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue.js +10 -0
  26. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue.js.map +1 -0
  27. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue2.js +316 -0
  28. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue2.js.map +1 -0
  29. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue3.js +105 -0
  30. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue3.js.map +1 -0
  31. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js +53 -0
  32. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js.map +1 -1
  33. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js +22 -5
  34. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js.map +1 -1
  35. package/dist/esm/components/CnPageRenderer/pageTypes.js +1 -0
  36. package/dist/esm/components/CnPageRenderer/pageTypes.js.map +1 -1
  37. package/dist/esm/components/CnReportsPage/CnReportsPage.vue2.js.map +1 -1
  38. package/dist/esm/components/CnReportsPage/CnReportsPage.vue3.js.map +1 -1
  39. package/dist/esm/components/CnStorePage/CnStorePage.vue2.js.map +1 -1
  40. package/dist/esm/components/CnStorePage/CnStorePage.vue3.js.map +1 -1
  41. package/dist/esm/components/CnWikiPage/CnWikiPage.vue2.js.map +1 -1
  42. package/dist/esm/components/CnWikiPage/CnWikiPage.vue3.js.map +1 -1
  43. package/dist/esm/index.js +6 -0
  44. package/dist/esm/index.js.map +1 -1
  45. package/dist/esm/l10n/en.json.js +6 -0
  46. package/dist/esm/l10n/en.json.js.map +1 -1
  47. package/dist/esm/l10n/nl.json.js +6 -0
  48. package/dist/esm/l10n/nl.json.js.map +1 -1
  49. package/dist/esm/nextcloud-vue.css +198 -9
  50. package/dist/esm/utils/headers.js +28 -2
  51. package/dist/esm/utils/headers.js.map +1 -1
  52. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  53. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  54. package/dist/nextcloud-vue.cjs.js +9162 -8283
  55. package/dist/nextcloud-vue.cjs.js.map +2 -2
  56. package/dist/nextcloud-vue.css +198 -9
  57. package/l10n/en.json +6 -0
  58. package/l10n/nl.json +6 -0
  59. package/package.json +3 -2
  60. package/scripts/.jsdoc-baselines.json +1 -0
  61. package/scripts/manifest-schema-hash.js +41 -0
  62. package/scripts/record-manifest-schema-hash.js +52 -0
  63. package/src/components/CnAppNav/CnAppNav.vue +169 -1
  64. package/src/components/CnBannerWidget/CnBannerWidget.vue +122 -4
  65. package/src/components/CnBrandStripe/CnBrandStripe.md +16 -0
  66. package/src/components/CnBrandStripe/CnBrandStripe.vue +58 -5
  67. package/src/components/CnDashboardPage/CnDashboardPage.vue +40 -8
  68. package/src/components/CnDataTable/CnDataTable.md +1 -1
  69. package/src/components/CnDataTable/CnDataTable.vue +42 -17
  70. package/src/components/CnDetailPage/CnDetailPage.vue +4 -1
  71. package/src/components/CnHeaderWidget/CnHeaderWidget.vue +3 -1
  72. package/src/components/CnLinkCardsPage/CnLinkCardsPage.vue +517 -0
  73. package/src/components/CnLinkCardsPage/index.js +4 -0
  74. package/src/components/CnObjectSidebar/CnFilesTab.vue +65 -0
  75. package/src/components/CnPageRenderer/pageTypes.js +1 -0
  76. package/src/components/CnReportsPage/CnReportsPage.vue +7 -0
  77. package/src/components/CnStorePage/CnStorePage.vue +7 -0
  78. package/src/components/CnWikiPage/CnWikiPage.vue +7 -0
  79. package/src/components/index.js +1 -0
  80. package/src/css/detail-page.css +10 -2
  81. package/src/css/table.css +1 -2
  82. package/src/index.js +7 -0
  83. package/src/public/index.js +5 -0
  84. package/src/schemas/app-manifest-v2.schema.json +45 -3
  85. package/src/types/manifest.d.ts +1 -1
  86. package/src/utils/headers.js +28 -2
  87. package/src/utils/validateManifestV2.compiled.js +2 -2
@@ -36,6 +36,16 @@
36
36
  </component>
37
37
  </div>
38
38
  </section>
39
+ <!-- The count could not be read. One quiet line, not the card and not an
40
+ alarm: hidden would read as "nothing to report", which is not known. -->
41
+ <p
42
+ v-else-if="checkFailed"
43
+ class="cn-banner-widget cn-banner-widget--unchecked"
44
+ :title="failureTooltip"
45
+ data-testid="cn-banner-widget-unchecked">
46
+ <span class="cn-banner-widget__unchecked-text">{{ uncheckedText }}</span>
47
+ <span class="cn-banner-widget__unchecked-reason">{{ failureTooltip }}</span>
48
+ </p>
39
49
  <div v-else-if="visible" class="cn-banner-widget">
40
50
  <NcNoteCard :type="resolvedVariant" class="cn-banner-widget__card">
41
51
  <component
@@ -52,6 +62,7 @@
52
62
  </template>
53
63
 
54
64
  <script>
65
+ import { translate as t } from '@nextcloud/l10n'
55
66
  import { NcNoteCard } from '@nextcloud/vue'
56
67
  import { followLinkClick, resolveHref } from '../../utils/linkNavigation.js'
57
68
  import { safeHref } from '../../utils/safeHref.js'
@@ -97,6 +108,12 @@ const MAX_ACTIONS = 2
97
108
  * condition evaluates true — a failed fetch never breaks (or spams) a
98
109
  * dashboard.
99
110
  *
111
+ * An attention card (`layout: "attention"`) whose `visibleWhen` request
112
+ * FAILS renders one quiet line instead, "Could not check", with the reason as
113
+ * a tooltip. A hidden card reads as "nothing needs attention", and a failed
114
+ * count does not know that. A request that succeeds with a value that does
115
+ * not meet the condition still renders nothing.
116
+ *
100
117
  * `layout: "attention"` turns the banner into an attention card: a card with
101
118
  * a coloured edge by severity, a `kicker` label, a `title`, a `reason` line
102
119
  * and up to two `actions` (the first is the primary one unless an action says
@@ -238,7 +255,9 @@ export default {
238
255
  * the banner renders from this verdict instead of fetching again —
239
256
  * no duplicate request and no hidden-until-self-evaluated flash —
240
257
  * and `value` feeds the `{value}` text placeholder. `null` (the
241
- * default) keeps the banner self-evaluating.
258
+ * default) keeps the banner self-evaluating. A host whose request
259
+ * failed passes `{ met: false, value: null, failed: true, reason }`,
260
+ * and an attention card then says it could not check.
242
261
  *
243
262
  * @type {object|null}
244
263
  */
@@ -259,6 +278,13 @@ export default {
259
278
  'action',
260
279
  ],
261
280
 
281
+ /**
282
+ * The banner's own state: the evaluated condition, the value it read,
283
+ * and whether the request for it failed.
284
+ *
285
+ * @return {object} The state.
286
+ * @spec openspec/changes/live-check-follow-ups/specs/live-check-follow-ups/spec.md#requirement-an-attention-card-says-when-it-could-not-check
287
+ */
262
288
  data() {
263
289
  return {
264
290
  /** Id that ties the attention card's region to its title. */
@@ -271,6 +297,10 @@ export default {
271
297
  * the text wherever it says `{value}`.
272
298
  */
273
299
  conditionValue: null,
300
+ /** Whether the visibleWhen request itself failed (not: answered "no"). */
301
+ conditionFailed: false,
302
+ /** Why it failed, as the request reported it. */
303
+ conditionFailureReason: '',
274
304
  }
275
305
  },
276
306
 
@@ -359,7 +389,9 @@ export default {
359
389
  return this.fillValue(reason)
360
390
  }
361
391
  const title = this.title || (this.content && this.content.title) || ''
362
- return title ? this.displayText : ''
392
+ // A card that sets `text` to the same words as its `title` says
393
+ // them once.
394
+ return title && this.displayText !== this.displayTitle ? this.displayText : ''
363
395
  },
364
396
 
365
397
  /**
@@ -417,6 +449,52 @@ export default {
417
449
  return this.conditionMet === true
418
450
  },
419
451
 
452
+ /**
453
+ * Whether to say the check failed: an attention card, with words to
454
+ * show, whose `visibleWhen` request failed. A plain banner stays
455
+ * hidden on failure, as it always did.
456
+ *
457
+ * @return {boolean}
458
+ * @spec openspec/changes/live-check-follow-ups/specs/live-check-follow-ups/spec.md#requirement-an-attention-card-says-when-it-could-not-check
459
+ */
460
+ checkFailed() {
461
+ const title = this.title || (this.content && this.content.title) || ''
462
+ return this.isAttention
463
+ && !!this.resolvedVisibleWhen
464
+ && this.conditionFailed
465
+ && (title !== '' || this.resolvedText !== '')
466
+ },
467
+
468
+ /**
469
+ * The one line shown when the check failed. It names the card by its
470
+ * title, unless the title needs the value that could not be read.
471
+ *
472
+ * @return {string}
473
+ * @spec openspec/changes/live-check-follow-ups/specs/live-check-follow-ups/spec.md#requirement-an-attention-card-says-when-it-could-not-check
474
+ */
475
+ uncheckedText() {
476
+ const title = this.title || (this.content && this.content.title) || this.resolvedText
477
+ if (!title || title.includes('{value}')) {
478
+ return t('nextcloud-vue', 'Could not check')
479
+ }
480
+ // `escape: false`: the result is rendered through `{{ }}`, which escapes.
481
+ return t('nextcloud-vue', 'Could not check: {subject}', { subject: title }, undefined, { escape: false })
482
+ },
483
+
484
+ /**
485
+ * The reason the check failed, for the tooltip and for assistive
486
+ * technology.
487
+ *
488
+ * @return {string}
489
+ * @spec openspec/changes/live-check-follow-ups/specs/live-check-follow-ups/spec.md#requirement-an-attention-card-says-when-it-could-not-check
490
+ */
491
+ failureTooltip() {
492
+ if (!this.conditionFailureReason) {
493
+ return t('nextcloud-vue', 'The check failed.')
494
+ }
495
+ return t('nextcloud-vue', 'The check failed: {reason}', { reason: this.conditionFailureReason }, undefined, { escape: false })
496
+ },
497
+
420
498
  /** Whether the banner navigates on click (route set + router present). */
421
499
  clickable() {
422
500
  return !!this.resolvedRoute && !!this.$router
@@ -491,30 +569,46 @@ export default {
491
569
  * modes — the Wave-1 banner shape is the canonical one, extracted to
492
570
  * `utils/visibleWhen.js` in Wave 3 so manifest actions reuse it),
493
571
  * keeping the read VALUE for the `{value}` text placeholder.
494
- * Fail-safe: any fetch/shape error leaves the banner hidden.
572
+ * Fail-safe: any fetch/shape error leaves the banner hidden. The
573
+ * failure is kept (`conditionFailed`, with its reason) so an
574
+ * attention card can say it could not check.
495
575
  *
496
576
  * @return {Promise<void>}
577
+ * @spec openspec/changes/live-check-follow-ups/specs/live-check-follow-ups/spec.md#requirement-an-attention-card-says-when-it-could-not-check
497
578
  */
498
579
  async evaluateCondition() {
499
580
  const outcome = this.resolvedConditionOutcome
500
581
  if (outcome) {
501
582
  this.conditionMet = outcome.met === true
502
583
  this.conditionValue = outcome.value !== undefined ? outcome.value : null
584
+ this.conditionFailed = outcome.failed === true
585
+ this.conditionFailureReason = outcome.failed === true && typeof outcome.reason === 'string' ? outcome.reason : ''
503
586
  return
504
587
  }
505
588
  const cond = this.resolvedVisibleWhen
506
589
  if (!cond) {
507
590
  this.conditionMet = null
508
591
  this.conditionValue = null
592
+ this.conditionFailed = false
593
+ this.conditionFailureReason = ''
509
594
  return
510
595
  }
511
596
  try {
512
597
  const value = await readVisibleWhenValue(cond)
513
598
  this.conditionValue = value
514
599
  this.conditionMet = compareVisibleWhen(value, cond.op || 'eq', cond.value)
515
- } catch {
600
+ this.conditionFailed = false
601
+ this.conditionFailureReason = ''
602
+ } catch (error) {
603
+ // Still hidden: a banner that cannot tell whether it applies
604
+ // should not claim attention. But not silently, because a
605
+ // failed count and "nothing to report" look the same on screen.
606
+ // eslint-disable-next-line no-console
607
+ console.warn('[CnBannerWidget] visibleWhen could not be evaluated, banner hidden:', error?.message || error)
516
608
  this.conditionMet = false
517
609
  this.conditionValue = null
610
+ this.conditionFailed = true
611
+ this.conditionFailureReason = typeof error?.message === 'string' ? error.message : String(error ?? '')
518
612
  }
519
613
  },
520
614
 
@@ -550,6 +644,30 @@ export default {
550
644
  color: inherit;
551
645
  }
552
646
 
647
+ /* The check failed. Muted text, no fill, no severity colour: it is a note
648
+ that something is not known, not a warning. */
649
+ .cn-banner-widget--unchecked {
650
+ box-sizing: border-box;
651
+ margin: 0;
652
+ padding: 8px 12px;
653
+ color: var(--color-text-maxcontrast);
654
+ font-size: 0.9em;
655
+ cursor: help;
656
+ }
657
+
658
+ /* The reason, for assistive technology; sighted readers get the tooltip. */
659
+ .cn-banner-widget__unchecked-reason {
660
+ position: absolute;
661
+ width: 1px;
662
+ height: 1px;
663
+ margin: -1px;
664
+ padding: 0;
665
+ overflow: hidden;
666
+ clip-path: inset(50%);
667
+ white-space: nowrap;
668
+ border: 0;
669
+ }
670
+
553
671
  /* Attention card. The severity edge is an inset shadow, so it takes no width. */
554
672
  .cn-banner-widget--attention {
555
673
  --cn-banner-accent: var(--color-primary-element);
@@ -20,3 +20,19 @@ Down the side of a panel, with `orientation="vertical"`:
20
20
  <span>Content beside the stripe</span>
21
21
  </div>
22
22
  ```
23
+
24
+ A theme whose motif is not three bands names an image. Here the canal of a school: a blue line, its bank and the water:
25
+
26
+ ```vue
27
+ <div style="--cn-brand-stripe-image: linear-gradient(to bottom, #1f4fd8 0 4px, transparent 4px 7px, #1fb5a8 7px 9px); --cn-brand-stripe-height: 9px">
28
+ <CnBrandStripe />
29
+ </div>
30
+ ```
31
+
32
+ On a dark band, such as a footer, `variant="inverse"` draws the theme's `--cn-brand-stripe-image-inverse`, else the one image, else the bands:
33
+
34
+ ```vue
35
+ <div style="background: #14338f; padding-bottom: 16px; --cn-brand-stripe-image: linear-gradient(to bottom, #1f4fd8 0 4px, transparent 4px 7px, #1fb5a8 7px 9px); --cn-brand-stripe-image-inverse: linear-gradient(to bottom, #1f4fd8 0 4px, #ffffff 4px 7px, #1fb5a8 7px 9px); --cn-brand-stripe-height: 9px">
36
+ <CnBrandStripe variant="inverse" />
37
+ </div>
38
+ ```
@@ -5,7 +5,10 @@
5
5
  <template>
6
6
  <div
7
7
  class="cn-brand-stripe"
8
- :class="{ 'cn-brand-stripe--vertical': orientation === 'vertical' }"
8
+ :class="{
9
+ 'cn-brand-stripe--vertical': orientation === 'vertical',
10
+ 'cn-brand-stripe--inverse': variant === 'inverse',
11
+ }"
9
12
  aria-hidden="true"
10
13
  data-testid="cn-brand-stripe">
11
14
  <span class="cn-brand-stripe__band cn-brand-stripe__band--1" />
@@ -17,7 +20,8 @@
17
20
  <script>
18
21
  /**
19
22
  * CnBrandStripe draws the organisation's brand stripe: up to three coloured
20
- * bands next to each other, for the top of a page, a card or a sidebar.
23
+ * bands next to each other, or the organisation's own motif, for the top of a
24
+ * page, a card or a sidebar.
21
25
  *
22
26
  * The theme decides what it looks like, through the library's own custom
23
27
  * properties: `--cn-brand-stripe-color-1/2/3` (the colours, start to end),
@@ -26,6 +30,12 @@
26
30
  * tokens onto these; the library never reads a theme's tokens. Without them the
27
31
  * stripe is one band in the Nextcloud primary colour, 4px high.
28
32
  *
33
+ * A theme whose motif is not three bands (hanging twigs, a canal and its bank,
34
+ * a slanted cut) names it in `--cn-brand-stripe-image`, any background-image
35
+ * value; the stripe then draws that image in the same box instead of the
36
+ * bands. `--cn-brand-stripe-image-inverse` is the same motif for a dark band;
37
+ * the `inverse` variant draws it, else the one image, else the bands.
38
+ *
29
39
  * The stripe is decoration: it carries no meaning and is hidden from
30
40
  * assistive technology.
31
41
  *
@@ -49,6 +59,19 @@ export default {
49
59
  default: 'horizontal',
50
60
  validator: (v) => ['horizontal', 'vertical'].includes(v),
51
61
  },
62
+
63
+ /**
64
+ * The ground the stripe sits on: `default` (a light surface) or
65
+ * `inverse` (a dark band such as a footer), which draws the theme's
66
+ * `--cn-brand-stripe-image-inverse` when it names one.
67
+ *
68
+ * @type {'default'|'inverse'}
69
+ */
70
+ variant: {
71
+ type: String,
72
+ default: 'default',
73
+ validator: (v) => ['default', 'inverse'].includes(v),
74
+ },
52
75
  },
53
76
  }
54
77
  </script>
@@ -56,19 +79,52 @@ export default {
56
79
  <!--
57
80
  The stripe reads the library's own `--cn-brand-stripe-*` properties, each
58
81
  with a Nextcloud fallback. A theming app sets them from its own tokens.
82
+
83
+ THE ROOT PAINTS, NOT THE BANDS. The three bands are one background on the
84
+ root, a gradient with hard stops computed from the ratios, so a theme's
85
+ motif image can take its place with one `var()` fallback. A band painted on
86
+ its own element could not be switched off by the presence of an image: CSS
87
+ cannot ask whether a custom property is set. The band elements stay, empty,
88
+ so markup and tests that count them keep working.
59
89
  -->
60
90
  <style scoped>
61
91
  .cn-brand-stripe {
92
+ --cn-brand-stripe-first: var(--cn-brand-stripe-ratio-1, 1);
93
+ --cn-brand-stripe-second: var(--cn-brand-stripe-ratio-2, 1);
94
+ --cn-brand-stripe-total: calc(
95
+ var(--cn-brand-stripe-first) + var(--cn-brand-stripe-second) + var(--cn-brand-stripe-ratio-3, 1)
96
+ );
97
+ --cn-brand-stripe-stop-1: calc(var(--cn-brand-stripe-first) / var(--cn-brand-stripe-total) * 100%);
98
+ --cn-brand-stripe-stop-2: calc(
99
+ (var(--cn-brand-stripe-first) + var(--cn-brand-stripe-second)) / var(--cn-brand-stripe-total) * 100%
100
+ );
101
+ --cn-brand-stripe-direction: to right;
102
+ --cn-brand-stripe-bands: linear-gradient(
103
+ var(--cn-brand-stripe-direction),
104
+ var(--cn-brand-stripe-color-1, var(--color-primary-element)) 0 var(--cn-brand-stripe-stop-1),
105
+ var(--cn-brand-stripe-color-2, var(--color-primary-element)) var(--cn-brand-stripe-stop-1) var(--cn-brand-stripe-stop-2),
106
+ var(--cn-brand-stripe-color-3, var(--color-primary-element)) var(--cn-brand-stripe-stop-2) 100%
107
+ );
62
108
  display: flex;
63
109
  flex: none;
64
110
  width: 100%;
65
111
  height: var(--cn-brand-stripe-height, 4px);
112
+ background-image: var(--cn-brand-stripe-image, var(--cn-brand-stripe-bands));
113
+ background-repeat: repeat-x;
114
+ background-size: auto 100%;
115
+ }
116
+
117
+ .cn-brand-stripe--inverse {
118
+ background-image: var(--cn-brand-stripe-image-inverse, var(--cn-brand-stripe-image, var(--cn-brand-stripe-bands)));
66
119
  }
67
120
 
68
121
  .cn-brand-stripe--vertical {
122
+ --cn-brand-stripe-direction: to bottom;
69
123
  flex-direction: column;
70
124
  width: var(--cn-brand-stripe-height, 4px);
71
125
  height: 100%;
126
+ background-repeat: repeat-y;
127
+ background-size: 100% auto;
72
128
  }
73
129
 
74
130
  .cn-brand-stripe__band {
@@ -78,17 +134,14 @@ export default {
78
134
 
79
135
  .cn-brand-stripe__band--1 {
80
136
  flex-grow: var(--cn-brand-stripe-ratio-1, 1);
81
- background-color: var(--cn-brand-stripe-color-1, var(--color-primary-element));
82
137
  }
83
138
 
84
139
  .cn-brand-stripe__band--2 {
85
140
  flex-grow: var(--cn-brand-stripe-ratio-2, 1);
86
- background-color: var(--cn-brand-stripe-color-2, var(--color-primary-element));
87
141
  }
88
142
 
89
143
  .cn-brand-stripe__band--3 {
90
144
  flex-grow: var(--cn-brand-stripe-ratio-3, 1);
91
- background-color: var(--cn-brand-stripe-color-3, var(--color-primary-element));
92
145
  }
93
146
 
94
147
  @media print {
@@ -2748,17 +2748,30 @@ export default {
2748
2748
  * the registry branch forwards to CnBannerWidget), or in the legacy
2749
2749
  * manifest `props` blob (the fallback getStatsBlockProps and
2750
2750
  * getChartProps already honour for their own widget types). `text`
2751
- * is only meaningful for banners.
2751
+ * is only meaningful for banners: it is what the banner would show,
2752
+ * which for an attention card is its `title` when it has no `text`.
2752
2753
  *
2753
2754
  * @param {object} def Widget definition.
2754
- * @return {{ text: string, visibleWhen: (object|null) }}
2755
+ * `layout` is returned so the page can tell an attention card from a
2756
+ * plain banner: only the former keeps its cell when its check fails.
2757
+ *
2758
+ * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-an-attention-card-with-a-title-keeps-its-cell
2759
+ * @spec openspec/changes/live-check-follow-ups/specs/live-check-follow-ups/spec.md#requirement-an-attention-card-says-when-it-could-not-check
2760
+ * @return {{ text: string, visibleWhen: (object|null), layout: string }}
2755
2761
  */
2756
2762
  widgetDisplayConfig(def) {
2757
2763
  const content = (def && typeof def.content === 'object' && def.content) || {}
2758
2764
  const props = (def && typeof def.props === 'object' && def.props) || {}
2765
+ // An attention card (`layout: "attention"`) is headed by `title`,
2766
+ // and its title only FALLS BACK to `text`. Reading `text` alone
2767
+ // called a card with a title and a reason empty, and gave its cell
2768
+ // up before its `visibleWhen` was even looked at.
2769
+ const layout = content.layout || props.layout || ''
2770
+ const title = layout === 'attention' ? (content.title || props.title || '') : ''
2759
2771
  return {
2760
- text: content.text || props.text || '',
2772
+ text: content.text || props.text || title || '',
2761
2773
  visibleWhen: (def && def.visibleWhen) || content.visibleWhen || props.visibleWhen || null,
2774
+ layout,
2762
2775
  }
2763
2776
  },
2764
2777
 
@@ -2768,15 +2781,22 @@ export default {
2768
2781
  * text at all — and must therefore surrender its grid cell in live
2769
2782
  * mode instead of leaving an empty card behind.
2770
2783
  *
2784
+ * One exception: an attention card whose `visibleWhen` request FAILED
2785
+ * keeps its cell, so the card can say it could not check. A collapsed
2786
+ * cell reads as "nothing needs attention", which a failed count does
2787
+ * not know. A count that was read and does not meet the condition
2788
+ * still collapses.
2789
+ *
2771
2790
  * @param {object} item Layout item.
2772
2791
  * @return {boolean} true when the cell must collapse.
2792
+ * @spec openspec/changes/live-check-follow-ups/specs/live-check-follow-ups/spec.md#requirement-an-attention-card-says-when-it-could-not-check
2773
2793
  */
2774
2794
  isCollapsedWidget(item) {
2775
2795
  const def = this.getWidgetDef(item.widgetId)
2776
2796
  if (!def || !def.type) {
2777
2797
  return false
2778
2798
  }
2779
- const { text, visibleWhen } = this.widgetDisplayConfig(def)
2799
+ const { text, visibleWhen, layout } = this.widgetDisplayConfig(def)
2780
2800
  if (this.isBannerDef(def) && text === '') {
2781
2801
  return true
2782
2802
  }
@@ -2784,6 +2804,9 @@ export default {
2784
2804
  return false
2785
2805
  }
2786
2806
  const outcome = this.widgetConditionOutcome[item.widgetId]
2807
+ if (outcome && outcome.failed === true && layout === 'attention' && this.isBannerDef(def)) {
2808
+ return false
2809
+ }
2787
2810
  return !outcome || outcome.met !== true
2788
2811
  },
2789
2812
 
@@ -2794,13 +2817,17 @@ export default {
2794
2817
  * Runs on created() and again whenever `widgets` changes (the
2795
2818
  * in-app editor mutates defs in place). Fail-safe like the banner
2796
2819
  * itself: any fetch/shape error counts as "not met", so a broken
2797
- * predicate collapses the cell rather than breaking the page.
2820
+ * predicate collapses the cell rather than breaking the page. The
2821
+ * outcome of a failed read carries `failed: true` and the `reason`,
2822
+ * which `isCollapsedWidget` and CnBannerWidget use to show an
2823
+ * attention card's "Could not check" line instead of nothing.
2798
2824
  *
2799
2825
  * Flips `widgetConditionsSettled` when the initial round is done —
2800
2826
  * synchronously when there is nothing to evaluate, so a dashboard
2801
2827
  * without conditional widgets never waits.
2802
2828
  *
2803
2829
  * @return {Promise<void>}
2830
+ * @spec openspec/changes/live-check-follow-ups/specs/live-check-follow-ups/spec.md#requirement-an-attention-card-says-when-it-could-not-check
2804
2831
  */
2805
2832
  async evaluateWidgetConditions() {
2806
2833
  const conditional = (this.widgets || []).filter((def) => def && def.id && def.type
@@ -2822,12 +2849,17 @@ export default {
2822
2849
  }
2823
2850
  await Promise.all(conditional.map(async (def) => {
2824
2851
  const cond = this.widgetDisplayConfig(def).visibleWhen
2825
- let outcome = { met: false, value: null }
2852
+ let outcome
2826
2853
  try {
2827
2854
  const value = await readVisibleWhenValue(cond)
2828
2855
  outcome = { met: compareVisibleWhen(value, cond.op || 'eq', cond.value), value }
2829
- } catch {
2830
- // fail-safe: hidden
2856
+ } catch (error) {
2857
+ // Fail-safe: not met. But the failure is kept, and said
2858
+ // out loud: a failed count and "nothing to report" look
2859
+ // the same on screen.
2860
+ outcome = { met: false, value: null, failed: true, reason: typeof error?.message === 'string' ? error.message : String(error ?? '') }
2861
+ // eslint-disable-next-line no-console
2862
+ console.warn(`[CnDashboardPage] visibleWhen of widget "${def.id}" could not be evaluated:`, error?.message || error)
2831
2863
  }
2832
2864
  // A newer run owns the map now — a stale verdict (possibly for
2833
2865
  // a def that no longer exists) must not overwrite its writes.
@@ -186,7 +186,7 @@ export default {
186
186
  | `includeColumns` | Array | `null` | Column keys to include (whitelist) in schema mode |
187
187
  | `sortKey` | String | `null` | Currently active sort column key (controlled) |
188
188
  | `sortOrder` | String | `'asc'` | Current sort direction: `'asc'` or `'desc'` |
189
- | `sortKeys` | Array | `[]` | Ordered multi-column ("shift+click") sort key list, `[{ key, order }, …]` (0–3 entries); takes precedence over `sortKey`/`sortOrder` when non-empty |
189
+ | `sortKeys` | Array | `[]` | Ordered multi-column ("shift+click") sort key list, `[{ key, order }, …]` (0–3 entries); takes precedence over `sortKey`/`sortOrder` when non-empty. Priority badges show only when two or more rendered, sortable columns are sorted; a key whose column is not rendered or not sortable (such as a `_uuid` tie-break) still sorts, but is not counted or numbered |
190
190
  | `selectedIds` | Array | `[]` | Array of selected row IDs (controlled) |
191
191
  | `rowClass` | Function | `null` | `(row) => string` — CSS class(es) applied to each `<tr>` |
192
192
  | `rowClickToView` | Boolean | `false` | Emit `row-click` on a row-body click even while `selectable` (selection then via the checkbox column only) |
@@ -104,9 +104,9 @@
104
104
  {{ effectiveSortKeys[sortKeyIndex(col.key)].order === 'asc' ? '▲' : '▼' }}
105
105
  </span>
106
106
  <span
107
- v-if="col.sortable && effectiveSortKeys.length > 1 && sortKeyIndex(col.key) !== -1"
107
+ v-if="col.sortable && sortBadgeFor(col.key) !== null"
108
108
  class="cn-table-sort-badge">
109
- {{ sortKeyIndex(col.key) + 1 }}
109
+ {{ sortBadgeFor(col.key) }}
110
110
  </span>
111
111
  </th>
112
112
 
@@ -309,15 +309,12 @@ import '../../css/table.css'
309
309
  * row selection, custom cell rendering via scoped slots, loading states,
310
310
  * and empty states.
311
311
  *
312
- * Sorting: a plain click on a sortable header is single-sort (cycle asc →
313
- * desc → cleared), unchanged from before. Shift+click (or Shift+Enter on a
314
- * focused header) appends the column as a secondary/tertiary sort key —
315
- * capped at 3 — with numbered priority badges (1, 2, 3) once more than one
316
- * key is active. Pass `sortKeys: [{key, order}, ...]` for multi-sort (falls
317
- * back to the legacy `sortKey`/`sortOrder` props when empty). The `sort`
318
- * event payload is extended, not replaced: `{key, order}` still mirrors the
319
- * primary key exactly as before; a new `keys` field carries the full
320
- * ordered list. See `src/utils/multiColumnSort.js` for the state machine.
312
+ * Sorting: a plain click on a sortable header is single-sort (cycle asc → desc → cleared), unchanged from before.
313
+ * Shift+click (or Shift+Enter on a focused header) appends the column as a secondary/tertiary sort key, capped at 3, with numbered priority badges (1, 2, 3) once two or more rendered, sortable columns are sorted.
314
+ * A sort key whose column is not rendered, or not sortable, still sorts, but is not counted or numbered.
315
+ * Pass `sortKeys: [{key, order}, ...]` for multi-sort (falls back to the legacy `sortKey`/`sortOrder` props when empty).
316
+ * The `sort` event payload is extended, not replaced: `{key, order}` still mirrors the primary key exactly as before; a new `keys` field carries the full ordered list.
317
+ * See `src/utils/multiColumnSort.js` for the state machine.
321
318
  *
322
319
  * When a `schema` prop is provided, columns are auto-generated from schema
323
320
  * properties and cells render through CnCellRenderer for type-aware formatting
@@ -918,6 +915,23 @@ export default {
918
915
  return []
919
916
  },
920
917
 
918
+ /**
919
+ * The active sort keys whose column renders a sort indicator, in priority order and deduplicated by first occurrence. Only these are counted and numbered by the priority badge.
920
+ *
921
+ * @return {Array<{key: string, order: 'asc'|'desc'}>}
922
+ */
923
+ renderedSortKeys() {
924
+ const shown = new Set(this.effectiveColumns.filter((c) => c && c.sortable).map((c) => c.key))
925
+ const seen = new Set()
926
+ return this.effectiveSortKeys.filter((k) => {
927
+ if (!k || !shown.has(k.key) || seen.has(k.key)) {
928
+ return false
929
+ }
930
+ seen.add(k.key)
931
+ return true
932
+ })
933
+ },
934
+
921
935
  totalColumns() {
922
936
  let count = this.effectiveColumns.length
923
937
  if (this.selectable) {
@@ -1513,7 +1527,7 @@ export default {
1513
1527
 
1514
1528
  /**
1515
1529
  * Index of `key` within `effectiveSortKeys`, or -1 when not active.
1516
- * Used by the template for the arrow, the numbered badge, and aria-sort.
1530
+ * Used by the template for the arrow.
1517
1531
  *
1518
1532
  * @param {string} key Column key.
1519
1533
  * @return {number}
@@ -1523,11 +1537,22 @@ export default {
1523
1537
  },
1524
1538
 
1525
1539
  /**
1526
- * `aria-sort` value for a column header: `'ascending'`/`'descending'`
1527
- * for the PRIMARY (index 0) active sort key only — secondary/tertiary
1528
- * keys carry the visible numbered badge instead, per WCAG guidance
1529
- * that `aria-sort` describes single-column sort state. `null` omits
1530
- * the attribute entirely (unsorted / not sortable).
1540
+ * The 1-based priority badge for `key` among the rendered sort keys, or null when fewer than two rendered columns are sorted or `key` is not one of them.
1541
+ *
1542
+ * @param {string} key Column key.
1543
+ * @return {number|null}
1544
+ */
1545
+ sortBadgeFor(key) {
1546
+ if (this.renderedSortKeys.length < 2) {
1547
+ return null
1548
+ }
1549
+ const index = this.renderedSortKeys.findIndex((k) => k.key === key)
1550
+ return index === -1 ? null : index + 1
1551
+ },
1552
+
1553
+ /**
1554
+ * `aria-sort` value for a column header: `'ascending'`/`'descending'` for the PRIMARY (index 0) active sort key only, per WCAG guidance that `aria-sort` describes single-column sort state.
1555
+ * `null` omits the attribute entirely (unsorted, not sortable, or not the primary key).
1531
1556
  *
1532
1557
  * @param {object} col Column definition.
1533
1558
  * @return {string|null}
@@ -103,7 +103,10 @@
103
103
  data-testid="cn-detail-page-type-eyebrow">
104
104
  {{ typeEyebrow }}
105
105
  </p>
106
- <h2 v-if="displayTitle" class="cn-detail-page__title">
106
+ <h2
107
+ v-if="displayTitle"
108
+ class="cn-detail-page__title"
109
+ :title="displayTitle">
107
110
  {{ displayTitle }}
108
111
  </h2>
109
112
  <!--
@@ -496,7 +496,9 @@ export default {
496
496
  'flex-direction': 'column',
497
497
  'align-items': this.flexAlignFromTextAlign,
498
498
  'justify-content': VERTICAL_ALIGN_FLEX[this.verticalAlign],
499
- padding: this.isPlain ? '0' : '16px 24px',
499
+ // Plain drops the coloured background, not the card padding:
500
+ // at 0 the greeting sat flush against the card's left edge.
501
+ padding: this.isPlain ? '16px' : '16px 24px',
500
502
  'box-sizing': 'border-box',
501
503
  gap: '8px',
502
504
  'text-align': this.textAlign,