@conduction/nextcloud-vue 2.69.0 → 2.71.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 (153) hide show
  1. package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js +25 -0
  2. package/dist/esm/components/CnActionsBar/CnActionsBar.vue2.js.map +1 -1
  3. package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js +155 -152
  4. package/dist/esm/components/CnActionsBar/CnActionsBar.vue3.js.map +1 -1
  5. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js +77 -0
  6. package/dist/esm/components/CnAppNav/CnAppNav.vue2.js.map +1 -1
  7. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js +8 -8
  8. package/dist/esm/components/CnAppNav/CnAppNav.vue3.js.map +1 -1
  9. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js +151 -13
  10. package/dist/esm/components/CnAppRoot/CnAppRoot.vue2.js.map +1 -1
  11. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js +3 -1
  12. package/dist/esm/components/CnAppRoot/CnAppRoot.vue3.js.map +1 -1
  13. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue.js +1 -0
  14. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue.js.map +1 -1
  15. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue2.js +28 -0
  16. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue2.js.map +1 -1
  17. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js +4 -2
  18. package/dist/esm/components/CnBreadcrumbs/CnBreadcrumbs.vue3.js.map +1 -1
  19. package/dist/esm/components/CnCalendarWidget/CnCalendarWidget.vue2.js +25 -1
  20. package/dist/esm/components/CnCalendarWidget/CnCalendarWidget.vue2.js.map +1 -1
  21. package/dist/esm/components/CnCalendarWidget/CnCalendarWidget.vue3.js +32 -17
  22. package/dist/esm/components/CnCalendarWidget/CnCalendarWidget.vue3.js.map +1 -1
  23. package/dist/esm/components/CnCapabilityTable/CnCapabilityTable.vue2.js +14 -1
  24. package/dist/esm/components/CnCapabilityTable/CnCapabilityTable.vue2.js.map +1 -1
  25. package/dist/esm/components/CnCapabilityTable/CnCapabilityTable.vue3.js.map +1 -1
  26. package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue2.js +43 -2
  27. package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue2.js.map +1 -1
  28. package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue3.js +3 -3
  29. package/dist/esm/components/CnDashboardGrid/CnDashboardGrid.vue3.js.map +1 -1
  30. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js +25 -1
  31. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue2.js.map +1 -1
  32. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js +40 -43
  33. package/dist/esm/components/CnDashboardPage/CnDashboardPage.vue3.js.map +1 -1
  34. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js +35 -1
  35. package/dist/esm/components/CnDataTable/CnDataTable.vue2.js.map +1 -1
  36. package/dist/esm/components/CnDataTable/CnDataTable.vue3.js.map +1 -1
  37. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js +54 -3
  38. package/dist/esm/components/CnDetailPage/CnDetailPage.vue2.js.map +1 -1
  39. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js +6 -3
  40. package/dist/esm/components/CnDetailPage/CnDetailPage.vue3.js.map +1 -1
  41. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js +28 -0
  42. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue2.js.map +1 -1
  43. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js +3 -2
  44. package/dist/esm/components/CnDetailWidgetHost/CnDetailWidgetHost.vue3.js.map +1 -1
  45. package/dist/esm/components/CnFeaturesAndRoadmapPage/CnFeaturesAndRoadmapPage.vue2.js +12 -0
  46. package/dist/esm/components/CnFeaturesAndRoadmapPage/CnFeaturesAndRoadmapPage.vue2.js.map +1 -1
  47. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue2.js +9 -0
  48. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue2.js.map +1 -1
  49. package/dist/esm/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue3.js.map +1 -1
  50. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js +29 -2
  51. package/dist/esm/components/CnFormDialog/CnFormDialog.vue2.js.map +1 -1
  52. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js +71 -34
  53. package/dist/esm/components/CnFormDialog/CnFormDialog.vue3.js.map +1 -1
  54. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js +54 -7
  55. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue2.js.map +1 -1
  56. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js +26 -8
  57. package/dist/esm/components/CnHeaderWidget/CnHeaderWidget.vue3.js.map +1 -1
  58. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js +165 -0
  59. package/dist/esm/components/CnIndexPage/CnIndexPage.vue2.js.map +1 -1
  60. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js +69 -26
  61. package/dist/esm/components/CnIndexPage/CnIndexPage.vue3.js.map +1 -1
  62. package/dist/esm/components/CnNextStepCard/CnNextStepCard.vue2.js.map +1 -1
  63. package/dist/esm/components/CnNextStepCard/CnNextStepCard.vue3.js.map +1 -1
  64. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js +4 -1
  65. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue2.js.map +1 -1
  66. package/dist/esm/components/CnObjectListWidget/CnObjectListWidget.vue3.js.map +1 -1
  67. package/dist/esm/components/CnObjectSidebar/CnNotesTab.vue2.js +1 -1
  68. package/dist/esm/components/CnObjectSidebar/CnNotesTab.vue2.js.map +1 -1
  69. package/dist/esm/components/CnObjectSidebar/CnNotesTab.vue3.js.map +1 -1
  70. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue2.js.map +1 -1
  71. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js +46 -32
  72. package/dist/esm/components/CnSetupWizard/CnSetupWizard.vue3.js.map +1 -1
  73. package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue2.js +17 -3
  74. package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue2.js.map +1 -1
  75. package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue3.js +25 -24
  76. package/dist/esm/components/CnStackedBarWidget/CnStackedBarWidget.vue3.js.map +1 -1
  77. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue2.js.map +1 -1
  78. package/dist/esm/components/CnStagesWidget/CnStagesWidget.vue3.js.map +1 -1
  79. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js +27 -0
  80. package/dist/esm/components/CnStatWidget/CnStatWidget.vue2.js.map +1 -1
  81. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js +7 -0
  82. package/dist/esm/components/CnStatWidget/CnStatWidget.vue3.js.map +1 -1
  83. package/dist/esm/components/CnStatsBlock/CnStatsBlock.vue2.js +24 -1
  84. package/dist/esm/components/CnStatsBlock/CnStatsBlock.vue2.js.map +1 -1
  85. package/dist/esm/components/CnStatsBlock/CnStatsBlock.vue3.js +1 -1
  86. package/dist/esm/components/CnStatsBlock/CnStatsBlock.vue3.js.map +1 -1
  87. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue2.js +11 -0
  88. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue2.js.map +1 -1
  89. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue3.js +4 -2
  90. package/dist/esm/components/CnStatsBlockWidget/CnStatsBlockWidget.vue3.js.map +1 -1
  91. package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue2.js +30 -9
  92. package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue2.js.map +1 -1
  93. package/dist/esm/components/CnWalkthrough/CnWalkthrough.vue3.js.map +1 -1
  94. package/dist/esm/components/CnWeekStripWidget/CnWeekStripWidget.vue2.js +15 -2
  95. package/dist/esm/components/CnWeekStripWidget/CnWeekStripWidget.vue2.js.map +1 -1
  96. package/dist/esm/components/CnWeekStripWidget/CnWeekStripWidget.vue3.js +31 -32
  97. package/dist/esm/components/CnWeekStripWidget/CnWeekStripWidget.vue3.js.map +1 -1
  98. package/dist/esm/composables/useWalkthrough.js +11 -3
  99. package/dist/esm/composables/useWalkthrough.js.map +1 -1
  100. package/dist/esm/nextcloud-vue.css +198 -0
  101. package/dist/esm/utils/capabilityComparison.js +29 -1
  102. package/dist/esm/utils/capabilityComparison.js.map +1 -1
  103. package/dist/esm/utils/columnFilters.js +54 -4
  104. package/dist/esm/utils/columnFilters.js.map +1 -1
  105. package/dist/esm/utils/userAutocomplete.js +69 -3
  106. package/dist/esm/utils/userAutocomplete.js.map +1 -1
  107. package/dist/esm/utils/validateManifestV2.compiled.js +2 -2
  108. package/dist/esm/utils/validateManifestV2.compiled.js.map +1 -1
  109. package/dist/nextcloud-vue.cjs.js +2778 -1630
  110. package/dist/nextcloud-vue.cjs.js.map +2 -2
  111. package/dist/nextcloud-vue.css +198 -0
  112. package/package.json +1 -1
  113. package/src/components/CnActionsBar/CnActionsBar.md +2 -0
  114. package/src/components/CnActionsBar/CnActionsBar.vue +28 -2
  115. package/src/components/CnAppNav/CnAppNav.vue +85 -8
  116. package/src/components/CnAppRoot/CnAppRoot.vue +153 -13
  117. package/src/components/CnBreadcrumbs/CnBreadcrumbs.vue +45 -0
  118. package/src/components/CnCalendarWidget/CnCalendarWidget.vue +32 -4
  119. package/src/components/CnCapabilityTable/CnCapabilityTable.vue +14 -0
  120. package/src/components/CnDashboardGrid/CnDashboardGrid.md +2 -0
  121. package/src/components/CnDashboardGrid/CnDashboardGrid.vue +54 -2
  122. package/src/components/CnDashboardPage/CnDashboardPage.vue +46 -19
  123. package/src/components/CnDataTable/CnDataTable.vue +35 -1
  124. package/src/components/CnDetailPage/CnDetailPage.md +2 -0
  125. package/src/components/CnDetailPage/CnDetailPage.vue +57 -3
  126. package/src/components/CnDetailWidgetHost/CnDetailWidgetHost.vue +30 -1
  127. package/src/components/CnFeaturesAndRoadmapPage/CnFeaturesAndRoadmapPage.vue +12 -0
  128. package/src/components/CnFeaturesAndRoadmapView/CnFeaturesAndRoadmapView.vue +9 -0
  129. package/src/components/CnFormDialog/CnFormDialog.vue +62 -2
  130. package/src/components/CnHeaderWidget/CnHeaderWidget.vue +95 -8
  131. package/src/components/CnIndexPage/CnIndexPage.vue +194 -7
  132. package/src/components/CnNextStepCard/CnNextStepCard.vue +11 -0
  133. package/src/components/CnObjectListWidget/CnObjectListWidget.vue +4 -1
  134. package/src/components/CnObjectSidebar/CnNotesTab.vue +1 -1
  135. package/src/components/CnSetupWizard/CnSetupWizard.vue +7 -0
  136. package/src/components/CnStackedBarWidget/CnStackedBarWidget.vue +34 -7
  137. package/src/components/CnStagesWidget/CnStagesWidget.vue +19 -0
  138. package/src/components/CnStatWidget/CnStatWidget.vue +36 -0
  139. package/src/components/CnStatsBlock/CnStatsBlock.md +2 -0
  140. package/src/components/CnStatsBlock/CnStatsBlock.vue +25 -2
  141. package/src/components/CnStatsBlockWidget/CnStatsBlockWidget.vue +13 -0
  142. package/src/components/CnWalkthrough/CnWalkthrough.vue +30 -9
  143. package/src/components/CnWeekStripWidget/CnWeekStripWidget.vue +24 -3
  144. package/src/composables/useWalkthrough.js +11 -3
  145. package/src/css/dashboard.css +44 -0
  146. package/src/css/detail-page.css +10 -0
  147. package/src/css/index-page.css +12 -0
  148. package/src/css/kpi-card.css +15 -0
  149. package/src/schemas/app-manifest-v2.schema.json +47 -5
  150. package/src/utils/capabilityComparison.js +28 -0
  151. package/src/utils/columnFilters.js +54 -4
  152. package/src/utils/userAutocomplete.js +68 -2
  153. package/src/utils/validateManifestV2.compiled.js +2 -2
@@ -1 +1 @@
1
- {"version":3,"file":"CnStagesWidget.vue3.js","sources":["../../../../src/components/CnStagesWidget/CnStagesWidget.vue?vue&type=template&id=358fde0f&lang.js"],"sourcesContent":["<!--\n SPDX-FileCopyrightText: 2026 Conduction B.V.\n SPDX-License-Identifier: EUPL-1.2\n\n CnStagesWidget: the record's stages as a placeable, configurable widget,\n where clicking a stage moves the record there.\n\n Keep this comment OUT of the template: a comment node beside the root element\n makes the component multi-root in Vue 3.\n-->\n<template>\n\t<div\n\t\tclass=\"cn-stages-widget\"\n\t\tdata-testid=\"cn-stages-widget\"\n\t\t:aria-busy=\"busy ? 'true' : null\">\n\t\t<!-- EVERY MESSAGE IS A NOTE CARD, the way the other 60-odd components in\n\t\t this library say something went wrong. Loose coloured prose under a\n\t\t strip reads as part of the record, not as a message about it, which\n\t\t is exactly how it looked on a live case: an orange sentence floating\n\t\t under the stages with nothing to say it was a notice. -->\n\t\t<NcNoteCard v-if=\"stagesError\" type=\"error\" data-testid=\"cn-stages-widget-load-error\">\n\t\t\t{{ tr('Could not load the stages') }}\n\t\t</NcNoteCard>\n\t\t<div v-else-if=\"stagesPending && stages.length === 0\" class=\"cn-stages-widget__loading\">\n\t\t\t<NcLoadingIcon :size=\"24\" :name=\"tr('Loading stages')\" />\n\t\t</div>\n\t\t<!-- Not the same as a case type with no stages, and must not say so. -->\n\t\t<NcNoteCard\n\t\t\tv-else-if=\"stagesUnaddressable\"\n\t\t\ttype=\"warning\"\n\t\t\tdata-testid=\"cn-stages-widget-unaddressable\">\n\t\t\t{{ tr('The stages cannot be read for this record yet') }}\n\t\t</NcNoteCard>\n\t\t<p v-else-if=\"stages.length === 0\" class=\"cn-stages-widget__notice\" data-testid=\"cn-stages-widget-empty\">\n\t\t\t{{ tr('No stages to show') }}\n\t\t</p>\n\t\t<template v-else>\n\t\t\t<!-- The bars variant (`variant: \"bars\"`): equal columns, a thin bar\n\t\t\t per step coloured by state, the label under it and an optional\n\t\t\t date line. No description: eight Woo steps with their\n\t\t\t descriptions crowded into five lines and clipped the first\n\t\t\t label. The current step says so for a screen reader. -->\n\t\t\t<ol\n\t\t\t\tv-if=\"isBars\"\n\t\t\t\tclass=\"cn-stages-widget__bars\"\n\t\t\t\t:aria-label=\"ariaLabel\"\n\t\t\t\tdata-testid=\"cn-stages-widget-bars\">\n\t\t\t\t<li\n\t\t\t\t\tv-for=\"stage in barStages\"\n\t\t\t\t\t:key=\"stage.id\"\n\t\t\t\t\tclass=\"cn-stages-widget__bar-step\"\n\t\t\t\t\t:class=\"`cn-stages-widget__bar-step--${stage.state}`\"\n\t\t\t\t\t:aria-current=\"stage.state === 'current' ? 'step' : null\"\n\t\t\t\t\t:data-testid=\"`cn-stages-widget-bar-${stage.id}`\">\n\t\t\t\t\t<span class=\"cn-stages-widget__bar\" aria-hidden=\"true\" />\n\t\t\t\t\t<component\n\t\t\t\t\t\t:is=\"stage.clickable ? 'button' : 'span'\"\n\t\t\t\t\t\t:type=\"stage.clickable ? 'button' : null\"\n\t\t\t\t\t\tclass=\"cn-stages-widget__bar-label\"\n\t\t\t\t\t\t:title=\"stage.hint || stage.label\"\n\t\t\t\t\t\t:aria-disabled=\"stage.disabled && stage.clickable ? 'true' : null\"\n\t\t\t\t\t\t:data-testid=\"`cn-stages-widget-stage-${stage.id}`\"\n\t\t\t\t\t\t@click=\"onBarClick(stage)\">\n\t\t\t\t\t\t{{ stage.label }}<span v-if=\"stage.state === 'current'\" class=\"cn-stages-widget__sr-only\"> ({{ tr('current step') }})</span>\n\t\t\t\t\t</component>\n\t\t\t\t\t<span v-if=\"stage.date\" class=\"cn-stages-widget__bar-date\">{{ stage.date }}</span>\n\t\t\t\t\t<span\n\t\t\t\t\t\tv-if=\"stage.reason\"\n\t\t\t\t\t\t:class=\"stage.reasonVisible ? 'cn-stages-widget__reason' : 'cn-stages-widget__sr-only'\"\n\t\t\t\t\t\t:data-testid=\"`cn-stages-widget-reason-${stage.id}`\">\n\t\t\t\t\t\t{{ stage.reason }}\n\t\t\t\t\t</span>\n\t\t\t\t</li>\n\t\t\t</ol>\n\t\t\t<CnTimelineStages\n\t\t\t\tv-else\n\t\t\t\t:stages=\"timelineStages\"\n\t\t\t\t:currentStage=\"currentStageId\"\n\t\t\t\t:orientation=\"orientation\"\n\t\t\t\t:size=\"size\"\n\t\t\t\t:clickable=\"interactive\"\n\t\t\t\t:aria-label=\"ariaLabel\"\n\t\t\t\t@stageClick=\"onStageClick\"\n\t\t\t\t@stageBlocked=\"onStageBlocked\">\n\t\t\t\t<template #label=\"{ stage }\">\n\t\t\t\t\t<span class=\"cn-timeline-stages__label\" :data-testid=\"`cn-stages-widget-stage-${stage.id}`\">\n\t\t\t\t\t\t{{ stage.label }}\n\t\t\t\t\t</span>\n\t\t\t\t\t<span v-if=\"stage.subtitle\" class=\"cn-timeline-stages__subtitle\">\n\t\t\t\t\t\t{{ stage.subtitle }}\n\t\t\t\t\t</span>\n\t\t\t\t\t<!-- What the move says about itself, or why the stage cannot be\n\t\t\t\t\t chosen.\n\t\t\t\t\t A note from a reachable action is SHOWN: it tells the\n\t\t\t\t\t person what happens if they click, and there is nowhere\n\t\t\t\t\t else for that to live.\n\t\t\t\t\t A REFUSAL is not shown here. It went on screen beside\n\t\t\t\t\t every stage the record could not reach, which on a real\n\t\t\t\t\t case page meant the same sentence printed three times\n\t\t\t\t\t over and once more under the strip. It is now reachable\n\t\t\t\t\t three ways instead: the stage's `title` on hover, the\n\t\t\t\t\t live region under the strip on a click or an Enter, and\n\t\t\t\t\t this node, which stays in the DOM inside the stage so it\n\t\t\t\t\t is part of the stage's accessible name and a screen\n\t\t\t\t\t reader reads it without any interaction at all. -->\n\t\t\t\t\t<span\n\t\t\t\t\t\tv-if=\"stage.reason\"\n\t\t\t\t\t\t:class=\"stage.reasonVisible ? 'cn-stages-widget__reason' : 'cn-stages-widget__sr-only'\"\n\t\t\t\t\t\t:data-testid=\"`cn-stages-widget-reason-${stage.id}`\">\n\t\t\t\t\t\t{{ stage.reason }}\n\t\t\t\t\t</span>\n\t\t\t\t</template>\n\t\t\t</CnTimelineStages>\n\n\t\t\t<!-- The guard could not be read at all. Said once, here, rather than\n\t\t\t by every stage claiming it is not reachable.\n\t\t\t AN ERROR, not a warning: nothing refused anything, a request\n\t\t\t failed. It must not wear the same colour as a stage a guard\n\t\t\t declined, or a broken instance and a guarded record would look\n\t\t\t like the same situation. -->\n\t\t\t<NcNoteCard\n\t\t\t\tv-if=\"actionsFailed\"\n\t\t\t\ttype=\"error\"\n\t\t\t\tdata-testid=\"cn-stages-widget-actions-error\">\n\t\t\t\t{{ tr('Could not check which stages can be reached') }}\n\t\t\t</NcNoteCard>\n\t\t\t<NcNoteCard\n\t\t\t\tv-if=\"moveError && !pendingAction\"\n\t\t\t\ttype=\"error\"\n\t\t\t\tdata-testid=\"cn-stages-widget-error\">\n\t\t\t\t{{ moveError }}\n\t\t\t</NcNoteCard>\n\t\t\t<!-- What a click on a stage that cannot be chosen answered.\n\t\t\t A WARNING, the level Ruben named for a move that cannot be made:\n\t\t\t the same colour the refused stage itself carries, so the sentence\n\t\t\t and the stage it belongs to read as one thing.\n\t\t\t `role=\"status\"` stays on it. NcNoteCard gives a warning card\n\t\t\t `role=\"note\"`, which is not announced, and this card appears in\n\t\t\t answer to a click: somebody who cannot see it has to hear it. -->\n\t\t\t<NcNoteCard\n\t\t\t\tv-if=\"blockedMessage && !moveError\"\n\t\t\t\ttype=\"warning\"\n\t\t\t\trole=\"status\"\n\t\t\t\tdata-testid=\"cn-stages-widget-blocked\">\n\t\t\t\t{{ blockedMessage }}\n\t\t\t</NcNoteCard>\n\t\t\t<p class=\"cn-stages-widget__sr-only\" aria-live=\"polite\">\n\t\t\t\t{{ statusMessage }}\n\t\t\t</p>\n\t\t</template>\n\n\t\t<!-- The SHARED transition input dialog, the one CnLifecycleActions uses.\n\t\t One dialog and one input vocabulary: a transition declares `inputs`\n\t\t and the dialog collects exactly those. Cancelling POSTs nothing. -->\n\t\t<CnTransitionInputDialog\n\t\t\tv-if=\"pendingAction\"\n\t\t\t:transition=\"pendingAction\"\n\t\t\t:schema=\"schema\"\n\t\t\t@confirm=\"onInputConfirm\"\n\t\t\t@close=\"pendingAction = null\" />\n\t</div>\n</template>\n\n<script>\nimport { emit as emitBus } from '@nextcloud/event-bus'\nimport { translate as t } from '@nextcloud/l10n'\nimport { NcLoadingIcon, NcNoteCard } from '@nextcloud/vue'\nimport { inject, ref } from 'vue'\nimport CnTransitionInputDialog from '../../dialogs/CnTransitionInputDialog.vue'\nimport CnTimelineStages from '../CnTimelineStages/CnTimelineStages.vue'\nimport { getByPath, useEndpointSource } from '../../composables/useEndpointSource.js'\nimport {\n\tactionNote,\n\tactionsByTarget,\n\tdeclaresInputs,\n\tisBlockedAction,\n\tperformTransition,\n\treadAvailableActions,\n\ttransitionError,\n} from '../../composables/useLifecycleTransitions.js'\nimport { useObjectStore } from '../../store/useObjectStore.js'\nimport { resolveObjectOpType } from '../../utils/actionsDispatcher.js'\nimport { resolveObjectTokenContext } from '../../utils/detailObjectContext.js'\nimport {\n\tdropOptionalUnresolved,\n\thasUnresolvedTokens,\n\tresolveFilterTokens,\n} from '../../utils/resolveFilterTokens.js'\nimport { normalizeStages, stageSavePayload } from './stagesModel.js'\n\n// The stepper's look lives in the global timeline stylesheet. Imported here\n// so the widget renders styled without the app's global css/index.css, the\n// same reason CnStatWidget imports kpi-card.css.\nimport '../../css/timeline-stages.css'\n\n/** The page-level refresh channel every endpoint widget and CnDetailPage answer. */\nconst PAGE_REFRESH_CHANNEL = 'cn:page:refresh'\n\n/**\n * Unwrap a value that may be a plain object or a `{ value }` holder.\n *\n * @param {unknown} v The injected value.\n * @return {unknown} The unwrapped value.\n */\nfunction unwrap(v) {\n\treturn (v && typeof v === 'object' && 'value' in v) ? v.value : v\n}\n\n/**\n * Flatten a register or schema reference to its slug or id.\n *\n * @param {unknown} value The reference (string, or an object with slug/id).\n * @return {string} The slug or id, or ''.\n */\nfunction refKey(value) {\n\tif (value && typeof value === 'object') {\n\t\treturn String(value.slug || value.id || '')\n\t}\n\treturn (value === undefined || value === null) ? '' : String(value)\n}\n\n/**\n * An Error whose message is safe to show a person.\n *\n * @param {string} message The message.\n * @return {Error} The error, marked for display.\n */\nfunction userError(message) {\n\tconst error = new Error(message)\n\terror.userMessage = message\n\treturn error\n}\n\n/**\n * CnStagesWidget: the stages a record moves through, as a placeable widget.\n * Clicking a stage moves the record to that stage.\n *\n * Registered as the `stages` widget type for the detail page. It reads the\n * current stage off the bound record, draws the stage list with\n * `CnTimelineStages`, and moves the record through OpenRegister's lifecycle\n * when a reachable stage is clicked. Nothing about the record type is\n * hard-coded.\n *\n * It is the same contract `CnLifecycleActions` speaks, rendered differently:\n * that component draws the allowed moves as buttons, this one draws them as a\n * timeline. Both go through `useLifecycleTransitions`, so there is one place\n * that knows what a transition is.\n *\n * ## The stage list\n *\n * The lifecycle says what is reachable NOW. It does not say what the whole\n * process looks like, and a timeline that only showed the next step would not\n * be a timeline. So the list of stages is configured, and it is display only:\n * it grants nothing.\n *\n * One of two sources:\n *\n * - `stagesEndpoint`: `{ url, method?, path, params?, idField?, labelField?,\n * descriptionField?, orderField?, finalField? }`. The url and params take\n * the shared token grammar (`@objectId`, `@object.<field>`), so\n * `/apps/myapp/api/types/@object.type/stages` names the record's own type.\n * `path` points at the array in the response.\n * - `stagesSource`: `{ register, schema, filter?, orderBy?, idField?,\n * labelField?, descriptionField?, finalField?, limit? }`, an OpenRegister\n * query whose filter takes the same tokens.\n *\n * `finalField` marks the stages that close the record, which the timeline\n * draws differently. It grants nothing either.\n *\n * ## Which stages can be reached\n *\n * `GET /apps/openregister/api/objects/{id}/available-actions`, the same\n * endpoint `CnLifecycleActions` reads. It answers\n * `{ actions: [{ action, to, requires, description, inputs?, blocked? }] }`\n * already filtered to the record's current state, so a stage is reachable\n * exactly when an action leads to it.\n *\n * That is why there is no `allowed` flag to read, no field mapping to get\n * backwards and no config that can remove the guard: a stage no action\n * reaches is disabled because nothing said it was reachable. It fails closed\n * by construction rather than by a check somebody has to remember to write.\n *\n * `description` becomes the note beside the stage. `requires` does NOT: it is\n * the guard class's dependency-injection tag, an identifier for OpenRegister\n * and not a sentence for a person, and it used to be printed on screen.\n *\n * An action may also answer `blocked: true`, which is the third case: the move\n * exists but a guard refuses it right now, and `description` says why. The\n * stage renders disabled with that reason on screen, which reads differently\n * from \"no action reaches this stage\" and differently again from \"the\n * availability read failed\".\n *\n * ## Moving\n *\n * `POST /apps/openregister/api/objects/{id}/transition` with `{ action }`, or\n * `{ action, data }` when the action declares `inputs: [{ field, required }]`,\n * mirroring `x-openregister-lifecycle.transitions.<action>.inputs`. An action\n * with inputs opens the shared `CnTransitionInputDialog` first, and cancelling\n * it sends nothing. OpenRegister re-validates the move, and a 403 or 422 is\n * shown where the click happened.\n *\n * `transition` selects the mode:\n *\n * - absent: read only. The stages render and nothing is clickable.\n * - `{ kind: 'lifecycle' }` (the registry default): the contract above.\n * - `{ kind: 'field' }`: an EXPLICIT opt-in for a record whose schema has no\n * lifecycle. It writes `currentField` on the record through the object\n * store, sending the stage change and the record's own properties, and it\n * REFUSES when it cannot prove it is updating rather than creating. There is\n * no server-side validation on this path, which is why it is not the\n * default: whatever the timeline offers is what happens.\n *\n * After a successful move the widget shows the new stage at once, fires\n * `cn:page:refresh` so the page re-reads the record, and re-reads the allowed\n * actions for the stage the record is now on.\n *\n * ```js\n * content: {\n * currentField: 'status',\n * stagesEndpoint: {\n * url: '/apps/myapp/api/case-types/@object.caseType/blueprint',\n * path: 'statusTypes',\n * orderField: 'order',\n * finalField: 'isFinal',\n * },\n * transition: { kind: 'lifecycle' },\n * unreachableReason: 'Not possible from the current stage',\n * }\n * ```\n */\nexport default {\n\tname: 'CnStagesWidget',\n\n\tcomponents: {\n\t\tCnTimelineStages,\n\t\tCnTransitionInputDialog,\n\t\tNcLoadingIcon,\n\t\tNcNoteCard,\n\t},\n\n\t// The detail host spreads `content` onto the widget as attributes. They are\n\t// config, not DOM attributes, so they must not land on the root element.\n\tinheritAttrs: false,\n\n\tprops: {\n\t\t/**\n\t\t * The widget's config. See the component description for every key:\n\t\t * `currentField`, `stagesEndpoint` or `stagesSource`, `transition`\n\t\t * (`{ kind: 'lifecycle' }` by default, or `{ kind: 'field' }`, or\n\t\t * absent for a read-only strip), `unreachableReason`, `orientation`,\n\t\t * `size`, `ariaLabel`, `label` and `variant` (`\"bars\"` draws a thin\n\t\t * coloured bar per step with the label under it and no description;\n\t\t * the source's `dateField` adds a date line).\n\t\t *\n\t\t * `ariaLabel` names the strip for a screen reader. It falls back to\n\t\t * `label`, the placement's own title, so a titled card does not have to\n\t\t * repeat itself, and then to \"Stages\".\n\t\t *\n\t\t * @type {{currentField?: string, stagesEndpoint?: object, stagesSource?: object, transition?: object, unreachableReason?: string, orientation?: ('horizontal'|'vertical'), size?: ('medium'|'small'), ariaLabel?: string, label?: string, variant?: ('dots'|'bars')}}\n\t\t */\n\t\tcontent: {\n\t\t\ttype: Object,\n\t\t\tdefault: () => ({}),\n\t\t},\n\n\t\t/**\n\t\t * The bound record, when the surface passes it. Falls back to the\n\t\t * detail page's injected object context.\n\t\t *\n\t\t * @type {object|null}\n\t\t */\n\t\tobjectData: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/**\n\t\t * The bound record's id, when the surface passes it. Falls back to the\n\t\t * injected object context.\n\t\t */\n\t\tobjectId: {\n\t\t\ttype: [String, Number],\n\t\t\tdefault: '',\n\t\t},\n\n\t\t/**\n\t\t * The object-store type slug of the bound record, used by the `field`\n\t\t * transition. Falls back to the context, then to the register and\n\t\t * schema of the page.\n\t\t */\n\t\tobjectType: {\n\t\t\ttype: String,\n\t\t\tdefault: '',\n\t\t},\n\n\t\t/**\n\t\t * The object store to save through. Falls back to the context's store,\n\t\t * then to the shared `useObjectStore()`.\n\t\t *\n\t\t * @type {object|null}\n\t\t */\n\t\tstore: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/**\n\t\t * The record's JSON Schema, forwarded to `CnTransitionInputDialog` so a\n\t\t * transition's declared inputs render with the property's title and\n\t\t * type instead of a bare text box. Optional, exactly as on\n\t\t * `CnLifecycleActions`.\n\t\t *\n\t\t * @type {object|null}\n\t\t */\n\t\tschema: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/**\n\t\t * Translate function for the manifest-authored strings. Falls back to\n\t\t * the injected `cnTranslate`, an identity function by default.\n\t\t *\n\t\t * @type {((key: string) => string)|null}\n\t\t */\n\t\ttranslate: {\n\t\t\ttype: Function,\n\t\t\tdefault: null,\n\t\t},\n\t},\n\n\temits: ['moved'],\n\n\tsetup(props) {\n\t\tconst objectCtxRaw = inject('cnObjectContext', null)\n\t\tconst detailCtxRaw = inject('cnDetailObjectContext', null)\n\t\tconst workspaceRaw = inject('cnWorkspaceContext', ref({}))\n\t\tconst appConfigRaw = inject('cnAppConfig', ref({}))\n\t\tconst cnTranslate = inject('cnTranslate', (key) => key)\n\n\t\t/**\n\t\t * The token context: the bound record, its id, and the page context.\n\t\t * Explicit props win over the injected detail context.\n\t\t *\n\t\t * @return {object} The context.\n\t\t */\n\t\tconst tokenCtx = () => {\n\t\t\tconst base = resolveObjectTokenContext(objectCtxRaw, detailCtxRaw) || {}\n\t\t\treturn {\n\t\t\t\t...base,\n\t\t\t\tobject: props.objectData || base.object || null,\n\t\t\t\tobjectId: (props.objectId !== '' && props.objectId !== null && props.objectId !== undefined) ? props.objectId : (base.objectId ?? null),\n\t\t\t\tworkspace: unwrap(workspaceRaw) || {},\n\t\t\t\tconfig: unwrap(appConfigRaw) || {},\n\t\t\t}\n\t\t}\n\n\t\t// Both reads go through the shared endpoint engine: tokens, request\n\t\t// dedup, a short cache, and the cn:page:refresh subscription.\n\t\tconst stagesRead = useEndpointSource(() => {\n\t\t\tconst cfg = props.content?.stagesEndpoint\n\t\t\treturn (cfg && cfg.url) ? { url: cfg.url, method: cfg.method, params: cfg.params } : null\n\t\t}, { ctx: tokenCtx })\n\n\t\treturn {\n\t\t\tdetailCtxRaw,\n\t\t\tobjectCtxRaw,\n\t\t\tcnTranslate,\n\t\t\ttokenCtx,\n\t\t\tstagesBody: stagesRead.data,\n\t\t\tstagesBodyLoading: stagesRead.loading,\n\t\t\tstagesBodyError: stagesRead.error,\n\t\t\tstagesBodyBlocked: stagesRead.blocked,\n\t\t}\n\t},\n\n\tdata() {\n\t\treturn {\n\t\t\t/** @type {Array<object>} Stage rows read from `stagesSource`. */\n\t\t\tsourceRows: [],\n\t\t\t/** @type {boolean} Whether the `stagesSource` read is in flight. */\n\t\t\tsourceLoading: false,\n\t\t\t/** @type {string} Why the `stagesSource` read failed, or ''. */\n\t\t\tsourceError: '',\n\t\t\t/**\n\t\t\t * @type {string|null} The stage a successful move went to, shown until\n\t\t\t * the re-read record catches up.\n\t\t\t */\n\t\t\tmovedTo: null,\n\t\t\t/**\n\t\t\t * @type {Array<object>} The moves OpenRegister allows from the\n\t\t\t * record's current state, as `/available-actions` answered them.\n\t\t\t */\n\t\t\tactions: [],\n\t\t\t/**\n\t\t\t * @type {boolean} Whether the allowed moves have been read at all\n\t\t\t * yet. Nothing is clickable before they have: an empty list and an\n\t\t\t * unread list look identical, and only one of them means \"no move\n\t\t\t * is allowed\".\n\t\t\t */\n\t\t\tactionsLoaded: false,\n\t\t\t/**\n\t\t\t * @type {boolean} The read failed, as opposed to answering that\n\t\t\t * there is nothing to do. A failure is reported as one rather than\n\t\t\t * dressed up as a policy decision about every stage.\n\t\t\t */\n\t\t\tactionsFailed: false,\n\t\t\t/**\n\t\t\t * @type {object|null} The action whose declared inputs are being\n\t\t\t * collected. Non-null mounts CnTransitionInputDialog, and the POST\n\t\t\t * waits for its confirm.\n\t\t\t */\n\t\t\tpendingAction: null,\n\t\t\t/** @type {boolean} Whether a move is running. */\n\t\t\tbusy: false,\n\t\t\t/** @type {string} Why the last move failed, or ''. */\n\t\t\tmoveError: '',\n\t\t\t/**\n\t\t\t * @type {string} What a click on a stage that cannot be chosen said,\n\t\t\t * shown until the next click. A refusal the person can see, rather\n\t\t\t * than a control that does nothing.\n\t\t\t */\n\t\t\tblockedMessage: '',\n\t\t\t/** @type {string} The last outcome, for the polite live region. */\n\t\t\tstatusMessage: '',\n\t\t}\n\t},\n\n\tcomputed: {\n\t\t/**\n\t\t * The bound record, or null while it is still loading.\n\t\t *\n\t\t * @return {object|null} The record.\n\t\t */\n\t\trecord() {\n\t\t\treturn this.tokenCtx().object || null\n\t\t},\n\n\t\t/**\n\t\t * The bound record's id, from every shape it can arrive in.\n\t\t *\n\t\t * The token context first, then the record's own `id`, then the\n\t\t * OpenRegister `@self.id` envelope. The context alone was not enough:\n\t\t * `resolveObjectTokenContext` has no `@self` fallback, so a record\n\t\t * shaped `{'@self': {id}}` reaching a surface that passes no explicit\n\t\t * `objectId` resolved to nothing.\n\t\t *\n\t\t * @return {string} The id, or '' when the record has none.\n\t\t */\n\t\trecordId() {\n\t\t\tconst fromCtx = this.tokenCtx().objectId\n\t\t\tif (fromCtx !== null && fromCtx !== undefined && fromCtx !== '') {\n\t\t\t\treturn String(fromCtx)\n\t\t\t}\n\t\t\tconst record = this.record\n\t\t\tif (!record) {\n\t\t\t\treturn ''\n\t\t\t}\n\t\t\tconst own = record.id ?? record['@self']?.id ?? record.uuid\n\t\t\treturn (own === undefined || own === null) ? '' : String(own)\n\t\t},\n\n\t\t/**\n\t\t * The stage id the record holds in `currentField`, or null.\n\t\t *\n\t\t * @return {string|null} The stage id.\n\t\t */\n\t\trecordStageId() {\n\t\t\tconst field = this.content.currentField\n\t\t\tif (!field || !this.record) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tconst raw = getByPath(this.record, field)\n\t\t\treturn (raw === undefined || raw === null || raw === '') ? null : String(raw)\n\t\t},\n\n\t\t/**\n\t\t * The stage to mark as current: a just-made move until the record\n\t\t * catches up, else the record's own.\n\t\t *\n\t\t * @return {string|null} The stage id.\n\t\t */\n\t\tcurrentStageId() {\n\t\t\treturn this.movedTo ?? this.recordStageId\n\t\t},\n\n\t\t/**\n\t\t * Whether the stages come from an endpoint (it wins over a source).\n\t\t *\n\t\t * @return {boolean} True for `stagesEndpoint`.\n\t\t */\n\t\tendpointStages() {\n\t\t\treturn Boolean(this.content.stagesEndpoint && this.content.stagesEndpoint.url)\n\t\t},\n\n\t\t/**\n\t\t * The field mapping of the active stage source.\n\t\t *\n\t\t * @return {object} The config.\n\t\t */\n\t\tstageConfig() {\n\t\t\treturn (this.endpointStages ? this.content.stagesEndpoint : this.content.stagesSource) || {}\n\t\t},\n\n\t\t/**\n\t\t * The ordered, normalised stages.\n\t\t *\n\t\t * @return {Array<{id: string, label: string, subtitle: string, final: boolean}>} The stages.\n\t\t */\n\t\tstages() {\n\t\t\tconst cfg = this.stageConfig\n\t\t\tconst rows = this.endpointStages ? getByPath(this.stagesBody, cfg.path) : this.sourceRows\n\t\t\tconst orderField = cfg.orderField || (this.endpointStages ? '' : cfg.orderBy)\n\t\t\treturn normalizeStages(rows, { ...cfg, orderField })\n\t\t},\n\n\t\t/**\n\t\t * Whether the stage list is still on its way.\n\t\t *\n\t\t * @return {boolean} True while loading.\n\t\t */\n\t\tstagesPending() {\n\t\t\tif (!this.endpointStages) {\n\t\t\t\treturn this.sourceLoading\n\t\t\t}\n\t\t\tif (this.stagesBodyLoading) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t\t// Blocked means the url has an unset token, so nothing was asked.\n\t\t\t// Wait only while the record that token reads from is still coming;\n\t\t\t// once it is here the endpoint will never resolve.\n\t\t\tif (this.stagesBodyBlocked) {\n\t\t\t\treturn this.record === null\n\t\t\t}\n\t\t\treturn this.stagesBody === null\n\t\t},\n\n\t\t/**\n\t\t * Whether the endpoint names a token the record does not carry, so the\n\t\t * stages can never be read. Kept apart from the empty state: they need\n\t\t * different fixes.\n\t\t *\n\t\t * @return {boolean}\n\t\t */\n\t\tstagesUnaddressable() {\n\t\t\treturn Boolean(this.endpointStages && this.stagesBodyBlocked && this.record !== null)\n\t\t},\n\n\t\t/**\n\t\t * Whether the stage list could not be read.\n\t\t *\n\t\t * @return {boolean} True on a failed read.\n\t\t */\n\t\tstagesError() {\n\t\t\treturn Boolean(this.endpointStages ? this.stagesBodyError : this.sourceError)\n\t\t},\n\n\t\t/**\n\t\t * The configured transition, or null when the widget is read-only.\n\t\t *\n\t\t * An unrecognised `kind` reads as read-only rather than as lifecycle: a\n\t\t * typo must not silently pick a mode nobody asked for, and a strip that\n\t\t * does nothing is the safe end of that mistake.\n\t\t *\n\t\t * @return {object|null} The transition.\n\t\t */\n\t\ttransition() {\n\t\t\tconst tr = this.content.transition\n\t\t\tif (!tr || typeof tr !== 'object') {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tif (tr.kind === 'field' || tr.kind === 'lifecycle') {\n\t\t\t\treturn tr\n\t\t\t}\n\t\t\treturn null\n\t\t},\n\n\t\t/**\n\t\t * Whether moves go through OpenRegister's lifecycle.\n\t\t *\n\t\t * @return {boolean} True on the lifecycle path.\n\t\t */\n\t\tlifecycleMode() {\n\t\t\treturn Boolean(this.transition) && this.transition.kind !== 'field'\n\t\t},\n\n\t\t/**\n\t\t * Whether the strip is interactive at all: a transition is configured\n\t\t * and there is a property to write the stage to.\n\t\t *\n\t\t * This deliberately does NOT fall to false while a move runs.\n\t\t * `clickable` drives the roving tabindex in CnTimelineStages, so\n\t\t * flipping it mid-move took every stage's focus stop away and dropped\n\t\t * a keyboard user's focus to `body` with nothing to restore it to. A\n\t\t * busy strip keeps its stops and disables its stages instead.\n\t\t *\n\t\t * @return {boolean} True when the widget is interactive.\n\t\t */\n\t\tinteractive() {\n\t\t\treturn Boolean(this.transition) && Boolean(this.content.currentField)\n\t\t},\n\n\t\t/**\n\t\t * Whether the widget may move the record right now.\n\t\t *\n\t\t * @return {boolean} True when it is interactive and idle.\n\t\t */\n\t\tcanMove() {\n\t\t\treturn this.interactive && !this.busy\n\t\t},\n\n\t\t/**\n\t\t * The allowed moves by the stage they lead to, or null while they are\n\t\t * unknown.\n\t\t *\n\t\t * Null is not the same as an empty map. Before `/available-actions` has\n\t\t * answered, nothing is known about any stage; after it has, an absent\n\t\t * stage is a stage no move reaches. Collapsing the two would make a\n\t\t * strip clickable for the length of one request.\n\t\t *\n\t\t * @return {Map<string, object>|null} The moves.\n\t\t */\n\t\tmoves() {\n\t\t\tif (!this.lifecycleMode) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tif (!this.actionsLoaded) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\treturn actionsByTarget(this.actions)\n\t\t},\n\n\t\t/**\n\t\t * The stages as CnTimelineStages renders them, each carrying whether it\n\t\t * can be chosen and, when not, why.\n\t\t *\n\t\t * @return {Array<object>} The timeline stages.\n\t\t */\n\t\ttimelineStages() {\n\t\t\treturn this.stages.map((stage) => {\n\t\t\t\tconst access = this.stageAccess(stage)\n\t\t\t\treturn {\n\t\t\t\t\tid: stage.id,\n\t\t\t\t\tlabel: stage.label,\n\t\t\t\t\tsubtitle: stage.subtitle,\n\t\t\t\t\t...access,\n\t\t\t\t\t// The hover route to a reason that is no longer printed\n\t\t\t\t\t// beside the stage. Only where the stage cannot be chosen: a\n\t\t\t\t\t// reachable move's note is already on screen, and a tooltip\n\t\t\t\t\t// repeating visible text is noise.\n\t\t\t\t\thint: access.disabled ? access.reason : '',\n\t\t\t\t}\n\t\t\t})\n\t\t},\n\n\t\t/**\n\t\t * The accessible name of the stage list.\n\t\t *\n\t\t * @return {string} The name.\n\t\t */\n\t\tariaLabel() {\n\t\t\tconst label = this.content.ariaLabel || this.content.label\n\t\t\treturn label ? this.effectiveTranslate(label) : this.tr('Stages')\n\t\t},\n\n\t\t/**\n\t\t * Whether the bars variant is on (`content.variant: \"bars\"`). Off by\n\t\t * default: the dot strip renders as it always has.\n\t\t *\n\t\t * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-stages-widget-draws-bars\n\t\t * @return {boolean}\n\t\t */\n\t\tisBars() {\n\t\t\treturn this.content.variant === 'bars'\n\t\t},\n\n\t\t/**\n\t\t * The stages for the bars variant: each with its `state` (`done`\n\t\t * before the current stage, `current`, `todo` after it, every stage\n\t\t * `todo` when none is current), its `date`, and whether it is a\n\t\t * button (the strip is interactive and the stage is not the current\n\t\t * one).\n\t\t *\n\t\t * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-stages-widget-draws-bars\n\t\t * @return {Array<object>}\n\t\t */\n\t\tbarStages() {\n\t\t\tconst current = this.stages.findIndex((s) => s.id === this.currentStageId)\n\t\t\treturn this.timelineStages.map((stage, index) => {\n\t\t\t\tlet state = 'todo'\n\t\t\t\tif (current >= 0 && index < current) {\n\t\t\t\t\tstate = 'done'\n\t\t\t\t} else if (index === current) {\n\t\t\t\t\tstate = 'current'\n\t\t\t\t}\n\t\t\t\treturn {\n\t\t\t\t\t...stage,\n\t\t\t\t\tdate: this.stages[index]?.date || '',\n\t\t\t\t\tstate,\n\t\t\t\t\tclickable: this.interactive && state !== 'current',\n\t\t\t\t}\n\t\t\t})\n\t\t},\n\n\t\t/** @return {'horizontal'|'vertical'} The layout direction. */\n\t\torientation() {\n\t\t\treturn this.content.orientation === 'vertical' ? 'vertical' : 'horizontal'\n\t\t},\n\n\t\t/** @return {'medium'|'small'} The indicator size. */\n\t\tsize() {\n\t\t\treturn this.content.size === 'small' ? 'small' : 'medium'\n\t\t},\n\n\t\t/**\n\t\t * Effective translate function for manifest-authored strings.\n\t\t *\n\t\t * @return {(key: string) => string} The function.\n\t\t */\n\t\teffectiveTranslate() {\n\t\t\treturn this.translate || this.cnTranslate || ((key) => key)\n\t\t},\n\n\t\t/**\n\t\t * A stable signature of the `stagesSource` query, so it refetches only\n\t\t * on a real change.\n\t\t *\n\t\t * @return {string} The signature.\n\t\t */\n\t\tsourceKey() {\n\t\t\tconst cfg = this.content.stagesSource\n\t\t\tif (this.endpointStages || !cfg || !cfg.register || !cfg.schema) {\n\t\t\t\treturn ''\n\t\t\t}\n\t\t\treturn JSON.stringify({ cfg, filter: resolveFilterTokens(cfg.filter || {}, this.tokenCtx()) })\n\t\t},\n\n\t\t/**\n\t\t * What the allowed-moves read depends on: the record it is about, and\n\t\t * whether the lifecycle is in use at all.\n\t\t *\n\t\t * @return {string} The signature.\n\t\t */\n\t\tactionsKey() {\n\t\t\treturn this.lifecycleMode ? String(this.recordId) : ''\n\t\t},\n\t},\n\n\twatch: {\n\t\tsourceKey: {\n\t\t\timmediate: true,\n\t\t\thandler() {\n\t\t\t\tthis.fetchSourceStages()\n\t\t\t},\n\t\t},\n\n\t\trecord(next, previous) {\n\t\t\tif (next === previous) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\t// A RE-READ RECORD IS AUTHORITATIVE, whatever it says.\n\t\t\t//\n\t\t\t// `movedTo` is the optimistic stage, shown so a move looks\n\t\t\t// immediate. It used to be cleared only when `recordStageId`\n\t\t\t// CHANGED, so a 200 that did not actually move the record (a guard\n\t\t\t// the server enforced silently, a no-op transition) left the strip\n\t\t\t// claiming a stage the record never reached, permanently, while the\n\t\t\t// availability answer described the real one. The arrival of a\n\t\t\t// fresh record ends the optimism either way.\n\t\t\tthis.movedTo = null\n\t\t},\n\n\t\trecordStageId(next, previous) {\n\t\t\tif (next === previous) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\t// The record moved without us, so the list in hand describes a stage\n\t\t\t// it has left. Our OWN move refetches inside `performMove`, while\n\t\t\t// `busy` still holds, so this does not double-request it.\n\t\t\tif (previous !== null && !this.busy) {\n\t\t\t\tthis.loadActions()\n\t\t\t}\n\t\t},\n\n\t\tactionsKey: {\n\t\t\timmediate: true,\n\t\t\thandler() {\n\t\t\t\tthis.loadActions()\n\t\t\t},\n\t\t},\n\t},\n\n\tmethods: {\n\t\t/**\n\t\t * Translate a library string.\n\t\t *\n\t\t * @param {string} text The source string.\n\t\t * @param {object} [vars] Placeholder values.\n\t\t * @return {string} The translated string.\n\t\t */\n\t\ttr(text, vars) {\n\t\t\treturn t('nextcloud-vue', text, vars)\n\t\t},\n\n\t\t/**\n\t\t * Whether a stage can be chosen and, when not, why.\n\t\t *\n\t\t * On the lifecycle path there is nothing to decide: OpenRegister has\n\t\t * already filtered the actions to the record's current state, so a\n\t\t * stage is reachable exactly when an action leads to it. There is no\n\t\t * flag to read and no way to configure the guard away.\n\t\t *\n\t\t * Three refusals live here and they must not read alike, because they\n\t\t * are not the same claim. NO ACTION REACHES THE STAGE is a policy\n\t\t * answer about the process. A BLOCKED action is a policy answer about\n\t\t * this record right now, and it comes with the app's own reason. A\n\t\t * FAILED READ is not an answer at all, and says so. Collapsing any two\n\t\t * of them tells somebody something nobody checked.\n\t\t *\n\t\t * Only ONE of the three earns colour, and `blocked` in the returned shape\n\t\t * is what carries it: the guard that refused THIS record. The other two\n\t\t * stay grey. A strip where every stage is orange says nothing, and the\n\t\t * failed read is not a refusal at all.\n\t\t *\n\t\t * @param {{id: string}} stage The stage.\n\t\t * @return {{disabled: boolean, reason: string, reasonVisible: boolean, blocked: boolean}} The access.\n\t\t */\n\t\tstageAccess(stage) {\n\t\t\tconst open = { disabled: false, reason: '', reasonVisible: false, blocked: false }\n\t\t\tif (!this.transition) {\n\t\t\t\treturn open\n\t\t\t}\n\t\t\t// You cannot move to where you already are. That is NOT the same as\n\t\t\t// blocked, so it is not announced as blocked: the stage carries\n\t\t\t// `aria-current=\"step\"` and nothing else. Marking it\n\t\t\t// `aria-disabled` on top said \"you may not go here\" about the place\n\t\t\t// the record already is. `onStageClick` refuses it explicitly.\n\t\t\tif (stage.id === this.currentStageId) {\n\t\t\t\treturn open\n\t\t\t}\n\t\t\t// A move is running, or the action list in hand is the one for the\n\t\t\t// stage the record has just LEFT. Everything is disabled until the\n\t\t\t// fresh list lands, and the focus stops stay.\n\t\t\tif (!this.canMove) {\n\t\t\t\treturn { disabled: true, reason: '', reasonVisible: false, blocked: false }\n\t\t\t}\n\t\t\t// The field path has no server to ask, which is exactly why it is an\n\t\t\t// explicit opt-in: every stage is offered and the write decides.\n\t\t\tif (!this.lifecycleMode) {\n\t\t\t\treturn open\n\t\t\t}\n\t\t\t// A FAILED READ IS NOT A POLICY DECISION. Saying \"not reachable from\n\t\t\t// the current stage\" about every stage would state, confidently and\n\t\t\t// wrongly, something nobody has checked.\n\t\t\t// NOT `blocked` either: nothing refused this move, the question was\n\t\t\t// never answered. Colouring it would dress a broken request up as a\n\t\t\t// policy decision.\n\t\t\tif (this.actionsFailed) {\n\t\t\t\treturn {\n\t\t\t\t\tdisabled: true,\n\t\t\t\t\t// Nothing is claimed about a stage the record has already\n\t\t\t\t\t// left: the card above says the read failed, once, and a\n\t\t\t\t\t// sentence about reaching somewhere the record has been\n\t\t\t\t\t// would be a third claim nobody made.\n\t\t\t\t\treason: this.isPastStage(stage) ? '' : this.tr('Could not check whether this stage can be reached'),\n\t\t\t\t\treasonVisible: false,\n\t\t\t\t\tblocked: false,\n\t\t\t\t}\n\t\t\t}\n\t\t\t// Not read yet. Not \"no moves allowed\", which is why this is not the\n\t\t\t// same branch as an empty list.\n\t\t\tif (this.moves === null) {\n\t\t\t\treturn { disabled: true, reason: '', reasonVisible: false, blocked: false }\n\t\t\t}\n\t\t\tconst move = this.moves.get(stage.id)\n\t\t\tif (!move) {\n\t\t\t\t// A STAGE THE RECORD HAS ALREADY PASSED IS NOT A REFUSAL. On a\n\t\t\t\t// live case the first stage carried \"Not possible from the\n\t\t\t\t// current status\" beside a green, completed circle, which reads\n\t\t\t\t// as something having gone wrong with something that already\n\t\t\t\t// happened. History needs no excuse: it says nothing at all.\n\t\t\t\t// A past stage an action DOES reach, a reopen, never gets here:\n\t\t\t\t// it has a move, so it stays clickable with its own note.\n\t\t\t\tif (this.isPastStage(stage)) {\n\t\t\t\t\treturn { disabled: true, reason: '', reasonVisible: false, blocked: false }\n\t\t\t\t}\n\t\t\t\tconst reason = this.content.unreachableReason\n\t\t\t\t\t? this.effectiveTranslate(this.content.unreachableReason)\n\t\t\t\t\t: this.tr('Not reachable from the current stage')\n\t\t\t\t// NOT printed beside the stage. This is the generic sentence, and\n\t\t\t\t// it lands on EVERY stage no action reaches, so on a case with\n\t\t\t\t// three stages ahead of it the strip printed one sentence three\n\t\t\t\t// times over. It is still reachable: `title` on hover, the live\n\t\t\t\t// region under the strip on a click or an Enter, and the\n\t\t\t\t// screen-reader node inside the stage, which puts it in the\n\t\t\t\t// stage's accessible name with no interaction at all.\n\t\t\t\t// NOT `blocked`: nothing refused this record, the process simply\n\t\t\t\t// does not lead here from where it stands. Colouring it would\n\t\t\t\t// make a normal timeline look like a wall of refusals.\n\t\t\t\treturn { disabled: true, reason, reasonVisible: false, blocked: false }\n\t\t\t}\n\t\t\t// OFFERED AND REFUSED. The move exists, so the stage keeps its place\n\t\t\t// in the process, but a guard says not now. The app's own reason is\n\t\t\t// the point of this branch, so it is carried rather than replaced by\n\t\t\t// wording of ours; the fallback is only for a guard that gave none.\n\t\t\t// THE ONE CASE THAT IS COLOURED. This is a refusal about this record,\n\t\t\t// so the stage reads as refused at a glance instead of looking like\n\t\t\t// one that is merely further down the process, and the reason is one\n\t\t\t// hover or one click away rather than printed beside it.\n\t\t\tif (isBlockedAction(move)) {\n\t\t\t\treturn {\n\t\t\t\t\tdisabled: true,\n\t\t\t\t\treason: actionNote(move) || this.tr('This move is not possible right now'),\n\t\t\t\t\treasonVisible: false,\n\t\t\t\t\tblocked: true,\n\t\t\t\t}\n\t\t\t}\n\t\t\t// What the move says about itself. It is not a refusal, so it is\n\t\t\t// shown rather than hidden: it tells the person what happens next.\n\t\t\treturn { ...open, reason: actionNote(move), reasonVisible: Boolean(actionNote(move)) }\n\t\t},\n\n\t\t/**\n\t\t * Whether the record has already passed a stage.\n\t\t *\n\t\t * Position in the configured list, which is the same thing\n\t\t * `CnTimelineStages` draws as \"completed\", so the two cannot disagree\n\t\t * about what is history.\n\t\t *\n\t\t * @param {{id: string}} stage The stage.\n\t\t * @return {boolean} Whether it sits behind the record's current stage.\n\t\t */\n\t\tisPastStage(stage) {\n\t\t\tconst current = this.stages.findIndex((s) => s.id === this.currentStageId)\n\t\t\t// -1: the record is on no stage we know, so nothing is behind it.\n\t\t\t// 0: it is on the first one, and there is nothing behind that either.\n\t\t\tif (current <= 0) {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\tconst index = this.stages.findIndex((s) => s.id === stage.id)\n\t\t\treturn index !== -1 && index < current\n\t\t},\n\n\t\t/**\n\t\t * A stage was clicked: move there, collecting the action's declared\n\t\t * inputs first when it has any.\n\t\t *\n\t\t * @param {{stage: {id: string}}} payload The CnTimelineStages event.\n\t\t * @return {void}\n\t\t */\n\t\t/**\n\t\t * A click on a bar-variant step: a disabled step reports why (as\n\t\t * CnTimelineStages does through `stage-blocked`), any other goes\n\t\t * through the shared move path.\n\t\t *\n\t\t * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-stages-widget-draws-bars\n\t\t * @param {object} stage The bar step.\n\t\t * @return {void}\n\t\t */\n\t\tonBarClick(stage) {\n\t\t\tif (!stage.clickable) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (stage.disabled) {\n\t\t\t\tthis.onStageBlocked({ stage })\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.onStageClick({ stage })\n\t\t},\n\n\t\tonStageClick({ stage }) {\n\t\t\tif (!this.canMove || !stage) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\t// The current stage is no longer marked disabled, so refuse it here.\n\t\t\t// You cannot move to where you already are, and re-firing the move\n\t\t\t// that just landed is exactly what a stray click would do.\n\t\t\tif (stage.id === this.currentStageId) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\t// No `target.disabled` check: CnTimelineStages emits `stage-blocked`\n\t\t\t// for those and never `stage-click`, so a second check here was a\n\t\t\t// branch nothing could reach and nothing could test.\n\t\t\tconst target = this.timelineStages.find((s) => s.id === stage.id)\n\t\t\tif (!target) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.blockedMessage = ''\n\t\t\tthis.moveError = ''\n\t\t\tconst move = this.moves ? this.moves.get(stage.id) : null\n\t\t\t// No move, or a move a guard has refused. Both fail closed here as\n\t\t\t// well as in `stageAccess`, so the POST cannot happen even if a\n\t\t\t// caller reaches this method some other way than a click on a stage\n\t\t\t// the strip already disabled.\n\t\t\tif (this.lifecycleMode && (!move || isBlockedAction(move))) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst request = {\n\t\t\t\tstage: { id: stage.id, label: target.label },\n\t\t\t\taction: move ? move.action : stage.id,\n\t\t\t\tinputs: (move && Array.isArray(move.inputs)) ? move.inputs : [],\n\t\t\t}\n\t\t\t// A transition that declares inputs collects them first, and\n\t\t\t// cancelling the dialog sends nothing. `CnTransitionInputDialog`\n\t\t\t// reads `transition.inputs` and `transition.label`, so the request\n\t\t\t// carries the shape that dialog already understands.\n\t\t\tif (this.lifecycleMode && declaresInputs(move)) {\n\t\t\t\tthis.pendingAction = {\n\t\t\t\t\taction: move.action,\n\t\t\t\t\tto: move.to,\n\t\t\t\t\tlabel: this.tr('Move to {stage}', { stage: target.label }),\n\t\t\t\t\tinputs: move.inputs,\n\t\t\t\t\t__request: request,\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.performMove(request)\n\t\t},\n\n\t\t/**\n\t\t * A stage that cannot be chosen was clicked: say why.\n\t\t *\n\t\t * SAY SOMETHING. This used to be silence. The reason was rendered as\n\t\t * screen-reader-only text, so a pointer user clicked, nothing moved,\n\t\t * nothing appeared, and nothing explained it. The sentence now goes on\n\t\t * screen in a polite live region, so both kinds of user get the same\n\t\t * answer from the same click.\n\t\t *\n\t\t * @param {{stage: {id: string}}} payload The CnTimelineStages event.\n\t\t * @return {void}\n\t\t */\n\t\tonStageBlocked({ stage }) {\n\t\t\tif (!stage) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst target = this.timelineStages.find((s) => s.id === stage.id)\n\t\t\tthis.blockedMessage = (target && target.reason)\n\t\t\t\t|| (this.busy\n\t\t\t\t\t? this.tr('Just a moment, the last move is still running')\n\t\t\t\t\t: this.tr('This stage cannot be chosen right now'))\n\t\t},\n\n\t\t/**\n\t\t * The input dialog confirmed: send the move with what it collected.\n\t\t *\n\t\t * @param {object} data The collected input values.\n\t\t * @return {void}\n\t\t */\n\t\tonInputConfirm(data) {\n\t\t\tconst pending = this.pendingAction\n\t\t\tthis.pendingAction = null\n\t\t\tif (!pending || !pending.__request) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.performMove(pending.__request, data)\n\t\t},\n\n\t\t/**\n\t\t * Perform a move.\n\t\t *\n\t\t * @param {{stage: {id: string, label: string}, action: string}} request The move.\n\t\t * @param {object} [data] The collected transition inputs, when any.\n\t\t * @return {Promise<void>}\n\t\t */\n\t\tasync performMove(request, data) {\n\t\t\tthis.busy = true\n\t\t\tthis.moveError = ''\n\t\t\ttry {\n\t\t\t\tif (this.lifecycleMode) {\n\t\t\t\t\tawait performTransition(this.recordId, request.action, data)\n\t\t\t\t} else {\n\t\t\t\t\tawait this.moveViaField(request)\n\t\t\t\t}\n\t\t\t\tthis.movedTo = request.stage.id\n\t\t\t\tthis.statusMessage = this.tr('Moved to {stage}', { stage: request.stage.label })\n\t\t\t\t/**\n\t\t\t\t * @event moved The record moved to another stage.\n\t\t\t\t * @type {{stage: string, action: string}}\n\t\t\t\t */\n\t\t\t\tthis.$emit('moved', { stage: request.stage.id, action: request.action })\n\t\t\t\t// The page re-reads the record and every endpoint widget\n\t\t\t\t// refetches.\n\t\t\t\temitBus(PAGE_REFRESH_CHANNEL, {})\n\t\t\t\tthis.refreshContextRecord()\n\t\t\t\t// A MOVE INVALIDATES THE LIST THAT MADE IT. Awaiting the fresh\n\t\t\t\t// list here, inside the try, is what keeps `busy` true across\n\t\t\t\t// the whole window: until it lands, the list in hand describes\n\t\t\t\t// the stage the record has just LEFT, and a click against it\n\t\t\t\t// would POST a move the server has already closed. Fails\n\t\t\t\t// closed, because `busy` only clears in the finally below.\n\t\t\t\tif (this.lifecycleMode) {\n\t\t\t\t\tawait this.loadActions()\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\tthis.moveError = transitionError(error, this.tr('The move could not be made'))\n\t\t\t} finally {\n\t\t\t\tthis.busy = false\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Read the moves OpenRegister allows from the record's current state.\n\t\t *\n\t\t * @return {Promise<void>}\n\t\t */\n\t\tasync loadActions() {\n\t\t\t// THE LIST IN HAND IS ALREADY WRONG. Whatever prompted this read,\n\t\t\t// the answer we are holding describes a state the record may have\n\t\t\t// left, so it stops being authoritative NOW rather than when the\n\t\t\t// replacement arrives. Without this the `recordStageId` watcher\n\t\t\t// path refetched with `busy` false and the old list still driving\n\t\t\t// the strip, so a stage the record had left stayed clickable and a\n\t\t\t// click POSTed a move out of a state that no longer existed.\n\t\t\tthis.actions = []\n\t\t\tthis.actionsLoaded = false\n\t\t\tthis.actionsFailed = false\n\t\t\tif (!this.lifecycleMode || !this.recordId) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst id = this.recordId\n\t\t\tconst read = await readAvailableActions(id)\n\t\t\t// The record may have moved on while the read was in flight; the\n\t\t\t// newer read owns the state.\n\t\t\tif (String(this.recordId) !== String(id)) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.actions = read.actions\n\t\t\tthis.actionsFailed = read.failed\n\t\t\t// A FAILED READ IS NOT AN ANSWER. Leaving `actionsLoaded` false\n\t\t\t// keeps every stage disabled, and `actionsFailed` is what makes the\n\t\t\t// widget say the guard could not be checked rather than let each\n\t\t\t// stage claim it is not reachable.\n\t\t\tthis.actionsLoaded = !read.failed\n\t\t},\n\n\t\t/**\n\t\t * Save the bound record with the stage field set to the target stage.\n\t\t *\n\t\t * The `{ kind: 'field' }` opt-in, for a record whose schema declares no\n\t\t * lifecycle. Nothing validates this move but the schema itself, which\n\t\t * is why it is not the default.\n\t\t *\n\t\t * @param {{stage: {id: string}}} request The move request.\n\t\t * @return {Promise<void>}\n\t\t */\n\t\tasync moveViaField(request) {\n\t\t\tconst record = this.record\n\t\t\tif (!record) {\n\t\t\t\tthrow userError(this.tr('The record is not loaded yet'))\n\t\t\t}\n\t\t\tconst store = this.resolveStore()\n\t\t\tif (!store) {\n\t\t\t\tthrow userError(this.tr('The move could not be made'))\n\t\t\t}\n\t\t\tconst type = this.resolveType(store)\n\t\t\tif (!type) {\n\t\t\t\tthrow userError(this.tr('The move could not be made'))\n\t\t\t}\n\t\t\t// REFUSE rather than save without an id. `saveObject` picks PUT over\n\t\t\t// POST purely on the presence of `id`, and an OpenRegister record\n\t\t\t// carries its id in `@self`, not at the top level, so a record whose\n\t\t\t// context id happened to be empty was POSTed as A BRAND NEW OBJECT\n\t\t\t// and the widget then announced a successful move. The record the\n\t\t\t// person was looking at never changed, and an orphan row was left\n\t\t\t// behind. `CnDetailPage.onEditFormConfirm` refuses the same case for\n\t\t\t// the same reason.\n\t\t\tconst id = this.recordId\n\t\t\tif (id === '') {\n\t\t\t\tthrow userError(this.tr('Cannot move: this record has no id, so the move would create a duplicate instead of updating it.'))\n\t\t\t}\n\t\t\tconst payload = stageSavePayload(record, id, this.content.currentField, request.stage.id)\n\t\t\tconst saved = await store.saveObject(type, payload)\n\t\t\tif (!saved) {\n\t\t\t\tconst error = typeof store.getError === 'function' ? store.getError(type) : null\n\t\t\t\tthrow userError((error && error.message) || this.tr('The move could not be made'))\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * The object store to save through: the prop, the context's, or the\n\t\t * shared one.\n\t\t *\n\t\t * @return {object|null} The store.\n\t\t */\n\t\tresolveStore() {\n\t\t\tif (this.store) {\n\t\t\t\treturn this.store\n\t\t\t}\n\t\t\tconst holder = unwrap(this.detailCtxRaw)\n\t\t\tif (holder && holder.store) {\n\t\t\t\treturn holder.store\n\t\t\t}\n\t\t\ttry {\n\t\t\t\treturn useObjectStore()\n\t\t\t} catch {\n\t\t\t\t// No active Pinia: there is no store to save through.\n\t\t\t\treturn null\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * The object-store type of the bound record.\n\t\t *\n\t\t * @param {object} store The store.\n\t\t * @return {string} The type slug, or ''.\n\t\t */\n\t\tresolveType(store) {\n\t\t\tif (this.objectType) {\n\t\t\t\treturn this.objectType\n\t\t\t}\n\t\t\tconst holder = unwrap(this.detailCtxRaw)\n\t\t\tif (holder && holder.objectType) {\n\t\t\t\treturn holder.objectType\n\t\t\t}\n\t\t\tconst ctx = this.tokenCtx()\n\t\t\tconst register = refKey(ctx.register)\n\t\t\tconst schema = refKey(ctx.schema)\n\t\t\tif (!register || !schema) {\n\t\t\t\treturn ''\n\t\t\t}\n\t\t\treturn resolveObjectOpType(store, { register, schema })\n\t\t},\n\n\t\t/**\n\t\t * Re-read the record on a surface where no page listens for the\n\t\t * refresh signal: the v2 slot grid, whose record lives in the store\n\t\t * cache. On a CnDetailPage the page re-reads it itself.\n\t\t *\n\t\t * @return {void}\n\t\t */\n\t\trefreshContextRecord() {\n\t\t\tif (unwrap(this.objectCtxRaw)) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst holder = unwrap(this.detailCtxRaw)\n\t\t\tif (!holder || !holder.store || typeof holder.store.fetchObject !== 'function') {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (!holder.objectType || holder.objectId === undefined || holder.objectId === null) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tholder.store.fetchObject(holder.objectType, holder.objectId).catch(() => {})\n\t\t},\n\n\t\t/**\n\t\t * Read the stage rows from the `stagesSource` OpenRegister query.\n\t\t *\n\t\t * @return {Promise<void>}\n\t\t */\n\t\tasync fetchSourceStages() {\n\t\t\tconst cfg = this.content.stagesSource\n\t\t\tif (!this.sourceKey) {\n\t\t\t\tthis.sourceRows = []\n\t\t\t\tthis.sourceLoading = false\n\t\t\t\tthis.sourceError = ''\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst filter = dropOptionalUnresolved(resolveFilterTokens(cfg.filter || {}, this.tokenCtx()))\n\t\t\tif (hasUnresolvedTokens(filter)) {\n\t\t\t\t// The record the filter names has not arrived yet: wait.\n\t\t\t\tthis.sourceRows = []\n\t\t\t\tthis.sourceLoading = true\n\t\t\t\tthis.sourceError = ''\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst store = this.resolveStore()\n\t\t\tif (!store) {\n\t\t\t\tthis.sourceError = 'no store'\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst key = this.sourceKey\n\t\t\tthis.sourceLoading = true\n\t\t\tthis.sourceError = ''\n\t\t\ttry {\n\t\t\t\tconst type = resolveObjectOpType(store, { register: String(cfg.register), schema: String(cfg.schema) })\n\t\t\t\tconst params = { ...filter, _limit: cfg.limit || 100 }\n\t\t\t\tif (cfg.orderBy) {\n\t\t\t\t\tparams[`_order[${cfg.orderBy}]`] = 'asc'\n\t\t\t\t}\n\t\t\t\tconst rows = await store.fetchCollection(type, params)\n\t\t\t\tif (key !== this.sourceKey) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tconst error = typeof store.getError === 'function' ? store.getError(type) : null\n\t\t\t\tif (error) {\n\t\t\t\t\tthis.sourceError = (error && error.message) || 'error'\n\t\t\t\t\tthis.sourceRows = []\n\t\t\t\t} else {\n\t\t\t\t\tthis.sourceRows = Array.isArray(rows) ? rows : []\n\t\t\t\t}\n\t\t\t} catch (e) {\n\t\t\t\tif (key !== this.sourceKey) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tthis.sourceError = (e && e.message) || 'error'\n\t\t\t\tthis.sourceRows = []\n\t\t\t} finally {\n\t\t\t\tif (key === this.sourceKey) {\n\t\t\t\t\tthis.sourceLoading = false\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t},\n}\n</script>\n\n<style scoped>\n.cn-stages-widget {\n\tdisplay: flex;\n\tflex-direction: column;\n\tgap: 8px;\n\tmin-width: 0;\n\twidth: 100%;\n\t/* On top of the surface's own content padding, which is sized for a KPI\n\t tile rather than a strip of 32px circles. */\n\tpadding: 4px;\n}\n\n.cn-stages-widget__loading {\n\tdisplay: flex;\n\tjustify-content: center;\n\tpadding: 8px 0;\n}\n\n.cn-stages-widget__notice {\n\tmargin: 0;\n\tcolor: var(--color-text-maxcontrast);\n}\n\n/* What a reachable move says it will do, beside the stage it leads to. The\n REFUSALS no longer come through here: they are a tooltip, a note card and a\n screen-reader node, which is why there is no colour variant of this rule.\n The rules for the two coloured states are NcNoteCard's own. */\n.cn-stages-widget__reason {\n\tdisplay: block;\n\tmargin-top: 2px;\n\tfont-size: 0.8em;\n\tline-height: 1.3;\n\tcolor: var(--color-text-maxcontrast);\n}\n\n/* The bars variant: equal columns, a thin bar per step. Theme hooks:\n --cn-stages-bar-height, --cn-stages-bar-radius, --cn-stages-bar-done,\n --cn-stages-bar-current, --cn-stages-bar-todo. */\n.cn-stages-widget__bars {\n\tdisplay: grid;\n\tgrid-template-columns: repeat(auto-fit, minmax(0, 1fr));\n\tgrid-auto-flow: column;\n\tgap: 8px;\n\tmargin: 0;\n\tpadding: 0;\n\tlist-style: none;\n}\n\n.cn-stages-widget__bar-step {\n\tdisplay: flex;\n\tflex-direction: column;\n\tgap: 8px;\n\tmin-width: 0;\n}\n\n.cn-stages-widget__bar {\n\tdisplay: block;\n\theight: var(--cn-stages-bar-height, 6px);\n\tborder-radius: var(--cn-stages-bar-radius, 3px);\n\tbackground: var(--cn-stages-bar-todo, var(--color-border));\n}\n\n.cn-stages-widget__bar-step--done .cn-stages-widget__bar {\n\tbackground: var(--cn-stages-bar-done, var(--color-primary-element));\n}\n\n.cn-stages-widget__bar-step--current .cn-stages-widget__bar {\n\tbackground: var(--cn-stages-bar-current, var(--color-error));\n}\n\n.cn-stages-widget__bar-label {\n\tdisplay: block;\n\tmargin: 0;\n\tpadding: 0;\n\tborder: 0;\n\tbackground: none;\n\tcolor: var(--color-text-maxcontrast);\n\tfont: inherit;\n\tfont-size: 14px;\n\ttext-align: start;\n\toverflow: hidden;\n\ttext-overflow: ellipsis;\n\twhite-space: nowrap;\n}\n\n/* A clickable label is a <button>, and Nextcloud's core stylesheet gives\n every `button:not(.button-vue)` a min-height of the clickable area (34 to\n 44px) with the text centred in it. That pushed each reachable stage's label\n about 12px below the current one, which is a <span>. Zeroed here so all\n labels sit 8px under their bar, as the board draws them. The visually\n hidden \"(current step)\" is absolutely positioned and takes no space. */\nbutton.cn-stages-widget__bar-label {\n\tmin-height: 0;\n\theight: auto;\n\tline-height: inherit;\n\tcursor: pointer;\n}\n\nbutton.cn-stages-widget__bar-label[aria-disabled=\"true\"] {\n\tcursor: not-allowed;\n}\n\nbutton.cn-stages-widget__bar-label:focus-visible {\n\toutline: 2px solid var(--color-primary-element);\n\toutline-offset: 2px;\n\tborder-radius: var(--border-radius-small, 4px);\n}\n\n.cn-stages-widget__bar-step--done .cn-stages-widget__bar-label {\n\tcolor: var(--color-main-text);\n\tfont-weight: 600;\n}\n\n.cn-stages-widget__bar-step--current .cn-stages-widget__bar-label {\n\tcolor: var(--color-main-text);\n\tfont-weight: 700;\n}\n\n.cn-stages-widget__bar-date {\n\tfont-size: 12px;\n\tcolor: var(--color-text-maxcontrast);\n}\n\n/* Text for screen readers only. */\n.cn-stages-widget__sr-only {\n\tposition: absolute;\n\twidth: 1px;\n\theight: 1px;\n\tpadding: 0;\n\tmargin: -1px;\n\toverflow: hidden;\n\tclip-path: inset(50%);\n\twhite-space: nowrap;\n\tborder: 0;\n}\n</style>\n"],"names":["_createElementBlock","_createBlock","_openBlock","_createVNode","_toDisplayString","_Fragment","_renderList","_normalizeClass","_createElementVNode","_resolveDynamicComponent","_createTextVNode","_withCtx"],"mappings":"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"}
1
+ {"version":3,"file":"CnStagesWidget.vue3.js","sources":["../../../../src/components/CnStagesWidget/CnStagesWidget.vue?vue&type=template&id=358fde0f&lang.js"],"sourcesContent":["<!--\n SPDX-FileCopyrightText: 2026 Conduction B.V.\n SPDX-License-Identifier: EUPL-1.2\n\n CnStagesWidget: the record's stages as a placeable, configurable widget,\n where clicking a stage moves the record there.\n\n Keep this comment OUT of the template: a comment node beside the root element\n makes the component multi-root in Vue 3.\n-->\n<template>\n\t<div\n\t\tclass=\"cn-stages-widget\"\n\t\tdata-testid=\"cn-stages-widget\"\n\t\t:aria-busy=\"busy ? 'true' : null\">\n\t\t<!-- EVERY MESSAGE IS A NOTE CARD, the way the other 60-odd components in\n\t\t this library say something went wrong. Loose coloured prose under a\n\t\t strip reads as part of the record, not as a message about it, which\n\t\t is exactly how it looked on a live case: an orange sentence floating\n\t\t under the stages with nothing to say it was a notice. -->\n\t\t<NcNoteCard v-if=\"stagesError\" type=\"error\" data-testid=\"cn-stages-widget-load-error\">\n\t\t\t{{ tr('Could not load the stages') }}\n\t\t</NcNoteCard>\n\t\t<div v-else-if=\"stagesPending && stages.length === 0\" class=\"cn-stages-widget__loading\">\n\t\t\t<NcLoadingIcon :size=\"24\" :name=\"tr('Loading stages')\" />\n\t\t</div>\n\t\t<!-- Not the same as a case type with no stages, and must not say so. -->\n\t\t<NcNoteCard\n\t\t\tv-else-if=\"stagesUnaddressable\"\n\t\t\ttype=\"warning\"\n\t\t\tdata-testid=\"cn-stages-widget-unaddressable\">\n\t\t\t{{ tr('The stages cannot be read for this record yet') }}\n\t\t</NcNoteCard>\n\t\t<p v-else-if=\"stages.length === 0\" class=\"cn-stages-widget__notice\" data-testid=\"cn-stages-widget-empty\">\n\t\t\t{{ tr('No stages to show') }}\n\t\t</p>\n\t\t<template v-else>\n\t\t\t<!-- The bars variant (`variant: \"bars\"`): equal columns, a thin bar\n\t\t\t per step coloured by state, the label under it and an optional\n\t\t\t date line. No description: eight Woo steps with their\n\t\t\t descriptions crowded into five lines and clipped the first\n\t\t\t label. The current step says so for a screen reader. -->\n\t\t\t<ol\n\t\t\t\tv-if=\"isBars\"\n\t\t\t\tclass=\"cn-stages-widget__bars\"\n\t\t\t\t:aria-label=\"ariaLabel\"\n\t\t\t\tdata-testid=\"cn-stages-widget-bars\">\n\t\t\t\t<li\n\t\t\t\t\tv-for=\"stage in barStages\"\n\t\t\t\t\t:key=\"stage.id\"\n\t\t\t\t\tclass=\"cn-stages-widget__bar-step\"\n\t\t\t\t\t:class=\"`cn-stages-widget__bar-step--${stage.state}`\"\n\t\t\t\t\t:aria-current=\"stage.state === 'current' ? 'step' : null\"\n\t\t\t\t\t:data-testid=\"`cn-stages-widget-bar-${stage.id}`\">\n\t\t\t\t\t<span class=\"cn-stages-widget__bar\" aria-hidden=\"true\" />\n\t\t\t\t\t<component\n\t\t\t\t\t\t:is=\"stage.clickable ? 'button' : 'span'\"\n\t\t\t\t\t\t:type=\"stage.clickable ? 'button' : null\"\n\t\t\t\t\t\tclass=\"cn-stages-widget__bar-label\"\n\t\t\t\t\t\t:title=\"stage.hint || stage.label\"\n\t\t\t\t\t\t:aria-disabled=\"stage.disabled && stage.clickable ? 'true' : null\"\n\t\t\t\t\t\t:data-testid=\"`cn-stages-widget-stage-${stage.id}`\"\n\t\t\t\t\t\t@click=\"onBarClick(stage)\">\n\t\t\t\t\t\t{{ stage.label }}<span v-if=\"stage.state === 'current'\" class=\"cn-stages-widget__sr-only\"> ({{ tr('current step') }})</span>\n\t\t\t\t\t</component>\n\t\t\t\t\t<span v-if=\"stage.date\" class=\"cn-stages-widget__bar-date\">{{ stage.date }}</span>\n\t\t\t\t\t<span\n\t\t\t\t\t\tv-if=\"stage.reason\"\n\t\t\t\t\t\t:class=\"stage.reasonVisible ? 'cn-stages-widget__reason' : 'cn-stages-widget__sr-only'\"\n\t\t\t\t\t\t:data-testid=\"`cn-stages-widget-reason-${stage.id}`\">\n\t\t\t\t\t\t{{ stage.reason }}\n\t\t\t\t\t</span>\n\t\t\t\t</li>\n\t\t\t</ol>\n\t\t\t<CnTimelineStages\n\t\t\t\tv-else\n\t\t\t\t:stages=\"timelineStages\"\n\t\t\t\t:currentStage=\"currentStageId\"\n\t\t\t\t:orientation=\"orientation\"\n\t\t\t\t:size=\"size\"\n\t\t\t\t:clickable=\"interactive\"\n\t\t\t\t:aria-label=\"ariaLabel\"\n\t\t\t\t@stageClick=\"onStageClick\"\n\t\t\t\t@stageBlocked=\"onStageBlocked\">\n\t\t\t\t<template #label=\"{ stage }\">\n\t\t\t\t\t<span class=\"cn-timeline-stages__label\" :data-testid=\"`cn-stages-widget-stage-${stage.id}`\">\n\t\t\t\t\t\t{{ stage.label }}\n\t\t\t\t\t</span>\n\t\t\t\t\t<span v-if=\"stage.subtitle\" class=\"cn-timeline-stages__subtitle\">\n\t\t\t\t\t\t{{ stage.subtitle }}\n\t\t\t\t\t</span>\n\t\t\t\t\t<!-- What the move says about itself, or why the stage cannot be\n\t\t\t\t\t chosen.\n\t\t\t\t\t A note from a reachable action is SHOWN: it tells the\n\t\t\t\t\t person what happens if they click, and there is nowhere\n\t\t\t\t\t else for that to live.\n\t\t\t\t\t A REFUSAL is not shown here. It went on screen beside\n\t\t\t\t\t every stage the record could not reach, which on a real\n\t\t\t\t\t case page meant the same sentence printed three times\n\t\t\t\t\t over and once more under the strip. It is now reachable\n\t\t\t\t\t three ways instead: the stage's `title` on hover, the\n\t\t\t\t\t live region under the strip on a click or an Enter, and\n\t\t\t\t\t this node, which stays in the DOM inside the stage so it\n\t\t\t\t\t is part of the stage's accessible name and a screen\n\t\t\t\t\t reader reads it without any interaction at all. -->\n\t\t\t\t\t<span\n\t\t\t\t\t\tv-if=\"stage.reason\"\n\t\t\t\t\t\t:class=\"stage.reasonVisible ? 'cn-stages-widget__reason' : 'cn-stages-widget__sr-only'\"\n\t\t\t\t\t\t:data-testid=\"`cn-stages-widget-reason-${stage.id}`\">\n\t\t\t\t\t\t{{ stage.reason }}\n\t\t\t\t\t</span>\n\t\t\t\t</template>\n\t\t\t</CnTimelineStages>\n\n\t\t\t<!-- The guard could not be read at all. Said once, here, rather than\n\t\t\t by every stage claiming it is not reachable.\n\t\t\t AN ERROR, not a warning: nothing refused anything, a request\n\t\t\t failed. It must not wear the same colour as a stage a guard\n\t\t\t declined, or a broken instance and a guarded record would look\n\t\t\t like the same situation. -->\n\t\t\t<NcNoteCard\n\t\t\t\tv-if=\"actionsFailed\"\n\t\t\t\ttype=\"error\"\n\t\t\t\tdata-testid=\"cn-stages-widget-actions-error\">\n\t\t\t\t{{ tr('Could not check which stages can be reached') }}\n\t\t\t</NcNoteCard>\n\t\t\t<NcNoteCard\n\t\t\t\tv-if=\"moveError && !pendingAction\"\n\t\t\t\ttype=\"error\"\n\t\t\t\tdata-testid=\"cn-stages-widget-error\">\n\t\t\t\t{{ moveError }}\n\t\t\t</NcNoteCard>\n\t\t\t<!-- What a click on a stage that cannot be chosen answered.\n\t\t\t A WARNING, the level Ruben named for a move that cannot be made:\n\t\t\t the same colour the refused stage itself carries, so the sentence\n\t\t\t and the stage it belongs to read as one thing.\n\t\t\t `role=\"status\"` stays on it. NcNoteCard gives a warning card\n\t\t\t `role=\"note\"`, which is not announced, and this card appears in\n\t\t\t answer to a click: somebody who cannot see it has to hear it. -->\n\t\t\t<NcNoteCard\n\t\t\t\tv-if=\"blockedMessage && !moveError\"\n\t\t\t\ttype=\"warning\"\n\t\t\t\trole=\"status\"\n\t\t\t\tdata-testid=\"cn-stages-widget-blocked\">\n\t\t\t\t{{ blockedMessage }}\n\t\t\t</NcNoteCard>\n\t\t\t<p class=\"cn-stages-widget__sr-only\" aria-live=\"polite\">\n\t\t\t\t{{ statusMessage }}\n\t\t\t</p>\n\t\t</template>\n\n\t\t<!-- The SHARED transition input dialog, the one CnLifecycleActions uses.\n\t\t One dialog and one input vocabulary: a transition declares `inputs`\n\t\t and the dialog collects exactly those. Cancelling POSTs nothing. -->\n\t\t<CnTransitionInputDialog\n\t\t\tv-if=\"pendingAction\"\n\t\t\t:transition=\"pendingAction\"\n\t\t\t:schema=\"schema\"\n\t\t\t@confirm=\"onInputConfirm\"\n\t\t\t@close=\"pendingAction = null\" />\n\t</div>\n</template>\n\n<script>\nimport { emit as emitBus } from '@nextcloud/event-bus'\nimport { translate as t } from '@nextcloud/l10n'\nimport { NcLoadingIcon, NcNoteCard } from '@nextcloud/vue'\nimport { inject, ref } from 'vue'\nimport CnTransitionInputDialog from '../../dialogs/CnTransitionInputDialog.vue'\nimport CnTimelineStages from '../CnTimelineStages/CnTimelineStages.vue'\nimport { getByPath, useEndpointSource } from '../../composables/useEndpointSource.js'\nimport {\n\tactionNote,\n\tactionsByTarget,\n\tdeclaresInputs,\n\tisBlockedAction,\n\tperformTransition,\n\treadAvailableActions,\n\ttransitionError,\n} from '../../composables/useLifecycleTransitions.js'\nimport { useObjectStore } from '../../store/useObjectStore.js'\nimport { resolveObjectOpType } from '../../utils/actionsDispatcher.js'\nimport { resolveObjectTokenContext } from '../../utils/detailObjectContext.js'\nimport {\n\tdropOptionalUnresolved,\n\thasUnresolvedTokens,\n\tresolveFilterTokens,\n} from '../../utils/resolveFilterTokens.js'\nimport { normalizeStages, stageSavePayload } from './stagesModel.js'\n\n// The stepper's look lives in the global timeline stylesheet. Imported here\n// so the widget renders styled without the app's global css/index.css, the\n// same reason CnStatWidget imports kpi-card.css.\nimport '../../css/timeline-stages.css'\n\n/** The page-level refresh channel every endpoint widget and CnDetailPage answer. */\nconst PAGE_REFRESH_CHANNEL = 'cn:page:refresh'\n\n/**\n * Unwrap a value that may be a plain object or a `{ value }` holder.\n *\n * @param {unknown} v The injected value.\n * @return {unknown} The unwrapped value.\n */\nfunction unwrap(v) {\n\treturn (v && typeof v === 'object' && 'value' in v) ? v.value : v\n}\n\n/**\n * Flatten a register or schema reference to its slug or id.\n *\n * @param {unknown} value The reference (string, or an object with slug/id).\n * @return {string} The slug or id, or ''.\n */\nfunction refKey(value) {\n\tif (value && typeof value === 'object') {\n\t\treturn String(value.slug || value.id || '')\n\t}\n\treturn (value === undefined || value === null) ? '' : String(value)\n}\n\n/**\n * An Error whose message is safe to show a person.\n *\n * @param {string} message The message.\n * @return {Error} The error, marked for display.\n */\nfunction userError(message) {\n\tconst error = new Error(message)\n\terror.userMessage = message\n\treturn error\n}\n\n/**\n * CnStagesWidget: the stages a record moves through, as a placeable widget.\n * Clicking a stage moves the record to that stage.\n *\n * Registered as the `stages` widget type for the detail page. It reads the\n * current stage off the bound record, draws the stage list with\n * `CnTimelineStages`, and moves the record through OpenRegister's lifecycle\n * when a reachable stage is clicked. Nothing about the record type is\n * hard-coded.\n *\n * It is the same contract `CnLifecycleActions` speaks, rendered differently:\n * that component draws the allowed moves as buttons, this one draws them as a\n * timeline. Both go through `useLifecycleTransitions`, so there is one place\n * that knows what a transition is.\n *\n * ## The stage list\n *\n * The lifecycle says what is reachable NOW. It does not say what the whole\n * process looks like, and a timeline that only showed the next step would not\n * be a timeline. So the list of stages is configured, and it is display only:\n * it grants nothing.\n *\n * One of two sources:\n *\n * - `stagesEndpoint`: `{ url, method?, path, params?, idField?, labelField?,\n * descriptionField?, orderField?, finalField? }`. The url and params take\n * the shared token grammar (`@objectId`, `@object.<field>`), so\n * `/apps/myapp/api/types/@object.type/stages` names the record's own type.\n * `path` points at the array in the response.\n * - `stagesSource`: `{ register, schema, filter?, orderBy?, idField?,\n * labelField?, descriptionField?, finalField?, limit? }`, an OpenRegister\n * query whose filter takes the same tokens.\n *\n * `finalField` marks the stages that close the record, which the timeline\n * draws differently. It grants nothing either.\n *\n * ## Which stages can be reached\n *\n * `GET /apps/openregister/api/objects/{id}/available-actions`, the same\n * endpoint `CnLifecycleActions` reads. It answers\n * `{ actions: [{ action, to, requires, description, inputs?, blocked? }] }`\n * already filtered to the record's current state, so a stage is reachable\n * exactly when an action leads to it.\n *\n * That is why there is no `allowed` flag to read, no field mapping to get\n * backwards and no config that can remove the guard: a stage no action\n * reaches is disabled because nothing said it was reachable. It fails closed\n * by construction rather than by a check somebody has to remember to write.\n *\n * `description` becomes the note beside the stage. `requires` does NOT: it is\n * the guard class's dependency-injection tag, an identifier for OpenRegister\n * and not a sentence for a person, and it used to be printed on screen.\n *\n * An action may also answer `blocked: true`, which is the third case: the move\n * exists but a guard refuses it right now, and `description` says why. The\n * stage renders disabled with that reason on screen, which reads differently\n * from \"no action reaches this stage\" and differently again from \"the\n * availability read failed\".\n *\n * ## Moving\n *\n * `POST /apps/openregister/api/objects/{id}/transition` with `{ action }`, or\n * `{ action, data }` when the action declares `inputs: [{ field, required }]`,\n * mirroring `x-openregister-lifecycle.transitions.<action>.inputs`. An action\n * with inputs opens the shared `CnTransitionInputDialog` first, and cancelling\n * it sends nothing. OpenRegister re-validates the move, and a 403 or 422 is\n * shown where the click happened.\n *\n * `transition` selects the mode:\n *\n * - absent: read only. The stages render and nothing is clickable.\n * - `{ kind: 'lifecycle' }` (the registry default): the contract above.\n * - `{ kind: 'field' }`: an EXPLICIT opt-in for a record whose schema has no\n * lifecycle. It writes `currentField` on the record through the object\n * store, sending the stage change and the record's own properties, and it\n * REFUSES when it cannot prove it is updating rather than creating. There is\n * no server-side validation on this path, which is why it is not the\n * default: whatever the timeline offers is what happens.\n *\n * After a successful move the widget shows the new stage at once, fires\n * `cn:page:refresh` so the page re-reads the record, and re-reads the allowed\n * actions for the stage the record is now on.\n *\n * ```js\n * content: {\n * currentField: 'status',\n * stagesEndpoint: {\n * url: '/apps/myapp/api/case-types/@object.caseType/blueprint',\n * path: 'statusTypes',\n * orderField: 'order',\n * finalField: 'isFinal',\n * },\n * transition: { kind: 'lifecycle' },\n * unreachableReason: 'Not possible from the current stage',\n * }\n * ```\n */\nexport default {\n\tname: 'CnStagesWidget',\n\n\tcomponents: {\n\t\tCnTimelineStages,\n\t\tCnTransitionInputDialog,\n\t\tNcLoadingIcon,\n\t\tNcNoteCard,\n\t},\n\n\t// The detail host spreads `content` onto the widget as attributes. They are\n\t// config, not DOM attributes, so they must not land on the root element.\n\tinheritAttrs: false,\n\n\tprops: {\n\t\t/**\n\t\t * The widget's config. See the component description for every key:\n\t\t * `currentField`, `stagesEndpoint` or `stagesSource`, `transition`\n\t\t * (`{ kind: 'lifecycle' }` by default, or `{ kind: 'field' }`, or\n\t\t * absent for a read-only strip), `unreachableReason`, `orientation`,\n\t\t * `size`, `ariaLabel`, `label` and `variant` (`\"bars\"` draws a thin\n\t\t * coloured bar per step with the label under it and no description;\n\t\t * the source's `dateField` adds a date line).\n\t\t *\n\t\t * `ariaLabel` names the strip for a screen reader. It falls back to\n\t\t * `label`, the placement's own title, so a titled card does not have to\n\t\t * repeat itself, and then to \"Stages\".\n\t\t *\n\t\t * @type {{currentField?: string, stagesEndpoint?: object, stagesSource?: object, transition?: object, unreachableReason?: string, orientation?: ('horizontal'|'vertical'), size?: ('medium'|'small'), ariaLabel?: string, label?: string, variant?: ('dots'|'bars')}}\n\t\t */\n\t\tcontent: {\n\t\t\ttype: Object,\n\t\t\tdefault: () => ({}),\n\t\t},\n\n\t\t/**\n\t\t * The bound record, when the surface passes it. Falls back to the\n\t\t * detail page's injected object context.\n\t\t *\n\t\t * @type {object|null}\n\t\t */\n\t\tobjectData: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/**\n\t\t * The bound record's id, when the surface passes it. Falls back to the\n\t\t * injected object context.\n\t\t */\n\t\tobjectId: {\n\t\t\ttype: [String, Number],\n\t\t\tdefault: '',\n\t\t},\n\n\t\t/**\n\t\t * The object-store type slug of the bound record, used by the `field`\n\t\t * transition. Falls back to the context, then to the register and\n\t\t * schema of the page.\n\t\t */\n\t\tobjectType: {\n\t\t\ttype: String,\n\t\t\tdefault: '',\n\t\t},\n\n\t\t/**\n\t\t * The object store to save through. Falls back to the context's store,\n\t\t * then to the shared `useObjectStore()`.\n\t\t *\n\t\t * @type {object|null}\n\t\t */\n\t\tstore: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/**\n\t\t * The record's JSON Schema, forwarded to `CnTransitionInputDialog` so a\n\t\t * transition's declared inputs render with the property's title and\n\t\t * type instead of a bare text box. Optional, exactly as on\n\t\t * `CnLifecycleActions`.\n\t\t *\n\t\t * @type {object|null}\n\t\t */\n\t\tschema: {\n\t\t\ttype: Object,\n\t\t\tdefault: null,\n\t\t},\n\n\t\t/**\n\t\t * Translate function for the manifest-authored strings. Falls back to\n\t\t * the injected `cnTranslate`, an identity function by default.\n\t\t *\n\t\t * @type {((key: string) => string)|null}\n\t\t */\n\t\ttranslate: {\n\t\t\ttype: Function,\n\t\t\tdefault: null,\n\t\t},\n\t},\n\n\temits: ['moved'],\n\n\tsetup(props) {\n\t\tconst objectCtxRaw = inject('cnObjectContext', null)\n\t\tconst detailCtxRaw = inject('cnDetailObjectContext', null)\n\t\tconst workspaceRaw = inject('cnWorkspaceContext', ref({}))\n\t\tconst appConfigRaw = inject('cnAppConfig', ref({}))\n\t\tconst cnTranslate = inject('cnTranslate', (key) => key)\n\n\t\t/**\n\t\t * The token context: the bound record, its id, and the page context.\n\t\t * Explicit props win over the injected detail context.\n\t\t *\n\t\t * @return {object} The context.\n\t\t */\n\t\tconst tokenCtx = () => {\n\t\t\tconst base = resolveObjectTokenContext(objectCtxRaw, detailCtxRaw) || {}\n\t\t\treturn {\n\t\t\t\t...base,\n\t\t\t\tobject: props.objectData || base.object || null,\n\t\t\t\tobjectId: (props.objectId !== '' && props.objectId !== null && props.objectId !== undefined) ? props.objectId : (base.objectId ?? null),\n\t\t\t\tworkspace: unwrap(workspaceRaw) || {},\n\t\t\t\tconfig: unwrap(appConfigRaw) || {},\n\t\t\t}\n\t\t}\n\n\t\t// Both reads go through the shared endpoint engine: tokens, request\n\t\t// dedup, a short cache, and the cn:page:refresh subscription.\n\t\tconst stagesRead = useEndpointSource(() => {\n\t\t\tconst cfg = props.content?.stagesEndpoint\n\t\t\treturn (cfg && cfg.url) ? { url: cfg.url, method: cfg.method, params: cfg.params } : null\n\t\t}, { ctx: tokenCtx })\n\n\t\treturn {\n\t\t\tdetailCtxRaw,\n\t\t\tobjectCtxRaw,\n\t\t\tcnTranslate,\n\t\t\ttokenCtx,\n\t\t\tstagesBody: stagesRead.data,\n\t\t\tstagesBodyLoading: stagesRead.loading,\n\t\t\tstagesBodyError: stagesRead.error,\n\t\t\tstagesBodyBlocked: stagesRead.blocked,\n\t\t}\n\t},\n\n\tdata() {\n\t\treturn {\n\t\t\t/** @type {Array<object>} Stage rows read from `stagesSource`. */\n\t\t\tsourceRows: [],\n\t\t\t/** @type {boolean} Whether the `stagesSource` read is in flight. */\n\t\t\tsourceLoading: false,\n\t\t\t/** @type {string} Why the `stagesSource` read failed, or ''. */\n\t\t\tsourceError: '',\n\t\t\t/**\n\t\t\t * @type {string|null} The stage a successful move went to, shown until\n\t\t\t * the re-read record catches up.\n\t\t\t */\n\t\t\tmovedTo: null,\n\t\t\t/**\n\t\t\t * @type {Array<object>} The moves OpenRegister allows from the\n\t\t\t * record's current state, as `/available-actions` answered them.\n\t\t\t */\n\t\t\tactions: [],\n\t\t\t/**\n\t\t\t * @type {boolean} Whether the allowed moves have been read at all\n\t\t\t * yet. Nothing is clickable before they have: an empty list and an\n\t\t\t * unread list look identical, and only one of them means \"no move\n\t\t\t * is allowed\".\n\t\t\t */\n\t\t\tactionsLoaded: false,\n\t\t\t/**\n\t\t\t * @type {boolean} The read failed, as opposed to answering that\n\t\t\t * there is nothing to do. A failure is reported as one rather than\n\t\t\t * dressed up as a policy decision about every stage.\n\t\t\t */\n\t\t\tactionsFailed: false,\n\t\t\t/**\n\t\t\t * @type {object|null} The action whose declared inputs are being\n\t\t\t * collected. Non-null mounts CnTransitionInputDialog, and the POST\n\t\t\t * waits for its confirm.\n\t\t\t */\n\t\t\tpendingAction: null,\n\t\t\t/** @type {boolean} Whether a move is running. */\n\t\t\tbusy: false,\n\t\t\t/** @type {string} Why the last move failed, or ''. */\n\t\t\tmoveError: '',\n\t\t\t/**\n\t\t\t * @type {string} What a click on a stage that cannot be chosen said,\n\t\t\t * shown until the next click. A refusal the person can see, rather\n\t\t\t * than a control that does nothing.\n\t\t\t */\n\t\t\tblockedMessage: '',\n\t\t\t/** @type {string} The last outcome, for the polite live region. */\n\t\t\tstatusMessage: '',\n\t\t}\n\t},\n\n\tcomputed: {\n\t\t/**\n\t\t * The bound record, or null while it is still loading.\n\t\t *\n\t\t * @return {object|null} The record.\n\t\t */\n\t\trecord() {\n\t\t\treturn this.tokenCtx().object || null\n\t\t},\n\n\t\t/**\n\t\t * The bound record's id, from every shape it can arrive in.\n\t\t *\n\t\t * The token context first, then the record's own `id`, then the\n\t\t * OpenRegister `@self.id` envelope. The context alone was not enough:\n\t\t * `resolveObjectTokenContext` has no `@self` fallback, so a record\n\t\t * shaped `{'@self': {id}}` reaching a surface that passes no explicit\n\t\t * `objectId` resolved to nothing.\n\t\t *\n\t\t * @return {string} The id, or '' when the record has none.\n\t\t */\n\t\trecordId() {\n\t\t\tconst fromCtx = this.tokenCtx().objectId\n\t\t\tif (fromCtx !== null && fromCtx !== undefined && fromCtx !== '') {\n\t\t\t\treturn String(fromCtx)\n\t\t\t}\n\t\t\tconst record = this.record\n\t\t\tif (!record) {\n\t\t\t\treturn ''\n\t\t\t}\n\t\t\tconst own = record.id ?? record['@self']?.id ?? record.uuid\n\t\t\treturn (own === undefined || own === null) ? '' : String(own)\n\t\t},\n\n\t\t/**\n\t\t * The stage id the record holds in `currentField`, or null.\n\t\t *\n\t\t * @return {string|null} The stage id.\n\t\t */\n\t\trecordStageId() {\n\t\t\tconst field = this.content.currentField\n\t\t\tif (!field || !this.record) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tconst raw = getByPath(this.record, field)\n\t\t\treturn (raw === undefined || raw === null || raw === '') ? null : String(raw)\n\t\t},\n\n\t\t/**\n\t\t * The stage to mark as current: a just-made move until the record\n\t\t * catches up, else the record's own.\n\t\t *\n\t\t * @return {string|null} The stage id.\n\t\t */\n\t\tcurrentStageId() {\n\t\t\treturn this.movedTo ?? this.recordStageId\n\t\t},\n\n\t\t/**\n\t\t * Whether the stages come from an endpoint (it wins over a source).\n\t\t *\n\t\t * @return {boolean} True for `stagesEndpoint`.\n\t\t */\n\t\tendpointStages() {\n\t\t\treturn Boolean(this.content.stagesEndpoint && this.content.stagesEndpoint.url)\n\t\t},\n\n\t\t/**\n\t\t * The field mapping of the active stage source.\n\t\t *\n\t\t * @return {object} The config.\n\t\t */\n\t\tstageConfig() {\n\t\t\treturn (this.endpointStages ? this.content.stagesEndpoint : this.content.stagesSource) || {}\n\t\t},\n\n\t\t/**\n\t\t * The ordered, normalised stages.\n\t\t *\n\t\t * @return {Array<{id: string, label: string, subtitle: string, final: boolean}>} The stages.\n\t\t */\n\t\tstages() {\n\t\t\tconst cfg = this.stageConfig\n\t\t\tconst rows = this.endpointStages ? getByPath(this.stagesBody, cfg.path) : this.sourceRows\n\t\t\tconst orderField = cfg.orderField || (this.endpointStages ? '' : cfg.orderBy)\n\t\t\treturn normalizeStages(rows, { ...cfg, orderField })\n\t\t},\n\n\t\t/**\n\t\t * Whether the stage list is still on its way.\n\t\t *\n\t\t * @return {boolean} True while loading.\n\t\t */\n\t\tstagesPending() {\n\t\t\tif (!this.endpointStages) {\n\t\t\t\treturn this.sourceLoading\n\t\t\t}\n\t\t\tif (this.stagesBodyLoading) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t\t// Blocked means the url has an unset token, so nothing was asked.\n\t\t\t// Wait only while the record that token reads from is still coming;\n\t\t\t// once it is here the endpoint will never resolve.\n\t\t\tif (this.stagesBodyBlocked) {\n\t\t\t\treturn this.record === null\n\t\t\t}\n\t\t\treturn this.stagesBody === null\n\t\t},\n\n\t\t/**\n\t\t * Whether the endpoint names a token the record does not carry, so the\n\t\t * stages can never be read. Kept apart from the empty state: they need\n\t\t * different fixes.\n\t\t *\n\t\t * @return {boolean}\n\t\t */\n\t\tstagesUnaddressable() {\n\t\t\treturn Boolean(this.endpointStages && this.stagesBodyBlocked && this.record !== null)\n\t\t},\n\n\t\t/**\n\t\t * Whether the stage list could not be read.\n\t\t *\n\t\t * @return {boolean} True on a failed read.\n\t\t */\n\t\tstagesError() {\n\t\t\treturn Boolean(this.endpointStages ? this.stagesBodyError : this.sourceError)\n\t\t},\n\n\t\t/**\n\t\t * The configured transition, or null when the widget is read-only.\n\t\t *\n\t\t * An unrecognised `kind` reads as read-only rather than as lifecycle: a\n\t\t * typo must not silently pick a mode nobody asked for, and a strip that\n\t\t * does nothing is the safe end of that mistake.\n\t\t *\n\t\t * @return {object|null} The transition.\n\t\t */\n\t\ttransition() {\n\t\t\tconst tr = this.content.transition\n\t\t\tif (!tr || typeof tr !== 'object') {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tif (tr.kind === 'field' || tr.kind === 'lifecycle') {\n\t\t\t\treturn tr\n\t\t\t}\n\t\t\treturn null\n\t\t},\n\n\t\t/**\n\t\t * Whether moves go through OpenRegister's lifecycle.\n\t\t *\n\t\t * @return {boolean} True on the lifecycle path.\n\t\t */\n\t\tlifecycleMode() {\n\t\t\treturn Boolean(this.transition) && this.transition.kind !== 'field'\n\t\t},\n\n\t\t/**\n\t\t * Whether the strip is interactive at all: a transition is configured\n\t\t * and there is a property to write the stage to.\n\t\t *\n\t\t * This deliberately does NOT fall to false while a move runs.\n\t\t * `clickable` drives the roving tabindex in CnTimelineStages, so\n\t\t * flipping it mid-move took every stage's focus stop away and dropped\n\t\t * a keyboard user's focus to `body` with nothing to restore it to. A\n\t\t * busy strip keeps its stops and disables its stages instead.\n\t\t *\n\t\t * @return {boolean} True when the widget is interactive.\n\t\t */\n\t\tinteractive() {\n\t\t\treturn Boolean(this.transition) && Boolean(this.content.currentField)\n\t\t},\n\n\t\t/**\n\t\t * Whether the widget may move the record right now.\n\t\t *\n\t\t * @return {boolean} True when it is interactive and idle.\n\t\t */\n\t\tcanMove() {\n\t\t\treturn this.interactive && !this.busy\n\t\t},\n\n\t\t/**\n\t\t * The allowed moves by the stage they lead to, or null while they are\n\t\t * unknown.\n\t\t *\n\t\t * Null is not the same as an empty map. Before `/available-actions` has\n\t\t * answered, nothing is known about any stage; after it has, an absent\n\t\t * stage is a stage no move reaches. Collapsing the two would make a\n\t\t * strip clickable for the length of one request.\n\t\t *\n\t\t * @return {Map<string, object>|null} The moves.\n\t\t */\n\t\tmoves() {\n\t\t\tif (!this.lifecycleMode) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\tif (!this.actionsLoaded) {\n\t\t\t\treturn null\n\t\t\t}\n\t\t\treturn actionsByTarget(this.actions)\n\t\t},\n\n\t\t/**\n\t\t * The stages as CnTimelineStages renders them, each carrying whether it\n\t\t * can be chosen and, when not, why.\n\t\t *\n\t\t * @return {Array<object>} The timeline stages.\n\t\t */\n\t\ttimelineStages() {\n\t\t\treturn this.stages.map((stage) => {\n\t\t\t\tconst access = this.stageAccess(stage)\n\t\t\t\treturn {\n\t\t\t\t\tid: stage.id,\n\t\t\t\t\tlabel: stage.label,\n\t\t\t\t\tsubtitle: stage.subtitle,\n\t\t\t\t\t...access,\n\t\t\t\t\t// The hover route to a reason that is no longer printed\n\t\t\t\t\t// beside the stage. Only where the stage cannot be chosen: a\n\t\t\t\t\t// reachable move's note is already on screen, and a tooltip\n\t\t\t\t\t// repeating visible text is noise.\n\t\t\t\t\thint: access.disabled ? access.reason : '',\n\t\t\t\t}\n\t\t\t})\n\t\t},\n\n\t\t/**\n\t\t * The accessible name of the stage list.\n\t\t *\n\t\t * @return {string} The name.\n\t\t */\n\t\tariaLabel() {\n\t\t\tconst label = this.content.ariaLabel || this.content.label\n\t\t\treturn label ? this.effectiveTranslate(label) : this.tr('Stages')\n\t\t},\n\n\t\t/**\n\t\t * Whether the bars variant is on (`content.variant: \"bars\"`). Off by\n\t\t * default: the dot strip renders as it always has.\n\t\t *\n\t\t * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-stages-widget-draws-bars\n\t\t * @return {boolean}\n\t\t */\n\t\tisBars() {\n\t\t\treturn this.content.variant === 'bars'\n\t\t},\n\n\t\t/**\n\t\t * The stages for the bars variant: each with its `state` (`done`\n\t\t * before the current stage, `current`, `todo` after it, every stage\n\t\t * `todo` when none is current), its `date`, and whether it is a\n\t\t * button (the strip is interactive and the stage is not the current\n\t\t * one).\n\t\t *\n\t\t * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-stages-widget-draws-bars\n\t\t * @return {Array<object>}\n\t\t */\n\t\tbarStages() {\n\t\t\tconst current = this.stages.findIndex((s) => s.id === this.currentStageId)\n\t\t\treturn this.timelineStages.map((stage, index) => {\n\t\t\t\tlet state = 'todo'\n\t\t\t\tif (current >= 0 && index < current) {\n\t\t\t\t\tstate = 'done'\n\t\t\t\t} else if (index === current) {\n\t\t\t\t\tstate = 'current'\n\t\t\t\t}\n\t\t\t\treturn {\n\t\t\t\t\t...stage,\n\t\t\t\t\tdate: this.stages[index]?.date || '',\n\t\t\t\t\tstate,\n\t\t\t\t\tclickable: this.interactive && state !== 'current',\n\t\t\t\t}\n\t\t\t})\n\t\t},\n\n\t\t/** @return {'horizontal'|'vertical'} The layout direction. */\n\t\torientation() {\n\t\t\treturn this.content.orientation === 'vertical' ? 'vertical' : 'horizontal'\n\t\t},\n\n\t\t/** @return {'medium'|'small'} The indicator size. */\n\t\tsize() {\n\t\t\treturn this.content.size === 'small' ? 'small' : 'medium'\n\t\t},\n\n\t\t/**\n\t\t * Effective translate function for manifest-authored strings.\n\t\t *\n\t\t * @return {(key: string) => string} The function.\n\t\t */\n\t\teffectiveTranslate() {\n\t\t\treturn this.translate || this.cnTranslate || ((key) => key)\n\t\t},\n\n\t\t/**\n\t\t * A stable signature of the `stagesSource` query, so it refetches only\n\t\t * on a real change.\n\t\t *\n\t\t * @return {string} The signature.\n\t\t */\n\t\tsourceKey() {\n\t\t\tconst cfg = this.content.stagesSource\n\t\t\tif (this.endpointStages || !cfg || !cfg.register || !cfg.schema) {\n\t\t\t\treturn ''\n\t\t\t}\n\t\t\treturn JSON.stringify({ cfg, filter: resolveFilterTokens(cfg.filter || {}, this.tokenCtx()) })\n\t\t},\n\n\t\t/**\n\t\t * What the allowed-moves read depends on: the record it is about, and\n\t\t * whether the lifecycle is in use at all.\n\t\t *\n\t\t * @return {string} The signature.\n\t\t */\n\t\tactionsKey() {\n\t\t\treturn this.lifecycleMode ? String(this.recordId) : ''\n\t\t},\n\t},\n\n\twatch: {\n\t\tsourceKey: {\n\t\t\timmediate: true,\n\t\t\thandler() {\n\t\t\t\tthis.fetchSourceStages()\n\t\t\t},\n\t\t},\n\n\t\trecord(next, previous) {\n\t\t\tif (next === previous) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\t// A RE-READ RECORD IS AUTHORITATIVE, whatever it says.\n\t\t\t//\n\t\t\t// `movedTo` is the optimistic stage, shown so a move looks\n\t\t\t// immediate. It used to be cleared only when `recordStageId`\n\t\t\t// CHANGED, so a 200 that did not actually move the record (a guard\n\t\t\t// the server enforced silently, a no-op transition) left the strip\n\t\t\t// claiming a stage the record never reached, permanently, while the\n\t\t\t// availability answer described the real one. The arrival of a\n\t\t\t// fresh record ends the optimism either way.\n\t\t\tthis.movedTo = null\n\t\t},\n\n\t\trecordStageId(next, previous) {\n\t\t\tif (next === previous) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\t// The record moved without us, so the list in hand describes a stage\n\t\t\t// it has left. Our OWN move refetches inside `performMove`, while\n\t\t\t// `busy` still holds, so this does not double-request it.\n\t\t\tif (previous !== null && !this.busy) {\n\t\t\t\tthis.loadActions()\n\t\t\t}\n\t\t},\n\n\t\tactionsKey: {\n\t\t\timmediate: true,\n\t\t\thandler() {\n\t\t\t\tthis.loadActions()\n\t\t\t},\n\t\t},\n\t},\n\n\tmethods: {\n\t\t/**\n\t\t * Translate a library string.\n\t\t *\n\t\t * @param {string} text The source string.\n\t\t * @param {object} [vars] Placeholder values.\n\t\t * @return {string} The translated string.\n\t\t */\n\t\ttr(text, vars) {\n\t\t\treturn t('nextcloud-vue', text, vars)\n\t\t},\n\n\t\t/**\n\t\t * Whether a stage can be chosen and, when not, why.\n\t\t *\n\t\t * On the lifecycle path there is nothing to decide: OpenRegister has\n\t\t * already filtered the actions to the record's current state, so a\n\t\t * stage is reachable exactly when an action leads to it. There is no\n\t\t * flag to read and no way to configure the guard away.\n\t\t *\n\t\t * Three refusals live here and they must not read alike, because they\n\t\t * are not the same claim. NO ACTION REACHES THE STAGE is a policy\n\t\t * answer about the process. A BLOCKED action is a policy answer about\n\t\t * this record right now, and it comes with the app's own reason. A\n\t\t * FAILED READ is not an answer at all, and says so. Collapsing any two\n\t\t * of them tells somebody something nobody checked.\n\t\t *\n\t\t * Only ONE of the three earns colour, and `blocked` in the returned shape\n\t\t * is what carries it: the guard that refused THIS record. The other two\n\t\t * stay grey. A strip where every stage is orange says nothing, and the\n\t\t * failed read is not a refusal at all.\n\t\t *\n\t\t * @param {{id: string}} stage The stage.\n\t\t * @return {{disabled: boolean, reason: string, reasonVisible: boolean, blocked: boolean}} The access.\n\t\t */\n\t\tstageAccess(stage) {\n\t\t\tconst open = { disabled: false, reason: '', reasonVisible: false, blocked: false }\n\t\t\tif (!this.transition) {\n\t\t\t\treturn open\n\t\t\t}\n\t\t\t// You cannot move to where you already are. That is NOT the same as\n\t\t\t// blocked, so it is not announced as blocked: the stage carries\n\t\t\t// `aria-current=\"step\"` and nothing else. Marking it\n\t\t\t// `aria-disabled` on top said \"you may not go here\" about the place\n\t\t\t// the record already is. `onStageClick` refuses it explicitly.\n\t\t\tif (stage.id === this.currentStageId) {\n\t\t\t\treturn open\n\t\t\t}\n\t\t\t// A move is running, or the action list in hand is the one for the\n\t\t\t// stage the record has just LEFT. Everything is disabled until the\n\t\t\t// fresh list lands, and the focus stops stay.\n\t\t\tif (!this.canMove) {\n\t\t\t\treturn { disabled: true, reason: '', reasonVisible: false, blocked: false }\n\t\t\t}\n\t\t\t// The field path has no server to ask, which is exactly why it is an\n\t\t\t// explicit opt-in: every stage is offered and the write decides.\n\t\t\tif (!this.lifecycleMode) {\n\t\t\t\treturn open\n\t\t\t}\n\t\t\t// A FAILED READ IS NOT A POLICY DECISION. Saying \"not reachable from\n\t\t\t// the current stage\" about every stage would state, confidently and\n\t\t\t// wrongly, something nobody has checked.\n\t\t\t// NOT `blocked` either: nothing refused this move, the question was\n\t\t\t// never answered. Colouring it would dress a broken request up as a\n\t\t\t// policy decision.\n\t\t\tif (this.actionsFailed) {\n\t\t\t\treturn {\n\t\t\t\t\tdisabled: true,\n\t\t\t\t\t// Nothing is claimed about a stage the record has already\n\t\t\t\t\t// left: the card above says the read failed, once, and a\n\t\t\t\t\t// sentence about reaching somewhere the record has been\n\t\t\t\t\t// would be a third claim nobody made.\n\t\t\t\t\treason: this.isPastStage(stage) ? '' : this.tr('Could not check whether this stage can be reached'),\n\t\t\t\t\treasonVisible: false,\n\t\t\t\t\tblocked: false,\n\t\t\t\t}\n\t\t\t}\n\t\t\t// Not read yet. Not \"no moves allowed\", which is why this is not the\n\t\t\t// same branch as an empty list.\n\t\t\tif (this.moves === null) {\n\t\t\t\treturn { disabled: true, reason: '', reasonVisible: false, blocked: false }\n\t\t\t}\n\t\t\tconst move = this.moves.get(stage.id)\n\t\t\tif (!move) {\n\t\t\t\t// A STAGE THE RECORD HAS ALREADY PASSED IS NOT A REFUSAL. On a\n\t\t\t\t// live case the first stage carried \"Not possible from the\n\t\t\t\t// current status\" beside a green, completed circle, which reads\n\t\t\t\t// as something having gone wrong with something that already\n\t\t\t\t// happened. History needs no excuse: it says nothing at all.\n\t\t\t\t// A past stage an action DOES reach, a reopen, never gets here:\n\t\t\t\t// it has a move, so it stays clickable with its own note.\n\t\t\t\tif (this.isPastStage(stage)) {\n\t\t\t\t\treturn { disabled: true, reason: '', reasonVisible: false, blocked: false }\n\t\t\t\t}\n\t\t\t\tconst reason = this.content.unreachableReason\n\t\t\t\t\t? this.effectiveTranslate(this.content.unreachableReason)\n\t\t\t\t\t: this.tr('Not reachable from the current stage')\n\t\t\t\t// NOT printed beside the stage. This is the generic sentence, and\n\t\t\t\t// it lands on EVERY stage no action reaches, so on a case with\n\t\t\t\t// three stages ahead of it the strip printed one sentence three\n\t\t\t\t// times over. It is still reachable: `title` on hover, the live\n\t\t\t\t// region under the strip on a click or an Enter, and the\n\t\t\t\t// screen-reader node inside the stage, which puts it in the\n\t\t\t\t// stage's accessible name with no interaction at all.\n\t\t\t\t// NOT `blocked`: nothing refused this record, the process simply\n\t\t\t\t// does not lead here from where it stands. Colouring it would\n\t\t\t\t// make a normal timeline look like a wall of refusals.\n\t\t\t\treturn { disabled: true, reason, reasonVisible: false, blocked: false }\n\t\t\t}\n\t\t\t// OFFERED AND REFUSED. The move exists, so the stage keeps its place\n\t\t\t// in the process, but a guard says not now. The app's own reason is\n\t\t\t// the point of this branch, so it is carried rather than replaced by\n\t\t\t// wording of ours; the fallback is only for a guard that gave none.\n\t\t\t// THE ONE CASE THAT IS COLOURED. This is a refusal about this record,\n\t\t\t// so the stage reads as refused at a glance instead of looking like\n\t\t\t// one that is merely further down the process, and the reason is one\n\t\t\t// hover or one click away rather than printed beside it.\n\t\t\tif (isBlockedAction(move)) {\n\t\t\t\treturn {\n\t\t\t\t\tdisabled: true,\n\t\t\t\t\treason: actionNote(move) || this.tr('This move is not possible right now'),\n\t\t\t\t\treasonVisible: false,\n\t\t\t\t\tblocked: true,\n\t\t\t\t}\n\t\t\t}\n\t\t\t// What the move says about itself. It is not a refusal, so it is\n\t\t\t// shown rather than hidden: it tells the person what happens next.\n\t\t\treturn { ...open, reason: actionNote(move), reasonVisible: Boolean(actionNote(move)) }\n\t\t},\n\n\t\t/**\n\t\t * Whether the record has already passed a stage.\n\t\t *\n\t\t * Position in the configured list, which is the same thing\n\t\t * `CnTimelineStages` draws as \"completed\", so the two cannot disagree\n\t\t * about what is history.\n\t\t *\n\t\t * @param {{id: string}} stage The stage.\n\t\t * @return {boolean} Whether it sits behind the record's current stage.\n\t\t */\n\t\tisPastStage(stage) {\n\t\t\tconst current = this.stages.findIndex((s) => s.id === this.currentStageId)\n\t\t\t// -1: the record is on no stage we know, so nothing is behind it.\n\t\t\t// 0: it is on the first one, and there is nothing behind that either.\n\t\t\tif (current <= 0) {\n\t\t\t\treturn false\n\t\t\t}\n\t\t\tconst index = this.stages.findIndex((s) => s.id === stage.id)\n\t\t\treturn index !== -1 && index < current\n\t\t},\n\n\t\t/**\n\t\t * A stage was clicked: move there, collecting the action's declared\n\t\t * inputs first when it has any.\n\t\t *\n\t\t * @param {{stage: {id: string}}} payload The CnTimelineStages event.\n\t\t * @return {void}\n\t\t */\n\t\t/**\n\t\t * A click on a bar-variant step: a disabled step reports why (as\n\t\t * CnTimelineStages does through `stage-blocked`), any other goes\n\t\t * through the shared move path.\n\t\t *\n\t\t * @spec openspec/changes/zuiddrecht-pixel-gaps/specs/zuiddrecht-pixel-gaps/spec.md#requirement-a-stages-widget-draws-bars\n\t\t * @param {object} stage The bar step.\n\t\t * @return {void}\n\t\t */\n\t\tonBarClick(stage) {\n\t\t\tif (!stage.clickable) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (stage.disabled) {\n\t\t\t\tthis.onStageBlocked({ stage })\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.onStageClick({ stage })\n\t\t},\n\n\t\tonStageClick({ stage }) {\n\t\t\tif (!this.canMove || !stage) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\t// The current stage is no longer marked disabled, so refuse it here.\n\t\t\t// You cannot move to where you already are, and re-firing the move\n\t\t\t// that just landed is exactly what a stray click would do.\n\t\t\tif (stage.id === this.currentStageId) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\t// No `target.disabled` check: CnTimelineStages emits `stage-blocked`\n\t\t\t// for those and never `stage-click`, so a second check here was a\n\t\t\t// branch nothing could reach and nothing could test.\n\t\t\tconst target = this.timelineStages.find((s) => s.id === stage.id)\n\t\t\tif (!target) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.blockedMessage = ''\n\t\t\tthis.moveError = ''\n\t\t\tconst move = this.moves ? this.moves.get(stage.id) : null\n\t\t\t// No move, or a move a guard has refused. Both fail closed here as\n\t\t\t// well as in `stageAccess`, so the POST cannot happen even if a\n\t\t\t// caller reaches this method some other way than a click on a stage\n\t\t\t// the strip already disabled.\n\t\t\tif (this.lifecycleMode && (!move || isBlockedAction(move))) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst request = {\n\t\t\t\tstage: { id: stage.id, label: target.label },\n\t\t\t\taction: move ? move.action : stage.id,\n\t\t\t\tinputs: (move && Array.isArray(move.inputs)) ? move.inputs : [],\n\t\t\t}\n\t\t\t// A transition that declares inputs collects them first, and\n\t\t\t// cancelling the dialog sends nothing. `CnTransitionInputDialog`\n\t\t\t// reads `transition.inputs` and `transition.label`, so the request\n\t\t\t// carries the shape that dialog already understands.\n\t\t\tif (this.lifecycleMode && declaresInputs(move)) {\n\t\t\t\tthis.pendingAction = {\n\t\t\t\t\taction: move.action,\n\t\t\t\t\tto: move.to,\n\t\t\t\t\tlabel: this.tr('Move to {stage}', { stage: target.label }),\n\t\t\t\t\tinputs: move.inputs,\n\t\t\t\t\t__request: request,\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.performMove(request)\n\t\t},\n\n\t\t/**\n\t\t * A stage that cannot be chosen was clicked: say why.\n\t\t *\n\t\t * SAY SOMETHING. This used to be silence. The reason was rendered as\n\t\t * screen-reader-only text, so a pointer user clicked, nothing moved,\n\t\t * nothing appeared, and nothing explained it. The sentence now goes on\n\t\t * screen in a polite live region, so both kinds of user get the same\n\t\t * answer from the same click.\n\t\t *\n\t\t * @param {{stage: {id: string}}} payload The CnTimelineStages event.\n\t\t * @return {void}\n\t\t */\n\t\tonStageBlocked({ stage }) {\n\t\t\tif (!stage) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst target = this.timelineStages.find((s) => s.id === stage.id)\n\t\t\tthis.blockedMessage = (target && target.reason)\n\t\t\t\t|| (this.busy\n\t\t\t\t\t? this.tr('Just a moment, the last move is still running')\n\t\t\t\t\t: this.tr('This stage cannot be chosen right now'))\n\t\t},\n\n\t\t/**\n\t\t * The input dialog confirmed: send the move with what it collected.\n\t\t *\n\t\t * @param {object} data The collected input values.\n\t\t * @return {void}\n\t\t */\n\t\tonInputConfirm(data) {\n\t\t\tconst pending = this.pendingAction\n\t\t\tthis.pendingAction = null\n\t\t\tif (!pending || !pending.__request) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.performMove(pending.__request, data)\n\t\t},\n\n\t\t/**\n\t\t * Perform a move.\n\t\t *\n\t\t * @param {{stage: {id: string, label: string}, action: string}} request The move.\n\t\t * @param {object} [data] The collected transition inputs, when any.\n\t\t * @return {Promise<void>}\n\t\t */\n\t\tasync performMove(request, data) {\n\t\t\tthis.busy = true\n\t\t\tthis.moveError = ''\n\t\t\ttry {\n\t\t\t\tif (this.lifecycleMode) {\n\t\t\t\t\tawait performTransition(this.recordId, request.action, data)\n\t\t\t\t} else {\n\t\t\t\t\tawait this.moveViaField(request)\n\t\t\t\t}\n\t\t\t\tthis.movedTo = request.stage.id\n\t\t\t\tthis.statusMessage = this.tr('Moved to {stage}', { stage: request.stage.label })\n\t\t\t\t/**\n\t\t\t\t * @event moved The record moved to another stage.\n\t\t\t\t * @type {{stage: string, action: string}}\n\t\t\t\t */\n\t\t\t\tthis.$emit('moved', { stage: request.stage.id, action: request.action })\n\t\t\t\t// The page re-reads the record and every endpoint widget\n\t\t\t\t// refetches.\n\t\t\t\temitBus(PAGE_REFRESH_CHANNEL, {})\n\t\t\t\tthis.refreshContextRecord()\n\t\t\t\t// A MOVE INVALIDATES THE LIST THAT MADE IT. Awaiting the fresh\n\t\t\t\t// list here, inside the try, is what keeps `busy` true across\n\t\t\t\t// the whole window: until it lands, the list in hand describes\n\t\t\t\t// the stage the record has just LEFT, and a click against it\n\t\t\t\t// would POST a move the server has already closed. Fails\n\t\t\t\t// closed, because `busy` only clears in the finally below.\n\t\t\t\tif (this.lifecycleMode) {\n\t\t\t\t\tawait this.loadActions()\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\tthis.moveError = transitionError(error, this.tr('The move could not be made'))\n\t\t\t} finally {\n\t\t\t\tthis.busy = false\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * Read the moves OpenRegister allows from the record's current state.\n\t\t *\n\t\t * @return {Promise<void>}\n\t\t */\n\t\tasync loadActions() {\n\t\t\t// THE LIST IN HAND IS ALREADY WRONG. Whatever prompted this read,\n\t\t\t// the answer we are holding describes a state the record may have\n\t\t\t// left, so it stops being authoritative NOW rather than when the\n\t\t\t// replacement arrives. Without this the `recordStageId` watcher\n\t\t\t// path refetched with `busy` false and the old list still driving\n\t\t\t// the strip, so a stage the record had left stayed clickable and a\n\t\t\t// click POSTed a move out of a state that no longer existed.\n\t\t\tthis.actions = []\n\t\t\tthis.actionsLoaded = false\n\t\t\tthis.actionsFailed = false\n\t\t\tif (!this.lifecycleMode || !this.recordId) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst id = this.recordId\n\t\t\tconst read = await readAvailableActions(id)\n\t\t\t// The record may have moved on while the read was in flight; the\n\t\t\t// newer read owns the state.\n\t\t\tif (String(this.recordId) !== String(id)) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tthis.actions = read.actions\n\t\t\tthis.actionsFailed = read.failed\n\t\t\t// A FAILED READ IS NOT AN ANSWER. Leaving `actionsLoaded` false\n\t\t\t// keeps every stage disabled, and `actionsFailed` is what makes the\n\t\t\t// widget say the guard could not be checked rather than let each\n\t\t\t// stage claim it is not reachable.\n\t\t\tthis.actionsLoaded = !read.failed\n\t\t},\n\n\t\t/**\n\t\t * Save the bound record with the stage field set to the target stage.\n\t\t *\n\t\t * The `{ kind: 'field' }` opt-in, for a record whose schema declares no\n\t\t * lifecycle. Nothing validates this move but the schema itself, which\n\t\t * is why it is not the default.\n\t\t *\n\t\t * @param {{stage: {id: string}}} request The move request.\n\t\t * @return {Promise<void>}\n\t\t */\n\t\tasync moveViaField(request) {\n\t\t\tconst record = this.record\n\t\t\tif (!record) {\n\t\t\t\tthrow userError(this.tr('The record is not loaded yet'))\n\t\t\t}\n\t\t\tconst store = this.resolveStore()\n\t\t\tif (!store) {\n\t\t\t\tthrow userError(this.tr('The move could not be made'))\n\t\t\t}\n\t\t\tconst type = this.resolveType(store)\n\t\t\tif (!type) {\n\t\t\t\tthrow userError(this.tr('The move could not be made'))\n\t\t\t}\n\t\t\t// REFUSE rather than save without an id. `saveObject` picks PUT over\n\t\t\t// POST purely on the presence of `id`, and an OpenRegister record\n\t\t\t// carries its id in `@self`, not at the top level, so a record whose\n\t\t\t// context id happened to be empty was POSTed as A BRAND NEW OBJECT\n\t\t\t// and the widget then announced a successful move. The record the\n\t\t\t// person was looking at never changed, and an orphan row was left\n\t\t\t// behind. `CnDetailPage.onEditFormConfirm` refuses the same case for\n\t\t\t// the same reason.\n\t\t\tconst id = this.recordId\n\t\t\tif (id === '') {\n\t\t\t\tthrow userError(this.tr('Cannot move: this record has no id, so the move would create a duplicate instead of updating it.'))\n\t\t\t}\n\t\t\tconst payload = stageSavePayload(record, id, this.content.currentField, request.stage.id)\n\t\t\tconst saved = await store.saveObject(type, payload)\n\t\t\tif (!saved) {\n\t\t\t\tconst error = typeof store.getError === 'function' ? store.getError(type) : null\n\t\t\t\tthrow userError((error && error.message) || this.tr('The move could not be made'))\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * The object store to save through: the prop, the context's, or the\n\t\t * shared one.\n\t\t *\n\t\t * @return {object|null} The store.\n\t\t */\n\t\tresolveStore() {\n\t\t\tif (this.store) {\n\t\t\t\treturn this.store\n\t\t\t}\n\t\t\tconst holder = unwrap(this.detailCtxRaw)\n\t\t\tif (holder && holder.store) {\n\t\t\t\treturn holder.store\n\t\t\t}\n\t\t\ttry {\n\t\t\t\treturn useObjectStore()\n\t\t\t} catch {\n\t\t\t\t// No active Pinia: there is no store to save through.\n\t\t\t\treturn null\n\t\t\t}\n\t\t},\n\n\t\t/**\n\t\t * The object-store type of the bound record.\n\t\t *\n\t\t * @param {object} store The store.\n\t\t * @return {string} The type slug, or ''.\n\t\t */\n\t\tresolveType(store) {\n\t\t\tif (this.objectType) {\n\t\t\t\treturn this.objectType\n\t\t\t}\n\t\t\tconst holder = unwrap(this.detailCtxRaw)\n\t\t\tif (holder && holder.objectType) {\n\t\t\t\treturn holder.objectType\n\t\t\t}\n\t\t\tconst ctx = this.tokenCtx()\n\t\t\tconst register = refKey(ctx.register)\n\t\t\tconst schema = refKey(ctx.schema)\n\t\t\tif (!register || !schema) {\n\t\t\t\treturn ''\n\t\t\t}\n\t\t\treturn resolveObjectOpType(store, { register, schema })\n\t\t},\n\n\t\t/**\n\t\t * Re-read the record on a surface where no page listens for the\n\t\t * refresh signal: the v2 slot grid, whose record lives in the store\n\t\t * cache. On a CnDetailPage the page re-reads it itself.\n\t\t *\n\t\t * @return {void}\n\t\t */\n\t\trefreshContextRecord() {\n\t\t\tif (unwrap(this.objectCtxRaw)) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst holder = unwrap(this.detailCtxRaw)\n\t\t\tif (!holder || !holder.store || typeof holder.store.fetchObject !== 'function') {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (!holder.objectType || holder.objectId === undefined || holder.objectId === null) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tholder.store.fetchObject(holder.objectType, holder.objectId).catch(() => {})\n\t\t},\n\n\t\t/**\n\t\t * Read the stage rows from the `stagesSource` OpenRegister query.\n\t\t *\n\t\t * @return {Promise<void>}\n\t\t */\n\t\tasync fetchSourceStages() {\n\t\t\tconst cfg = this.content.stagesSource\n\t\t\tif (!this.sourceKey) {\n\t\t\t\tthis.sourceRows = []\n\t\t\t\tthis.sourceLoading = false\n\t\t\t\tthis.sourceError = ''\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst filter = dropOptionalUnresolved(resolveFilterTokens(cfg.filter || {}, this.tokenCtx()))\n\t\t\tif (hasUnresolvedTokens(filter)) {\n\t\t\t\t// The record the filter names has not arrived yet: wait.\n\t\t\t\tthis.sourceRows = []\n\t\t\t\tthis.sourceLoading = true\n\t\t\t\tthis.sourceError = ''\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst store = this.resolveStore()\n\t\t\tif (!store) {\n\t\t\t\tthis.sourceError = 'no store'\n\t\t\t\treturn\n\t\t\t}\n\t\t\tconst key = this.sourceKey\n\t\t\tthis.sourceLoading = true\n\t\t\tthis.sourceError = ''\n\t\t\ttry {\n\t\t\t\tconst type = resolveObjectOpType(store, { register: String(cfg.register), schema: String(cfg.schema) })\n\t\t\t\tconst params = { ...filter, _limit: cfg.limit || 100 }\n\t\t\t\tif (cfg.orderBy) {\n\t\t\t\t\tparams[`_order[${cfg.orderBy}]`] = 'asc'\n\t\t\t\t}\n\t\t\t\tconst rows = await store.fetchCollection(type, params)\n\t\t\t\tif (key !== this.sourceKey) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tconst error = typeof store.getError === 'function' ? store.getError(type) : null\n\t\t\t\tif (error) {\n\t\t\t\t\tthis.sourceError = (error && error.message) || 'error'\n\t\t\t\t\tthis.sourceRows = []\n\t\t\t\t} else {\n\t\t\t\t\tthis.sourceRows = Array.isArray(rows) ? rows : []\n\t\t\t\t}\n\t\t\t} catch (e) {\n\t\t\t\tif (key !== this.sourceKey) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tthis.sourceError = (e && e.message) || 'error'\n\t\t\t\tthis.sourceRows = []\n\t\t\t} finally {\n\t\t\t\tif (key === this.sourceKey) {\n\t\t\t\t\tthis.sourceLoading = false\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t},\n}\n</script>\n\n<style scoped>\n.cn-stages-widget {\n\tdisplay: flex;\n\tflex-direction: column;\n\tgap: 8px;\n\tmin-width: 0;\n\twidth: 100%;\n\t/* On top of the surface's own content padding, which is sized for a KPI\n\t tile rather than a strip of 32px circles. */\n\tpadding: 4px;\n}\n\n.cn-stages-widget__loading {\n\tdisplay: flex;\n\tjustify-content: center;\n\tpadding: 8px 0;\n}\n\n.cn-stages-widget__notice {\n\tmargin: 0;\n\tcolor: var(--color-text-maxcontrast);\n}\n\n/* What a reachable move says it will do, beside the stage it leads to. The\n REFUSALS no longer come through here: they are a tooltip, a note card and a\n screen-reader node, which is why there is no colour variant of this rule.\n The rules for the two coloured states are NcNoteCard's own. */\n.cn-stages-widget__reason {\n\tdisplay: block;\n\tmargin-top: 2px;\n\tfont-size: 0.8em;\n\tline-height: 1.3;\n\tcolor: var(--color-text-maxcontrast);\n}\n\n/* The bars variant: equal columns, a thin bar per step. Theme hooks:\n --cn-stages-bar-height, --cn-stages-bar-radius, --cn-stages-bar-done,\n --cn-stages-bar-current, --cn-stages-bar-todo. */\n.cn-stages-widget__bars {\n\tdisplay: grid;\n\tgrid-template-columns: repeat(auto-fit, minmax(0, 1fr));\n\tgrid-auto-flow: column;\n\tgap: 8px;\n\tmargin: 0;\n\tpadding: 0;\n\tlist-style: none;\n}\n\n.cn-stages-widget__bar-step {\n\tdisplay: flex;\n\tflex-direction: column;\n\tgap: 8px;\n\tmin-width: 0;\n}\n\n.cn-stages-widget__bar {\n\tdisplay: block;\n\theight: var(--cn-stages-bar-height, 6px);\n\tborder-radius: var(--cn-stages-bar-radius, 3px);\n\tbackground: var(--cn-stages-bar-todo, var(--color-border));\n}\n\n.cn-stages-widget__bar-step--done .cn-stages-widget__bar {\n\tbackground: var(--cn-stages-bar-done, var(--color-primary-element));\n}\n\n.cn-stages-widget__bar-step--current .cn-stages-widget__bar {\n\tbackground: var(--cn-stages-bar-current, var(--color-error));\n}\n\n.cn-stages-widget__bar-label {\n\tdisplay: block;\n\tmargin: 0;\n\tpadding: 0;\n\tborder: 0;\n\tbackground: none;\n\tcolor: var(--color-text-maxcontrast);\n\tfont: inherit;\n\tfont-size: 14px;\n\ttext-align: start;\n\toverflow: hidden;\n\ttext-overflow: ellipsis;\n\twhite-space: nowrap;\n}\n\n/* A clickable label is a <button>, and Nextcloud's core stylesheet gives\n every `button:not(.button-vue)` a min-height of the clickable area (34 to\n 44px) with the text centred in it. That pushed each reachable stage's label\n about 12px below the current one, which is a <span>. Zeroed here so all\n labels sit 8px under their bar, as the board draws them. The visually\n hidden \"(current step)\" is absolutely positioned and takes no space. */\nbutton.cn-stages-widget__bar-label {\n\tmin-height: 0;\n\theight: auto;\n\tline-height: inherit;\n\tcursor: pointer;\n}\n\n/* The same label under a theme that restyles every button with !important\n (thematiq's nldesign sheet: `button:not(.action-button)` gets 8px 16px\n padding, `min-width: max-content` and `overflow: visible`), and under core's\n `margin: 3px 3px 3px 0`, which outranks the scoped base rule. Together they\n put each clickable label about 10px below the current step's <span> and let\n a long label run over its neighbours, so eight Woo steps overlapped instead\n of ending in an ellipsis. Only !important beats !important; this selector is\n the more specific of the two. Without such a theme nothing changes: these\n are the values the base rule already sets. */\nbutton.cn-stages-widget__bar-label.cn-stages-widget__bar-label {\n\tmin-width: 0 !important;\n\tmargin: 0 !important;\n\tpadding: 0 !important;\n\tborder-width: 0 !important;\n\toverflow: hidden !important;\n\ttext-overflow: ellipsis !important;\n\twhite-space: nowrap !important;\n}\n\nbutton.cn-stages-widget__bar-label[aria-disabled=\"true\"] {\n\tcursor: not-allowed;\n}\n\nbutton.cn-stages-widget__bar-label:focus-visible {\n\toutline: 2px solid var(--color-primary-element);\n\toutline-offset: 2px;\n\tborder-radius: var(--border-radius-small, 4px);\n}\n\n.cn-stages-widget__bar-step--done .cn-stages-widget__bar-label {\n\tcolor: var(--color-main-text);\n\tfont-weight: 600;\n}\n\n.cn-stages-widget__bar-step--current .cn-stages-widget__bar-label {\n\tcolor: var(--color-main-text);\n\tfont-weight: 700;\n}\n\n.cn-stages-widget__bar-date {\n\tfont-size: 12px;\n\tcolor: var(--color-text-maxcontrast);\n}\n\n/* Text for screen readers only. */\n.cn-stages-widget__sr-only {\n\tposition: absolute;\n\twidth: 1px;\n\theight: 1px;\n\tpadding: 0;\n\tmargin: -1px;\n\toverflow: hidden;\n\tclip-path: inset(50%);\n\twhite-space: nowrap;\n\tborder: 0;\n}\n</style>\n"],"names":["_createElementBlock","_createBlock","_openBlock","_createVNode","_toDisplayString","_Fragment","_renderList","_normalizeClass","_createElementVNode","_resolveDynamicComponent","_createTextVNode","_withCtx"],"mappings":"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"}
@@ -536,6 +536,33 @@ var script = {
536
536
  return rules.find((r) => r && this.matchesRule(current, r)) || null
537
537
  },
538
538
 
539
+ /**
540
+ * The second caption line: `content.note` through the host translate
541
+ * function, or '' without it.
542
+ *
543
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-3/specs/zuiddrecht-pixel-gaps-3/spec.md#requirement-a-stat-tile-can-carry-a-second-caption-line
544
+ * @return {string}
545
+ */
546
+ resolvedNote() {
547
+ const note = this.content && this.content.note;
548
+ return typeof note === 'string' && note !== '' ? this.effectiveTranslate(note) : ''
549
+ },
550
+
551
+ /**
552
+ * The colour class of the second caption line (`content.noteVariant`,
553
+ * the same names as `captionVariant`), or '' for the muted default.
554
+ *
555
+ * @spec openspec/changes/zuiddrecht-pixel-gaps-3/specs/zuiddrecht-pixel-gaps-3/spec.md#requirement-a-stat-tile-can-carry-a-second-caption-line
556
+ * @return {string}
557
+ */
558
+ noteVariantClass() {
559
+ const variant = this.content && this.content.noteVariant;
560
+ if (!variant || variant === 'default' || !Object.hasOwn(VARIANT_COLORS, variant)) {
561
+ return ''
562
+ }
563
+ return `cn-kpi-card__label--${variant === 'danger' ? 'error' : variant}`
564
+ },
565
+
539
566
  /**
540
567
  * The caption's colour class: the matching override's
541
568
  * `captionVariant`, else `content.captionVariant`, as