@conduction/nextcloud-vue 2.56.0-beta.8 → 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 (61) hide show
  1. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +128 -0
  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 +79 -2
  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 +30 -6
  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/CnLinkCardsPage/CnLinkCardsPage.vue2.js.map +1 -1
  20. package/dist/esm/components/CnLinkCardsPage/CnLinkCardsPage.vue3.js.map +1 -1
  21. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js +53 -0
  22. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue2.js.map +1 -1
  23. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js +22 -5
  24. package/dist/esm/components/CnObjectSidebar/CnFilesTab.vue3.js.map +1 -1
  25. package/dist/esm/components/CnReportsPage/CnReportsPage.vue2.js.map +1 -1
  26. package/dist/esm/components/CnReportsPage/CnReportsPage.vue3.js.map +1 -1
  27. package/dist/esm/components/CnStorePage/CnStorePage.vue2.js.map +1 -1
  28. package/dist/esm/components/CnStorePage/CnStorePage.vue3.js.map +1 -1
  29. package/dist/esm/components/CnWikiPage/CnWikiPage.vue2.js.map +1 -1
  30. package/dist/esm/components/CnWikiPage/CnWikiPage.vue3.js.map +1 -1
  31. package/dist/esm/l10n/en.json.js +5 -0
  32. package/dist/esm/l10n/en.json.js.map +1 -1
  33. package/dist/esm/l10n/nl.json.js +5 -0
  34. package/dist/esm/l10n/nl.json.js.map +1 -1
  35. package/dist/esm/nextcloud-vue.css +108 -9
  36. package/dist/esm/utils/validateManifestV2.compiled.js +1 -1
  37. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  38. package/dist/nextcloud-vue.cjs.js +857 -490
  39. package/dist/nextcloud-vue.cjs.js.map +2 -2
  40. package/dist/nextcloud-vue.css +108 -9
  41. package/l10n/en.json +5 -0
  42. package/l10n/nl.json +5 -0
  43. package/package.json +3 -2
  44. package/scripts/manifest-schema-hash.js +41 -0
  45. package/scripts/record-manifest-schema-hash.js +52 -0
  46. package/src/components/CnAppNav/CnAppNav.vue +128 -0
  47. package/src/components/CnBannerWidget/CnBannerWidget.vue +113 -2
  48. package/src/components/CnBrandStripe/CnBrandStripe.md +16 -0
  49. package/src/components/CnBrandStripe/CnBrandStripe.vue +58 -5
  50. package/src/components/CnDashboardPage/CnDashboardPage.vue +30 -6
  51. package/src/components/CnDataTable/CnDataTable.md +1 -1
  52. package/src/components/CnDataTable/CnDataTable.vue +42 -17
  53. package/src/components/CnLinkCardsPage/CnLinkCardsPage.vue +35 -2
  54. package/src/components/CnObjectSidebar/CnFilesTab.vue +65 -0
  55. package/src/components/CnReportsPage/CnReportsPage.vue +7 -0
  56. package/src/components/CnStorePage/CnStorePage.vue +7 -0
  57. package/src/components/CnWikiPage/CnWikiPage.vue +7 -0
  58. package/src/css/table.css +1 -2
  59. package/src/public/index.js +5 -0
  60. package/src/schemas/app-manifest-v2.schema.json +1 -1
  61. package/src/utils/validateManifestV2.compiled.js +1 -1
@@ -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
 
@@ -419,6 +449,52 @@ export default {
419
449
  return this.conditionMet === true
420
450
  },
421
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
+
422
498
  /** Whether the banner navigates on click (route set + router present). */
423
499
  clickable() {
424
500
  return !!this.resolvedRoute && !!this.$router
@@ -493,27 +569,36 @@ export default {
493
569
  * modes — the Wave-1 banner shape is the canonical one, extracted to
494
570
  * `utils/visibleWhen.js` in Wave 3 so manifest actions reuse it),
495
571
  * keeping the read VALUE for the `{value}` text placeholder.
496
- * 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.
497
575
  *
498
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
499
578
  */
500
579
  async evaluateCondition() {
501
580
  const outcome = this.resolvedConditionOutcome
502
581
  if (outcome) {
503
582
  this.conditionMet = outcome.met === true
504
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 : ''
505
586
  return
506
587
  }
507
588
  const cond = this.resolvedVisibleWhen
508
589
  if (!cond) {
509
590
  this.conditionMet = null
510
591
  this.conditionValue = null
592
+ this.conditionFailed = false
593
+ this.conditionFailureReason = ''
511
594
  return
512
595
  }
513
596
  try {
514
597
  const value = await readVisibleWhenValue(cond)
515
598
  this.conditionValue = value
516
599
  this.conditionMet = compareVisibleWhen(value, cond.op || 'eq', cond.value)
600
+ this.conditionFailed = false
601
+ this.conditionFailureReason = ''
517
602
  } catch (error) {
518
603
  // Still hidden: a banner that cannot tell whether it applies
519
604
  // should not claim attention. But not silently, because a
@@ -522,6 +607,8 @@ export default {
522
607
  console.warn('[CnBannerWidget] visibleWhen could not be evaluated, banner hidden:', error?.message || error)
523
608
  this.conditionMet = false
524
609
  this.conditionValue = null
610
+ this.conditionFailed = true
611
+ this.conditionFailureReason = typeof error?.message === 'string' ? error.message : String(error ?? '')
525
612
  }
526
613
  },
527
614
 
@@ -557,6 +644,30 @@ export default {
557
644
  color: inherit;
558
645
  }
559
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
+
560
671
  /* Attention card. The severity edge is an inset shadow, so it takes no width. */
561
672
  .cn-banner-widget--attention {
562
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 {
@@ -2752,8 +2752,12 @@ export default {
2752
2752
  * which for an attention card is its `title` when it has no `text`.
2753
2753
  *
2754
2754
  * @param {object} def Widget definition.
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
+ *
2755
2758
  * @spec openspec/changes/link-cards-page/specs/link-cards-page/spec.md#requirement-an-attention-card-with-a-title-keeps-its-cell
2756
- * @return {{ text: string, visibleWhen: (object|null) }}
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 }}
2757
2761
  */
2758
2762
  widgetDisplayConfig(def) {
2759
2763
  const content = (def && typeof def.content === 'object' && def.content) || {}
@@ -2767,6 +2771,7 @@ export default {
2767
2771
  return {
2768
2772
  text: content.text || props.text || title || '',
2769
2773
  visibleWhen: (def && def.visibleWhen) || content.visibleWhen || props.visibleWhen || null,
2774
+ layout,
2770
2775
  }
2771
2776
  },
2772
2777
 
@@ -2776,15 +2781,22 @@ export default {
2776
2781
  * text at all — and must therefore surrender its grid cell in live
2777
2782
  * mode instead of leaving an empty card behind.
2778
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
+ *
2779
2790
  * @param {object} item Layout item.
2780
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
2781
2793
  */
2782
2794
  isCollapsedWidget(item) {
2783
2795
  const def = this.getWidgetDef(item.widgetId)
2784
2796
  if (!def || !def.type) {
2785
2797
  return false
2786
2798
  }
2787
- const { text, visibleWhen } = this.widgetDisplayConfig(def)
2799
+ const { text, visibleWhen, layout } = this.widgetDisplayConfig(def)
2788
2800
  if (this.isBannerDef(def) && text === '') {
2789
2801
  return true
2790
2802
  }
@@ -2792,6 +2804,9 @@ export default {
2792
2804
  return false
2793
2805
  }
2794
2806
  const outcome = this.widgetConditionOutcome[item.widgetId]
2807
+ if (outcome && outcome.failed === true && layout === 'attention' && this.isBannerDef(def)) {
2808
+ return false
2809
+ }
2795
2810
  return !outcome || outcome.met !== true
2796
2811
  },
2797
2812
 
@@ -2802,13 +2817,17 @@ export default {
2802
2817
  * Runs on created() and again whenever `widgets` changes (the
2803
2818
  * in-app editor mutates defs in place). Fail-safe like the banner
2804
2819
  * itself: any fetch/shape error counts as "not met", so a broken
2805
- * 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.
2806
2824
  *
2807
2825
  * Flips `widgetConditionsSettled` when the initial round is done —
2808
2826
  * synchronously when there is nothing to evaluate, so a dashboard
2809
2827
  * without conditional widgets never waits.
2810
2828
  *
2811
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
2812
2831
  */
2813
2832
  async evaluateWidgetConditions() {
2814
2833
  const conditional = (this.widgets || []).filter((def) => def && def.id && def.type
@@ -2830,12 +2849,17 @@ export default {
2830
2849
  }
2831
2850
  await Promise.all(conditional.map(async (def) => {
2832
2851
  const cond = this.widgetDisplayConfig(def).visibleWhen
2833
- let outcome = { met: false, value: null }
2852
+ let outcome
2834
2853
  try {
2835
2854
  const value = await readVisibleWhenValue(cond)
2836
2855
  outcome = { met: compareVisibleWhen(value, cond.op || 'eq', cond.value), value }
2837
- } catch {
2838
- // 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)
2839
2863
  }
2840
2864
  // A newer run owns the map now — a stale verdict (possibly for
2841
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}
@@ -395,6 +395,13 @@ export default {
395
395
  padding: calc(5 * var(--default-grid-baseline));
396
396
  }
397
397
 
398
+ /* Clear the Nextcloud navigation toggle button (44px wide, absolutely
399
+ positioned at the left edge of .app-content) plus 12px breathing room, as
400
+ CnPageHeader does. Only the header shifts; the cards keep the full width. */
401
+ .cn-link-cards-page__header {
402
+ padding-inline-start: 56px;
403
+ }
404
+
398
405
  .cn-link-cards-page__title {
399
406
  margin: 0 0 var(--default-grid-baseline);
400
407
  }
@@ -445,12 +452,32 @@ export default {
445
452
  gap: calc(3 * var(--default-grid-baseline));
446
453
  min-width: 0;
447
454
  padding: calc(4 * var(--default-grid-baseline));
448
- text-decoration: none;
455
+ transition: background-color var(--animation-quick, 100ms) ease, border-color var(--animation-quick, 100ms) ease, box-shadow var(--animation-quick, 100ms) ease;
456
+ }
457
+
458
+ /* A card is a link, and it must not look like a line of prose. A theme may
459
+ underline every link with `!important` (thematiq: `a { text-decoration:
460
+ underline !important }`, specificity 0,0,1). Among `!important` rules the
461
+ higher specificity wins, so this class rule has to carry it too; without it
462
+ the label and the description both rendered underlined on a live Nextcloud. */
463
+ .cn-link-cards-page__card,
464
+ .cn-link-cards-page__card:hover,
465
+ .cn-link-cards-page__card:focus,
466
+ .cn-link-cards-page__card:active {
467
+ text-decoration: none !important;
449
468
  }
450
469
 
470
+ /* Hover moves the whole card: border, background and a soft lift. */
451
471
  .cn-link-cards-page__card:hover {
452
472
  background: var(--color-background-hover);
453
473
  border-color: var(--color-primary-element);
474
+ box-shadow: 0 2px 6px var(--color-box-shadow);
475
+ }
476
+
477
+ @media (prefers-reduced-motion: reduce) {
478
+ .cn-link-cards-page__card {
479
+ transition: none;
480
+ }
454
481
  }
455
482
 
456
483
  .cn-link-cards-page__card:focus-visible {
@@ -471,8 +498,14 @@ export default {
471
498
  min-width: 0;
472
499
  }
473
500
 
501
+ /* The label is the card's title. Its colour is set here, on the span, because
502
+ a theme may force its link colour on the `a` itself; an inherited link
503
+ colour made the title read as a hyperlink. */
474
504
  .cn-link-cards-page__label {
475
- font-weight: 600;
505
+ color: var(--color-main-text);
506
+ font-size: 1.05em;
507
+ font-weight: 700;
508
+ line-height: 1.3;
476
509
  overflow-wrap: anywhere;
477
510
  }
478
511